System Design
Advanced 14 min read Messaging & events

Durable workflows and job scheduling

In one line

Long, multi-step business processes and scheduled jobs must survive crashes, retries and deploys; job queues and schedulers handle simple one-step work, while durable execution engines like Temporal persist every step so a workflow resumes exactly where it stopped.

What it is

Some work is a single short task: resize an image, send an email. A job queue (SQS, Sidekiq, Celery, a Postgres table) handles it well: enqueue, a worker picks it up, retries on failure, dead-letters after N attempts. Some work runs on a schedule: generate invoices at midnight, retry failed payouts every 15 minutes. A job scheduler stores schedules and fires jobs at the right time, exactly one fire per schedule even with many scheduler nodes.

Other work is a multi-step process that runs for minutes to weeks: onboarding (verify email, KYC check, human approval, create account), order fulfilment, loan disbursal, refunds, an AI agent calling tools. Built with cron jobs and status columns, these become fragile state machines: half-finished records, duplicated steps after retries, and nobody knows where a stuck process is.

Durable execution engines (Temporal, its predecessor Uber Cadence, AWS Step Functions, Azure Durable Functions, Restate) let you write the whole process as one function. The engine records the result of every step (activity) in an event history. If the worker crashes, another worker replays the history to rebuild the function's state and continues from the next step, without re-running completed ones. Timers ('wait 3 days') and waits for external signals ('manager approved') are durable and cost nothing while waiting.

Data pipeline orchestrators (Airflow, Dagster, Argo Workflows) are a related but different tool: they schedule DAGs of batch tasks, usually on time intervals, and are a poor fit for per-user business processes with millions of concurrent instances.

Why interviewers ask

Design rounds on payments, orders, booking, onboarding, job schedulers and AI agents all hit this: 'what if the service crashes after charging the card but before confirming the booking?' Interviewers want to hear how state is persisted, how steps are retried safely (idempotency), how timeouts and compensations (sagas) work, and how a scheduler avoids firing a job twice or never. Knowing when a simple queue is enough and when durable execution pays off is a strong senior signal.

How it works

  1. 1Simple jobs: put a job record in a durable queue with an id, payload and attempt count. Workers lease a job (SQS visibility timeout, or SELECT ... FOR UPDATE SKIP LOCKED in Postgres), process it, and delete or mark it done. If the worker dies, the lease expires and another worker retries. So delivery is at-least-once: jobs must be idempotent.
  2. 2Retries: exponential backoff with jitter (1 s, 2 s, 4 s ... capped), a maximum attempt count, then a dead-letter queue with the error for inspection and replay.
  3. 3Scheduling: store schedules in a database with next_run_at indexed. Scheduler nodes poll for due rows and claim them atomically (an UPDATE that sets the next run time, or a lease), then enqueue the job. Only the node that wins the claim fires it. Add jitter so 10,000 'midnight' jobs do not all hit the database at 00:00:00.
  4. 4Durable workflows: split code into a workflow (deterministic orchestration: which step next, waits, branches) and activities (side effects: API calls, DB writes, LLM calls), each with its own timeout and retry policy.
  5. 5The engine stores an event history per workflow: activity scheduled, completed with result, timer started, signal received. Workers are stateless; any worker can continue any workflow by replaying its history.
  6. 6Determinism rule: workflow code must give the same decisions on replay, so it must not read the clock, random numbers or the network directly; use the SDK's time, side-effect and activity APIs.
  7. 7Long waits use durable timers and signals; a workflow can sleep 48 hours or wait for an approval click without holding a thread.
  8. 8Failures that cannot be retried trigger compensations (saga): refund the payment, release the seat, cancel the shipment, in reverse order.
  9. 9Versioning: changing workflow code while old instances are mid-flight requires version markers (Temporal's GetVersion / patching), or the replay of old histories will not match the new code.

Worked example · Refund workflow for a marketplace

  1. A customer requests a refund for a Rs 2,499 order. The workflow starts with id refund-ord_981, so starting it twice returns the same running workflow.
  2. Step 1, activity: ask the seller-service whether pickup is needed. Result recorded in history.
  3. Step 2, durable timer and signal: wait up to 5 days for the courier's PickedUp signal; send a reminder on day 3. Waiting costs a row in the database, not a thread.
  4. The worker pod is killed by a deploy on day 2. On day 3 the signal arrives; a new worker replays history (steps 1-2 done, waiting for signal), receives it and continues.
  5. Step 3, activity: call the payment gateway's refund API with idempotency key refund-ord_981. The first call times out; the retry policy calls again after 2 s with the same key, and the gateway returns the original refund instead of refunding twice.
  6. Step 4: credit loyalty points back and notify the customer. If the refund fails permanently (card closed), the workflow follows a different branch: credit a wallet instead and open a support ticket.
  7. Scale: 50,000 refunds a day, most waiting days for pickup, means about 200,000 open workflows at once. Temporal holds them as history rows in its persistence (Cassandra, Postgres or MySQL), with no thread per workflow.

Picture it

A durable workflow is like a file that travels between desks in a government office with a checklist stapled on top. Each officer ticks a step and signs. If an officer goes on leave, anyone can pick up the file, read the ticks and continue from the next step, without redoing the signed ones. A cron job with a status column is more like sticky notes on different desks.

Reference code

// Temporal workflow (Go SDK). Workflow code must be deterministic:
// no time.Now(), no random, no direct network calls. Side effects live in activities.
func OnboardingWorkflow(ctx workflow.Context, in Signup) error {
    ao := workflow.ActivityOptions{
        StartToCloseTimeout: 30 * time.Second,
        RetryPolicy: &temporal.RetryPolicy{
            InitialInterval: time.Second, BackoffCoefficient: 2, MaximumAttempts: 8,
        },
    }
    ctx = workflow.WithActivityOptions(ctx, ao)

    if err := workflow.ExecuteActivity(ctx, SendVerifyEmail, in.UserID).Get(ctx, nil); err != nil {
        return err
    }

    // Wait up to 24h for a signal; the wait costs nothing and survives restarts.
    verified := workflow.GetSignalChannel(ctx, "email-verified")
    timer := workflow.NewTimer(ctx, 24*time.Hour)
    sel := workflow.NewSelector(ctx)
    ok := false
    sel.AddReceive(verified, func(c workflow.ReceiveChannel, _ bool) { c.Receive(ctx, nil); ok = true })
    sel.AddFuture(timer, func(workflow.Future) {})
    sel.Select(ctx)
    if !ok {
        return workflow.ExecuteActivity(ctx, CloseSignup, in.UserID).Get(ctx, nil)
    }

    // Idempotency key = workflow id, so a retried activity cannot create two accounts.
    key := workflow.GetInfo(ctx).WorkflowExecution.ID
    return workflow.ExecuteActivity(ctx, ProvisionAccount, in.UserID, key).Get(ctx, nil)
}

Key patterns

Plain job queue

When: Short, independent tasks (seconds), like emails, thumbnails, webhooks out

How: Durable queue, leases or visibility timeout, retries with backoff, DLQ, idempotent handlers.

Scheduler with atomic claim

When: Recurring or delayed jobs at scale

How: Schedules table indexed by next_run_at; nodes claim due rows with UPDATE ... WHERE next_run_at <= now() using SKIP LOCKED or leases; enqueue to workers; jitter big batches.

Durable execution

When: Multi-step, long-running, stateful processes with waits and human steps

How: Workflow code orchestrates; activities do side effects with retries; history persisted by the engine; signals and timers for waits.

Saga with compensations

When: Steps across services that cannot share a transaction

How: Record each completed step; on permanent failure, run compensating actions in reverse order.

Workflow id as dedupe key

When: Starting workflows from events or APIs that may repeat

How: Use a business id (order id, loan id) as the workflow id; the engine rejects or returns the existing run.

Real-world use

Uber Cadence and Temporal

Uber built Cadence to run long-lived business workflows reliably; its creators later founded Temporal, an open-source fork now used widely, for example by Coinbase for crypto transaction flows.

AWS Step Functions

A managed state-machine service. Standard workflows can run up to one year with exactly-once step execution semantics inside the service; Express workflows run up to 5 minutes at high volume with at-least-once semantics.

Netflix Conductor

Netflix built Conductor to orchestrate microservice workflows such as media encoding and content ingestion; it was open-sourced and is now maintained by the community and Orkes.

Apache Airflow

Created at Airbnb for data pipelines: DAGs of batch tasks on schedules. Great for ETL and ML pipelines, not for millions of per-user business processes.

Database-backed job queues

Tools like Sidekiq (Redis), Celery, Oban and River (Postgres) and Solid Queue use leases or SELECT ... FOR UPDATE SKIP LOCKED to run background jobs reliably without extra infrastructure.

In a project

Your fintech app's loan disbursal is a chain of cron jobs and a status column: KYC_DONE, AGREEMENT_SIGNED, NACH_REGISTERED, DISBURSED. Every week some loans get stuck between states, and twice money was disbursed twice after a job retried.

  1. Model disbursal as one Temporal workflow per loan, id = loan id, so duplicate starts are impossible.
  2. Turn each step into an activity with a timeout and retry policy: KYC verify, e-sign agreement, register NACH mandate, disburse via bank API.
  3. Pass an idempotency key (loan id + step) to every external call; the bank API's transfer reference uses it, so a retried disbursal cannot pay twice.
  4. Use signals for human and partner callbacks (agreement signed, mandate approved) with durable timers for reminders and expiry.
  5. Add compensation: if disbursal fails permanently after the mandate is registered, cancel the mandate and notify the user.
  6. Use workflow queries to power an ops dashboard showing exactly which step each loan is in, and alert on workflows waiting longer than the SLA.

Impact: Stuck loans become visible with their exact step, double disbursals stop because every money-moving call is idempotent, and the cron jobs and their state-repair scripts are deleted.

Trade-offs

Use it when

  • Processes with many steps, long waits, human approvals or external callbacks
  • Money movement and order pipelines where a half-finished process is costly
  • AI agents that call tools over minutes and must resume after failure
  • You need visibility into where each process instance is stuck

Avoid it when

  • A single 200 ms task: a plain queue is simpler and cheaper
  • Batch data pipelines on a schedule: Airflow, Dagster or Argo fit better
  • Very high-throughput, low-latency event processing: use stream processing (Kafka consumers, Flink)
  • Huge payloads in workflow history: store blobs in S3 and pass references

Common mistakes

  • Using cron plus status columns for long processes, then writing repair scripts for stuck states.
  • Non-idempotent activities: a retry after a timeout creates a second account or payment.
  • Calling time.Now(), random or HTTP clients inside workflow code, causing non-determinism errors on replay.
  • Running every scheduled job on every scheduler node, so the midnight job fires three times.
  • Firing 100,000 jobs at exactly 00:00 with no jitter, overloading the database.
  • Changing workflow code without versioning while old runs are still in flight.
  • Using Airflow for per-user business processes, or Temporal for a one-step email job.

Interview questions

Quick revision

  • Job queue: short tasks, leases, retries with backoff, DLQ, idempotent handlers.
  • Scheduler: next_run_at index, atomic claim, jitter, at-least-once.
  • Durable execution: workflow (deterministic) + activities (side effects).
  • Engine persists history; workers replay to resume.
  • Timers and signals: wait days for free.
  • Every activity idempotent; pass idempotency keys.
  • Saga: compensate in reverse on permanent failure.
  • Version workflow code changes.
  • Airflow = data DAGs; Temporal/Step Functions = business processes.

Check yourself

1. Why must Temporal workflow code avoid calling time.Now() directly?

2. A job queue worker crashes after sending an email but before acknowledging the job. What happens next?

3. Three scheduler nodes each poll for due jobs. How do you make sure a job fires only once per schedule?

4. Which tool fits a nightly ETL pipeline of 30 dependent batch tasks best?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free