Why in-memory timers are not enough
setTimeout, asyncio.sleep and in-process schedulers (APScheduler with a memory store, python-telegram-bot's JobQueue, node-cron) are fine for prototypes. In production they lose every pending job when the process restarts, and every deploy is a restart. They also run twice if you ever start two instances.
The fix is to make the database the source of truth and treat the in-process scheduler as, at most, an optimization.
The jobs table
CREATE TABLE scheduled_messages (
id BIGSERIAL PRIMARY KEY,
platform TEXT NOT NULL, -- 'telegram', 'discord', 'whatsapp'
chat_id TEXT NOT NULL,
body TEXT NOT NULL,
due_at TIMESTAMPTZ NOT NULL, -- always stored in UTC
tz TEXT NOT NULL, -- IANA zone of the user, e.g. 'Europe/Madrid'
rrule TEXT, -- NULL for one-off, or a cron expression
status TEXT NOT NULL DEFAULT 'pending', -- pending | sending | sent | failed
attempts INT NOT NULL DEFAULT 0,
last_error TEXT
);
CREATE INDEX ON scheduled_messages (status, due_at);
Store instants in UTC and the user's IANA time zone separately. The zone is needed to compute the next run of recurring jobs and to display times back to the user.
The poller
A loop (or a cron trigger) runs every 30-60 seconds, claims due jobs and sends them:
-- PostgreSQL: claim up to 50 due jobs without colliding with other workers
UPDATE scheduled_messages SET status = 'sending', attempts = attempts + 1
WHERE id IN (
SELECT id FROM scheduled_messages
WHERE status = 'pending' AND due_at <= now()
ORDER BY due_at
LIMIT 50
FOR UPDATE SKIP LOCKED
)
RETURNING *;
async def tick():
for job in await claim_due_jobs():
try:
await send(job.platform, job.chat_id, job.body)
if job.rrule:
await reschedule(job.id, next_run(job.rrule, job.tz)) # back to pending
else:
await mark(job.id, "sent")
except RetryableError as e: # 429, 5xx, timeouts
await retry_later(job.id, backoff(job.attempts), str(e))
except Exception as e: # blocked by user, chat deleted
await mark(job.id, "failed", str(e))
FOR UPDATE SKIP LOCKED lets several workers run safely. Jobs stuck in sending after a crash can be reset to pending after a timeout; that means a rare duplicate is possible, so make sends safe to repeat where it matters. Pick retry delays with the backoff calculator. SQLite works for a single worker; skip the SKIP LOCKED clause and run only one poller. More on storage in databases and state for bots.
Parsing what users type
Users write "in 10m", "tomorrow 9:00" or "every Monday at 8". Keep the grammar small and confirm the interpretation:
Reminder set for Tue 28 Oct, 09:00 (Europe/Madrid). [Cancel]
Ask for the time zone once during onboarding (offer common choices with buttons) and store it. Never assume the server's zone. The timezone scheduler and timestamp converter help when checking results.
Recurring schedules, time zones and DST
For "every day at 08:00" compute each next run in the user's zone, then convert to UTC:
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
def next_daily(hour: int, minute: int, tz: str, now_utc: datetime) -> datetime:
zone = ZoneInfo(tz)
local_now = now_utc.astimezone(zone)
candidate = local_now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if candidate <= local_now:
candidate = (candidate + timedelta(days=1)).replace(hour=hour, minute=minute)
return candidate.astimezone(ZoneInfo("UTC"))
Adding 24 hours in UTC instead drifts by an hour when daylight saving time starts or ends. Also decide what happens to a 02:30 reminder on a day when 02:30 does not exist locally (usually: send at the next valid time). For cron-style rules, libraries such as croniter (Python) or cron-parser (Node.js) compute next runs with a time zone; test expressions in the cron builder.
Serverless: Cloudflare Workers cron triggers
On Workers there is no long-running loop. A cron trigger calls your scheduled handler instead:
# wrangler.toml
[triggers]
crons = ["* * * * *"] # every minute, evaluated in UTC
export default {
async scheduled(controller, env, ctx) {
const { results } = await env.DB.prepare(
"SELECT * FROM scheduled_messages WHERE status = 'pending' AND due_at <= ?1 LIMIT 50"
).bind(new Date().toISOString()).all();
for (const job of results) ctx.waitUntil(sendAndMark(env, job));
},
};
With D1 (SQLite), claim jobs with a conditional UPDATE ... WHERE id = ? AND status = 'pending' and check that one row changed before sending. See bots on Cloudflare Workers.
Platform rules that affect schedules
- Telegram: the Bots FAQ suggests staying around 30 messages per second overall and about 1 message per second per chat (20 per minute in a group). Spread large digests over time instead of sending them in the same second.
- Discord: per-route rate limits apply; libraries queue automatically, but a big burst still arrives late. Prefer one message to a channel over hundreds of DMs.
- WhatsApp: outside the 24-hour customer service window you can only send approved templates, and template messages are charged. A daily reminder to a user who has not written recently must be a template. Estimate the cost with the WhatsApp cost calculator.
- Users who blocked the bot: Telegram returns 403 and WhatsApp an error code; mark those jobs failed and stop the schedule.
More detail in rate limits explained and the reliability and rate limits guide.
Let users manage their schedules
/reminderslists upcoming jobs with their local times and a delete button.- Every recurring message includes a one-tap way to stop it.
- Quiet hours: do not send non-urgent messages at night in the user's zone.
Test it
- Inject the clock: functions take
nowas a parameter so tests can travel in time. - Test the DST transitions of the zones your users live in.
- Kill the worker mid-batch in staging and confirm no job is lost. See testing bots.
Sources
- Python documentation, zoneinfo: docs.python.org/3/library/zoneinfo.html
- PostgreSQL documentation, SELECT FOR UPDATE SKIP LOCKED: postgresql.org/docs/current/sql-select.html
- Cloudflare Workers, cron triggers: developers.cloudflare.com/workers/configuration/cron-triggers
- Telegram Bots FAQ, sending limits: core.telegram.org/bots/faq
- Meta for Developers, WhatsApp template messages: developers.facebook.com/docs/whatsapp/cloud-api/guides/send-message-templates
Related comparisons and tools
Frequently asked questions
Can I use setTimeout for reminders?
Only for prototypes. In-memory timers disappear on every restart or deploy. Store jobs in a database and poll for due ones.
Should I store reminder times in local time or UTC?
Store the instant in UTC and the user's IANA time zone separately. Compute recurring times in the user's zone so daylight saving changes are handled.