W-02 · Case study
Whispr
Parsing “tomorrow at 11pm” is the easy part. The real work is making sure a reminder fires exactly once — through webhook retries, restarts and failed deliveries.
- Live — bot on Telegram, Mini App on Vercel, backend on Render
- Author
- MongoDB (messages, reminders, users, event log)
- Bull on Redis — delayed jobs, retries, dead-letter queue
- Node.js · Express · MongoDB · Redis + Bull · chrono-node · Zod · React 19 Mini App

Thesis
A reminder is a state machine, not a setTimeout.
§ 01
The problem
Reminders usually fail at the moment of capture. Opening an app, picking a date, filling a form — that friction is enough for most intentions to stay intentions. Chat is where people already write things down, so that is where scheduling should happen.
§ 02
What makes it hard
Telegram can deliver the same update more than once. Processes restart. Sends fail. And natural-language time is ambiguous: “tomorrow night” depends on where you are. Each of these, handled carelessly, produces the worst outcome for a reminder app — a reminder that fires twice, or never.
§ 03
The governing idea
Every reminder moves through explicit states — parsed → scheduled → firing → fired | failed — and workers only ever move it forward. Two components never call each other directly; a queue always sits between them. That makes every transition durable and every failure visible.
The extraction service never computes delays and the scheduler never interprets language. Keeping those apart is what makes each one testable.
§ 04
The system
A message arrives at a secret-verified webhook, is checked against the update id, and is parsed by a rule-based extractor built on chrono-node (capped at 500 characters, resolved forward in the user's timezone). The result must pass a Zod schema before a reminder is stored. A delayed Bull job is then scheduled to fire it; the notifier retries with exponential backoff and, after five attempts, hands the job to a dead-letter queue and marks the reminder failed with a reason.
A repair sweep runs every five minutes and recovers anything stalled in parsed, scheduled or firing.
Diagram 01
From a sentence to a delivered reminder
“remind me to drink water in 10 minutes”
Telegram delivers the update. The secret header is verified before anything else happens.
Every stage, in words
- 01Receive (POST /webhook/telegram) — Telegram delivers the update. The secret header is verified before anything else happens.
- 02Deduplicate (update_id → jobId) — Seen update ids are dropped. The processing job uses the update id as its Bull jobId, so racing requests collapse into one.
- 03Interpret (ai.service.js · chrono-node) — Rule-based extraction: normalise “10mins”, find the time expression, resolve it forward in the user's timezone, strip it from the task text.
- 04Validate (Zod · AIOutputSchema) — The extracted intent must match the schema. Unrecognised input is rejected here, before anything is stored.
- 05Persist (MongoDB · state: parsed) — The reminder is saved. A unique index on (user, platform message id) prevents a second copy.
- 06Schedule (Bull delayed job · state: scheduled) — A delayed job is added with jobId = reminder id. If scheduling fails, the reminder stays parsed for the repair sweep.
- 07Deliver (notifier · state: fired | failed) — The job fires and the message is sent. Failed sends back off exponentially; after five attempts the job goes to the dead-letter queue.
§ 05
Why duplicate scheduling matters
A duplicate here isn't cosmetic: it is a second notification at 11pm. So deduplication happens at three layers. The webhook drops an update id it has already seen. The processing job is enqueued with the update id as its Bull jobId, so two requests racing each other still produce one job. And the reminder model has a unique index on user and platform message id, so even a third path can't store the same reminder twice.
The fire job uses the reminder's own id as its jobId too. When the repair sweep re-schedules a stalled reminder, it can't create a second job alongside the first.
§ 06
Current state
Live on Telegram, with the Mini App for listing, editing and deleting reminders and setting timezone and locale. Health and readiness endpoints check MongoDB and Redis.
§ 07
Next
- Asking a clarifying question when a time is ambiguous, instead of guessing.
- Structured logs per state transition and queue metrics — depth, retry rate, dead-letter count.
- Recurring reminders, voice-note input and WhatsApp, as listed on the roadmap.