Which Dependency Must Answer Before the User Can Be Told Anything?
Checkout depends on cart, inventory, payment and orders. Not all of them must answer before the customer sees a result; asking which ones must — and what you would tell the customer if the others are still working — is the decision that separates a synchronous call from an asynchronous one.
The situation, the reflex, and why it stalls
Every lesson starts where being stuck starts: someone has a problem, and the first move that comes to mind feels like progress.
When a request depends on several components, which of them must answer before you can respond to the user — and how does that answer decide what is synchronous and what is not?
Checkout calls the cart, checks inventory, charges the card, creates the order and sends a receipt. I have written it as one function that does all five in order and returns. It takes a while, it hangs when email is slow, and I have been told to "make it async" without anyone saying which part.
Make it fast by making it all asynchronous: put the whole checkout on a queue, return "processing" immediately, and let a worker do everything. Or the opposite reflex: keep it all synchronous because that is simpler to reason about, and add a spinner. Either way it is one decision for five dependencies.
All-async returns "processing" for a request whose most important answer — was the card declined? — the customer needs before they leave. They leave, the decline happens in a worker, and nobody is there to try another card. The response was fast and told them nothing.
- All-async returns "processing" for a request whose most important answer — was the card declined? — the customer needs before they leave. They leave, the decline happens in a worker, and nobody is there to try another card. The response was fast and told them nothing.
- All-sync means the order is held hostage by the slowest, least important dependency. A slow email provider makes checkout time out after the payment succeeded, and the customer sees an error for an order that exists.
- The question "which of these does the customer need an answer from?" was never asked because "async" was treated as a property of the endpoint rather than of each edge under it.
- The queue arrives, and with it a worker, retries, a dead-letter queue and a status-polling endpoint — all to make email non-blocking, which one line of "send later" would have done.
The move
Precisely enough to apply it to a problem you have never seen — not a slogan.
- A dependency must answer before you respond if, and only if, the response would be a lie without it. Go through each dependency of the request and ask: if this one has not answered yet, what can I honestly tell the user? If the honest answer is "nothing useful", the dependency is on the response path. If there is an honest, useful thing to say, the dependency can finish afterwards.
- Be strict about "honest". "Your order is confirmed" is a lie if payment has not answered. "Your order has been placed and you will receive a receipt shortly" is honest if the receipt is the only thing outstanding. The sentence you can truthfully show is the test.
- Then look at what happens to a dependency that finishes afterwards and fails. If a failure after the response must be able to change what the user was told, it was on the response path after all. If the failure can be handled without them — retried, compensated, reported later — it was safely deferred.
- The result is not "sync" or "async" for the endpoint; it is a partition of the dependencies into before-the-response and after, each with the sentence the user sees and the plan for a late failure. The queue, if one is needed, serves the second group only.
Checkout and the things it waits for
The diagram is checkout with its dependencies partitioned. The edges on the left are the ones whose answer the customer's response cannot do without; the edges through the queue are the ones that finish after the customer has been told something true. The queue exists because the warehouse notification must not be lost, and it carries only the things that share that property.
The decision, per dependency
This is the decision the reflex made once for the whole endpoint, made instead for each edge. The options are not sync and async; they are what the user is told and what happens to a late failure. The queue is a consequence of the third option, not a starting point.
Must this dependency answer before the user is told anything?
when No truthful, useful sentence exists without its answer. Cart, inventory and payment for checkout; storage of the message before "sent" in the chat app.
cost Its latency and its failures are the user's. An UNKNOWN answer needs its own truthful sentence and its own later resolution.
when A truthful sentence exists without it and a failure changes nothing the user was told. Analytics events; a cache warm-up.
cost Work silently disappears under load or crash. Fine only if nobody needs it to have happened.
when A truthful sentence exists without it, but a failure would eventually break a promise — the warehouse notification behind "your order will ship".
cost Durability: a queue or an outbox, a worker, retries, and a way to notice when retries run out (The Transactional Outbox, A Dead-Letter Queue Is a Workflow, Not a Bin).
What a late failure looks like from the customer's side
The partition is only finished when each "after" has a failure row. The table is the checkout list with the failure written from the customer's chair — the symptom column is what they experience, which is the thing the design has to make acceptable.
The slice under it is the smallest thing that proves the partition works: an order placed, a receipt deferred, and the deferral surviving a restart. It proves connectivity and durability; it does not prove the retry policy is right.
- BrowserSubmits the cart; shows "order placed, receipt on its way" with the order id.
- CheckoutValidates cart and stock, takes a test-mode payment, writes the order and an outbox row in one transaction.
- WorkerReads the outbox row and sends the receipt; marks it sent.
- DatabaseHolds the order and the outbox row; the row survives a restart of the worker.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Receipt email fails | Customer has an order and no email; may think the order did not go through | Email provider down or address invalid | Retry with backoff; show the order on the account page so the email is not the only proof; alert after retries are exhausted |
| Warehouse notice lost | Paid order never ships; customer complains days later | Process crashed between response and enqueue | Write the notice in the same transaction as the order (outbox) so it cannot be lost; a worker delivers it |
| Payment outcome UNKNOWN | Customer sees "being confirmed" and waits | Provider timed out; money may or may not have moved | A truthful sentence, a state the account page shows, and a reconciliation step that resolves it from the provider's record |
How to do it
Most important first.
- List every dependency of the request and, for each, the sentence you could truthfully show the user if it had not answered yet.
- Mark as "before" every dependency whose absence leaves no truthful useful sentence. Mark the rest "after" (What Must Be True?).
- For each "after", write what happens when it fails later: who retries, what is compensated, what the user is told and how (What If Payment Fails?).
- Check the "before" group for a dependency that is only there because it was easy to call inline. Email is the usual one.
- Only if the "after" group needs retries, ordering or durability does a queue enter the design; a fire-and-forget call is the simpler thing for an "after" that may be lost (Add Complexity Only When Required).
Worked on a concrete problem
The move has to produce something. This is what it produced.
- Checkout, dependency by dependency. Cart: without it there is no order — before. Inventory: if the last unit is gone, the customer must be told before paying — before. Payment: without its answer, "confirmed" is a lie and "processing" is useless — before, including the UNKNOWN outcome, which gets its own honest sentence. Order creation: the order id is what the customer is shown — before. Receipt email: "you will receive a receipt shortly" is honest — after. Analytics event: nobody is told anything — after. Warehouse notification: the customer does not need it — after, but its failure must be retried because a paid order that never ships is a failure the customer will feel.
- The late-failure check on the "after" group: email fails — retry a few times, then surface in admin; the order stands. Warehouse notification fails — must be retried until it succeeds or a person is alerted, so this one does need durability; a queue is justified for it and for nothing else in checkout. Analytics fails — dropped, nobody cares.
- The file-upload service: virus scanning of the uploaded file. Can the user be told something honest before the scan finishes? "Uploaded; available once checked" — yes, so scanning is after, and the state model gains a PENDING_SCAN that the interface must expose. The move produced a state, not just a sequencing decision.
How you know it worked
What now exists that did not before, and what question you can now ask.
- The dependencies are in two named groups, and for every one in the "after" group there is a sentence the user sees and a plan for a late failure.
- The response path has shrunk to the dependencies whose absence would make the response a lie, and the customer-visible latency is now bounded by those alone.
- Any queue in the design has a list of the specific things it carries, and each of them is something that must survive a failure after the response.
- The UNKNOWN payment outcome has its own truthful sentence rather than being mapped to success or failure.
The questions you can now ask
The field this whole domain exists for. After this lesson, these are the questions to put to an unfamiliar problem.
- ?If this dependency had not answered yet, what could I truthfully and usefully tell the user?
- ?For each thing I defer, what happens when it fails after the user has been told — and who notices?
- ?Which deferred things must survive a crash, and which may simply be lost?
- ?What new state did deferring create, and does the interface expose it?
What can go wrong
- The partition is done for latency alone and the truthfulness test is skipped. Payment is deferred because it is slow, and the customer is told "confirmed" before the card is charged.
- Everything in the "after" group is given the same durability. Analytics gets a queue with retries because the warehouse notification needed one, and the queue becomes the busiest component in the system.
- The user's sentence is written and the interface is not updated to make it possible. "Your order will be confirmed shortly" needs a way for the user to find out when it is — a state, a page, a notification — and the deferral created that requirement.
- The move is applied once and the groups never move. A dependency that was "after" becomes "before" when the requirement changes — fraud checks, for instance — and the partition needs to be redone, not patched.
- Every deferred dependency adds a state the user can be in — placed-but-no-receipt, uploaded-but-unscanned — and each state needs a screen, a message and a way out.
- Keeping payment on the response path means checkout latency includes the provider's, and no queue will fix that; the honest response is slower than the dishonest one.
- Durable deferral costs a queue, a worker and operational attention. Non-durable deferral costs lost work. The partition says which you need; it does not make either free.
- "Async is faster, so more async is better." Async moves work off the response path; it does not do the work faster, and it makes the user's view of the result stale by exactly the amount deferred.
- "If I use a queue for one thing, I should use it for everything." The queue carries what must survive a failure after the response. Things that may be lost do not need it, and putting them on it makes the important things wait.
- "The customer wants the fastest response." The customer wants an honest one fast enough. A declined card in the response beats a "processing" spinner followed by an email.
Where this applies
Problem-solving advice is stated as universal far more often than it is. These labels say what each method is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view.
- GENERALThe truthfulness test — what can I say without this answer — applies to any request with more than one dependency, in any domain: a search that fans out to several indexes, a chat message that must be stored before it is shown as sent.
- DOMAIN-SPECIFICFor money, payment is nearly always on the response path because the user must be able to act on a decline. For a file upload or an analytics dashboard, almost everything can be after, because "we will show it when it is ready" is honest and cheap. The move is the same; where the line falls depends on what a wrong sentence costs.
- ILLUSTRATIVEThe five checkout dependencies and their partition are invented for the shape of the argument; a real checkout has more, including fraud and tax, and each gets the same question.
Where the depth lives
This domain asks the question and hands the answer off by name.