System designCore12 min

Background jobs & scheduling

Sending a text, rendering an invoice or emailing a nightly report shouldn't make anyone wait, and shouldn't be lost when a server restarts. Background jobs move that work to workers that retry it, and schedulers start work at a set time. Most of the craft is in the details: jobs that run twice, retries that wait days, queues that starve, and clocks that skip an hour.

1 · The problem

Work the user doesn't wait for belongs in a job

A booking platform serves 4,000 hair salons. When a client books a haircut, the platform saves the booking, confirms it by text message, emails the salon, and adds it to the stylist's calendar. Then, 24 hours before the appointment, it sends a reminder. Every month it renders an invoice for each salon, and every morning at 7:00 it emails each owner a summary of the day.

Only the first step has to happen before the client sees "Booked". The rest is slow, depends on outside services that time out, or is due at a time nobody is making a request. Putting it in the request would make the client wait for the text-message provider, and fail the booking when the provider is down.

Work

Where it runs

Why

Save the booking

In the request

The client needs the answer

Confirmation text, salon email, calendar sync

Background job, now

Slow, flaky providers; can retry

Reminder 24 hours ahead

Background job, at a set time

Due when no request is happening

Monthly invoices, 7:00 summaries

Recurring schedule

Due on the calendar

A background job is a small message naming a piece of work, put on a queue and run later by a worker process (Message queues & pub/sub). A scheduler creates jobs at a set time, once or repeatedly. When the client needs to follow the job's progress, the job also needs a status the client can poll (Long-running & bulk APIs).

2 · The job

A job carries an ID, runs after the commit, and may run twice

Three rules prevent most job bugs, and each comes from a real failure:

  • Pass IDs, not objects. Job arguments are serialized, so a whole booking object either doesn't survive the trip or arrives stale. Pass booking_id: 9917 and load the booking when the job runs, so it sees the current data.
  • Enqueue after the commit. A worker can pick up a job within milliseconds, before the transaction that created the booking has committed. It then finds nothing.
  • Make the job idempotent. Job systems run each job at least once, not exactly once.

The job ran before the booking existed. Enqueue after the commit, or write the job into an outbox table in the same transaction (Distributed transactions: 2PC & sagas). Celery added delay_on_commit for exactly this.

At-least-once is not a corner case. In Sidekiq, for example, a job that completed can run again if Redis goes down after the job finishes but before Sidekiq records it as done, and a job that fails halfway is retried from the start. A confirmation job should therefore check whether the text was already sent for booking 9917, or send it with an idempotency key the provider honors (Idempotency).

Celery makes the trade explicit. By default it acknowledges a task just before running it, so a worker that dies mid-task loses it rather than repeating it. With acks_late, it acknowledges after the task returns, so a crash means a repeat instead of a loss, which is only safe for idempotent tasks (Delivery semantics).

3 · Retries

Retries wait longer each time, then give up somewhere visible

The text-message provider is down for twenty minutes. Retrying every confirmation immediately would hammer it and fail again. Job systems retry with growing delays. Sidekiq's formula is retry_count⁴ + 15 seconds, plus a random amount that grows with the count:

Retry

Base delay

Cumulative wait, roughly

1st

15 s

15 s

2nd

16 s

31 s

3rd

31 s

1 minute

5th

271 s

7 minutes

25th, the last

331,791 s, about 3.8 days

About 20 days

A twenty-minute outage is absorbed within the first handful of retries; a bug in the job gives the team about three weeks to ship a fix before the job stops retrying. After the last retry, Sidekiq moves the job to its dead set, where it stays for six months for someone to retry it by hand. Celery's automatic backoff adds random jitter and caps each delay at ten minutes by default.

Not every failure deserves a retry. A malformed phone number fails the same way on attempt 25 as on attempt 1, so the job should recognize a permanent error and stop at once, keeping retries for transient ones such as timeouts. Where failed jobs end up, and how to replay them safely, is the subject of Dead-letter queues & poison messages; the backoff arithmetic is on Timeouts, retries, backoff & jitter.

4 · Queues

Separate queues keep slow bulk work from delaying urgent work

On the first of the month, the invoice job enqueues 4,000 PDF renders at once. If everything shares one queue, a client who books at 00:05 gets the confirmation text after the last invoice is rendered. The fix is several queues, with workers that know which matters more:

Booking platformcriticalconfirmations, resetsdefaultsalon emails, calendarsbulkinvoices, summariesCritical workersserve critical onlyGeneral workersdefault before bulk

Dedicated workers for the critical queue mean no burst of invoices can delay a confirmation. The general pool serves the rest.

Workers can poll queues in two ways, and Sidekiq shows both:

  • Strict order: check critical, then default, then bulk. A job in default runs only when critical is empty, so a flood of critical jobs holds everything else back.
  • Weighted: a queue with weight 2 is checked twice as often as one with weight 1, so every queue keeps moving, at different speeds.

Sidekiq can't mix the two in one process, so a queue that must always go first gets its own processes, as in the figure. That is a bulkhead: a separate pool so one kind of work can't exhaust capacity for another (Bulkheads). Keep the number of queues small, and watch how long the oldest job in each queue has been waiting: a growing wait is the earliest sign the workers can't keep up (Throughput, utilization & queueing).

5 · Scheduling

Schedules fail in ways the cron line doesn't show

Some jobs run at a moment: the reminder for a 15:00 haircut is a job scheduled for 15:00 the day before. Others repeat: the invoice run is 0 2 1 * *, 02:00 on the first of each month, in cron's minute, hour, day, month, weekday notation. That one line hides several decisions:

Question

What can go wrong

What schedulers do

Which time zone?

02:00 on the server's clock, not the business's

Kubernetes CronJobs use the controller's local zone unless .spec.timeZone is set

What about daylight saving?

02:30 doesn't exist one night a year, and happens twice another

Cron runs a skipped job right after the jump; EventBridge Scheduler skips it

What if the scheduler was down?

The run was missed

Kubernetes starts it late within startingDeadlineSeconds, or skips it

What if the last run is still going?

Two runs overlap

Kubernetes: Allow (default), Forbid or Replace

Can it run twice, or not at all?

Duplicate invoices, or none

Kubernetes says both can happen; jobs must be idempotent

The daylight-saving row is a real trap: two widely used schedulers make opposite choices for the same missing half hour, and in the autumn both run the job once. Schedule business jobs in an explicit time zone, and avoid the hour from 01:00 to 03:00 local time unless the job is harmless to skip or delay.

Then there is midnight. Many teams schedule their daily jobs at 0 0 * * *, so the launches pile up in the same moment, and a job that fans out to thousands of workers makes a spike for everyone.

Some cron systems add a ? field that lets the system pick a value by hashing the job's configuration, spreading launches over the hour; EventBridge Scheduler offers a flexible time window, for example any time in the 15 minutes after the scheduled time. Run the 7:00 summaries any time in a window ending at 7:00, not at the stroke of it.

6 · At scale

One scheduler decides; many small jobs do the work

The 7:00 summary is due in each salon's own time zone, so it is really 4,000 small deadlines spread around the clock. One giant job looping over all salons would run for hours, and a crash halfway would leave nobody knowing which owners got their email. Instead, a short scheduler job runs every 15 minutes, finds the salons that are due, and enqueues one job each:

Each job is small, retried on its own, and keyed by salon and date, so a scheduler that runs twice, or a job that repeats, sends no second email.

Only one scheduler may make that decision, or three job servers will each enqueue the same 118 jobs. Elect a single leader, or let a unique key in the database reject the duplicates (Distributed locks). A distributed cron runs its replicas under a consensus protocol so they agree on what has been launched, and records each launch before and after starting it.

Even then, a scheduler can die between starting a launch and recording it. The safer choice is to prefer skipping a launch over risking a double launch, since a skipped run is easier to recover from, unless the operation is idempotent and can simply be repeated. The salon platform makes every job idempotent, which lets it choose repeating instead: a duplicate summary job finds the sent mark and does nothing.

7 · In practice

Keep jobs small, idempotent and observable

A job that grows into many steps, such as "charge the card, then book the stylist, then refund if the stylist is unavailable", needs its progress recorded between steps so a crash resumes instead of restarting. That is a workflow, not a job (Workflow orchestration & durable execution).

For the salon platform, the checklist:

  • Move everything the user doesn't wait for into jobs, and keep job arguments to IDs.
  • Enqueue after the commit, or through an outbox.
  • Make every job idempotent, keyed by what it acts on and when.
  • Retry transient errors with backoff and jitter; stop at once on permanent ones.
  • Separate urgent and bulk work into queues, with dedicated workers for the urgent one.
  • Schedule in an explicit time zone, away from the daylight-saving hour, and spread launches out instead of all at midnight.
  • Have one scheduler decide, and many small jobs act.
  • Watch queue wait time and the dead set, and alert on both.

Rule

What goes wrong without it

Enqueue after the commit

A worker looks for booking 9917 before it exists

Make every job idempotent

A retried job sends the confirmation twice

Separate urgent and bulk work

An invoice run delays password resets

Spread launches out

Every salon's summary starts at midnight, all at once

Watch queue wait time

Jobs pile up unseen until customers complain

Check yourself

5 questions

1. A worker loads booking 9917 from the primary database and finds nothing, though the booking exists a second later. What is the most likely cause?
2. Why should a confirmation job check whether the text was already sent?
3. The invoice run is scheduled for 02:30 local time. What happens on the night clocks jump from 02:00 to 03:00?
4. On the 1st, 4,000 invoice jobs delay confirmation texts by an hour. What is the best fix?
5. Three job servers each run the same scheduler, and each enqueues the 7:00 summary jobs. How does the platform avoid three emails per owner?

Takeaways

Remember this

  • Anything the user doesn't wait for, or that is due on the calendar, belongs in a background job run by workers.
  • Pass IDs, enqueue after the commit, and make every job idempotent, because jobs run at least once.
  • Retry transient failures with growing, jittered delays, stop on permanent ones, and send exhausted jobs somewhere visible.
  • Separate urgent and bulk queues, with dedicated workers for urgent work, and watch how long jobs wait.
  • Schedule in an explicit time zone away from the daylight-saving hour, spread launches out, and let one scheduler fan out many small idempotent jobs.