A synchronous call keeps the caller waiting on an open connection until the work is finished and the result comes back on that same connection. An asynchronous design splits the interaction in two: the caller submits work and immediately gets an acknowledgement (typically a job id), the work runs later on separate workers, and the result is delivered by polling, a webhook, or a stream. Interviewers probe this topic because "just put a queue in front" is a reflex answer that hides real decisions: what the client sees while it waits, what happens when work arrives faster than it can be processed, how a retry avoids running a job twice, and how a caller learns that a job died. For a Backend or AI engineer it is unavoidable, because LLM generation, document processing, embedding backfills and fine-tuning are all slow, expensive and bursty, which is exactly the profile where a blocking request per unit of work stops working.
Synchronous means you ask for something and then stand there waiting, doing nothing else, until you get the full answer back. The person or system on the other end must finish the entire task before you can move on.
Asynchronous means you hand off a request and get a quick "got it, working on it" reply, then go do something else while the actual work happens in the background. You find out it's done later, either by checking back or by being notified.
Picture two ways of ordering coffee. At a small stand with one barista, you stand at the counter and wait — nothing else happens until your cup is in your hand (synchronous). At a large café, you place your order, get handed a buzzer, and go sit down; the buzzer vibrates when your drink is ready (asynchronous). Both get you coffee, but one ties you to the counter the whole time and the other frees you up while the work happens.
SYNCHRONOUS ASYNCHRONOUS (queue + workers)
client --POST--> server ... works 30 s ... --200--> client --POST--> API --enqueue--> queue
(connection, thread/slot, proxy timeout, client <--202 + job id-- (ms) |
client timeout all held for 30 s) client polls / gets webhook / SSE v
worker pool
Little's law: in-flight = arrival rate x time in system result stored, job marked SUCCEEDED <-- (N workers)
ILLUSTRATIVE: 200 requests/s x 30 s each = 6,000 requests in flight at once.
Each holds a connection, memory, often a thread or a GPU slot. A 2x latency spike (a slow model)
doubles that to 12,000 with no change in traffic: latency feeds back into load.Little's law (in-flight work = arrival rate x average time in the system) is the cleanest way to explain the problem. In a synchronous design, slow downstream work directly increases how many requests are open at once, so a model that gets slower under load also needs more open connections under load, which is a runaway loop. The pressure also shows up in places you do not control: load balancers, proxies and clients each have their own idle and request timeouts, and a request that takes longer than the shortest of them fails even though the server is still working on it. Asynchronous processing breaks the loop by separating "accepting work" (fast, bounded, cheap) from "doing work" (slow, expensive, rate-limited by worker capacity). The API tier stays responsive whatever the backend is doing, and the queue absorbs the difference between arrival rate and processing rate. That is generally what "smoothing traffic spikes" means in practice.
Synchronous is not wrong. If the work takes tens of milliseconds and the caller needs the answer to proceed (a price lookup, a login), a direct call is simpler, easier to debug and has no eventual-consistency window. Concurrency inside a request (asyncio, thread pools, fan-out to several services in parallel) is also not the same as an asynchronous architecture: a Python async def handler still holds the client's connection open for the whole duration.
POST /v1/summaries -> 202 Accepted
Idempotency-Key: 7f3a... Location: /v1/jobs/job_81
{"document_id": "d42"} Retry-After: 2
{"job_id": "job_81", "state": "QUEUED"}
GET /v1/jobs/job_81 -> 200 {"state": "RUNNING", "progress": 0.4}
GET /v1/jobs/job_81 -> 200 {"state": "SUCCEEDED", "result_url": "/v1/summaries/s9"}
Delivery options for the result:
Polling client asks repeatedly simplest, works through any proxy; wasteful, adds up to one interval of delay
Webhook server POSTs to client URL no polling; needs client endpoint, signature (HMAC), retries, replay protection
SSE one long-lived HTTP response server pushes events; one-way; auto-reconnect via Last-Event-ID
WebSocket bidirectional channel interactive, but stateful connections are costlier to scale and load balance
Polling cost (ILLUSTRATIVE): 10,000 waiting clients x 1 poll / 2 s = 5,000 status requests/s.
With Retry-After + exponential backoff (2 s, 4 s, 8 s, capped) the same clients average far fewer.
The status resource is the heart of the pattern: 202 Accepted means "received, not done", and a Location header (or a body field) tells the client where to look. Polling is the lowest-common-denominator choice because it needs nothing from the client's network, but you pay for it in wasted requests and up to one polling interval of extra latency, so the server should send Retry-After and clients should back off. Webhooks invert control and remove the polling load, but they move complexity to you: the receiver can be down (so you need retries with backoff and a dead-letter path), can receive the same event twice (so it must be idempotent), and must be able to verify that the caller is really you (HMAC signature over the body plus a timestamp to block replays). Server-sent events fit browser or app clients that are watching one job: the response stays open, the server writes events, and the client reconnects automatically. See Streaming Responses and WebSockets for the transport trade-offs.
The state model deserves as much design as the endpoint. A typical job has QUEUED, RUNNING, SUCCEEDED, FAILED, and often CANCELLED and EXPIRED, with timestamps, an attempt counter, an error code that distinguishes retryable from permanent failures, and a result pointer rather than an embedded large payload. Decide up front how long results are retained, whether a client can cancel, and what a client sees if it asks about an unknown id. Write the transitions down: only forward moves are legal, and a job in a terminal state never changes (this is what makes retried "mark complete" writes safe).
Unbounded queue, arrival 12 jobs/s, capacity 10 jobs/s (ILLUSTRATIVE)
backlog grows 2 jobs/s -> after 10 min: 1,200 queued
wait for the newest job = backlog / capacity = 1,200 / 10 = 120 s (and still growing)
A client timeout of 30 s means the results it eventually gets are for requests it gave up on.
Bounded queue + deadline:
API: if queue is full -> 429/503 + Retry-After (reject early, cheaply)
Worker: if job.deadline has passed -> drop it, mark EXPIRED (do not burn a GPU on a dead job)
Client: gets a fast no, or a slow-but-valid yes, never a silent 10-minute wait
Where the time goes: latency = queue wait + service time
Capacity below arrival rate makes queue wait unbounded; adding queue depth only hides it.
A queue does not create capacity, it only defers the problem. If arrivals exceed what workers can process for long enough, an unbounded queue turns an overload into ever-growing latency and memory use, and eventually every job is stale. Backpressure is the set of mechanisms that push that reality back to producers: bounded queues that reject or block when full, admission control at the API (see Rate Limiting & Throttling), per-job deadlines so workers skip work nobody wants any more, priorities so interactive requests are not stuck behind a 10,000-item batch, and autoscaling keyed on queue depth or age of the oldest message rather than on CPU (see Autoscaling for Inference Workloads). Queue depth is a lagging signal; the age of the oldest waiting job is closer to what the user feels.
Timeouts must be set on every hop and be consistent: a client timeout shorter than server-side work gives the "wasted work" case the demo below shows, where the server completes work for callers that already gave up. The fix is to propagate a deadline through the stack, check it before starting expensive work, and cancel where the framework supports it. Timeouts, retries and their interaction are covered in Timeouts, Retries & Backoff; failing fast when a dependency is down is in Fault Tolerance & Circuit Breakers. For queue technology itself (brokers, streams, consumer groups, ordering) see Message Queues & Event Streaming.
Two independent sources of duplicate work:
1. Client retries: POST /jobs -> timeout -> client retries -> TWO jobs?
fix: client sends Idempotency-Key; server maps key -> job_id and returns the same job
2. Queue redelivery: worker takes job, crashes before ack -> broker redelivers (at-least-once)
fix: make the handler idempotent (dedupe on job id / attempt, upsert the result, guard side effects)
Worker failure timeline (visibility-timeout style broker, e.g. SQS-like):
t=0 worker A receives job (invisible to others for 60 s)
t=20 worker A crashes
t=60 job becomes visible again -> worker B receives it (attempt 2)
... after max attempts (say 3) -> move to dead-letter queue for a human/replay tool
Retry rules of thumb: retry transient errors (timeout, 429, 503) with exponential backoff + jitter;
do NOT retry permanent errors (bad input, 4xx validation); cap attempts; alert on the DLQ.
Delivery guarantees are the honest part of the answer: most brokers offer at-least-once delivery, so a job can run more than once, and end-to-end exactly-once is not something you get from the queue alone. What you can build is effectively once: at-least-once delivery combined with idempotent processing, so that a duplicate has no additional effect (upsert by job id, a unique constraint on the result row, an idempotency key on any external call such as a payment or an email). The two duplicate sources need separate defences: the idempotency key on the submit endpoint stops a retrying client from creating two jobs, and the idempotent handler stops a redelivered message from doing the work twice. See Idempotency & Exactly-Once Semantics.
Failures need classification. Transient failures (a timeout, a rate-limit response, a briefly unavailable model server) deserve a bounded number of retries with exponential backoff and jitter; permanent failures (an empty document, a schema violation) should fail immediately, otherwise they cycle forever. A message that always fails is a poison message: without an attempt cap it can block a partition or burn GPU time forever, so cap attempts and route the survivors to a dead-letter queue with the error and payload. Also decide what a retry means for a non-idempotent step in the middle of a long job (say the job called an external API before failing): either checkpoint progress, or make each step idempotent, or accept and document the duplicate risk.
Workload Latency need Pattern
------------------------------- ---------------------- ----------------------------------------------
Chat reply first token in ~1 s sync request, STREAMED (feels sync, is long-lived)
Summarise a 200-page PDF seconds to minutes 202 + job + SSE/poll/webhook; queue + workers
Nightly embedding backfill hours OK batch/offline queue, low priority, big batches
Fine-tuning / evaluation run minutes to hours job scheduler, checkpoints, cancel + resume
Worker pool for GPU jobs: queue -> worker (one GPU or one model server) -> result store
- concurrency per worker = what the GPU/serving engine sustains, NOT "as many as arrive"
- priority lanes: interactive > standard > batch, so batch fills idle GPU time only
- checkpoint long jobs; on worker loss resume from the last checkpoint, not from zero
- cancellation: a cancelled job must free its GPU slot promptly
LLM work is a good fit for async for three reasons: a single job can take seconds to minutes (long documents, agents making many model calls), GPU capacity is expensive and fixed in the short run so bursts must queue rather than scale instantly (new replicas can take minutes to load weights), and much of the demand is not latency-sensitive at all. Chat is the interesting hybrid: the user wants a response now, so it is a synchronous request whose response is streamed token by token; the connection stays open for the whole generation, which is why streaming deployments need generous timeouts, connection draining and per-connection resource limits. Many model providers also offer an offline or batch interface that accepts a large file of requests and returns results within a stated window, typically at a reduced price; the specifics change, so check current provider documentation. The cost logic is the same as running your own workers at low priority: trading latency for utilisation.
The worker side is where GPU realities show up. Workers should pull work at a rate the serving engine can sustain (see Batching & Dynamic Batching), so the queue, not the GPU, absorbs bursts. Mixed workloads need priority lanes or separate pools so a large backfill cannot starve interactive users. Agents that call tools repeatedly are effectively long-running jobs with many steps and side effects, so they need the same state model, checkpoints and idempotent tool calls. Finally, route requests to workers sensibly (see Load Balancing): a pull-based queue naturally balances by worker availability, which sidesteps some of the routing problems of push-based balancing.
202 Accepted + a job id/Location, then poll (with Retry-After and backoff), a signed and retried webhook, or SSE; model job state explicitly (queued, running, succeeded, failed, cancelled, expired) with only forward transitions.Retry-After), per-job deadlines, priorities, and scale on queue age or depth.QUEUED (or returns the existing job if the key was seen), and puts the job on a queue.202 Accepted with the job id and a status URL, or a fast rejection (429/503 with Retry-After) if the bounded queue is full or the client is over quota.RUNNING, increments the attempt count, and does the work under a per-attempt timeout, honouring the job's deadline.SUCCEEDED; on a transient error it releases or re-queues the job with backoff; on a permanent error or after the maximum attempts it marks FAILED and routes it to the dead-letter queue.The first program is a real asyncio run: 20 jobs arrive at once, 4 workers each take 0.1 s per job, and the client gives up after 0.25 s. The synchronous style has each client wait for its result while the server keeps working on abandoned requests; the asynchronous style acknowledges immediately, queues up to 8 waiting jobs and rejects the rest with 429. The two are not doing the same thing on purpose: the point is to see where each pattern spends its capacity. The second program is a toy job store showing idempotent submission and retries with a permanent versus a transient failure. Timings are real measurements from one machine and vary slightly from run to run.
# Python 3: sync-blocking vs queue-based async under a burst (asyncio)
import asyncio, time
JOB_S, WORKERS, BURST, CLIENT_TIMEOUT = 0.10, 4, 20, 0.25 # 20 jobs arrive at once, 4 workers, 0.1 s each
async def sync_style():
"""Client holds the request open until the result is ready; the server keeps working
on a request even after the client has given up (nothing cancels it)."""
pool = asyncio.Semaphore(WORKERS)
done_work = 0
async def handle():
nonlocal done_work
async with pool:
await asyncio.sleep(JOB_S)
done_work += 1
async def client(i):
t0 = time.perf_counter()
task = asyncio.ensure_future(handle())
try:
await asyncio.wait_for(asyncio.shield(task), CLIENT_TIMEOUT)
return ("ok", time.perf_counter() - t0)
except asyncio.TimeoutError:
return ("timeout", time.perf_counter() - t0)
res = await asyncio.gather(*(client(i) for i in range(BURST)))
await asyncio.sleep(0.4) # let abandoned work finish, to count it
ok = [t for s, t in res if s == "ok"]
print(f"SYNC : ok={len(ok)} timed_out={BURST-len(ok)} server_work_done={done_work} "
f"(wasted on abandoned requests={done_work-len(ok)})")
print(f" client-visible latency of successes: max={max(ok):.2f}s")
async def async_style(queue_max=8):
q = asyncio.Queue(maxsize=queue_max)
status, finished_at = {}, {}
t_start = time.perf_counter()
async def worker():
while True:
job_id = await q.get()
status[job_id] = "RUNNING"
await asyncio.sleep(JOB_S)
status[job_id] = "SUCCEEDED"; finished_at[job_id] = time.perf_counter() - t_start
q.task_done()
workers = [asyncio.create_task(worker()) for _ in range(WORKERS)]
async def submit(i):
t0 = time.perf_counter()
try:
q.put_nowait(i); status[i] = "QUEUED"; code = 202
except asyncio.QueueFull:
code = 429 # backpressure: reject fast, tell client to retry later
return code, time.perf_counter() - t0
acks = []
for i in range(BURST):
acks.append(await submit(i)); await asyncio.sleep(0.001) # burst, 1 ms apart
accepted = sum(1 for c, _ in acks if c == 202)
print(f"ASYNC: 202 accepted={accepted} 429 rejected={BURST-accepted}, "
f"max ack latency={max(t for _, t in acks)*1000:.1f} ms")
await q.join()
fin = sorted(finished_at.values())
print(f" accepted jobs finished between {fin[0]:.2f}s and {fin[-1]:.2f}s "
f"(clients poll GET /jobs/id; none held a connection open)")
for w in workers: w.cancel()
asyncio.run(sync_style())
asyncio.run(async_style())
# Real output from python3:
# SYNC : ok=8 timed_out=12 server_work_done=20 (wasted on abandoned requests=12)
# client-visible latency of successes: max=0.20s
# ASYNC: 202 accepted=12 429 rejected=8, max ack latency=0.0 ms
# accepted jobs finished between 0.10s and 0.30s (clients poll GET /jobs/id; none held a connection open)
# Python 3: job states, idempotency key, retry of transient vs permanent errors
import itertools
class JobStore:
"""Toy job table: idempotent submit, explicit states, bounded retries."""
MAX_ATTEMPTS = 3
def __init__(self):
self.ids = itertools.count(1)
self.jobs = {} # job_id -> dict
self.by_key = {} # idempotency_key -> job_id
def submit(self, payload, idempotency_key):
if idempotency_key in self.by_key: # a client retry: return the SAME job
return 200, self.by_key[idempotency_key]
job_id = f"job-{next(self.ids)}"
self.jobs[job_id] = {"state": "QUEUED", "attempts": 0, "payload": payload, "result": None, "error": None}
self.by_key[idempotency_key] = job_id
return 202, job_id # 202 Accepted: work not done yet
def run(self, job_id, handler):
j = self.jobs[job_id]
while j["attempts"] < self.MAX_ATTEMPTS:
j["attempts"] += 1
j["state"] = "RUNNING"
try:
j["result"] = handler(j["payload"], j["attempts"])
j["state"] = "SUCCEEDED"; return
except TimeoutError as e: # transient: retry
j["error"] = str(e); j["state"] = "QUEUED"
except ValueError as e: # permanent: do not retry
j["error"] = str(e); j["state"] = "FAILED"; return
j["state"] = "FAILED" # attempts exhausted (a real system would dead-letter it)
store = JobStore()
def flaky(payload, attempt):
if attempt < 3: raise TimeoutError(f"upstream timeout on attempt {attempt}")
return f"summary of {payload!r}"
def bad_input(payload, attempt):
raise ValueError("document is empty")
print(store.submit("doc-A", "key-123")) # first submit
print(store.submit("doc-A", "key-123")) # client retried after a lost response
print(store.submit("doc-B", "key-456"))
store.run("job-1", flaky); store.run("job-2", bad_input)
for jid, j in store.jobs.items():
print(jid, j["state"], "attempts =", j["attempts"], "| result =", j["result"], "| last_error =", j["error"])
# Real output from python3:
# (202, 'job-1')
# (200, 'job-1')
# (202, 'job-2')
# job-1 SUCCEEDED attempts = 3 | result = summary of 'doc-A' | last_error = upstream timeout on attempt 2
# job-2 FAILED attempts = 1 | result = None | last_error = document is empty
Reading the first result: in the synchronous run only 8 of 20 callers got an answer before their 0.25 s timeout, and the server still completed all 20 jobs, so 12 units of GPU-style work were wasted on callers who had left. In the asynchronous run acknowledgement was effectively instant, 12 jobs were accepted and completed (the last around 0.3 s), and 8 were turned away immediately with a 429 that tells the client to come back, so no capacity was spent on work nobody would use. Which is better depends on the product, but the async run made the overload visible and bounded instead of silent and wasteful. The count of accepted jobs (12) reflects 4 workers already pulling plus a queue of 8 in this timing, so it is a property of this configuration, not a general number.
Q: When is asynchronous processing worth its complexity?
A: When the work is slow relative to what a connection should be held for (seconds or more), bursty enough that provisioning for the peak would be wasteful, retryable, or not needed by the caller immediately. The complexity you take on is a job model, a queue, worker fleet, retries, idempotency, and a delivery channel for results. If the call takes 50 ms and the caller needs the answer to continue, a synchronous call is simpler and correct; adding a queue there just adds latency and failure modes.
Q: Design the API for a document-summarisation endpoint that takes 30-90 seconds.
A: POST creates a job and returns 202 with a job id and Location, carrying an Idempotency-Key so client retries do not duplicate work. Clients then poll GET /jobs/{id} using Retry-After, or register a signed webhook, or open an SSE stream for progress. The job record holds state, attempts, timestamps, an error code and a pointer to the result, and results expire after a stated retention. Trade-off: polling is simple but adds latency and load, webhooks and SSE cut both but need more infrastructure on the client side or the server side respectively.
Q: What is backpressure and how do you implement it on a queue-backed API?
A: It is the mechanism that pushes overload signals back to producers rather than absorbing them without limit. In practice that means a bounded queue (or a cap on queue age) so the API returns 429/503 with Retry-After when full, per-tenant quotas so one client cannot fill the queue, deadlines so stale jobs are dropped, and autoscaling on queue age. Without it, a sustained overload just converts into ever-growing latency (12 jobs/s arriving at 10 jobs/s capacity gives a 120 s wait after 10 minutes, illustratively).
Q: Is exactly-once processing possible with a queue?
A: Not end to end in the general case: the broker can deliver at least once, and a worker can crash between doing the work and acknowledging it, so a duplicate is always possible. What you build is effectively-once: at-least-once delivery plus idempotent handlers (upsert keyed on the job id, unique constraints, idempotency keys on external calls). Some systems offer exactly-once semantics inside their own boundary (for example transactional writes between a broker and its own state), but the moment a side effect leaves that boundary you are back to idempotency.
Q: A client times out after 30 s on a synchronous endpoint. What can go wrong on the server, and how do you fix it?
A: The server generally does not know the client left, so it finishes the work and burns capacity on a result nobody receives, and the client's retry can start a second copy of the same work, doubling load exactly when the system is slow. Fixes are a propagated deadline that the server checks and honours, cancellation where the framework supports it, idempotency keys so a retry joins the original work, and if the work is legitimately long, moving to the 202 pattern so the request is not tied to the connection.
Q: How do you choose between polling, webhooks and SSE for delivering results?
A: Polling when clients are simple, external, or behind restrictive networks, at the cost of wasted requests and up to one interval of delay (mitigate with Retry-After and backoff). Webhooks for server-to-server integrations, at the cost of needing signature verification, retries, idempotent receivers and a way to replay missed events. SSE (or WebSockets) for a user watching progress live, at the cost of holding a connection per watcher, which affects load balancing and connection limits. Many APIs offer polling as the baseline and webhooks as the optimisation.
Q: How would you serve both a chat feature and a nightly re-embedding job on the same GPU fleet?
A: Chat is a streamed synchronous path with a tight time-to-first-token target; the re-embedding job is an async batch with no latency target. I would put them in separate queues or priority lanes and let the batch job use idle capacity with lower priority and preemption or a cap on the share of GPU it may take. The trade-off is lower average utilisation than pooling everything (and more scheduling logic) in exchange for protecting the interactive tail latency.
Follow-up: A worker takes a job, calls a payment or email API, then crashes before acknowledging. What happens on redelivery?
A: The job runs again, so the external side effect happens twice unless you prevent it. Use an idempotency key derived from the job id on the external call so the provider deduplicates it, or record "step X done" in the job record in the same transaction that produces the effect and skip completed steps on retry. If the external system supports neither, you can only choose between at-most-once (risk of missing the effect) and at-least-once (risk of a duplicate), and you should say which one you chose and why.
Follow-up: Queue depth is rising during a traffic spike. What do you do and in what order?
A: First confirm whether it is arrival rate or a slow or stuck worker (worker utilisation, oldest-message age, error and retry rates). Then protect the system by shedding or rejecting the lowest-priority work and enforcing per-tenant limits, and scale workers if there is capacity to add (mindful of GPU start-up times of minutes and downstream limits such as a provider's rate limit). Also check that retries are not amplifying the load. The trade-off is that shedding drops some requests now to keep completion time for the rest within the SLO.
Follow-up: Why not make everything async?
A: Because it converts a function call into a distributed workflow: you gain resilience and smoothing but pay in latency (queue wait), harder debugging (a request is spread over several components and time), eventual consistency (the result is not available when the API returns), a job store to run and clean up, and more failure modes such as lost jobs, duplicates and poison messages. It is justified per workload where the benefits outweigh those costs, not as a default.
In system designs the pattern appears wherever a user action triggers heavy or unreliable work: video transcoding, report generation, document ingestion into a RAG index, bulk email, and LLM tasks such as summarising, extracting or evaluating. State the numbers as estimates: assume 5 jobs/s average with a 5x burst lasting a minute or two, each job taking about 40 s of worker time; average concurrency is 5 x 40 = 200 busy workers (Little's law), so provisioning for the peak needs about 1,000 workers, while a queue plus workers sized for roughly 300 and a maximum accepted wait of a few minutes trades some latency for about a 3x cost saving (all illustrative). The interviewer expects the whole path: API returns 202, the job table and idempotency keys, the queue and its bounds, the worker pool with concurrency matched to the GPU or downstream limit, retries with backoff and a dead-letter queue, how results are delivered, and how you monitor queue age and completion-time percentiles. For chat-style LLM features, describe the hybrid: a synchronous, streamed path for interactive use with timeouts and connection draining, and an async path for long or offline work. Link the pieces: rate limiting at the door, a broker or stream for the queue (see the networking view of message queues and the sibling page on message queues and event streaming), autoscaling on queue age, and circuit breakers around the model provider.
Async trades simplicity, immediacy and easy debugging for resilience, smoothing and independent scaling. You buy isolation from slow backends and the ability to provision for average load instead of peak, and you pay with a job lifecycle to design, eventual consistency (the caller must handle "not ready yet"), operational load (queues, DLQs, workers, monitoring), and the need for idempotency everywhere because duplicates become normal. It is the wrong choice when the work is fast and the caller genuinely needs the result to proceed, when strict read-your-writes behaviour is required immediately after the request, when the team cannot operate the extra components reliably, or when the queue would only hide a capacity shortfall that will surface later as unbounded latency. Even when async is right, it needs limits (bounded queues, deadlines, quotas) to stay a smoothing device and not a slow-motion outage, and for user-facing generation the usual compromise is a streamed synchronous response, not a job at all.