Synchronous vs Asynchronous Processing — How I Decide in Real Projects
by Arif Ikhsanudin, Backend Developer
The wrong reason most teams go async
An engineer profiles the order creation endpoint. It takes 800ms. Most of that is the email confirmation being sent synchronously via SMTP. Someone suggests making it async: push the email to a background job queue, return the response immediately, deliver the email out-of-band. Response time drops to 50ms. Problem solved, case closed.
Except: the caller — a React frontend — was already handling a 800ms wait with a loading spinner. The user experience does not change. What changed is the architecture, which now has a Redis dependency, a Sidekiq worker fleet, retry logic, dead letter queues, and monitoring for worker lag. You added complexity to solve a problem that did not exist. This is the most common way teams introduce unnecessary async processing.
The actual question: what are you promising the caller?
Synchronous processing makes a contract: "When I return HTTP 200, the work is done." The caller can rely on that. If you return 200 and the email failed, that is a bug in your system, not a contract ambiguity.
Asynchronous processing makes a different contract: "I have accepted your request. The work will happen eventually." The caller cannot rely on the work being done when the response arrives. If the caller needs the result immediately — to render a UI, to make a downstream decision, to pass data to another system — async processing requires additional machinery to communicate completion: webhooks, polling, WebSockets, or SSE (Server-Sent Events).
That machinery is not free. If your caller needs synchronous semantics but you chose async processing, you have not simplified the system — you have moved the complexity from the server to the protocol between client and server.
When synchronous is clearly right
Synchronous processing is right whenever: the operation completes in under 2-3 seconds on P99, the caller needs the result to proceed, and the operation failure should surface immediately as an error response.
Database reads and writes are the obvious case. Creating a user, updating a record, running a search query — these should be synchronous. The caller needs confirmation that the record exists before rendering the UI. Making record creation async to avoid a 50ms database write is almost never the right optimization.
Form validation, payment authorization, permission checks — all synchronous. The user is waiting. The decision needs to happen before the next step.
# Synchronous — right for anything the user is waiting on
class OrdersController < ApplicationController
def create
order = Order.new(order_params)
if order.save
# Payment authorization is synchronous — the user needs the result now
result = PaymentService.authorize(order, payment_params)
if result.success?
order.update!(status: :confirmed, payment_id: result.charge_id)
render json: OrderSerializer.new(order), status: :created
else
order.update!(status: :payment_failed)
render json: { error: result.error_message }, status: :payment_required
end
else
render json: { errors: order.errors }, status: :unprocessable_entity
end
end
end
When async is clearly right
Move work async when all three of these are true: the caller does not need the result to proceed, the work could take longer than your HTTP timeout budget, or the work is a side effect that should not block the primary transaction.
Email and notification delivery: async. The user does not wait for the email to send. If it fails, you retry. The core operation — order created, user registered — is separate from the notification.
Third-party webhook dispatching: async. External APIs are slow and fail. A synchronous call to a webhook endpoint inside your request cycle means your response time is coupled to a third party's SLA. That is not acceptable.
Report generation, data exports, video processing: async. These are long-running operations that will exceed HTTP timeouts (typically 30-60 seconds). Use async processing with a job status endpoint the client can poll, or a webhook callback.
# Async — right for side effects and long-running work
class OrdersController < ApplicationController
def create
order = Order.create!(order_params.merge(status: :confirmed))
# Side effects — enqueue, don't block
OrderConfirmationMailer.delay.send_confirmation(order.id)
InventoryService.delay.reserve_items(order.id)
AnalyticsJob.perform_later(event: 'order_created', order_id: order.id)
render json: OrderSerializer.new(order), status: :created
end
end
The failure mode you must design for
Async processing introduces a failure class that synchronous processing does not have: the operation was accepted but never completed. Your queue worker crashed. Redis ran out of memory. The downstream service was down during all retry attempts. The job ended up in the dead letter queue and nobody noticed for three days.
With synchronous processing, a failure is immediate and visible — the HTTP response is an error. With async processing, you need explicit monitoring for job queue lag, dead letter queue depth, and worker health. This is not optional; it is the operational cost of deferral.
Tools like Sidekiq Pro (job unique locking, rate limiting, reliable scheduling) or SQS with FIFO queues and visibility timeout configuration give you the primitives to handle these failure cases. But you have to design for them. The common mistake is treating async processing as "fire and forget" when it is actually "fire and eventually confirm."
The practical rule
Draw the line at user-waiting. If a human is waiting for the response to proceed, process synchronously and optimize the operation if it is slow. If the response is acknowledging receipt of work that will happen later, process asynchronously and invest in the operational tooling to monitor and recover that deferred work.