A signup form took eight seconds. Inside the request, in order: the user got saved, a welcome email went out, a PDF was generated, a record was pushed to a CRM. For all eight seconds the user stared at a spinning spinner — when the only thing they cared about was whether their account had been created.
Should a job run inside the HTTP request, or go to a queue? The line is clearer than it looks.
The decision line: is the user waiting on the result?
One question decides most of it: does the user have to see the result of this job?
- If the answer is “yes,” the job stays synchronous — it finishes inside the request, before the response returns.
- If the answer is “no,” the job goes to a queue — the request says “accepted” and returns.
In the form above, the user is only waiting for the answer to “was my account created?” The email, the PDF, the CRM — none of their results need to show up on that screen. All three were on the wrong side.
What has to stay synchronous
Jobs whose result the user sees directly stay inside the request:
- Input validation — the error must come back immediately.
- Creating the actual resource — user creation, order creation.
- The result of a payment authorization — did the card go through or not, the user has to know right then.
- Data the user will see in the next step.
Putting these on a queue means telling the user “check back later” — and that is usually a bad experience.
What should go to a queue
Every job whose result the user doesn’t need right then is asynchronous:
- Email and notifications.
- PDF and report generation.
- Webhook delivery.
- Third-party synchronization — CRM, analytics, search index.
- Image processing, exports.
For these, the right response is 202 Accepted: “I got your request, I’m working on it.” The user doesn’t wait, and the system does the work at its own pace.
Response time sharpens the line
Every endpoint should have a p95 budget — say 300 ms. If a single step eats that budget on its own and the user doesn’t have to see that step’s result, the step goes to a queue.
A synchronous chain is only as fast as its slowest link. If PDF generation takes 4 seconds, user creation takes 4 seconds too — even though the PDF has nothing to do with the response.
Fault tolerance: the real distinction
The real issue isn’t speed, it’s failure. Synchronous and asynchronous handle failure in completely different ways:
- Synchronous failure = the user sees an error screen. The work is lost.
- Asynchronous failure = the job is retried, drops to a DLQ if it has to, and the queue processes it later.
From this comes a single rule: an unstable third party should never sit on the synchronous path. If that CRM call is inside the request, your signup form goes down every time the CRM does. As a queued call, the retry completes on its own once the CRM comes back. The third party’s uptime stops being your uptime.
The 202-and-status-poll pattern
Once a job goes to a queue, leave the user a way to follow up:
POST /exports → 202 Accepted { "id": "exp_1a2b", "status": "processing" }
GET /exports/exp_1a2b → 200 OK { "status": "done", "url": "..." }
The user isn’t waiting on the result, but sometimes they get curious. A status endpoint, or a notification sent once it’s ready, closes that gap.
Where does the line dissolve?
Some jobs sit between the two: the user wants to see the result, but the job is also slow. There’s a third path here — start the job synchronously, return the first meaningful result immediately, hand the rest off to a queue. Create the order record instantly (synchronous), generate the invoice in the background (asynchronous).
Draw the line per job; “everything to a queue” is as wrong as “everything synchronous.”
Synchronous or asynchronous isn’t a performance question, it’s a “who’s waiting” question. No job the user isn’t waiting on should make their request wait.
Tie up the request only with the work whose answer they actually want.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.