A daily-streak learning app sends a lot of push notifications — and one badly timed batch can train users to swipe the app away forever. On
TalkDrill, our in-house English-fluency app, the reminder engine has to nudge thousands of learners to practise without ever feeling like spam. This post is the engineering of that scheduler: the frequency caps, the quiet-hours logic, the timezone handling that kept us from waking people at 3 am, and the A/B test that told us where the line was.
≤ 2/day
Hard Frequency Cap
21:30–08:00
Default Quiet Hours (local)
1 job/15 min
Scheduler Tick
7 send slots
Per User, Per Day, Max
## What is a notification cadence engine, in plain terms?
A cadence engine decides, for each user, whether to send a push right now — based on their local time, how many they've already had today, what they last did in the app, and which message type is queued. It is a per-user gate sitting in front of your push provider. The hard part is not sending notifications; it is correctly deciding not to send most of them.
Note up front: the TalkDrill figures here are illustrative composites of how the engine is designed, not live production metrics.
## Why this matters now
Push fatigue is the fastest way to lose a daily-habit app. Once a user mutes your channel or turns off notifications at the OS level, you have lost your cheapest retention lever and there is no winning it back without a reinstall. iOS and Android both expose per-app notification controls one tap away, and users reach for them the moment a reminder feels disrespectful — wrong hour, too many, irrelevant. The whole engine exists to never give them that reason.
The goal isn't more sends — it's the fewest sends that still hold the habit. Every notification you suppress is one you'll never have to apologise for. The scheduler optimises for restraint first, reach second.
## The five rules the scheduler enforces
We covered the retention outcomes in our post on the
TalkDrill streak engine and D7 retention; this is the plumbing underneath it. The scheduler applies five gates, in order, and the first one that fails kills the send.
🌙
1. Quiet-hours gate
No send between the user's local 21:30 and 08:00 by default. Computed from the user's stored IANA timezone, not the server's. A queued message waits for the morning window.
🔢
2. Frequency cap
Hard ceiling of two notifications per user per day across all message types. A rolling counter keyed by user-id and local calendar date in Redis.
⏱️
3. Min-gap rule
At least four hours between any two sends to the same user, so even two allowed notifications never arrive back-to-back.
🎯
4. Relevance gate
If the user already practised today, the streak-reminder is suppressed — you don't nag someone who just did the thing. Only a different message type may pass.
The fifth gate is priority: when two messages are eligible in the same tick, the higher-priority one (a streak about to break) wins the single slot, and the lower-priority one (a feature tip) is dropped, not deferred. Dropping beats stacking.
Why drop, not defer? Deferred messages pile up and fire as a clump the next day, which is exactly the spam pattern you're trying to avoid. A feature tip that missed its slot is rarely worth sending late — let it die and queue a fresh one tomorrow.
## How do you build it? (the step-by-step)
This is the architecture, runnable on the kind of stack we use for client work — Node.js workers, Redis for counters, a cron-style ticker, and a push provider (FCM for Android, APNs for iOS).
1
Store each user's timezone, not an offset
Save the IANA zone string like Asia/Kolkata on the user record, captured at signup from the device. Offsets break on DST; zone strings don't. India has no DST, but your NRI users in America/New_York do. Verification: render each user's "now" in their zone and eyeball ten accounts across three continents.
2
Run a 15-minute ticker, not a per-user timer
Every 15 minutes, the worker queries "which users are now inside their send window and due a message?" This scales far better than scheduling millions of individual timers. Verification: the tick processes the full eligible set in under the 15-minute budget — log the duration and alert if it creeps past 60% of the window.
3
Gate each candidate through the five rules
For every candidate send, check quiet-hours, frequency cap, min-gap, relevance, then priority — short-circuiting on the first failure. Use a Redis INCR with a daily-expiry key for the counter. Verification: unit-test each gate with a user who should be blocked by exactly that gate and no other.
4
Pick the send time inside the window
Don't fire every morning send at exactly 08:00 — your provider throttles and users get a synchronised buzz. Spread sends across the window, ideally nudging toward each user's historical active hour. Verification: plot send timestamps for one cohort; you want a spread, not a spike at the window edges.
5
Record the send and respect opt-outs
Write every send to a log with user, type, and local timestamp; increment the counter; honour any channel-level mute immediately. Verification: a muted user receives zero sends in the next 100 ticks, confirmed from the log.
6
Wire a kill switch and a dry-run mode
One flag stops all sends; a dry-run mode logs what would have been sent without sending. You will want both the first time a bug threatens to message everyone twice. Verification: flip dry-run on and confirm the log fills while the push provider stays silent.
## Where do timezones bite? (the mistakes)
Three failure modes account for most "we woke up our users" incidents. Each starts with the symptom.
The symptom is users in one country complaining about 3 am pushes. The cause is scheduling off the server clock (often UTC) instead of the user's local time. The fix is the IANA-zone approach in step one — never an offset, never the server's now.
The symptom is a clump of notifications arriving together after a quiet spell. The cause is deferring suppressed messages instead of dropping them, so they release in a batch. The fix is drop-don't-defer for low-priority types.
The symptom is the frequency cap "not working" near midnight. The cause is keying the daily counter by UTC date while users live in local dates — a user at 23:00 IST and 01:00 IST straddles two UTC days but one local day. The fix is keying the counter by the user's local calendar date.
The DST trap. If you store offsets like "+5:30" instead of zone strings, your US and EU users get shifted by an hour twice a year and start receiving pushes inside their quiet window. We've debugged this exact bug in two client apps. Always store the IANA zone.
## The A/B test: how aggressive is too aggressive?
We ran a cadence A/B across a notification-receptive cohort. Arm A was the conservative engine above (cap of two, quiet hours, drop-don't-defer). Arm B added a third daily slot and a shorter two-hour min-gap. The aggressive arm bought a small short-term bump in opens — and a meaningfully higher channel-mute rate within the first week, which is a one-way door. The conservative arm held retention with fewer sends. We shipped conservative.
The lesson generalises beyond our app: open rate is a vanity metric for notifications, because it rewards the exact behaviour that kills you. You can always lift opens by sending more — right up until users mute you, and then your reachable audience shrinks and never recovers. The metric that actually matters is reachable-and-engaged users over time. A cadence that sends half as much but keeps 95% of users notification-on will beat an aggressive one every quarter, because the aggressive one is quietly burning down the list it depends on. Measure the mute rate as carefully as you measure opens, and treat any cadence change that raises it as a regression — even if opens went up that week.
- User timezone stored as an IANA zone string, captured at signup
- Quiet hours computed in local time, default 21:30–08:00
- Hard frequency cap of 2/day, counter keyed by local date
- Minimum 4-hour gap between any two sends
- Relevance gate: suppress streak nudge if user already practised
- Priority resolution drops the loser, never defers it
- Sends spread across the window, not fired at the edge
- Kill switch + dry-run mode tested before any broad campaign
## A real example: the cadence we ship for clients
We rebuilt the reminder layer for a composite Bengaluru habit-tracking app, 30,000 installs, that was firing up to five pushes a day off a UTC cron. Their channel-mute rate was climbing and they couldn't see why. We ported the five-gate engine, moved the counter to local-date keys, and dropped them to a two-per-day cap with proper quiet hours.
Within three weeks the mute rate fell and daily-active held steady on roughly half the notification volume — fewer sends, same engagement, far fewer angry reviews about midnight buzzes. As
Vivek, who leads product at Softechinfra, told that client: the restraint is the feature, and the quiet inbox is what earns the next month of opens. The same scheduler design backs the reminders on
TalkDrill, our in-house app, and it's the pattern our
mobile development team reaches for on every habit product. For proof of the retention side of this work, see
the TalkDrill case study.
## Frequently asked questions
### How many push notifications per day is too many?
For a daily-habit app, a hard cap of two per user per day is a safe default, with a four-hour minimum gap so they never arrive together. The exact ceiling depends on your category, but the failure mode is always the same: cross the line and users mute the channel permanently.
### Why store a timezone instead of a UTC offset?
Offsets like "+5:30" don't account for daylight saving, so users in the US and EU drift by an hour twice a year and start getting pushes inside their quiet hours. Storing the IANA zone string (such as Asia/Kolkata) lets the scheduler compute correct local time year-round.
### What are quiet hours and what should they be?
Quiet hours are the local-time window where the scheduler never sends, so you don't wake users. A sensible default is roughly 21:30 to 08:00 in the user's own timezone. Messages queued during quiet hours wait for the next morning window rather than firing late at night.
### Should suppressed notifications be sent later or dropped?
Drop low-priority ones. Deferred messages pile up and release as a clump, which is the exact spam pattern that drives mutes. A high-priority message like a streak about to break can be retried in the next window, but a feature tip that missed its slot is better replaced by a fresh one tomorrow.
### How do you measure if your cadence is too aggressive?
Watch the channel-mute and notification-disable rate, not just open rate. An aggressive cadence often lifts short-term opens while quietly raising mutes — and a mute is a one-way door. If mutes climb after a cadence change, you've gone too far regardless of what opens did.
### Can this scheduler run on a small stack?
Yes. A Node.js worker on a 15-minute cron, Redis for the per-user daily counters, and your push provider (FCM and APNs) is enough to run this for tens of thousands of users. The architecture scales by querying eligible users per tick rather than scheduling a timer per user.
Need a retention-safe notification engine for your app?
We build per-user cadence schedulers — frequency caps, quiet hours, timezone-correct sends, and the A/B test rig to tune them — for Indian app teams in about 10 working days. Typical project: ₹60k–₹1.2L. Suitable if your push volume is climbing and your mute rate is too. No slides — just your retention curve and our honest take.
Book a 20-min Call