active | Session completed today | ++ on first session of the day, no-op after |
| at_risk | After 7 pm with no session yet | Unchanged — emit push notification 1 |
| final_call | After 10:30 pm with no session yet | Unchanged — emit push notification 2 |
| shielded | Day ended at 2 am with no session, shields available | Apply shield, decrement shield count, streak unchanged |
| broken | Day ended at 2 am with no session, no shields | Reset to 0, emit "streak restore available" notification next day |
| restored | 2 sessions completed within 24h of break | Restore previous streak count, mark restore used |
CREATE TABLE user_streaks (
user_id UUID PRIMARY KEY REFERENCES users(id),
current_streak INT NOT NULL DEFAULT 0,
longest_streak INT NOT NULL DEFAULT 0,
last_session_at TIMESTAMPTZ,
state TEXT NOT NULL DEFAULT 'active'
CHECK (state IN ('active','at_risk','final_call','shielded','broken','restored')),
shields_remaining_this_month INT NOT NULL DEFAULT 2,
shields_reset_at TIMESTAMPTZ NOT NULL,
restore_available BOOLEAN NOT NULL DEFAULT TRUE,
last_state_change TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_user_streaks_state_change
ON user_streaks (state, last_state_change);
The cron job that runs every 30 minutes is one query: SELECT user_id FROM user_streaks WHERE state = 'active' AND last_session_at < now() - interval '20 hours' AND shields_remaining_this_month > 0. Index the right column and this query stays under 30 ms even at 50K users.
## The Pre-Ship Checklist (Streak Engine)
- State machine documented and unit-tested for every transition
- Time zone handling — every "today" check uses the user's local zone, not server zone
- Grace window of 24-26 hours (we picked 26)
- 2-3 monthly shields (more = devalues the streak; fewer = no real grace)
- Restore option for one broken streak (cap at 1 to prevent gaming)
- Push cadence capped at 2 per day, off between 11 pm and 7 am
- Festival shield list updated twice a year
- Sunday family-time quiet hours (or your geography's equivalent)
- "Streak repaired" positive notification (not just risk-warnings)
- Audit log for every state transition (debug "why did my streak break")
user.timezone for streak math.
Symptom: "App uninstalls correlate with notification volume." Cause: too many pushes. Cap hard. Two per day is enough.
Symptom: "Streak counter is off by one for some users." Cause: counting the same day twice when a session crosses midnight. Use the streak day boundary (your 2 am cutoff), not ::date.
Symptom: "Restore feature is being abused." Cause: no per-broken-streak cap. Limit restores to one per broken streak instance.
Symptom: "Engagement on streak feature is high but D7 unchanged." Cause: the streak feature is engaging the wrong users — power users who would have retained anyway. Look at D7 lift in the at-risk cohort, not the average.
## When NOT To Build This
Skip a streak engine if (a) your app is not actually a daily-use product — adding artificial daily pressure to a weekly app makes users quit faster, (b) your audience is professional / B2B — streaks read as childish in serious tools, or (c) you have not solved D1 yet. Streaks are a D7+ tool. If your D1 is below 30%, fix onboarding first. Our D1 activation post describes the screens that come before the streak engine matters.
## Real Example — A 32-Year-Old IELTS Aspirant In Pune
One of our shielded users in late August: a 32-year-old IELTS aspirant in Pune with a 14-day streak. She missed two days during a family wedding. On the morning after the second miss, she got the "we saved your streak with a shield, 1 left this month" notification. She opened the app, did 8 minutes, and continued the streak. Six weeks later her streak is at 51 days. Without the shield rule, our cohort data says she had a 38% chance of churning that week. Multiply that decision by ~600 users a month who hit a similar pattern, and the unit economics of two free shields are straightforward.
## A Detail That Saved Us On Day 41
On day 41 of the rollout, support tickets spiked: "my streak says 0 but I have done sessions all week." Investigation found that users who used multiple devices were getting their streak counted on the device that synced last. The streak join key was a (user_id, device_id) pair instead of just user_id. Fix took 90 minutes; recovery script for affected users took 6 hours of careful eyeballing. The lesson: every user-facing metric must have one source of truth, indexed only by user_id, never by device.
## FAQ
### Why 26-hour grace day instead of 24?
The 24-hour version had a hard edge at midnight that punished late users. 26 hours gives a 2 am soft edge, which is past the most common "I almost forgot" hour (11:30 pm). Tested vs 25 and 28 hours; 26 had the best D7 lift per hour added.
### Why 2 monthly shields and not 4?
We tested 1, 2, 3, and 4 shields per month. D7 lift maxed out at 2 (5 percentage points). Adding more shields started reducing the perceived value of the streak — users started joking "my streak is fake, I just used 4 shields." 2 hits the right balance.
### What about Android battery-optimisation killing the cron-driven push?
Push delivery is server-side via FCM / APNs, not on-device cron. Android's battery doze does not affect server pushes (it affects when the device wakes to display them). Test by enabling battery saver and verifying delivery within 30 minutes.
### Did you A/B test the push copy?
Yes. Personalisation (first name + streak count) outperformed generic copy by 4.2x in open rate. Question marks ("Ready to study?") underperformed statements ("Your streak is waiting") by 1.8x. Emojis had no measurable lift in our Indian audience.
### What about users in different time zones?
We store user timezone at signup. Every push and every streak calculation runs in user-local time. Three users moved time zones during the test (NRIs returning to India); we built a time-zone-change handler that gives one free streak shield to absorb the discontinuity.
### Does the streak engine work for B2B / enterprise customers?
We have not tried. Our mobile development service has clients in HR-tech where streaks would feel infantilising. The engine is built for consumer habit-formation; enterprise needs different motivation patterns.
### What metric did you track to know the engine was actually working?
D7 cohort retention, weekly. Not D1 (too noisy week-to-week), not D30 (too slow to react to changes). D7 is the sweet spot for a 6-week experiment cadence. Secondary metric: median streak length of users who hit D7 (rose from 4 to 9 days).
### Is the streak engine open-source?
Not currently. The schema and state machine are simple enough to rebuild from this post; the value sits in the calibrated thresholds, not the code. If a few teams ask, we will publish the React Native client component and the Postgres migration.
Want a retention-engine review for your mobile app?
We will spend 90 minutes auditing your D1 / D7 / D30 funnel, your push cadence, and your streak / habit mechanics. ₹0 for the call, ₹18,000 for a written 12-page audit with prioritised fixes. We have shipped retention engines for 3 production apps including TalkDrill and PenLeap. Email contact@softechinfra.com.
Book the 90-min audit call