← all topics

Design a Delayed Payment Scheduling System

Hello Interview structure · plain notes, one idea per line · schedule now, execute later, exactly once

Understanding the Problem

💸 What are we building?

Functional Requirements

  1. Users should be able to create, query, and cancel scheduled payments.
  2. The system should execute each payment at its due time, with retries and backoff on failure.
  3. A payment must be charged at most once against the gateway, no matter what crashes.
  4. Every attempt is audited in full; nothing terminal disappears silently.

Below the line (out of scope):

Non-Functional Requirements

Do the scale math first, because it decides the architecture
  1. A payment executes at most once against the gateway no matter what crashes (the money-side requirement) and at least once gets attempted (the product-side requirement); the two are reconciled to exactly-once effect via gateway idempotency keys.
  2. Amounts are integers in minor units (cents), never floats and never DECIMAL.
  3. Trigger lateness p99 under 60 seconds, measured due_time to first attempt.
  4. API availability 99.95%.

The Set Up

Defining the Core Entities

API or System Interface

POST   /v1/scheduled-payments               // payer = authenticated actor
  Header: Idempotency-Key: key              // keys scoped (actor, key)
  { "payee": P, "amount": 2500, "dueAt": "2026-09-01T00:00:00Z" }
  → 201 { paymentId, status: PENDING }
  → 409 same key, different body
  // validated: amount > 0 and ≤ cap; dueAt in the future and ≤ horizon (1y)

GET    /v1/scheduled-payments/{id}          // { status, dueAt, attempts: [...] }
GET    /v1/scheduled-payments?status=&cursor=
DELETE /v1/scheduled-payments/{id}
  → 200 CANCELLED           // won the race
  → 409 ALREADY_PROCESSING  // lost it: too late

High-Level Design

1) Users should be able to create, query, and cancel scheduled payments

dp-hi1

2) The system should execute each payment at its due time

dp-hi2

3) Failures retry with backoff, and exhausted payments park for a human

dp-hi3

4) Every attempt is audited, and payers hear about outcomes

dp-hi4

Potential Deep Dives

1) Why poll a database instead of using a delay queue?

Bad Solution: broker delay queues as the schedule
  • The broker holds each message until its delay expires, then delivers it to a worker.
  • Brokers cap the delay horizon, and a year-out payment needs a year-out timer.
  • Cancel means tombstone-chasing a message already inside the queue, and crash recovery means reconciling queue state with table state: the queue has become a second truth store.
Good Solution: Redis ZSET or a timing wheel as an accelerator
  • Redis sorted set with score = due_time, pop everything ≤ now: fine as an accelerator; wrong as truth, because its persistence and atomic-claim story is weaker than the database it would shadow.
  • Timing wheel (an in-memory ring of time buckets): the right tool inside a process; OS timers and Kafka brokers use one. Wrong across processes and restarts at day-plus horizons.
Great Solution: the database is the schedule; poll a partitioned index
  • The argument to say out loud: every alternative to polling ends up duplicating the payments table's state into a second system, and then cancel, edit, crash recovery, and audit all become synchronization problems between the two.
  • Polling a partitioned index at a few-second interval costs almost nothing at 100M rows/day and keeps one truth: cancel is a row update, recovery is free because the row is still there.
  • If pushed on "why is the queue-based design worse, really": because cancel, edit, and audit are row operations, and a delay queue moves the trigger into a second store that cannot CAS against the row's status. Cancelling means chasing an in-flight message. Polling keeps every race on one row, which is why every race in this design has a two-line answer.
  • The moment polling actually strains, the load is due-time spikes, which deep dive 4 solves in the key, not by changing the architecture.

2) How is a due payment triggered exactly once, across concurrent pollers and crashing workers?

Bad Solution: one cron job, once a minute, execute due rows serially
  • It is correct at small scale, and it is where to start; say that.
  • It breaks on three things: execution time exceeding the interval (the backlog compounds), needing more than one worker (no claim semantics), and a crash mid-batch (no leases).
Good Solution: claim batches with FOR UPDATE SKIP LOCKED
-- each poller, every ~5s, on the live partition:
UPDATE payments p SET
    status = 'PROCESSING',
    lease_until = now() + interval '2 min',
    attempt = attempt + 1
FROM (
  SELECT id FROM payments
  WHERE status = 'PENDING' AND due_time <= now()
  ORDER BY due_time
  LIMIT 100
  FOR UPDATE SKIP LOCKED
) due WHERE p.id = due.id
RETURNING p.*;
  • SKIP LOCKED is the whole trick: concurrent pollers lock disjoint sets, because rows another poller holds are skipped, not waited on. No coordination service, no leader election.
  • If pushed on "two pollers sweep at the same instant, walk the rows": both run the claim; the inner SELECTs lock disjoint sets; each poller's UPDATE stamps only its own batch. No row appears in two batches, no poller waits. The serialization point is row locks, which existed anyway.
Great Solution: the claim plus leases plus an attempt-number fencing token
  • The lease bounds crash damage: expired leases are CASed back to PENDING by the reaper, attempt count intact, and the next sweep retries. At any instant exactly one holder has an unexpired lease or the row is PENDING.
  • The attempt number is the fencing token: recorded on every attempt row and passed through logging, so a zombie worker whose lease expired mid-call is distinguishable in the audit trail, and its late outcome write CASes against the attempt it owns, failing harmlessly if the world moved on.
  • Be precise about the claim: this machinery makes triggering effectively exactly-once. The gateway call itself is where at-most-once lives, which is the next dive.

3) How do we make sure the gateway never charges twice?

Bad Solution: gateway idempotency key per attempt
  • Keying by attempt id makes every retry a fresh charge, which is the classic double-billing bug. Name it before the interviewer does.
Good Solution: gateway idempotency key = paymentId
  • This is the load-bearing choice: every retry of a payment, across crashes, lease expiries, and backoff, presents the same key, so the gateway collapses them into at most one charge.
  • Retries follow the backoff schedule (1m, 5m, 30m, 2h, 12h with jitter, budget ~5); terminal failures go straight to FAILED plus notification, no retries.
Great Solution: the key, plus honest handling of ambiguity, plus reconciliation
  • Ambiguous outcomes are the hard case: a timeout after sending means the charge may or may not have happened. Never guess. Keep the payment PROCESSING, and retry with the same key (safe by the above) or query the gateway's status endpoint for that key. Only a definitive gateway answer moves the row to COMPLETED or a retryable FAILED.
  • If pushed on "the worker charges the card, then dies before writing COMPLETED": the row stays PROCESSING, the lease expires, the reaper flips it to PENDING, the next claim retries with the same gateway key, the gateway replays its recorded success without charging again, and the new worker writes COMPLETED. The charge happened once; our record caught up. If the gateway's answer were ever lost too, the daily reconciliation repairs it from settlement.
  • Every attempt is a row: (payment_id, attempt, started_at, gateway_key, outcome, gateway_ref). The daily reconciliation diffs gateway settlement against exactly this table.

4) What breaks at midnight on the 1st of the month?

payments (
  id uuid pk, payer, payee
  amount_minor bigint        -- integer cents, never DECIMAL
  due_time timestamptz, due_day partition key
  status PENDING | PROCESSING | COMPLETED | FAILED | CANCELLED
  attempt int, lease_until
  idem_actor, idem_key unique (actor, key)
) PARTITION BY due_day
  INDEX (status, due_time)       -- the sweep index
  INDEX (payer, created_at)      -- user's list view

attempts ( payment_id, attempt pk, gateway_key, outcome, gateway_ref, at )

5) The races: cancel vs execute, zombie workers, edits

6) What happens when things die, and how do we operate it?

Final Design

dp-arch

What is Expected at Each Level