The problem
"Design a reminders service." People set reminders in a phone app, one-off like "call mom at 18:00" or repeating like "every Monday at 9:00", and the phone rings at that time, even with the app closed. They can snooze, edit or delete a reminder at any moment. Shared lists and location reminders ("when I get home") wait for later.
This practice problem reuses the pattern of Design a Job Scheduler: save each reminder with the time it's due next, find what's due by time, and work out the next time after each ring. What's new: hundreds of millions of tiny jobs owned by people, their time zones, a phone to reach on time, and changes that race with a reminder about to ring.
The numbers
From the interviewer: 100 million users, about 10 reminders saved each, and about 2 ringing per user a day. Round a day to 100,000 seconds (86,400 really).
- Saved: 100 million × 10 = 1 billion reminders; at about 500 bytes each, 500 GB, split by user.
- Rings: 100 million × 2 = 200 million a day, about 2,000 a second on average (about 2,300 with 86,400), or about 140,000 a minute.
- Round times: people pick 09:00 and 18:00, so busy minutes hold far more. Say 3 million ring at 09:00 New York time on a Monday.
Finding due reminders: time buckets
In the Job Scheduler, one index on next_run_at found the due jobs. Here the reminders are split by user (Sharding), since the app asks for one person's list, so each minute's due ones are spread over every shard. Splitting by time instead would make "show my reminders" ask every shard; timers in the servers' memory vanish in a restart.
The answer is a time bucket per UTC minute, in a key-value store of its own, listing the ids due then:
bucket (UTC) reminder ids
Oct 24 17:59 r12, r907
Oct 24 18:00 r1, r33, r508, …
Oct 26 13:00 r2, …
Saving a reminder writes its row on the user's shard and adds its id to its minute's bucket. Each minute the workers read one key, about 140,000 ids on average, and load each row. A busy minute is split into parts, say 100 parts of 30,000 ids for the 3 million at 09:00, each read by its own worker.
Time zones and daylight saving
Ana in New York sets "water the plants, every Monday at 9:00", first on Oct 26, when New York is 4 hours behind UTC. On Sunday, Nov 1, daylight saving time ends there and clocks go back an hour.
Monday fixed 13:00 UTC zone and local time
Oct 26 09:00 New York 09:00 New York (13:00 UTC)
Nov 2 08:00 New York 09:00 New York (14:00 UTC)
Storing the first ring's UTC time and adding 7 days rings an hour early for 19 Mondays, until clocks go forward on March 14. Storing her offset, −4 hours, fails the same way: it's only the summer offset. So a repeating reminder keeps its time zone and local time, America/New_York and Monday 09:00, and each ring works out the next UTC time from the time zone database. Two edge cases need a rule: on Nov 1, 01:30 happens twice in New York (ring at the first), and on March 14, 02:30 never happens (ring at 03:30).
Reaching the phone
A closed app can't ask the server what's due: phones don't let it check in every minute, and 100 million phones asking each minute would be about 1.7 million requests a second. The work splits three ways:
- Our workers read the bucket, check each row and work out the next time of a repeating reminder.
- The Notification System, as in its own lesson, finds the user's devices, retries, and drops repeats, with the reminder's id and due time as the idempotency key. Its urgent queue keeps reminders ahead of bulk newsletters.
- Apple's and Google's push services (APNs, FCM) reach the closed app and show the alert.
Last-second changes
At 18:00:00.10 a worker reads r1, "call mom": pending. At 18:00:00.15 its owner taps Delete. At 18:00:00.20 the worker sends the push: a deleted reminder rang. Reading the row again only shortens the gap, and removing the id from the bucket is too late. The fix is one conditional update on each side, a check and a write in one step (Concurrency and Locks):
worker: UPDATE reminders SET status = 'sending'
WHERE id = 'r1' AND status = 'pending' AND due = '18:00'
delete: UPDATE reminders SET status = 'deleted'
WHERE id = 'r1' AND status = 'pending'
Whichever lands first changes one row; the other changes 0 rows and stops. A snooze or an edit to 18:30 changes the due time, so the 18:00 claim finds 0 rows and the 18:30 bucket rings it. The bucket only says which rows to look at; the row decides.
Same pattern as the Job Scheduler
A worker can crash after its claim, so a row stuck in "sending" is handed out again, and the idempotency key keeps the retry from ringing twice. After an outage, the workers catch up by reading the missed minutes' buckets. The API stays small:
POST /reminders
{: "Water the plants", : "every Monday 09:00",
: "America/New_York"}
→ 201 Created {: "r2", : "2026-10-26T13:00Z"}
PATCH /reminders/r2 {: "10m"}
DELETE /reminders/r1
Summary
- Scope: one-off and repeating reminders; snooze, edit, delete.
- Data: a row per reminder, split by user, with its status and next UTC time.
- Numbers: about 2,000 rings a second, far more at round minutes.
- Services: the workers decide what rings; the Notification System sends it.
- Scale: a time bucket per UTC minute, busy minutes split into parts.
- Time zones: repeats keep the zone and local time.
- Last-second changes: one conditional update on the status.