Blogs

August 2026
5 min read

How we killed the hourly cron

The scheduler wasn't a performance problem. It was a product constraint.

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.

The thing I missed for months: if your scheduler only wakes up once an hour, then "remind me at 9:00 PM" can only ever mean "remind me sometime in the 9 PM hour." The polling interval had quietly become a product limit. I could not offer a feature I wanted to offer, because of a loop.

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 shape of the win: the old job's cost scaled with the number of users. The new job's cost scales with the number of things actually due. Those are wildly different numbers, and only one of them grows when Tabby grows.

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.

The honest summary: I traded a system that was wasteful but incapable of being wrong for one that is efficient and precise but has to be kept honest. For a feature people configure to the minute, that's the right trade. It would have been the wrong trade for something nobody tunes.

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?

Tabby, the product behind the writing.

Tabby lives in your Telegram chat — nothing to install.

Start Using Tabby

More from Behind the Build

5 min read

Why we built Tabby on Telegram

The decision to build a chat-first expense tracker instead of another app.

Read →

4 min read

Why we keep the core free

Our philosophy on what's free, what's Pro, and why it matters.

Read →

4 min read

Why we built Listen Mode

How we made group expense tracking feel like a normal conversation.

Read →

3 min read

The thinking behind Group Contexts

Why we let you track multiple projects in a single group chat.

Read →

4 min read

Why budgets should text you back

The behavioral design behind Tabby's budget alerts at 50%, 75%, and 100%.

Read →

3 min read

Why we added receipt scanning

The case for letting your camera do the logging.

Read →

4 min read

Recurring transactions vs. subscription tracking

They sound similar, but they solve very different problems.

Read →