Skip to content
QSWEQB
hardDesignMidSeniorStaff

One of your endpoints takes four minutes to complete. How should the API expose it?

Accept the request, return 202 with the location of an operation resource, and do the work behind a queue. Clients poll that resource or receive a signed callback, and either way the operation id is the contract - one that a callback can reach before the submitter has finished recording it.

6 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate make the operation an addressable resource rather than a status field on the target
  • Whether polling is given a server-controlled interval instead of leaving the client to choose
  • That callbacks are treated as at-least-once and possibly out of order, so the receiver must be idempotent
  • Whether the callback is authenticated in a way that survives an attacker who knows the URL
  • Does the candidate name what happens when the callback arrives before the submitter has committed the operation id

Answer

Holding the connection is the thing to rule out first

Four minutes of synchronous work fails for reasons that have nothing to do with your service. Load balancers and reverse proxies terminate idle connections on timeouts typically measured in tens of seconds, mobile networks drop connections when a handset changes cell, and client HTTP libraries default to timeouts far below four minutes. Even where the connection survives, the client cannot distinguish a slow success from a hang, so it retries, and now you are doing the work twice.

Worse, a synchronous long call couples your capacity to the client's patience. Every in-flight request occupies a connection and a thread or task for four minutes, so a modest burst exhausts the pool and requests that would have taken milliseconds start queueing behind exports.

So the request and the work get separated. The endpoint validates the request, records the intent durably, enqueues it, and returns immediately. 202 Accepted is the status defined for exactly this — the request is valid and has been accepted for processing, and the processing has not happened yet.

The operation is a resource

The design decision that matters is what the client gets back. Returning a bare job id in a body is common and weak. Return 202 with a Location header pointing at an addressable operation resource, so the client follows a URL it was given rather than assembling one from a template it has hard-coded.

HTTP/1.1 202 Accepted
Location: /v1/operations/op_8fc21d
Retry-After: 5
Content-Type: application/json

{"id":"op_8fc21d","status":"pending","createdAt":"2026-07-26T09:14:02Z"}

A GET on that resource returns the same shape with a status that moves through pending, running and then exactly one terminal state: succeeded, failed, or cancelled. On success it carries a link to the result rather than the result inline, because the result may be large and has a different cache lifetime and possibly different authorisation from the operation record. On failure it carries a structured error using the same error format as the rest of your API, with the same stable machine-readable identifier, because a client branching on an asynchronous failure needs exactly what it needs on a synchronous one.

Make the operation resource distinct from the thing being operated on. It is tempting to skip it and let clients poll the target — GET /exports/123 with a status field — but that conflates two lifecycles. An operation that fails may leave no target at all, an operation may act on several targets, and the operation record has its own retention: you want to keep it long enough to answer a late retry and no longer.

Two details make polling behave. Send Retry-After so the server sets the interval, otherwise some client will poll every 100 ms and you have built a denial-of-service tool with your own name on it. And keep the poll endpoint cheap — it is a point read of one row and must not recompute progress, or a thousand pollers will cost more than the work they are waiting for.

Callbacks, and the race that catches people

Polling wastes requests when operations take minutes, so most APIs offer a callback as well. The client registers an endpoint, and on completion you POST the operation record to it. This is where the honest design work is, because you are now the client of somebody else's unreliable service.

sequenceDiagram
    participant C as Client
    participant A as API
    participant W as Worker
    participant H as Client callback endpoint
    C->>A: POST /exports with Idempotency-Key
    A-->>C: 202 Accepted plus Location op_8fc21d
    A->>W: enqueue op_8fc21d
    W->>H: POST completion for op_8fc21d
    H-->>W: 200 OK
    C->>A: GET /operations/op_8fc21d
    A-->>C: 200 succeeded plus result link

The interesting part is the gap between the second and fourth messages. Nothing forces the client to have finished writing op_8fc21d into its own database before the callback for it arrives, and for a fast operation it very often has not — the callback and the 202 response race each other through two different network paths. A receiver that treats an unknown operation id as an error will reject a perfectly valid callback, and since your retry will very likely succeed a second later, the bug looks intermittent and gets closed as flaky. The fix belongs on both sides: the receiver stores the callback against the id even when it has no local record yet and reconciles later, and the sender retries with backoff rather than treating one rejection as final. Saying this before being asked is the strongest single thing a candidate can do on this question, because it is the failure that shows up in production and never in a test.

Everything else about callback delivery follows from accepting that it is at-least-once and unordered. At-least-once means the receiver must be idempotent, keyed on the operation id, because a lost acknowledgement produces a duplicate delivery that is indistinguishable from a new event. Unordered means a running notification can arrive after the succeeded one, so each callback should carry the terminal state rather than a delta, and receivers should ignore a transition backwards. Retries need exponential backoff with a cap and a total giving-up point, after which the operation record is still there to be polled — which is why you offer both mechanisms rather than choosing one.

Authentication cannot be the secrecy of the URL. Sign the callback with an HMAC over the raw request body plus a timestamp, using a per-customer secret, and send both in a header; the receiver recomputes it and rejects a signature that fails or a timestamp outside a small window, which is what stops a captured callback from being replayed later. Two implementation notes that are usually where this goes wrong: the signature must be computed over the bytes as sent, so a receiver that parses and re-serialises the JSON before verifying will fail on formatting differences it did not cause, and the comparison must be constant-time. Support two active secrets at once so a customer can rotate without a gap.

Cancellation and the retried submit

Cancellation is a request, not a command, and the API should say so. A POST to a cancel action on the operation records the intent and returns; whether the work stops depends on the worker reaching a checkpoint where stopping is safe. So the operation goes to a cancelling state and then to cancelled or, legitimately, to succeeded because it finished first. A client that expects cancellation to be synchronous and total will build the wrong retry logic, so document it.

The submit itself needs an idempotency key, for the same reason the original synchronous call did: a client that does not receive the 202 will resend, and without a key you now have two operations doing the same expensive work. With a key, the second submit returns the original 202 and the original operation id. That is also what makes the whole pattern safe to retry from the client's perspective, which is the property that lets you tell a client to retry aggressively on a network failure.

When synchronous is still right

Do not reach for this pattern by reflex. Work that completes in a few hundred milliseconds should stay synchronous, because an operation resource adds a round trip, a state machine and a retention policy to something that did not need them. And where a client genuinely needs incremental output rather than a completion signal — streaming tokens from a model, tailing a log — a long-lived streamed response is the better fit, with the caveat that you must then handle resumption yourself, since a dropped stream has no operation id to come back to unless you also gave it one.

The operation id is the real contract. Once a client holds one it can poll, receive a callback, retry the submit and ask about cancellation, and every one of those paths must agree on the same record.

Likely follow-ups

  • The callback fires before your client has committed the operation id to its database. What breaks and how do you fix it?
  • How do you let a client cancel an operation that has already started?
  • Your callbacks to one customer have been failing for six hours. What do you do with the backlog?
  • When is holding the connection open with streamed progress the better answer?

Related questions

Further reading

api-designasynchronouswebhookspollingidempotency