Skip to content

← Writing

engineering

Sync vs Async

· Jerwin Arnado · 7 min read ·

Part six of the series, opening the section on how the pieces of a system talk to each other. The first decision, and the one with the biggest blast radius: does the caller wait for the work to finish, or hand it off and move on? Synchronous versus asynchronous. Get this right and slow work stops blocking users; get it wrong and you either freeze the UI or lose work silently.

The two shapes

  • Synchronous (request/response): the caller sends a request and blocks until it gets a response. Simple, direct, and the right default — you ask, you wait, you get the answer or an error. Reading a page, fetching a record, submitting a form that must succeed before the user continues: all synchronous.
  • Asynchronous (fire-and-forget via a queue): the caller drops a message on a queue and returns immediately; a separate worker processes it later. The caller doesn’t get the result inline — it moves on, and the work happens out of band.

The mistake most apps make isn’t choosing wrong in general — it’s doing everything synchronously, so the user waits on work they shouldn’t have to.

The tell: work that doesn’t belong in the request

Here’s the smell. A user registers, and the controller does this, inline, while they stare at a spinner:

public function store(RegisterRequest $request)
{
    $user = User::create($request->validated());
    Mail::to($user)->send(new WelcomeEmail($user));   // 800ms to the mail provider
    $this->generateAvatar($user);                     // 1.2s of image work
    $this->syncToCrm($user);                          // 600ms to a flaky third party
    return redirect()->route('dashboard');            // user waited ~2.6s for none of it
}

The user waited 2.6 seconds, and worse: if the CRM is down, registration fails over work the user doesn’t care about. None of those three tasks needs to block the response. Hand them off:

public function store(RegisterRequest $request)
{
    $user = User::create($request->validated());

    SendWelcomeEmail::dispatch($user);   // ┐
    GenerateAvatar::dispatch($user);     // ├─ queued — return in ~50ms
    SyncToCrm::dispatch($user);          // ┘

    return redirect()->route('dashboard');
}

Registration now returns in milliseconds and succeeds even if the CRM is on fire — the SyncToCrm job retries on its own schedule. The user got what they needed; the rest happens behind them.

What async buys you

Decoupling with a queue buys four distinct things — worth naming, because they’re the reasons to reach for it:

  • Responsiveness: the user waits only for what they actually need, not for slow side-effects.
  • Resilience: a failing downstream (mail, CRM, a third-party API) no longer fails the user’s request. The job waits and retries; the user never knows.
  • Load smoothing (the buffer): a traffic spike becomes a longer queue, not a pile of errors. Workers drain it at their own steady pace. The queue absorbs the burst — this is the single most underrated benefit.
  • Throughput: independent work runs in parallel across many workers instead of serially inside one request.

That third one — the queue as a shock absorber — is why async is how systems survive spikes without over-provisioning for peak. A flood of signups doesn’t melt the mail server; it just makes the email queue temporarily deeper.

What async costs you

There is no free decoupling. The queue hands you a new set of problems, and pretending it doesn’t is how async bites:

  • No inline result. The caller left. If the work produces something the user needs to see, you now need a way to tell them — polling, a websocket, an email, a notification. Complexity you didn’t have.
  • Eventual consistency, again. For a window after the request, the world is in a half-done state: the user exists but has no avatar yet. The UI has to tolerate that gap.
  • Retries mean duplicates. A queue that guarantees delivery guarantees at-least-once delivery — the same job can run twice (a worker crashes after doing the work but before acknowledging it). Which means the work must be safe to repeat. That requirement is so central it gets its own chapter next.
  • Failures move out of sight. A synchronous error is right there in the response. A queued job that fails fails silently unless you’re watching — you need a dead-letter queue and monitoring or failed jobs vanish into the void.

A simple rule for choosing

Ask: does the user need the result of this work to continue?

  • Yes → synchronous. Logging in, loading data, charging a card at checkout, anything where the next screen depends on the outcome.
  • No → asynchronous. Emails, notifications, thumbnails, search indexing, syncing to other systems, reports, webhooks out. If it can happen “a few seconds later” without the user noticing, it should.

The edge cases are where judgment lives — a payment is synchronous (the user must know it worked), but emailing the receipt is asynchronous (they don’t need to watch it send). One checkout, both patterns, split along that one question.

Caveats and best practices

  • Default to synchronous; reach for async deliberately. Async is powerful and it’s complexity. Don’t queue work that’s fast and must-succeed-inline just because queues sound scalable. The right first question is “does the user need this now?”, not “could this be a job?”
  • Every async job must be idempotent. At-least-once delivery is a promise the queue will occasionally keep twice. If running a job twice double-charges or double-emails, you have a bug waiting for a bad day — see the next chapter.
  • Make the queue’s failure visible. Configure retries with backoff, a dead-letter/failed table, and an alert. Silent job failure is worse than a synchronous error because nobody finds out until a user complains.
  • Mind the ordering assumption. Queues don’t guarantee strict order by default. If job B assumes job A already ran, you have a race. Chain them, or make the dependency explicit.
  • Show the gap in the UI. “Avatar generating…”, “Report will be emailed shortly.” The half-done window is real; design for it instead of pretending the work is instant.

Conclusion

Sync → caller waits; simple, correct default; use when the user needs the result
Async → hand off to a queue, return now; use when they don't
Buys → responsiveness, resilience, load-smoothing (the buffer), throughput
Costs → no inline result, eventual consistency, duplicates (retries), hidden failures
Rule → "does the user need this to continue?" yes=sync, no=async

Synchronous is the honest default; async is the tool that keeps slow, flaky, or bursty work from holding the user hostage — as long as you accept the queue’s bill: duplicates, eventual consistency, and failures you have to go looking for. The biggest item on that bill is duplicates, which is why the next chapter is the most useful pattern in the book: idempotency and safe retries.