Use an asynchronous image-generation job with strict prompt validation, then return Base64 only for small results and a short-lived signed URL for everything else. For a media company generating synthetic supplier invoices to test field extraction, that contract protects extraction quality without making an Express worker hold a connection open through unpredictable rendering and storage time.
This is the architecture decision: acknowledge accepted work with 202, keep generation behind an adapter, store the original image plus its provenance, and expose one stable result schema with two explicit delivery modes. The client chooses base64 or signed_url; the server still enforces a byte limit before embedding data. Quality is an input to scheduling, not an excuse to let latency drift without bounds.
The invoice detail matters. A prompt such as "two-page supplier invoice, USD, three line items, faint fold through tax total" is useful test material only if the generator preserves legible totals, dates, vendor identifiers, and page geometry. A beautiful but unreadable image is a failed fixture. So is a fast image that silently drops the second page.
What invariants keep synthetic invoice generation useful for field extraction?
The first invariant is semantic: the accepted prompt describes a synthetic document, never a real supplier record. Reject account numbers, email addresses, phone numbers, and other obvious personal or payment data before the request enters a queue. Validation should also normalize surrounding whitespace, cap prompt length, restrict requested image counts, and reject unknown delivery modes. Don't "repair" an ambiguous request behind the caller's back; return a stable client error with a field path.
The second invariant is provenance. Each accepted job needs an immutable request identifier, the normalized prompt or its governed representation, requested quality, output format, creation time, and generator revision. An extraction team must be able to distinguish a model regression from a fixture change. Keep both.
The third invariant is delivery integrity. Base64 is an encoding, not storage. It expands the response and pushes image bytes through JSON parsing, application memory, logs, proxies, and tracing systems. A signed URL keeps the API response small and lets the object store serve the bytes, but the URL becomes a temporary bearer credential. Set a brief expiry, bind the object key to the job, use HTTPS, and never log the query string.
The failure boundary should be equally plain. Prompt and shape errors are synchronous 4xx responses. Accepted jobs move through queued, running, succeeded, or failed; a failed generation returns a stable public error category rather than a provider response. Result retrieval is idempotent. Retrying creation requires an idempotency key, because an impatient client can otherwise create duplicate images and contaminate a quality comparison.
I've learned from OTP delivery gaps that "accepted" and "delivered" are different states. Image generation needs the same discipline — a 202 proves queue admission, not usable output. The extraction test should begin only after the result is stored, its media type and byte count are verified, and the job says succeeded.
How should a Nodejs Express image generation API return a signed URL or Base64?
Treat Express as the HTTP shell around a service contract. A POST on the job collection validates and enqueues; a GET on an individual job reports state and, after success, returns exactly one delivery object. Route naming belongs to the application, while the state and result semantics belong to the contract.
The critical path below is deliberately shown in Python because the important artifact is the boundary logic, independent of the web framework. In an Express codebase, the same functions map cleanly to validation middleware, a queue producer, a worker, and a status handler. The generator and object store remain ports, so provider-specific request fields never leak into the public endpoint.
from dataclasses import dataclass
from typing import Literal, Protocol
import base64
import re
import uuid
Delivery = Literal["base64", "signed_url"]
Quality = Literal["draft", "review"]
MAX_PROMPT_CHARS = 800
MAX_INLINE_BYTES = 750_000
class ClientError(Exception):
def __init__(self, code: str, field: str):
self.code = code
self.field = field
class Generator(Protocol):
def render(self, prompt: str, quality: Quality) -> tuple[bytes, str]: ...
class ObjectStore(Protocol):
def put(self, key: str, body: bytes, media_type: str) -> None: ...
def sign_get(self, key: str, expires_in_seconds: int) -> str: ...
@dataclass(frozen=True)
class ImageJob:
job_id: str
prompt: str
quality: Quality
delivery: Delivery
SENSITIVE_PATTERNS = (
re.compile(r"\b\d{12,19}\b"),
re.compile(r"\b[^\s@]+@[^\s@]+\.[^\s@]+\b"),
)
def validate_request(payload: dict) -> ImageJob:
prompt = payload.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
raise ClientError("PROMPT_REQUIRED", "prompt")
prompt = " ".join(prompt.split())
if len(prompt) > MAX_PROMPT_CHARS:
raise ClientError("PROMPT_TOO_LONG", "prompt")
if any(pattern.search(prompt) for pattern in SENSITIVE_PATTERNS):
raise ClientError("SENSITIVE_DATA", "prompt")
quality = payload.get("quality", "review")
if quality not in ("draft", "review"):
raise ClientError("QUALITY_INVALID", "quality")
delivery = payload.get("delivery", "signed_url")
if delivery not in ("base64", "signed_url"):
raise ClientError("DELIVERY_INVALID", "delivery")
return ImageJob(
job_id=str(uuid.uuid4()),
prompt=prompt,
quality=quality,
delivery=delivery,
)
def complete_job(job: ImageJob, generator: Generator, store: ObjectStore) -> dict:
image, media_type = generator.render(job.prompt, job.quality)
if not image or not media_type.startswith("image/"):
return {"job_id": job.job_id, "status": "failed", "error": "INVALID_OUTPUT"}
key = f"synthetic-invoices/{job.job_id}/original"
store.put(key, image, media_type)
if job.delivery == "base64" and len(image) <= MAX_INLINE_BYTES:
result = {
"kind": "base64",
"media_type": media_type,
"data": base64.b64encode(image).decode("ascii"),
}
else:
result = {
"kind": "signed_url",
"media_type": media_type,
"url": store.sign_get(key, expires_in_seconds=300),
"expires_in_seconds": 300,
}
return {"job_id": job.job_id, "status": "succeeded", "result": result}
Notice the downgrade rule: requesting Base64 does not guarantee inline delivery when the encoded response would be too large. Document that behavior in the API schema and return the actual result.kind. A strict client can reject the alternate mode; a practical extraction worker can follow either representation after checking media_type and a configured maximum download size.
Fail closed.
That short rule covers malformed JSON, unsupported media types, prompts that become empty after normalization, duplicate idempotency keys with different bodies, and result URLs requested after expiry. It also keeps compliance review tractable: no prompt body in access logs, no image bytes in traces, and no signed query string in analytics.
The quality-latency decision belongs in the job contract
"High quality" is too vague for an operational API. For synthetic invoices, define quality by what the downstream extractor needs: character legibility at the target resolution, stable page boundaries, preservation of requested fields, and enough visual variation to exercise the parser. Define latency separately as queue delay plus generation time plus storage time. One score cannot substitute for the other.
Use named service tiers such as draft and review, then map those names internally to generator settings. A draft can support fast prompt iteration; a review image can be admitted to a controlled extraction evaluation only after automated checks pass. Avoid exposing raw model knobs in the public contract. They couple callers to a generator revision and make two jobs with the same declared purpose difficult to compare.
The practical scheduler is a deadline budget. Consider a two-page synthetic invoice requested in review quality, with a faint fold intentionally crossing the tax total. The queue may admit it quickly, yet the first rendered artifact can still be useless to the extraction evaluation if the expected second page is absent, the currency glyph is illegible, or the total moved to a visually plausible but semantically wrong row. The worker therefore cannot stop at "received image bytes." It verifies media type and dimensions, records the generator revision, checks that the artifact matches the requested page count and test specification, stores the immutable original, and only then marks the job successful. Record queue, render, verification, and storage durations separately. Reserve time for the last two stages before dispatching generation, and decline or defer work that cannot finish inside the remaining deadline budget. If rendering completes after the caller's deadline, the result can still be stored for later retrieval, but it must not be reported as an on-time success in latency metrics. This distinction is easy to lose in a single timer, and once lost it leads a team to optimize the wrong stage: faster queue admission can make the dashboard look better while invoice fixtures continue arriving too late or with text too weak for meaningful extraction tests.
I'm not sure which delivery representation will dominate a given workload until real image-size and consumer-location distributions are measured. Your mileage may vary. Instrument the choice rather than guessing: result bytes, queue duration, render duration, storage duration, delivery kind, URL refresh count, and extraction acceptance outcome. Never attach prompts, Base64 payloads, or signed URLs to those metric labels.
Here is the option record I would put beside the API contract:
| Option | Latency behavior | Quality and operations | Choose it when |
|---|---|---|---|
| Synchronous Base64 response | One request spans render and transfer | Simple consumer, high worker memory and response-size pressure | Images are predictably small and generation fits a hard request budget |
| Async job plus signed URL | Adds queue and polling steps; keeps status calls small | Durable artifact, independent download, expiry must be handled | Production extraction tests, larger files, or bursty demand |
| Async job plus bounded Base64 | Adds polling; final JSON carries the bytes | No second download, but parsing and observability need strict limits | A controlled internal consumer cannot fetch object URLs |
| Offline batch | Longest feedback loop | Efficient for fixed evaluation sets; poor fit for interactive iteration | The complete prompt set is known before execution |
The OpenAI Batch API guide is one public example of the offline-batch pattern, while the LiteLLM repository documents a self-hosted gateway approach. Neither changes the external contract proposed here. Batch and gateway choices sit behind the adapter; job identity, validation, status, and delivery behavior stay owned by the application.
Test the seams that happy-path demos skip
Unit tests should hit every validator boundary and ensure sensitive values never reach the queue mock. Contract tests should prove that a 202 body contains a job identifier and status location, that polling is idempotent, and that succeeded contains one well-formed delivery variant. Use generated byte arrays in these tests, not production documents.
Then test the races. Two create requests with the same idempotency key and same body should resolve to one job; the same key with a different body should be rejected. A signed URL refresh must point to the same immutable object. A cancellation arriving as a worker finishes must settle on one documented state. A worker retry after storage succeeds must not overwrite provenance with a new render.
Quality evaluation needs its own suite because HTTP success says nothing about invoice usefulness. Build synthetic prompts that vary currency placement, long vendor names, multipage line items, faint stamps, rotated scans, and subtotal-versus-tax layouts. Run the extractor, compare its structured fields with the intended synthetic specification, and retain the generator revision with the score. Don't use a visual spot check as the release gate.
Deployment should separate API, queue worker, and object-serving concerns. Apply concurrency limits at the worker, per-tenant admission limits at the API, and bounded retries only for errors classified as retryable by the internal adapter. Alert on age of oldest queued job and extraction rejection rate, not merely request throughput. A growing queue can look healthy if dashboards count every 202 as success.
Compliance work is less glamorous and more important. Set retention independently for prompts, generated images, and operational metadata. Make deletion addressable by job identifier. Keep access logs free of document contents. For a media company receiving supplier invoices, synthetic fixtures should remain unmistakably synthetic so they cannot be mistaken for payable records or enter a finance workflow.
Why reject the synchronous-only design?
The synchronous-only option puts generation, verification, encoding, and transfer inside one HTTP lifetime. Its appeal is real: fewer endpoints, no polling, and a tiny client state machine. The catch is that its failure and latency boundaries are coupled. A slow render occupies server capacity; a large Base64 result amplifies memory and transfer costs; a client disconnect obscures whether the artifact should be retained.
It is not suitable when generation time varies widely, invoice images can be large or multipage, traffic arrives in bursts, or results need auditable retention. Use the asynchronous contract in those cases.
Stick with synchronous delivery when the caller and server share a trusted network, inputs and outputs have tight byte bounds, generation reliably fits below the request timeout, and there is no need to retrieve the artifact later. A local design tool producing one small draft preview can reasonably make that trade. Even there, keep prompt validation, idempotency, output verification, and response-size limits; synchrony removes queue machinery, not correctness obligations.
For the synthetic invoice workload, the final decision is an async job with explicit draft and review intent, durable provenance, signed URLs by default, and bounded Base64 as a compatibility mode. That gives extraction quality room to be verified while keeping latency measurable at each stage. The public contract stays stable if the generator, gateway, queue, or storage implementation changes later.
Top comments (0)