Skip to content

Async Work

When the payment for an order succeeds, BoxOffice has a lot to do: mark the order confirmed, turn the hold into sold seats, render a ticket PDF with a QR code (about 800 ms of CPU), e-mail it through the provider (300 ms, sometimes 5 s), send a push notification, update the sales dashboard, and re-index availability for search. If the checkout request does all of that before answering, the fan waits seconds, every slow provider slows checkout, and an e-mail outage fails ticket sales.

None of that work changes the answer the fan is waiting for β€” "your order is confirmed". It can happen a moment later, somewhere else. This page is about that "somewhere else": workers that process jobs from a queue, and schedulers that start work at a given time β€” and about making both correct when jobs are retried and replicas run in parallel.


What belongs in the request path?

Ask one question about every step: does the user's answer depend on it?

Step after "pay" Answer depends on it? Where it runs
Verify payment succeeded, confirm hold β†’ order CONFIRMED yes β€” it is the answer in the request (or the payment webhook handler)
Render ticket PDF no worker
Send e-mail with the ticket no worker (notification)
Push notification no worker (notification)
Update sales dashboard no analytics consumer
Update search availability no search consumer

Everything below the first row becomes a job (a unit of work described by a small message) placed on a queue and executed by workers (processes that pull jobs and run them).

sequenceDiagram
    participant F as Fan
    participant O as order
    participant Q as Queue / Kafka
    participant W as ticket worker
    participant N as notification worker
    participant E as E-mail provider
    F->>O: POST /orders/ord_9/confirm
    O->>O: order CONFIRMED (one DB transaction)
    O->>Q: OrderConfirmed {orderId: ord_9}
    O-->>F: 200 Your order is confirmed
    Q->>W: render tickets for ord_9
    W->>W: PDF + QR β†’ object store
    W->>Q: TicketsIssued {orderId: ord_9}
    Q->>N: TicketsIssued
    N->>E: send e-mail with link
    E-->>N: 202 queued

What you gain:

  • Latency: checkout answers in tens of milliseconds instead of seconds.
  • Availability: the e-mail provider can be down for 20 minutes; jobs wait in the queue and checkout keeps selling.
  • Load leveling: the queue absorbs the on-sale spike; workers drain it at their own, steady pace.
  • Independent scaling: ticket rendering is CPU-heavy, e-mail is I/O-bound β€” each worker pool scales on its own.

What you pay: the result is eventually there, not immediately; you need to show status ("your tickets are being issued"), handle failures without a user to show an error to, and make every job safe to run more than once.

Publishing reliably

"Commit the order, then publish the event" has a gap: if the process dies between the two, the order exists and no ticket is ever issued. The transactional outbox closes it β€” write the event to an outbox table in the same transaction as the order, and let a relay publish it. Class 8 (messaging) covers it; hands-on 7 builds it.

In the BoxOffice code: the e-mail leaves the request (lab 7)

Hands-on 7 β€” Outbox keeps both versions of POST /orders behind one switch, NOTIFY_MODE, so you can measure the difference. In sync the fan waits while order calls notification; in outbox the order and its OrderPlaced event are two inserts in one transaction, and the e-mail happens later. In a test run on a busy laptop at 50 orders/s, p50 went from 4.12 s (sync) to 47 ms (outbox); with notification stopped for 20 s, about a third of the sync orders failed and none of the outbox ones did (measurements). Note also ON CONFLICT (hold_id) DO NOTHING: a retried order for the same hold creates nothing new and emits no second event.

services/order/src/main/java/boxoffice/order/OrderService.java (place an order)
@Transactional
public PlaceResult place(OrderRequest req) {
    int inserted = jdbc.sql("""
            INSERT INTO orders (id, show_id, hold_id, customer_email, amount, status)
            VALUES (?, ?, ?, ?, ?, 'PENDING_PAYMENT')
            ON CONFLICT (hold_id) DO NOTHING
            """)
            .params(UUID.randomUUID(), req.showId(), req.holdId(), req.customerEmail(), req.amount())
            .update();

    OrderView order = jdbc.sql(SELECT + " WHERE hold_id = ?")
            .param(req.holdId())
            .query(OrderView.class)
            .single();

    if (inserted == 0) {
        // same hold already ordered: idempotent replay, no second event
        return new PlaceResult(order, false);
    }

    OrderEvent event = OrderEvent.placed(order);
    if (props.notifyMode() == NotifyMode.SYNC) {
        notifications.send(event); // BEFORE: the fan waits for the e-mail
    } else {
        outbox.append(event.eventId(), "order", order.id(), event.eventType(), event);
    }
    return new PlaceResult(order, true);
}

How many workers?

Workers are sized like any server, with Little's law: the number of jobs in progress equals the arrival rate times the time each job takes.

\[ \text{workers needed} = \text{job arrival rate} \times \text{time per job} \]

When arrivals exceed capacity, the excess accumulates as backlog, and the backlog drains only at the difference between capacity and arrivals:

\[ \text{backlog growth} = \lambda_{\text{in}} - \mu_{\text{workers}}, \qquad \text{drain time} = \frac{\text{backlog}}{\mu_{\text{workers}} - \lambda_{\text{in}}} \]

Worked example β€” ticket rendering at on-sale

Assume (illustrative) 60,000 seats sell in about 10 minutes β†’ 100 orders/s; off-peak, 10 orders/s. Rendering one order's tickets takes 0.8 s of a worker.

  • To keep up at peak: 100 Γ— 0.8 = 80 workers busy all the time.
  • With only 40 workers (capacity 50/s), the backlog grows by 50 jobs/s: after 10 minutes, 30,000 jobs are waiting. The last fan waits about 30,000 / 50 = 600 s = 10 minutes for the tickets, and after the peak the backlog drains at 50 βˆ’ 10 = 40 jobs/s, taking another 12.5 minutes.
  • Is that acceptable? Only the SLO can say. If the promise is "tickets in your inbox within 5 minutes", 40 workers are not enough.
2026-11-01T21:54:23.595102 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/

Two lessons from the plot. First, a queue converts a capacity problem into a delay: nothing failed with 40 workers, fans just waited. Second, the downstream sets the real limit: if the e-mail provider accepts at most, say, 100 messages per second on your plan, 500 notification workers do not send faster β€” they only collect 429 Too Many Requests. Size the worker pool for the slowest dependency and let the queue hold the rest.

Scale workers on queue depth or consumer lag, not CPU: a worker waiting on a slow provider uses no CPU but is not free. Serverless & autoscaling shows how (KEDA).

Try it in the playground

Open the async-queue scenario: producers write to a queue drained by a pool of workers.

  1. Run the spike profile and watch backlog and consumer lag climb during the burst, then fall.
  2. Double the worker replicas: the peak backlog drops and drains sooner β€” check it against the formula above.
  3. Lower the downstream's rate limit: more workers stop helping, and errors appear instead.

Designing jobs that survive the real world

A queue is not magic: workers crash in the middle of jobs, brokers redeliver, providers time out. Most brokers give at-least-once delivery: a job is removed (acknowledged) only after the worker says it finished, so a crash before the ack means the job is delivered again. Every rule below follows from that.

Rule Why BoxOffice example
Make jobs idempotent the same job will run twice before rendering, check whether ord_9 already has tickets; e-mail with a dedupe key ord_9:tickets
Ack after the work, not before acking first turns a crash into a lost job commit the Kafka offset after the PDF is stored
Put ids in the message, not data messages stay small; the worker reads the current state {"orderId": "ord_9"}, not the whole order
Retry with backoff, then park transient errors heal; permanent ones must not block the queue 4 attempts at 1 s, 2 s, 4 s, then the dead-letter queue (DLQ)
Set a timeout per job a stuck job holds a worker forever PDF rendering aborts after 10 s
Separate queues by priority a bulk job must not delay urgent ones tickets (urgent) and marketing-emails (bulk) on different queues and pools
Record job status users and support need to see progress ticket_status = PENDING / ISSUED / FAILED on the order
Respect downstream limits more parallelism than the provider allows = errors a rate limiter in the notification worker

A poison message is one that fails every time (a malformed payload, a bug). Without a retry limit it is retried forever and, on ordered partitions, blocks every message behind it. The DLQ parks it for a human (or a fixed version of the code) and lets the queue move on. Monitor the DLQ size: a growing DLQ is an incident.

With Spring for Apache Kafka, retries with backoff and a dead-letter topic are a few annotations3:

ticket-worker: TicketJobListener.java
@Component
class TicketJobListener {

    private final TicketService tickets;

    TicketJobListener(TicketService tickets) { this.tickets = tickets; }

    @RetryableTopic(
        attempts = "4",                                    // 1 try + 3 retries
        backOff = @BackOff(delay = 1000, multiplier = 2.0), // 1 s, 2 s, 4 s (Spring Kafka 4)
        dltTopicSuffix = ".dlt")
    @KafkaListener(topics = "order.confirmed.v1", groupId = "ticket-worker")
    void onOrderConfirmed(OrderConfirmed event) {
        if (tickets.alreadyIssued(event.orderId())) {
            return;                                        // idempotent: duplicate delivery
        }
        tickets.renderAndStore(event.orderId());           // PDF + QR β†’ object store
    }

    @DltHandler
    void onDeadLetter(OrderConfirmed event) {
        tickets.markFailed(event.orderId());               // visible to support; alert on rate
    }
}

Where jobs live

Option Durable? Good for Watch out
In-process (@Async, a thread pool) no β€” lost on crash or deploy fire-and-forget work you can afford to lose not a queue; no retries, no visibility
Redis-based queues (Sidekiq, Celery, BullMQ) mostly (depends on persistence settings) simple job queues with retries and schedules Redis memory and persistence configuration
Broker queues (SQS, RabbitMQ, Cloud Tasks) yes one job β†’ one worker, per-message ack, delays, DLQ ordering is weak or per group
Event log (Kafka, Pub/Sub, Kinesis) yes, replayable many consumers of the same facts, high throughput, ordering per key parallelism capped by partitions; offset-based acks
Workflow engines (Temporal, AWS Step Functions) yes, with state multi-step processes that last minutes to days, with compensations a new platform to learn and operate

Durable execution engines deserve a mention: when a process has many steps and waits β€” "event cancelled β†’ refund 60,000 orders β†’ e-mail each fan β†’ wait 7 days β†’ close the accounting" β€” writing it as a workflow, whose progress the engine persists after every step, is simpler and safer than chaining queues by hand4.


Schedulers

Some work is triggered by time, not by a request or an event:

  • every 30 s, release expired holds so their seats can be sold again;
  • at 10:00:00, open the sale for an event;
  • 24 h before each show, send a reminder to every ticket holder;
  • every night, reconcile payments with the provider's report (integrations).

The duplicate-cron problem

The obvious implementation is a @Scheduled method in the service. It works on one replica. With 10 replicas, the job runs 10 times: 10 reminder e-mails per fan, 10 concurrent sweeps fighting over the same rows. The same happens with a cron entry copied to every VM. There are four standard fixes:

Run the job as its own short-lived pod, created by the cluster on a schedule, so the service replicas never run it.

release-holds-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: release-expired-holds
spec:
  schedule: "* * * * *"              # every minute (cron's finest granularity)
  concurrencyPolicy: Forbid          # never two runs at the same time
  startingDeadlineSeconds: 30        # skip a run that could not start in time
  successfulJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 50      # kill a run that hangs
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: sweeper
              image: ghcr.io/boxoffice/inventory:1.8.2
              args: ["--job=release-expired-holds"]

Simple and visible (kubectl get jobs). Granularity is one minute and a pod start costs seconds, so it suits jobs that run every minute or less often.

Every replica tries to run the job, but only the one that acquires a lock in a shared store (PostgreSQL, Redis) actually does.

inventory: HoldSweeper.java
@Component
class HoldSweeper {

    private static final Logger log = LoggerFactory.getLogger(HoldSweeper.class);
    private final HoldRepository holds;

    HoldSweeper(HoldRepository holds) { this.holds = holds; }

    @Scheduled(fixedRate = 30_000)
    @SchedulerLock(name = "releaseExpiredHolds",
                   lockAtMostFor = "PT25S",   // lock expires even if this replica dies
                   lockAtLeastFor = "PT5S")   // absorb small clock differences
    void releaseExpiredHolds() {
        int released = holds.releaseExpired(Instant.now(), 5_000);   // batch of 5,000
        log.info("released {} expired holds", released);
    }
}

ShedLock stores the lock as a row (name, lock_until, locked_by) and acquires it with a conditional UPDATE, so it needs no extra infrastructure beyond the service's own database2.

The replicas elect one leader that runs all scheduled work; if it dies, another takes over after the lease expires. Kubernetes offers Lease objects for this (the same mechanism its own controllers use), and Spring Integration, etcd and ZooKeeper provide election recipes.

Fits services with many periodic tasks, or tasks that must keep running state (a leader that holds a connection to a provider's stream).

The cloud calls you on a schedule: AWS EventBridge Scheduler, Google Cloud Scheduler, Azure Logic Apps / Functions timers. It invokes an HTTP endpoint, a queue or a function β€” at most once per schedule as far as the scheduler is concerned, still at least once from your endpoint's point of view if it retries.

Zero infrastructure; supports one-off schedules ("at 2027-03-14T10:00:00-03:00"), which cron cannot express.

Locks expire: fencing tokens

A distributed lock with an expiry has a subtle hole, described by Martin Kleppmann1: a replica takes the lock, then pauses (a long garbage collection, a VM migration) beyond the lock's expiry. Another replica takes the lock and starts the job. The first one wakes up, still believing it holds the lock, and both write.

sequenceDiagram
    participant A as Replica A
    participant L as Lock store
    participant B as Replica B
    participant DB as Database
    A->>L: acquire (token 33), expires in 25 s
    Note over A: GC pause: 40 s
    B->>L: lock expired β†’ acquire (token 34)
    B->>DB: release holds (token 34)
    A->>DB: release holds (token 33) β€” still thinks it owns the lock
    DB-->>A: rejected: token 33 < 34 already seen

The fix is a fencing token: the lock store hands out an increasing number with each acquisition, and the protected resource rejects writes carrying an older token. When the resource cannot check tokens, the job itself must be idempotent β€” which you need anyway. A lock is an optimization that avoids duplicated effort; correctness must come from idempotency or from the database (a conditional UPDATE ... WHERE status = 'ACTIVE' AND expires_at < now() releases each hold at most once, no matter how many sweepers run).

Delayed work: three ways to expire a hold

Releasing a hold 10 minutes after it was created is a delayed job. Three designs, often combined:

Design How Pros Cons
Sweeper a scheduled job releases WHERE expires_at < now() in batches simple, catches everything seats return up to one period late; batch load on the DB
Delayed message on hold, enqueue "release H-7" with a 10-min delay (SQS delay, Redis sorted set by due time, RabbitMQ delayed exchange) precise timing, no scanning one message per hold; must still be idempotent (hold may be confirmed)
Lazy expiry treat a hold as free when expires_at < now() at read time correct immediately, no job needed for correctness expired rows remain until cleaned

BoxOffice's rule: correctness never waits for the scheduler. A hold whose expires_at has passed cannot be confirmed, whether the sweeper has run or not (lazy expiry), and a sweeper returns its seats to sale. If the scheduler is down for 5 minutes, no seat is sold twice; the seats of expired holds only come back later. (A fully lazy design goes further: new holds may take the seats of an expired hold at once, without waiting for the sweeper.)

In the BoxOffice code: lazy check and sweeper in inventory (lab 6)

In Hands-on 6 β€” Holds, confirm is a conditional UPDATE that includes expires_at > now(), so an expired hold answers 409 even if no job has touched it. expireHolds is the sweeper: every 5 s it expires at most 500 holds and frees their seats in one statement. It runs on every inventory replica, with no lock and no leader: FOR UPDATE SKIP LOCKED makes each replica skip the rows another one is already processing, which solves the duplicate-cron problem inside the database. The price is the one in the table: a seat comes back up to 5 s after its hold expired.

services/inventory/src/main/java/boxoffice/inventory/HoldService.java (confirm, sweeper)
/** Payment succeeded (lab 7 calls this): seats become SOLD. */
@Transactional
public HoldOut confirm(String userId, UUID holdId) {
    int changed = jdbc.sql("""
            UPDATE inventory.holds SET status = 'CONFIRMED'
            WHERE id = :id AND user_id = :userId AND status = 'ACTIVE' AND expires_at > now()""")
            .param("id", holdId).param("userId", userId)
            .update();
    if (changed == 0) {
        throw new ResponseStatusException(HttpStatus.CONFLICT, "hold is not active (expired, released or unknown)");
    }
    jdbc.sql("UPDATE inventory.seats SET status = 'SOLD', version = version + 1 WHERE hold_id = :id")
            .param("id", holdId).update();
    return find(userId, holdId);
}
@Scheduled(fixedDelay = 5000)
@Transactional
public void expireHolds() {
    int freed = jdbc.sql("""
            WITH expired AS (
                UPDATE inventory.holds SET status = 'EXPIRED'
                WHERE id IN (SELECT id FROM inventory.holds
                             WHERE status = 'ACTIVE' AND expires_at < now()
                             ORDER BY expires_at
                             LIMIT 500
                             FOR UPDATE SKIP LOCKED)
                RETURNING id)
            UPDATE inventory.seats
            SET status = 'AVAILABLE', hold_id = NULL, version = version + 1
            WHERE status = 'HELD' AND hold_id IN (SELECT id FROM expired)""")
            .update();
    if (freed > 0) {
        log.info("expired holds freed {} seats", freed);
    }
}

Opening the sale at 10:00

Do not make "the sale opens" depend on a cron firing exactly at 10:00:00 β€” schedulers are late by seconds, and a missed run would mean no sale. Store onSaleAt = 10:00:00 with the event; inventory simply rejects holds before that instant (a check against the clock, with clocks synchronized by NTP β€” distributed). The scheduler only does the preparation: at 09:45 it scales the fleet up and warms the caches (serverless & autoscaling).

The notification-system scenario combines a scheduler, a queue and the e-mail/push providers. Try raising the scheduler's job count to simulate the 24-hour reminder burst, and watch where the backlog builds up.

Open the full simulator


Trade-offs at a glance

Do it in the request Do it in a worker
Latency for the user sum of every step only the essential steps
Failure of a non-essential step fails the request retried later; user unaffected
Consistency immediate eventual β€” show status
Load spikes hit every dependency at once absorbed by the queue
Debugging one stack trace follow a job through queues (tracing)
Operational parts none broker, workers, DLQ, monitoring of lag

In the interview

Whenever a design has a slow, failure-prone or bursty step that does not change the user's answer, say: "I'd move it off the request path: the API writes the order and an event in one transaction (outbox), and a worker pool consumes it β€” idempotent, with retries, exponential backoff and a DLQ. I'd scale the workers on consumer lag." For scheduled work, mention that cron on every replica runs N times, and pick CronJob, a lock or leader election β€” plus idempotency because locks expire. Then give a number: "100 jobs/s Γ— 0.8 s = 80 workers at peak".

Best practices

  • Alert on the age of the oldest job, not only on the queue size β€” 10 jobs that are 3 hours old are an incident; 10,000 jobs that drain in 30 s are not.
  • Derive the worker count and write the arithmetic next to the setting β€” workers = jobs/s Γ— time per job Γ· target utilization: 100 e-mails/s Γ— 50 ms = 5 busy threads, so 6 or 7. In BoxOffice: NOTIFY_CONCURRENCY 1 vs 6 in lab 7.
  • Use the database as a small job queue before adding a broker β€” FOR UPDATE SKIP LOCKED lets several workers share a table safely, inside the transactions you already have. In BoxOffice: the hold sweeper of lab 6 (box above).
  • Record every scheduled run β€” start, end, items processed and outcome, as a log line and a metric; a sweeper that freed 0 seats for an hour during a sale is a signal. In BoxOffice: expireHolds logs how many seats it freed.
  • Keep schedules in UTC and store the next due time β€” local time zones skip or repeat runs at daylight-saving changes, and a stored due time lets you detect a missed run.

Common pitfalls

  • @Async treated as a queue: work disappears on every deploy or crash.
  • Non-idempotent jobs: at-least-once delivery turns a retry into a second e-mail or a second charge.
  • No retry limit and no DLQ: one poison message blocks a partition forever.
  • Scaling workers on CPU: I/O-bound workers look idle while the backlog grows.
  • More workers than the provider allows: only produces 429s.
  • @Scheduled on every replica: the job runs N times.
  • Trusting a lock for correctness: locks expire; use fencing tokens or idempotent, conditional writes.
  • The scheduler on the critical path: if "the sale opens" or "the hold expires" requires a job to fire on time, a late job becomes an outage.

How it connects

flowchart LR
    RP["request path: only what<br/>the answer depends on"] --> Q["jobs on a queue<br/>β†’ Messaging"]
    Q --> W["workers<br/>rate Γ— time per job"]
    W --> JOB["idempotent jobs, ack after work,<br/>retries with backoff"]
    JOB --> DLQ["DLQ for poison messages"]
    W --> SCALE["scale on queue lag<br/>β†’ Autoscaling"]
    CRON["scheduled work<br/>on N replicas"] --> LOCK["CronJob, distributed lock,<br/>leader election"]
    LOCK --> FENCE["locks expire: fencing tokens<br/>β†’ Distributed consensus"]
    CRON --> LAZY["lazy expiry + sweeper<br/>seat holds β†’ Databases"]

Palette components: queue (jobs and delayed messages) Β· worker (consumers sized by Little's law) Β· scheduler (cron and delayed work, once per cluster) Β· kafka (commit the offset after the work) Β· sql (ShedLock row, holds that expire lazily) Β· objstore (ticket PDFs) Β· notifications Β· email (the ticket e-mail, off the request path). Scenarios: async-queue, notification-system. Continues in: Serverless & Autoscaling β€” scaling on queue depth, Messaging β€” queues and pub/sub, Messaging β€” delivery semantics, Distributed β€” consensus (fencing), Hands-on 7.

Key takeaways

  • Keep only what the user's answer depends on in the request; move the rest to jobs on a queue processed by workers.
  • Size workers with Little's law (rate Γ— time per job); a queue turns overload into delay, and the slowest downstream sets the real limit.
  • Delivery is at least once: idempotent jobs, ack after work, retries with backoff, a DLQ for poison messages, priorities by queue.
  • Scheduled work on N replicas runs N times: use a CronJob, a distributed lock (ShedLock) or leader election β€” and stay idempotent, because locks expire (fencing tokens).
  • Design time-based rules so that correctness does not depend on a job firing on time (lazy expiry + sweeper).


  1. KLEPPMANN, M. How to do distributed locking. 2016. See also KLEPPMANN, M. Designing Data-Intensive Applications, chapter 8 (fencing tokens). ↩

  2. Kubernetes β€” CronJob; ShedLock. ↩

  3. Spring for Apache Kafka β€” Non-blocking retries. ↩

  4. Temporal documentation β€” Durable execution. ↩