Module 7 / Background jobs

Module 7 · lesson 6 of 6 · Lab 20 min

Lab: Receipts and payouts

Email receipts and pay restaurants without blocking checkout.

In this lab you'll take two jobs Tiffin does badly today and redesign them with everything from this module: a slow receipt email that makes checkout slow and sometimes breaks it, and a weekly restaurant payout that someone currently runs by hand. One of them sends emails. The other moves money. You'll see how the same tools apply to both, and where money demands more care.

As always, decide for yourself before you open each answer.

The brief

Two years after launch, Tiffin looks like this:

  • 200,000 orders a day across 3,000 restaurants, average order ₹400.
  • 60% of orders land between 7pm and 10pm: 120,000 in 10,800 seconds ≈ 11 orders a second, with spikes to about 13.
  • Eight stateless app servers behind a load balancer, one Postgres primary with read replicas, Redis for caching.

Receipts. Checkout charges the card (800 ms typical), saves the order, then emails a receipt through an email provider before responding. The email call takes 1.5 seconds typically and 8 seconds at p99. About 0.5% of calls fail, and the provider had two 20-minute outages last quarter. During those outages, checkout returned errors and Tiffin lost an estimated 1,800 orders.

Payouts. Every Monday, each restaurant is paid for the previous week (Monday 00:00 to Sunday 23:59, India time): the sum of its delivered orders minus Tiffin's 20% commission, minus refunds. Today an engineer runs a script on her laptop on Monday morning. It calls the payment provider's payouts API once per restaurant. Last month her laptop's Wi-Fi dropped halfway through, she re-ran the script, and 340 restaurants were paid twice.

Some quick maths on payouts: 1,400,000 orders a week × ₹400 × 80% ≈ ₹45 crore a week, or about ₹1.5 lakh per restaurant. Finance has two requirements: never pay a restaurant twice for the same week, and never skip one.

Step 1: What stays in checkout?

Draw the line

Which parts of checkout must still happen before the response, and what happens to the receipt?

Show one answer

Stays synchronous: validate the cart, charge the card, save the order. The customer needs a clear "placed" or "not placed", and the kitchen shouldn't cook an unpaid order.

Moves out: the receipt email. In the same database transaction that saves the order, insert a send_receipt job.

The receipt fails both tests from "Now or later?": the customer doesn't need it to see "Order placed", and a failed email shouldn't cancel a paid order. Moving it out takes checkout from about 2.3 seconds typical (and 10+ at p99) down to about 0.8 seconds. More importantly, the next 20-minute email outage costs zero orders.

Step 2: Pick the queue

Postgres or a separate queue?

Tiffin could use a jobs table in its existing Postgres, or a managed queue like SQS. Which would you pick for receipts, and what would make you change your mind later?

Show one answer

A jobs table in Postgres, claimed with FOR UPDATE SKIP LOCKED.

The deciding reason: the order and the job are written in one transaction, so there's never a paid order without a receipt job, or a receipt job without an order. The load is tiny for Postgres, about 13 inserts and 13 deletes a second at peak.

Move to a separate broker when job volume grows to hundreds or thousands a second and the churn starts to hurt the primary (lots of dead rows for vacuum to clean up), or when other teams need to consume the same events. At that point, use the outbox pattern from Module 15 to keep the "one transaction" guarantee.

Saving the order and enqueuing the job in one transaction is the most valuable property here. With SQS, you'd be writing to two systems, and a crash in between loses a receipt or sends one for an order that doesn't exist. You'd need an outbox to fix that, which is more machinery than Tiffin needs today.

BEGIN;
INSERT INTO orders (...) VALUES (...) RETURNING id;             -- 9812
INSERT INTO jobs (type, payload) VALUES ('send_receipt', '{"order_id": 9812}');
COMMIT;

Step 3: Size the workers and set retries

Survive a 20-minute outage

How many concurrent receipt workers do you need at peak? What retry policy lets every receipt survive a 20-minute email outage, and how long does the backlog take to clear afterwards?

Show one answer

Workers: 13 jobs/s × 1.5 s = about 20 in progress. With room for p99 slowness, run about 50 concurrent workers: two small machines, each running one process with 25 concurrent jobs.

Retries: exponential backoff with full jitter, 10-second base, capped at 10 minutes, maximum 15 attempts. Before jitter, the delays go 10 s, 20 s, 40 s, 80 s, 160 s, 320 s, then 600 s each, so 15 attempts span about 90 minutes. Full jitter roughly halves that on average, to about 45 minutes, still well past a 20-minute outage. It's an average, though: a job that happens to draw short delays on most of its tries can use up all 15 attempts in under 20 minutes. For a receipt that failed at the very start of the outage, that chance is about 1 in 1,200, and across the whole backlog you'd expect about one such receipt per outage. That one lands in the dead-letter queue and is redriven. If you want even that to be rare, raise the limit to 20 attempts, which makes it more than ten thousand times rarer.

Backlog: a 20-minute outage at peak builds up 13 × 1,200 = about 15,600 receipts. After the provider recovers, the jobs wake up over the next 10 minutes (the backoff cap). 50 workers at 1.5 s each send about 33 a second, against 13 a second still arriving, so they gain about 20 a second. 15,600 / 20 ≈ 13 minutes. Every receipt is out within about 20 minutes of recovery.

Two details matter here. First, the email call needs a timeout of about 10 seconds, well below the 30-second visibility timeout, or a hung call lets a second worker start the same job. Second, check the email provider's own rate limit before draining at full speed. If it allows 100 emails a second, 33 is fine. If it allows 20, you'd configure the workers to stay under it, and the backlog takes longer to clear.

Step 4: Make the receipt job safe to run twice

One receipt, usually

The email provider doesn't support idempotency keys. How does the receipt job avoid sending duplicates, and which way should it fail if it crashes at the worst moment?

Show one answer

Add receipt_sent_at to orders. The job:

  1. Reads the order. If receipt_sent_at is set, it's done, so acknowledge and stop.
  2. Sends the email.
  3. Sets receipt_sent_at = now().
  4. Acknowledges the job.

A crash between steps 2 and 3, or an email call that times out after the provider actually accepted it, means one duplicate email. That's the right way to fail: a duplicate receipt is a shrug, a missing receipt is a support ticket. Both cases are rare.

Notice we didn't reach for anything clever. The duplicate window can't be closed when the email provider can't deduplicate, so you choose which way to be wrong and move on.

Step 5: Schedule the payouts

Now the money. First, how does the payout get triggered, exactly once a week, without anyone's laptop?

Trigger it once

Eight app servers. Payouts should start at 02:00 every Monday India time. How do you trigger the run so it starts exactly once, and what happens if the trigger is missed?

Show one answer

Schedule in UTC: 02:00 Monday in India is 20:30 UTC on Sunday, so 30 20 * * 0.

Every server (or a managed scheduler) tries to claim the run, and the one that wins enqueues one pay_restaurant job per restaurant: 3,000 jobs, each carrying restaurant_id and the period start. It doesn't make payments itself.

The claim and the 3,000 jobs must commit together. If they were separate steps, a crash after the claim would leave a week marked as started with no jobs behind it, and no other server would ever try again. The queue lives in Postgres, so one statement does both:

WITH claimed AS (
  INSERT INTO job_runs (job_name, scheduled_for)
  VALUES ('weekly-payouts', '2026-10-18 20:30:00+00')
  ON CONFLICT DO NOTHING
  RETURNING scheduled_for
)
INSERT INTO jobs (type, payload)
SELECT 'pay_restaurant',
       jsonb_build_object('restaurant_id', r.id, 'period_start', '2026-10-12')
FROM restaurants r, claimed;

If another server already claimed the run, claimed is empty and no jobs are inserted. If the server crashes halfway, neither the claim nor any job is saved, and the next attempt starts clean.

For missed triggers: the payout job pays any finished week without a payout, not just "last week". And an hourly check alerts if Monday 03:00 IST passes without a job_runs row for this week.

Splitting the run into one job per restaurant matters. If the payment provider fails for restaurant 381, only that job retries. Under the old script, one failure stopped the whole run halfway, which is exactly how 340 restaurants got paid twice.

Step 6: Never pay twice

This is the step that matters most. A pay_restaurant job can run twice for every reason in "Jobs that can run twice", and each duplicate is about ₹1.5 lakh.

Design the payout job

Design the table and the steps of pay_restaurant so that no number of retries, crashes or duplicate jobs can pay a restaurant twice for the same week. Also: what if a refund for last week's order arrives after the payout amount was calculated?

Show one answer

A payouts table with a unique key on the business fact, "this restaurant, this week":

CREATE TABLE payouts (
  id            bigserial PRIMARY KEY,
  restaurant_id bigint      NOT NULL,
  period_start  date        NOT NULL,
  amount_paise  bigint      NOT NULL,
  status        text        NOT NULL DEFAULT 'pending',  -- pending, paid, failed
  provider_ref  text,
  created_at    timestamptz NOT NULL DEFAULT now(),
  UNIQUE (restaurant_id, period_start)
);

The job:

  1. Calculate the amount and insert the row with ON CONFLICT DO NOTHING. If the row already exists, use the stored amount, never a fresh calculation.
  2. If status = 'paid', stop.
  3. Call the provider's payout API with idempotency key payout-{payouts.id}.
  4. Save provider_ref and set status = 'paid'.

A crash after step 3 and before step 4 is the dangerous one. The retry repeats step 3 with the same key, and the provider returns the original payout instead of sending money again.

Late refunds don't change a stored payout. They're deducted from next week's payout as an adjustment. Changing an amount that may already be paid is how ledgers stop adding up.

Here's the dangerous crash played out, for restaurant 381's payout row 4471:

%% caption: The retry finds the stored row and repeats the call with the same key, so the provider sends money once.
sequenceDiagram
  autonumber
  participant W as Payout job
  participant P as Postgres
  participant X as Payment provider
  W->>P: insert payout, on conflict skip
  P-->>W: row 4471, pending
  W->>X: pay, key payout-4471
  X-->>W: paid
  Note over W: crashes before saving
  Note over W,X: the job runs again
  W->>P: insert payout, on conflict skip
  P-->>W: row 4471 exists, same amount
  W->>X: pay, key payout-4471
  X-->>W: the original payout
  W->>P: save provider_ref, paid
The retry finds the stored row and repeats the call with the same key, so the provider sends money once.

There are three layers of protection here, and each covers a different gap:

Layer Stops
job_runs claim The whole run starting twice
UNIQUE (restaurant_id, period_start) Two payout records for the same restaurant and week, even from two concurrent jobs
Provider idempotency key A second transfer after a crash between "provider paid" and "we recorded it"

Store money as integer paise, not floating-point rupees: 0.1 + 0.2 is not 0.3 in floating point, and finance will find every rounding error.

What if the payment provider didn't support idempotency keys? Then, before sending, the job would ask the provider "do you have a payout with my reference payout-4471?" and only send if not. That's weaker, because two jobs could ask at the same moment, so you'd also hold a row lock on the payout row (SELECT ... FOR UPDATE) while asking and sending. For money, idempotency keys are worth choosing a provider over.

Step 7: Know when it breaks

Pick the alerts

List the alerts you'd set up for receipts and payouts.

Show one answer

Receipts: oldest ready send_receipt job older than 5 minutes; any dead receipt jobs; zero receipts sent in 5 minutes between 7pm and 10pm.

Payouts: no job_runs row by 03:00 IST Monday; any pay_restaurant job in the dead-letter queue; any payouts row still pending at 09:00 IST; and a daily reconciliation that compares Tiffin's paid payouts with the provider's report and alerts on any difference.

Reconciliation deserves its own mention. Every other protection is about your code behaving correctly. Reconciliation checks the result against an outside record, so it catches the bugs you didn't think of.

The final design

%% caption: Checkout only waits for what the customer needs. Receipts and payouts run behind it, retried, deduplicated and watched.
flowchart TD
  CA([Customer app]) -- checkout --> AS["App server<br/>validate, charge, save"]
  AS -- "order and job, one transaction" --> PG[("Postgres<br/>orders, jobs, payouts")]:::hot
  SC["Scheduler<br/>Sunday 20:30 UTC"] -- "job_runs claim, once a week" --> PG
  PG --> RW["Receipt workers<br/>50 concurrent, backoff"]
  RW --> EP[Email provider]
  PG --> PW["Payout workers<br/>one job per restaurant"]:::hot
  PW -- "idempotency key" --> PP[Payment provider]
Checkout only waits for what the customer needs. Receipts and payouts run behind it, retried, deduplicated and watched.

Checkout is about three times faster and no longer fails when the email provider does. Receipts survive a 20-minute outage and catch up within about 20 minutes of recovery. Payouts run themselves every Monday as one job per restaurant, and a restaurant can't be paid twice for a week, however many times a job runs.

Build it on AWS

Tiffin runs in Mumbai (ap-south-1) on ECS with Fargate, an Application Load Balancer and RDS for PostgreSQL. The default AWS answer for background work is SQS plus workers, and later modules use it. This lab is the exception worth understanding: its own design keeps the job queue inside Postgres, and on AWS that's still right. The AWS-specific decisions are elsewhere: which service sends the email, and what triggers Monday's payouts. Prices are us-east-1 list prices as of late 2026, fine for comparing options; Mumbai's differ, so check the AWS pricing calculator before quoting them.

The architecture

%% caption: Receipts and payouts on AWS. The job queue stays in RDS; SES sends the email; EventBridge Scheduler starts Monday's run.
flowchart TD
  APP["App service<br/>ECS on Fargate"] -- "order and job, one transaction" --> RDS[("RDS for PostgreSQL<br/>orders, jobs, payouts")]:::hot
  EBS["EventBridge Scheduler<br/>Monday 02:00, Asia/Kolkata"] -- "RunTask" --> PT["Payout planner<br/>one-off ECS task"]
  PT --> RDS
  RDS --> W["Worker service<br/>ECS on Fargate, 2 tasks"]
  W --> SES["Amazon SES<br/>receipts"]:::hot
  W -- "through a NAT gateway" --> PP[Payment provider]
Receipts and payouts on AWS. The job queue stays in RDS; SES sends the email; EventBridge Scheduler starts Monday's run.
Generic box AWS service The setting that matters
Job queue A jobs table in RDS for PostgreSQL (unchanged) Same transaction as the order
Workers An ECS service on Fargate, 2 tasks of 25 concurrent jobs Scale on the age of the oldest ready job, not CPU
Email provider Amazon SES Production access, a sending rate above the backlog drain, and its own account for marketing
Scheduler EventBridge Scheduler, cron in the Asia/Kolkata time zone, target ECS RunTask Retries and a dead-letter queue on the schedule
Payment provider External, reached through a NAT gateway The NAT's Elastic IP is what the provider allowlists

Argument 1: shouldn't the queue be SQS, now that we're on AWS?

SQS would cost almost nothing here: about 6 million jobs a month, each a send, a receive and a delete, is 18 million requests, roughly $7 at $0.40 per million. Cost isn't the question. The question is the one Step 2 answered: the order and its receipt job must be saved together or not at all. RDS can do that in one transaction. SQS can't join a Postgres transaction, so moving to SQS today means building the outbox (Module 15) to get back a guarantee Tiffin already has for free.

The jobs table's cost on RDS is real but small: about 13 inserts and 13 deletes a second, plus vacuum cleaning up the dead rows. Watch two numbers on the database: dead tuples on the jobs table, and how long the SKIP LOCKED claim query takes at dinner.

Recommendation: keep the jobs table. Move to SQS behind an outbox when job volume reaches hundreds a second, when the jobs table shows up in the primary's top queries, or when another team needs to consume the same events. That's the day Module 15's design starts paying for itself.

Argument 2: SES is the email provider, so learn its rules first

Receipts are a textbook SES workload: one message per order, sent through SendEmail once per recipient. At 200,000 orders a day, that's about 6 million emails a month, about $600 at $0.10 per thousand. But SES has rules that change the design:

  • New accounts start in the sandbox: at most 200 messages in 24 hours, one per second, and only to verified addresses. Request production access well before launch; AWS says the first response can take up to 96 hours.
  • The sending rate is a quota, and it must cover the backlog drain. Step 3 drains a backlog at 33 receipts a second. If the account's maximum send rate is lower, SES rejects the extra sends with a ThrottlingException ("Maximum sending rate exceeded") and drops those messages; it doesn't queue them for you. Request a rate comfortably above 33, and have the workers pace themselves to the account's limit (the GetAccount API returns it).
  • Reputation is per account. If bounce or complaint rates climb too high, AWS can pause sending for the whole account. Receipts go to people who just ordered, so they rarely bounce or get reported as spam. Marketing email does both. Send marketing from a separate AWS account, so a bad promotional campaign can never stop order receipts.
  • Bad addresses are handled for you. The account-level suppression list (on by default for accounts created after November 2019) stops SES delivering to addresses that hard-bounced or complained. Those sends still count against the daily quota, but no longer hurt your bounce rate.
  • There is no idempotency key. Step 4's choice stands: receipt_sent_at in the order row, and a rare duplicate receipt is the accepted way to fail.

Argument 3: what starts Monday's payouts?

The laptop is replaced by EventBridge Scheduler: a cron schedule evaluated in the Asia/Kolkata time zone, firing at 02:00 every Monday with its flexible time window turned off, and starting a one-off ECS task (the RunTask target). That task makes the job_runs claim and enqueues 3,000 pay_restaurant jobs in the same statement, as in Step 5. It finishes in seconds.

Three details matter:

  • Keep the job_runs claim. The scheduler retries a target it couldn't start, and a retry can't be told apart from a second run. The claim already makes a second run harmless, so nothing changes. The scheduler doesn't replace the claim; it replaces the laptop.
  • Give the schedule a dead-letter queue, and alarm on it. Together with the 03:00 "no run yet" alert from Step 7, a missed trigger pages someone within an hour.
  • Payouts leave through a NAT gateway with an Elastic IP. Payment providers often allowlist the IP addresses that may call their payouts API. That IP is now part of the payout system: changing it is a support ticket with the provider, not a config change. Keep the provider's API key in Secrets Manager, readable only by the worker's task role.

What it costs

Line Monthly, roughly
SES: about 6 million receipts ~$600
Worker service: 2 Graviton Fargate tasks (0.5 vCPU, 1 GB) ~$29
EventBridge Scheduler: four runs a month (inside the free tier) ~$0
Jobs table on the existing RDS ~$0 extra
Total ~$630

That's about 10 cents per thousand orders, and more than 90% of it is SES. The payouts that move ₹45 crore a week cost almost nothing to run. Their cost is entirely in getting them right.

The same design on GCP and Azure

Role AWS Google Cloud Azure
Workers ECS on Fargate Cloud Run Container Apps
Database and jobs table RDS for PostgreSQL Cloud SQL for PostgreSQL Azure Database for PostgreSQL
Email Amazon SES (no first-party service; a provider from the Marketplace) Azure Communication Services Email
Scheduler EventBridge Scheduler Cloud Scheduler Logic Apps, or a Container Apps job on a schedule

The design moves unchanged. On Google Cloud the email provider is a third party, which makes Step 4's "the provider can't deduplicate" just as true.

The backlog that didn't drain

Tiffin moved to SES on a Tuesday. On Friday, SES has a short regional hiccup during dinner, and 15,000 receipts pile up. When it recovers, the workers start draining at 33 a second, the logs fill with ThrottlingException, and on Saturday morning support has dozens of "no receipt" tickets. What happened, and what do you change?

Show one answer

The account's maximum sending rate was lower than 33 a second, probably still at the level granted with production access. SES rejected the excess sends with "Maximum sending rate exceeded", and a rejected send is dropped, not queued. The jobs would normally retry, so the missing receipts mean some job code treated the throttle as a permanent failure, or marked the receipt sent before the call succeeded.

Three changes. Treat ThrottlingException as retryable, with backoff (AWS suggests waiting up to 10 minutes). Pace the workers to the account's actual send rate, read from SES at startup, instead of a hard-coded 50 workers. And request a higher sending rate, sized for the backlog drain rather than the average.

What to remember

  1. Keep only what the customer needs in the request. Here: validate, charge, save. The receipt moves to a job.
  2. Writing the order and its job in one Postgres transaction means no paid order ever lacks a receipt job.
  3. Size workers with arrival rate × job time, and pick retry settings that outlast your dependency's realistic outages.
  4. When a provider can't deduplicate, choose the cheaper way to be wrong. For receipts, that's a rare duplicate.
  5. Split a big scheduled run into one job per item, so one failure retries alone.
  6. For money, stack protections: a claimed run, a unique key on the business fact, a stored amount and a provider idempotency key. Then reconcile against the provider's records.
  7. On AWS, keep the job queue in the database while one transaction is worth more than a separate broker. SQS's low price isn't the reason to move; volume or a second consumer is.
  8. Managed services have quotas that shape the design: SES's sending rate must cover the backlog drain, a throttled send is dropped, and marketing belongs in a separate account from receipts.