Async & long-running operations
Published
Why this, first. Real APIs can’t do everything inside one synchronous request — exports, transcodes, and batch jobs outlive the connection. Senior interviews probe the two async patterns (202 + polling and webhooks) and, crucially, their delivery guarantees. That’s where idempotency (Lesson 3) comes back: a webhook you can’t dedupe is a bug waiting to bill a customer twice.
The work outlives the request
so you make it a resource
return 202 & return now
then poll or get a callback
202 means "I've taken this, but it isn't done." Turn the work into a resource the client can track, then return immediately.3 The client polls the status resource with GET; it returns
pending/running and, when finished, a done flag plus a
link to the actual result resource. This is Google’s long-running
Operation
model: a uniform envelope carrying name, done, and either an
error or a response.
1
Keep the operation queryable after completion so a client that crashed mid-poll can recover.
When synchronous stops fitting
Reach for async the moment the work outlives the request. Holding a connection open for minutes is a resource leak and a reliability trap — a dropped socket loses the client’s only handle on work that’s still running.
Long jobs Data exports, video transcode, batch recompute — minutes, not milliseconds.
Slow dependencies Third-party calls you don’t control and can’t speed up.
Timeout risk Anything that would blow a gateway or load-balancer timeout window.
The senior instinct Turn the work into a resource the client can track, then return now.
The job as a state machine
The status resource is just a small state machine the client reads on each poll. Model it explicitly — terminal states are the recovery story.
done=true resolves to exactly one of succeeded (a response) or failed (an error) — never both,
never neither.1 Polling vs. webhook callback
Polling makes the client chase you. A webhook inverts it: the client registers
a callback URL, and the server POSTs an event when something happens. Efficient and
push-based — but it hands the consumer a fistful of distributed-systems problems.
Webhook delivery semantics
Naming these — and saying how you’d handle each — is the senior signal.
1 · Delivery is at-least-once The same event can arrive more than once. The consumer must be idempotent and dedupe on an event id — Lesson 3 cashing in. 2
2 · Ordering is not guaranteed Events can land out of order. Carry timestamps / sequence numbers; never assume the latest write is the last you received.
3 · Authenticity must be verified Anyone can POST a public URL. Sign the
payload with an HMAC header (shared secret) so the receiver confirms it’s
really you.
2
4 · Replay must be blocked A captured valid request can be resent. Put a timestamp in the signed payload and reject stale ones even when the signature checks out.
Ack fast, work later. Return a 2xx immediately, do the heavy work
async. A non-2xx or timeout triggers retries with backoff; after enough give-ups,
route to a dead-letter for inspection.
✗ Myth: “I’ve signed the webhook, so each event is genuine and safe to apply once.”
✓ Truth: the signature proves who sent it — not how often. You still need event-id dedupe and a replay window.
♻️ Memory rule: Outlives the request → make it a resource → 202 + status to poll, or a signed webhook to call back. Webhooks are at-least-once and out-of-order, so the consumer must be idempotent and time-aware — or it’s wrong.
Retrieval practice
Close the lesson in your mind first, then answer from memory. Effortful recall is what converts this from “I read it” to “I own it.” Immediate feedback below.
Q1. A server returns <code>202 Accepted</code> with a <code>Location</code> header. This signals that…
Q2. Webhook delivery is at-least-once, so the consumer must…
Q3. Why include a timestamp inside the signed webhook payload?
Q4. A webhook receiver should return a quick <code>2xx</code> ack and…
Rehearse the answer out loud
Interviewer: "Design an API for generating a large data export that can take several minutes, and notify the client when it's ready." ~60 seconds, from memory (no scrolling up) — then reveal and compare. Don't read the model first.
“Don’t block the request. POST /exports returns
202 Acceptedwith a Location header to /exports/{id} — a status resource.
The client polls that (pending → running →
succeeded), honouring Retry-After; when done it links to a
downloadable
result resource, a signed, expiring URL.
I’d also offer a completion webhook: deliver a
signed (HMAC)
event POST. Because delivery is at-least-once, the consumer
dedupes on the event id and stays idempotent; it doesn’t rely on ordering,
and a timestamp lets it reject replays. The receiver acks with a fast 2xx and
works async — non-2xx triggers retries with backoff, then a dead-letter. And
I’d protect the result URL with auth and an expiry.”
Why this scores: it uses 202 + a status resource correctly
and handles webhook delivery semantics — idempotent consumer, signing, retries —
instead of assuming perfect delivery. Offering both push and poll, plus securing the result,
is the senior signal.
I’m your teacher — ask me anything. Want to compare the Google
Operation
envelope with a hand-rolled jobs API? Curious how to verify a Stripe-style HMAC signature step
by step? Want me to grade your rehearsal answer or fire harder follow-ups on dead-lettering and
exactly-once myths? Just say so in the chat.
Primary source (read this next)
📖
“Long-running operations” — Google AIP-151: the cleanest spec for the 202-style operation model — the Operation resource,
done, polling, and result linkage. For webhook delivery semantics in the wild,
Stripe’s webhooks guide is the high-trust
reference.
Interview — pick your bar
Answer out loud in ~60s, then reveal. Core = recall · Senior = trade-offs & failure modes · Staff = synthesis under ambiguity · System Design = open design round (a different axis, not a harder level).
What does 202 Accepted mean?
"I've accepted the request for processing, but it isn't done and may not even have started." It's deliberately non-committal about the outcome — unlike 201 Created, nothing is guaranteed to exist yet. The response points the client at where to check progress.
What does the Location header point to in a 202 response?
To the status/operation resource the client should poll (e.g. /jobs/{id} or /operations/{id}), not to the final result. Contrast with 201 Created, where Location points at the newly created resource itself.
What is a webhook, and how does it differ from polling?
A webhook inverts the direction: instead of the client polling, the client registers a callback URL and the server POSTs an event to it when something happens. It's push-based — efficient and near-real-time — but it makes the client a server and hands it a fistful of distributed-systems problems (duplicates, ordering, auth).
What delivery guarantee do webhooks give, and what follows from it?
Delivery is at-least-once: failed deliveries are retried, so the same event can arrive more than once. The direct consequence: the consumer must be idempotent and dedupe on a stable event id. Assuming exactly-once delivery is the classic bug — it's how you bill a customer twice.
What's the core mental shift when moving from sync to async design?
Turn the work into a resource. Instead of returning the result, you create a first-class operation/job resource the client can address, query, and recover against. The request returns fast with a handle to that resource; the result becomes a separate resource the operation links to once it's done.
Why keep the operation queryable after it has completed?
So a client that crashed or lost the network mid-poll can recover — it re-queries the operation id and learns the outcome and result link. If you delete the operation the instant it finishes, you create a race where the client never learns it succeeded. Retain completed operations for a sensible TTL.
Webhooks aren't ordered. How do you build a correct consumer anyway?
Carry a timestamp and/or sequence number in each event and never assume the last event you received is the latest write. Reconcile against current state (e.g. "ignore an update older than what I've already applied"), or fetch authoritative state from a status resource. Design so out-of-order arrival is merely handled, not fatal.
A valid signed request can be captured and resent. How do you stop replay?
Include a timestamp in the signed payload and reject stale ones (outside a small tolerance window, e.g. a few minutes). A replayed event then fails even though its signature is genuine, because it's too old. Combine with consumer-side dedupe on event id so a replay inside the window is also a no-op.
"Just give me exactly-once delivery." How do you respond?
Exactly-once delivery is effectively unachievable over an unreliable network (the two-generals problem). What you can get is exactly-once effect: at-least-once delivery plus an idempotent consumer keyed on the event id. The senior framing is "I want exactly-once effect, not exactly-once delivery" — and then you build dedupe rather than chase an impossible guarantee.
How do idempotency keys interact with the 202 submission?
The submitting POST is not idempotent, so a retried submission could spawn a duplicate job. Accept an Idempotency-Key: on a repeat, return the same 202 and Location for the already-created operation instead of starting a second one. That makes "submit a long job" safely retryable — the client gets one operation no matter how many times the request is replayed.
How do you keep one slow or failing consumer from degrading webhook delivery for everyone?
Isolate per-consumer: independent delivery queues, per-endpoint concurrency limits and circuit breakers so a timing-out endpoint backs off without starving others; bound and parallelize retries; and dead-letter aggressively rather than letting a black-hole endpoint consume the retry budget. The principle is bulkheading — failure of one tenant's endpoint must not become a shared-fate outage of the delivery pipeline.
- Clarify: expected duration, completion SLA, who the client is (can it receive a callback?), idempotency & consistency needs.
- Make the work a resource:
POST→202 Accepted+Locationto a status/operation resource; keep it queryable after completion (TTL) for crash recovery. - Model the operation as a state machine:
queued → running → succeeded | failed | cancelled;done=trueresolves to exactly one of result or error; link the result as its own resource. - Choose the notification axis: polling (
Retry-After+ client backoff, works behind firewalls) vs signed webhooks (push, near-real-time) — mature APIs offer both, poll as the fallback. - Make submission safe:
Idempotency-Keyso a retried submit returns the same operation, not a duplicate job. - Webhook delivery semantics: at-least-once & unordered → consumer dedupes on event id, stays idempotent; HMAC-sign + reject stale (replay window); ack fast
2xxthen work async. - Reliability & scale: retries with backoff + jitter → dead-letter; per-consumer isolation/bulkheading; observability on delivery, retry, and lag.
Design an API for a large data export that can take several minutes, then notifies the client when it's ready.
A strong answer covers: don't block — POST /exports → 202 Accepted + Location to /exports/{id}, a status resource → client polls (pending→running→succeeded) honoring Retry-After; on done it links to a result resource — a signed, expiring download URL → also offer a completion webhook: an HMAC-signed event POST → because delivery is at-least-once, the consumer dedupes on event id and stays idempotent, doesn't rely on ordering, and uses a timestamp to reject replays → receiver acks fast 2xx and works async; non-2xx → retries with backoff → dead-letter → protect the result URL with auth + expiry → keep the operation queryable after completion so a client that crashed mid-poll can recover.
Design a webhook delivery platform that guarantees every event eventually lands without one bad endpoint taking the system down.
A strong answer covers: persist every event durably first, then deliver from a queue — never couple producing the event to delivering it → delivery is at-least-once: sign each payload (HMAC over raw bytes, constant-time compare) with a timestamp so consumers verify authenticity and reject replays → on non-2xx/timeout, retry with exponential backoff + jitter over a bounded window, then dead-letter and surface the failing endpoint → give a webhook-independent recovery path: a list/status API to fetch the gap and a replay of dead-lettered events → isolate per consumer (independent queues, per-endpoint concurrency limits, circuit breakers) so one black-hole endpoint can't starve the fleet — bulkheading → coalesce at high fan-out, apply back-pressure, and track delivery/retry/lag per endpoint → name the trade-off: exactly-once delivery is impossible over an unreliable network, so you ship at-least-once delivery + an idempotent consumer keyed on event id (exactly-once effect).
📄 Keep handy: API design reference
◂ Prev ·
Next ▸ Lesson 11These notes reflect my current understanding and are updated as I learn, build, and discover better explanations.