Tabby sends you things on a schedule. A nudge in the evening if you haven't logged anything. A spending summary on Sunday night. A fresh set of budgets on the 1st of the month. A heads-up before a free trial turns into a charge.
The obvious way to build that is a cron job. Every hour, wake up, loop over every user, and ask each one: is it your time yet? That is exactly how Tabby worked for a long time, and it worked fine. Until I looked at what it was actually costing, and what it was quietly preventing.
The cost nobody notices
With hourly polling, the work you do scales with how many users you have, not with how much there is to do. Every hour, Tabby walked the entire user table to discover that almost nobody needed anything. At any given hour, the overwhelming majority of those checks are answered "no."
That is wasteful, but waste alone is a weak reason to rewrite something that works. The real problem was subtler.
Tabby lets you pick your reminder time, and users are spread across dozens of timezones. Hourly granularity is the coarsest possible answer to a question people asked precisely.
Turning schedules into rows
The fix was to stop asking "who needs something right now?" and start writing down the answer in advance.
Now, when you finish onboarding or change a setting, Tabby computes when your next reminder should fire, converts that moment from your local timezone into UTC, and stores it as a row in a scheduled_events table. One row per upcoming thing: a type, a chat, a timestamp, a status, a retry count.
The loop that runs every minute no longer knows anything about users. It asks one question: are there any pending rows whose time has passed? That query is indexed on type, status and time, and it returns nothing at all for most of the day.
The precision came along for free. Checking every minute instead of every hour made "9:00 PM" mean 9:00 PM, in your timezone, without a single extra line of scheduling logic.
Failure gets easier, not harder
The part I didn't expect: moving the schedule into the database made failure handling simpler.
When your scheduler lives in memory, a crash or a deploy loses whatever it was holding. When the schedule is a table, a restart loses nothing. The rows are still there, still pending, still overdue, and the next tick picks them up.
It also gives failure somewhere to be recorded. If Telegram is briefly unreachable, the row goes back to pending with its retry count bumped and its time pushed out a few minutes. Tabby tries three times, five minutes apart, then stops and marks the row failed rather than hammering a dead endpoint forever.
Those numbers aren't arbitrary. Most transient network problems clear inside five minutes. Fifteen minutes is about the longest you can delay an evening reminder before it stops being an evening reminder. Past that, retrying isn't resilience, it's noise.
And some failures aren't transient at all. If you've blocked the bot, no number of retries will help, so Tabby reads that specific error, marks the account inactive, and stops writing to a chat that doesn't want it.
The trade-off
Polling had one real virtue: it was stateless. It couldn't be wrong, because it recomputed everything from scratch every hour. There was no schedule to keep correct.
Pre-computed events give that up. Now there is state, and state can drift. Change your reminder time and the row that already exists is stale. Move timezones and every future row is wrong. Every setting that feeds into a schedule has to know it must regenerate the rows behind it, and forgetting that in one place is a bug polling could never have had.
What I'd tell myself earlier
I put off this rewrite because I filed it as a performance task, and performance wasn't hurting. It sat in the backlog behind things that felt more like features.
It was a feature. Every time I sketched something scheduled and thought "that'll need to be hourly, then," that was the old loop making a product decision on my behalf. The rewrite didn't just make the job cheaper. It removed a ceiling I'd stopped noticing was there.
Worth asking, if you've got a cron job of your own: is it doing what you want, or have you quietly designed around what it allows?