Scheduling Messages and Background Jobs in Telegram Bots
Reminders, daily digests and scheduled broadcasts that survive restarts — database-driven scheduling, APScheduler, time zones and avoiding missed or duplicate jobs.
On this page
Reminders, daily digests, subscription renewals, scheduled announcements — sooner or later most Telegram bots need to do something at a particular time rather than in response to a message. Scheduling is easy to get working and easy to get subtly wrong: jobs vanish on restart, fire twice, or run at the wrong hour for half your users. This article shows patterns that hold up in production.
The trap: timers in memory
The simplest approach is a timer:
asyncio.get_running_loop().call_later(3600, send_reminder, user_id)
It works until the bot restarts — for a deploy, a crash, or host maintenance — and every pending timer disappears. On a host with push-to-deploy, restarts are routine, so in-memory timers are only suitable for things that don’t matter if they’re lost, like deleting a temporary message after a minute.
Anything a user is counting on needs to be stored.
Pattern 1: Store jobs in your database and poll
The most robust pattern is also the simplest. Store each scheduled item with its due time, and have a background loop check for due items every few seconds:
CREATE TABLE reminders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
text TEXT NOT NULL,
due_at TIMESTAMPTZ NOT NULL,
sent_at TIMESTAMPTZ
);
CREATE INDEX reminders_due ON reminders (due_at) WHERE sent_at IS NULL;
import asyncio
import logging
async def reminder_loop(bot, pool):
while True:
try:
async with pool.acquire() as conn:
rows = await conn.fetch(
"""UPDATE reminders SET sent_at = now()
WHERE id IN (
SELECT id FROM reminders
WHERE sent_at IS NULL AND due_at <= now()
ORDER BY due_at LIMIT 50
FOR UPDATE SKIP LOCKED)
RETURNING user_id, text"""
)
for r in rows:
try:
await bot.send_message(r["user_id"], f"⏰ {r['text']}")
except Exception:
logging.exception("Reminder to %s failed", r["user_id"])
except Exception:
logging.exception("Reminder loop error")
await asyncio.sleep(5)
Start the loop as a background task when the bot starts (for example with asyncio.create_task() in aiogram’s startup hook). Why this works well:
- Restarts are harmless. Due reminders are still in the table; the loop picks them up when the bot comes back.
- No duplicates. Marking rows as sent in the same statement that selects them — with
FOR UPDATE SKIP LOCKEDin PostgreSQL — means two processes can’t send the same reminder. - Easy to inspect and edit. Users can list and cancel reminders with plain queries.
The trade-off is a delay of up to the polling interval, which is fine for reminders.
This is PostgreSQL; MySQL 8 supports FOR UPDATE SKIP LOCKED too, though you’ll select and update in two steps within a transaction. With SQLite and a single process, a simple select-then-update is enough.
Pattern 2: A scheduler library with a persistent job store
For cron-style schedules — “every day at 09:00”, “every Monday” — a scheduler library saves you from writing the calendar logic. In Python, APScheduler (the stable 3.x series) is the common choice, and it can persist jobs in a database:
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
scheduler = AsyncIOScheduler(
jobstores={"default": SQLAlchemyJobStore(url="sqlite:///jobs.sqlite")},
timezone="UTC",
)
scheduler.add_job(
send_daily_digest, "cron", hour=9, minute=0,
id="daily-digest", replace_existing=True, misfire_grace_time=3600,
)
scheduler.start()
Two parameters matter on a server:
idwithreplace_existing=True— without them, every restart adds another copy of the job, and your digest goes out twice, then three times.misfire_grace_time— if the bot was down at 09:00, how late may the job still run? An hour is sensible for a digest; for time-critical jobs, a smaller value avoids sending stale messages.
In Node.js, libraries like croner or node-cron handle cron expressions; store any per-user schedules in your database and register them on startup.
Time zones: store UTC, display local
Users live in different time zones, and “remind me at 8 pm” means something different to each of them.
- Store every timestamp in UTC (
TIMESTAMPTZin PostgreSQL, or UTCDATETIMEvalues in MySQL). - Store each user’s time zone as an IANA name like
Asia/KolkataorEurope/Berlin— not a fixed offset, because offsets change with daylight saving time. - Convert at the edges: parse user input in their zone, convert to UTC for storage, convert back when displaying.
In Python, zoneinfo (standard library since 3.9) does the conversion:
from datetime import datetime
from zoneinfo import ZoneInfo
local = datetime(2026, 6, 1, 20, 0, tzinfo=ZoneInfo("Asia/Kolkata"))
due_utc = local.astimezone(ZoneInfo("UTC"))
Ask users for their time zone once — or infer it from a city they type — and save it in their profile.
Scheduled broadcasts
A scheduled announcement to thousands of users is a broadcast and must respect Telegram’s limits — roughly 30 messages per second overall. Don’t loop and send as fast as possible when the time arrives. Instead, when the schedule fires, enqueue the recipients and let a paced sender work through them, honouring retry_after on 429 errors and skipping users who blocked the bot. The pattern is in Telegram Bot API limits.
Keep heavy work off the update path
Background jobs share the process with your update handlers. A job that generates a big report or processes media can make the bot sluggish while it runs. Keep jobs asynchronous, break large jobs into batches with short pauses, and for truly heavy work, run a separate worker process that reads from the same database. On Kerit Cloud’s Ultra plan you can run a bot and a worker side by side on one server — see running multiple Telegram bots on one server.
When cron on the server is better
If a task doesn’t need the bot’s live connection — a nightly cleanup script, a report emailed to you — a system cron job on a VPS is simpler than scheduling inside the bot. Cron jobs and scheduled tasks on Linux explains the syntax. On managed bot hosting, keep scheduling inside the bot process using the patterns above.
A checklist for reliable schedules
- [ ] Nothing important scheduled only in memory
- [ ] Due items stored with UTC timestamps
- [ ] Job IDs stable, with
replace_existingso restarts don’t duplicate - [ ] A misfire policy for jobs missed while the bot was down
- [ ] Items marked as sent atomically, so they can’t send twice
- [ ] User time zones stored as IANA names
- [ ] Broadcasts paced and resumable
- [ ] Errors in one job logged without stopping the loop
Summary
Scheduled work in a Telegram bot must survive restarts. Store reminders and jobs in a database and poll for due items, or use a scheduler like APScheduler with a persistent job store, stable IDs and a misfire policy. Keep timestamps in UTC and user time zones as IANA names, pace scheduled broadcasts, and keep heavy jobs from blocking your update handlers.