Streaks that survive timezones: the dayKey protocol
A reading streak that resets because your server lives in UTC will kill your retention loop. Here's a small protocol that fixes it.
Streaks are the cheapest retention mechanic that actually works, and the easiest to get wrong. The failure mode is always the same: the server decides what "today" is in UTC, a user in Mumbai logs their reading at 11:30pm, and the app tells them their 40-day streak died. They uninstall, and they're right to.
Whose midnight is it?
The core question of any streak system: when does a day end? There are three wrong answers:
- Server time: punishes everyone not living in your datacenter's timezone
- Store a timezone per user: breaks the week the user flies somewhere, and timezone-aware date math in every query is a bug farm
- Trust the client completely: now streaks are a client-side integer and cheating is an HTTP request
The dayKey compromise
The protocol I settled on for Penspace: the client computes a local calendar day key (2026-03-18, in the user's local time) and sends it with the activity log. The server doesn't try to know the user's timezone. It just validates that the claimed dayKey is plausible: it must fall within the window of calendar days that exist anywhere on Earth right now (roughly UTC−12 to UTC+14, a ~26-hour window).
- The user's local midnight is respected, because they computed the key
- Cheating is bounded: you can't claim yesterday after the window closes, and you can't bank future days
- The server stores canonical day records and computes current/longest streaks from them; the client never sends "my streak is N"
Validation is one comparison. No timezone tables, no DST edge cases in queries, no per-user state that goes stale.
Reminders live server-side
The companion mistake is local-only reminder notifications: they die with reinstalls and OS battery savers. Penspace runs a nightly cron that finds streaks at risk and sends FCM pushes: the reminder is a server opinion derived from the same day records, so it can't disagree with the streak itself.
One protocol note that saved me: make the client compute the dayKey at the moment of the activity, not at sync time. Offline logs synced the next morning should count for the day the reading actually happened: that's not an edge case, that's your most devoted users on the subway.
Small system, disproportionate stakes. The streak is the product for habit apps; treat its correctness like you'd treat payments.