Your team decides to add a daily streak feature to track consecutive days of user activity. Should be straightforward, right? Three weeks later, you're debugging why users in different time zones lose streaks unfairly, handling edge cases around daylight saving time, and dealing with race conditions when users act near midnight.
Streak tracking seems simple until you implement it. The core logic (did user act today?) hides complexity in what "today" means. Whose today? Using server time would create unfair treatment for users in time zones far away from your server. Using local time zones is better but adds complexity to the solution, and requires careful testing and validation.
Trophy is a pre-built platform for building gamification features like streaks handling streak tracking including time zones, streak freezes, and edge cases. The complete implementation guide in the Trophy docs covers the full process but this provides an outline of the data model for a streak feature, what's required to handle all common edge cases and features, and provides an honest comparison to how this differs when using Trophy.
| Concern | Build It Yourself | Use an API |
|---|---|---|
| Data model | You design and maintain the events and user_streaks tables yourself | Pre-built schema; you send events, the API tracks state |
| Timezone handling | You write the "midnight problem" logic (store in UTC, convert per user, handle DST transitions) | Handled automatically; pass the user's timezone on signup |
| Streak freezes and grace periods | Custom columns, conditional logic, and product rules you own forever | Config flags; toggle freezes or grace windows without code |
| Reminders | Your cron job, your push/email integration, your send-frequency rules | Built-in triggers; set conditions, the API sends or fires a webhook |
| Time to production | 2–6 weeks for a basic streak; longer if you add freezes, reminders, and experiments | Hours to days, depending on how custom your UI is |
Key Points
- The technical challenges that make streak tracking harder than it appears
- A suggested domain model for a streaks feature
- How to manage time zones
- Implementation patterns for scalable streak systems
- Comparison of building in-house vs using a platform like Trophy
The Technical Reality
Before building streak tracking, understand the problems you're solving.
Time zone fairness requires per-user calculations. A user in Tokyo and a user in New York shouldn't compete on different playing fields. Server-time streaks give systematic advantages to users in certain time zones. Implementing time zone handling correctly takes weeks and ongoing maintenance as time zone rules change with daylight savings.
Consistency guarantees prevent race conditions. If a user acts at 11:59 PM and 12:01 AM, that's one action or two depending on timing. Concurrent requests near midnight create edge cases. Without proper handling, users might extend streaks twice for one action in some cases or lose streaks despite acting in others.
Streak freeze logic adds complexity. Users need forgiveness for missed days without making streaks meaningless. Tracking freeze counts, granting new freezes over time, and applying them correctly when streaks would break requires careful state management. This is the type of feature that silently breaks in production without the proper validation harness in place.
Historical data for streak calendars and analytics needs efficient storage and querying. Showing users their past year of activity means storing and retrieving daily snapshots. This grows linearly with users and time.
DST transitions create days that are 23 or 25 hours long. Naive date math breaks. Users might lose streaks on spring-forward days or extend twice on fall-back days without proper handling.
Track User Activity
Every streak starts with raw events. Log each action (a completed lesson, a workout, a journal entry, a meditation session) with the user ID, event type, and timestamp. A second table holds the calculated streak state so you can query it without re-scanning the event log.
CREATE TABLE events (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL,
event_type VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE user_streaks (
user_id UUID PRIMARY KEY,
current_streak INT NOT NULL DEFAULT 0,
longest_streak INT NOT NULL DEFAULT 0,
last_active_date DATE NOT NULL -- stored in user's local calendar date, e.g. '2025-01-15'
);
last_active_date is a calendar date in the user's local timezone, not a UTC timestamp. This matters: a user who finishes at 11 p.m. Pacific should credit that calendar day, not the next UTC day.
Calculate and Store the Streak
When a new event arrives, compare today's local date to last_active_date and update the streak accordingly:
- Same day: The user already extended today. Do nothing.
- Next calendar day: Increment
current_streakby 1 and setlast_active_dateto today. - Gap of two or more days: The streak broke. Update
longest_streakifcurrent_streakwas higher, resetcurrent_streakto 1, and setlast_active_dateto today.
from datetime import date
def update_streak(user_streak, today: date):
gap = (today - user_streak.last_active_date).days
if gap == 0:
return # already extended today
if gap == 1:
user_streak.current_streak += 1
else:
if user_streak.current_streak > user_streak.longest_streak:
user_streak.longest_streak = user_streak.current_streak
user_streak.current_streak = 1
user_streak.last_active_date = today
Run this logic inside the same transaction that inserts the event. If the insert fails, the streak update rolls back with it.
Timezone Handling and the Midnight Problem
Storing event timestamps in UTC and computing streak windows in each user's local time is the best starting point for ensuring that a streak feature handles all timezones and time-based edge cases properly, but it's not the whole picture.
Why UTC storage? Your database stays consistent. You avoid ambiguity when users travel or when DST shifts the clock. Every event has one canonical timestamp.
Why local-time computation? A user in Tokyo completing a lesson at 11 PM local time shouldn't lose their streak because your server thinks it's already tomorrow. Convert the UTC timestamp to the user's IANA timezone (like Asia/Tokyo or America/New_York) before comparing to last_active_date.
DST breaks naive date math. On spring-forward days, 24-hour arithmetic skips a calendar day. On fall-back days, the same hour occurs twice. Use a timezone library that handles DST transitions correctly. In JavaScript, Intl.DateTimeFormat with the timeZone option works. In backend code, libraries like date-fns-tz, Luxon, or Python's zoneinfo handle edge cases.
Race conditions near midnight create double-counting or missed extensions. If a user acts at 11:59 PM and again at 12:01 AM, those are two calendar days. Your system needs to determine which day each event belongs to before updating streak state, and handle concurrent requests that arrive in the same millisecond window.
For a deeper look at timezone edge cases in gamification systems, see Handling Time Zones in Gamification.
Streak Freezes and Grace Periods
Inevitably users will miss days, which in a streak setting can inadvertently punish users for taking a day off. Streak freezes and grace periods give users a break without that demotivation.
Grace periods let actions shortly after midnight count for the previous day. If a user opens your app at 12:30 AM and completes their daily task, you can credit that to yesterday instead of resetting their streak. A 3 to 6 hour window is common. Beyond that, users start gaming the system by acting at 5 AM and calling it "yesterday."
Implementation: when an event arrives, check if last_active_date is yesterday and the current local time is within your grace window. If both are true, treat the event as extending the previous day's streak rather than starting a new day.
Streak freezes give users a limited number of free passes. The pattern:
- Store a
freeze_countper user. - Grant new freezes over time (one per week, or one per 7-day streak maintained).
- Cap freezes at a maximum (two or four prevents hoarding).
- When a user misses a day, check if they have freezes remaining. If yes, decrement
freeze_countand preserve the streak. If no, reset.
The tricky part is timing. Freezes should apply automatically at the end of the day, not when the user next opens the app. This requires a scheduled job that runs constantly granting new streak freezes to users that qualify, and consuming streak freezes for users that need one.
Remind Users Before They Lose It
A reminder only works if it arrives before the streak breaks. Run a cron job every 15 minutes (or hourly), find users whose local time falls inside the reminder window (e.g., 8–10 p.m.), filter to those who have not extended today, and send both a push notification and an email. Mark each user as notified so you send at most one reminder per day.
# cron: */15 * * * *
from datetime import datetime, timedelta
import pytz
def send_streak_reminders():
users = get_users_with_active_streaks()
for user in users:
tz = pytz.timezone(user.timezone)
local_now = datetime.now(tz)
local_today = local_now.date()
# Skip if already extended today
if user.last_active_date == local_today:
continue
# Skip if already notified today
if user.last_reminder_date == local_today:
continue
# Check reminder window (e.g., 20:00–22:00 local)
if not (20 <= local_now.hour < 22):
continue
# Skip if user opted out
if not user.reminders_enabled:
continue
# Send push and email
send_push(
user_id=user.id,
title="Your streak is waiting",
body=f"You're on a {user.current_streak}-day streak. One action keeps it alive."
)
send_email(
to=user.email,
subject="Don't lose your streak",
body=f"You've built a {user.current_streak}-day streak. Log in to keep it going."
)
# Mark notified
user.last_reminder_date = local_today
save(user)
Best practices:
- One reminder per day. More than that trains users to ignore you.
- Respect preferences. Let users disable reminders or choose push, email, or both.
- Personalize the copy. Include the streak count. A generic "come back" message converts worse than one that names what the user will lose.
- Time it right. Send when the user typically engages, or default to evening local time when there is still time to act.
Building Streak Analytics
Product teams that need to operate a streaks feature at scale will inevitably need analytics to determine if the streak feature is actually increasing user engagement and retention.
The types of analytics teams will need includes:
- Average streak length as the core data point for streak health.
- Distribution of streak length across the user base i.e. 'share of users on an X day streak' for understanding adoption and loss patterns.
- Streak freeze grant/consumption rates for understanding if freezes are balanced or if they are artificially inflating streak numbers.
- Streak retention tracking to understand how your streak feature is impacting user engagement and retention
The complexity of computing these analytics comes from the shear volume of data required to track them. This is easy to understand when you consider that, in order to track historical streak data you need one user_streaks row per user per day. For a user base of 1,ooo users, that's 30K rows/month. For 100,000 users, clearly the data volume grows significantly.
Additionally, raw user interaction events must also be stored, so that the streak can be calculated correctly. Depending on the nature of the user interactions being tracked, these can run to 10-100X the data volume of user_streaks. For example, users might complete workouts a couple of times a week, but they might flip flashcards hundreds of times a day. For a user base of 100,000 users, the raw event log could run into 100M+ rows within a few months.
Within a year, an average 100,000 user app could be storing 1B+ rows of events and streak data. At that scale, analytics crashes the database, computation slows down, eventually slowing down the UI to a halt.
To fix this, teams usually implement data aggregation over defined timeframes, piping the relational data into a separate column-based data models, and caching for hot reads. Each layer adds complexity, more maintenance and more points of failure.
Consecutive-Day Logic Across Time Zones and DST
Streaks feel simple until your users span multiple continents. If you store a UTC timestamp and compare against "24 hours ago," a user in Tokyo and a user in Los Angeles will see different streak behavior for the same activity pattern. Worse, Daylight Saving Time will break your math twice a year.
Store UTC, evaluate locally. The correct pattern: persist every timestamp in UTC, but evaluate streak logic against the user's local calendar date. Use an IANA timezone identifier (e.g., America/New_York), not a raw UTC offset like +05:30. Offsets don't encode DST rules, so they go stale the moment clocks change.
Compare calendar-date strings, not rolling windows. A 24-hour rolling window sounds correct but fails under DST. In spring, a "spring forward" day is only 23 hours long. In fall, a "fall back" day stretches to 25 hours. If your code checks whether the previous activity occurred within the last 86,400 seconds, a user who was active at 11 PM on both days can still break their streak during the short day. Calendar-date comparison sidesteps the problem: convert each timestamp to the user's local YYYY-MM-DD string and compare strings.
Here's a minimal implementation using date-fns-tz:
import { toZonedTime, format } from 'date-fns-tz';
function toLocalDate(utcDate: Date, tz: string): string {
return format(toZonedTime(utcDate, tz), 'yyyy-MM-dd', { timeZone: tz });
}
type StreakResult = 'extended' | 'maintained' | 'broken';
function evaluateStreak(
lastActivityUtc: Date,
currentUtc: Date,
tz: string
): StreakResult {
const lastLocal = toLocalDate(lastActivityUtc, tz);
const todayLocal = toLocalDate(currentUtc, tz);
if (lastLocal === todayLocal) return 'maintained';
const yesterday = new Date(currentUtc);
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayLocal = toLocalDate(yesterday, tz);
return lastLocal === yesterdayLocal ? 'extended' : 'broken';
}
For a deeper walkthrough (including edge cases around the midnight boundary), see the complete timezone and DST implementation guide.
Reset at local midnight, not UTC midnight. A single cron job at 00:00 UTC fires at 7 PM Eastern and 5 PM Pacific. That's useless for resetting streaks at the user's actual midnight. You have two options: evaluate lazily on the user's next interaction (simplest), or schedule per-timezone jobs that fire at each IANA zone's local midnight (more infra, but lets you send "streak broken" messages immediately).
Send reminders relative to local expiry. A "you're about to lose your streak" push notification should land in the user's evening, a few hours before their local midnight. If your scheduler runs on server time, users in far-flung timezones will get nudges at 3 AM or miss the window entirely. Compute the send time from the user's stored timezone, not from your server's clock.
Trophy handles this automatically. When you log a metric event, Trophy evaluates streak periods using calendar-day comparison in the user's timezone (pulled from the tz field on each event). DST transitions don't break streaks. Reminders fire relative to local expiry time, so users get nudges when they can still act. You define the streak rule once; we handle the edge cases.
Implementation Estimate
Here's realistic timeline for building streaks in-house based on the practice shared in this article:
Week 1: Basic implementation. Track user actions in the events table and computed streaks in the user_streaks table. Simple consecutive-day logic, load test at scale. Works in development with sample data.
Week 2: Time zone handling. Per-user timezone storage. Converting calculations to local time. Handling timezone changes when users travel across time zone borders.
Week 3: Streak freezes. Implementing freeze grants, consumption, and limits. Making freeze logic interact correctly with streak extension.
Week 4-5: Edge cases. DST transitions. Grace periods. Race conditions near midnight. Historical data queries for calendar views.
Ongoing: Maintenance. Time zone rule updates. Bug fixes for edge cases discovered in production. Performance optimization as usage scales.
That's 5+ weeks of engineering time plus ongoing maintenance. Trophy's infrastructure eliminates this timeline, reducing implementation to 1 day to 1 week of integration work.
Building with Trophy
Trophy's integration is faster because it has pre-built streak infrastructure that's been battle-tested on over 1M users. Here's what implementation looks like when using Trophy.
Step 1: Set Up Event Tracking
Trophy uses event-based architecture with timezone-aware computation and cached state. This combination provides both correct and performant user interaction tracking without needing to spin up scalable event ingestion pipeliens yourself.
In Trophy's dashboard you create 'Metrics' for each user interaction you want to track and add code to send events to Trophy when users take those actions:
import { TrophyApiClient } from '@trophyso/node';
// Initialize Trophy SDK
const trophy = new TrophyApiClient({
apiKey: 'YOUR_API_KEY'
});
// Track event for a user
const response = await trophy.metrics.event(
"workouts-completed",
{
user: {
id: 'user-id',
email: '[email protected]',
tz: 'Europe/London', // Time zone tracking built-in
subscribedToEmails: true, // Handles streak reminder preferences
},
value: 1, // Means user completed 1 workout
}
);Notice how you can send an IANA timezone ID to Trophy for each user. Trophy will use this to compute streaks in each users time zone automatically with all edge cases handled.
Also notice how Trophy supports streak reminder email preferences via the subscribedToEmails field. Trophy processes new events via UPSERT meaning you can easily keep this field in-sync with your database without dedicated field-sync code.
Step 2: Configure Streak Settings

In Trophy's dashboard, configure your streak frequency (daily, weekly, or monthly) and select which metrics should extend streaks and their thresholds.
For example, if users should maintain streaks by completing lessons, select your lessons_completed metric and set the streak threshold to 1. Trophy computes streak logic automatically in real time based on your configuration when events arrive for that metric. If you change your mind, you can update your configuration at any time and Trophy will start tracking streaks according to your new logic and keep existing streaks in tact.
Configure streak freezes if desired: initial freeze count for new users, freeze accumulation rate, and maximum freeze count. Trophy grants and consumes freezes automatically based on your settings.
Step 3: Display Streak Information
Fetch user's streak status to display in your app:
// Get user's current streak
const streak = await trophy.users.streak('user-123');
console.log({
length: streak.length, // Current streak length (e.g., 15)
started: streak.started, // When streak started
expires: streak.expires, // When streak expires in user's timezone
freezesRemaining: streak.freezes // Number of freezes available
});
Trophy's API returns comprehensive streak data including expiration time in the user's local timezone. Use this for reminder notifications or UI that shows time until streak expires.
Step 5: Display Streak History
For calendar views showing past activity:
// Get streak with historical data
const streak = await trophy.users.streak('user-123', {
historyPeriods: 90 // Last 90 days of history
});
// streak.streakHistory contains daily activity for calendar display
streak.streakHistory.forEach(period => {
console.log(`${period.periodStart}: streak length ${period.length}`);
});
Trophy returns activity data for building calendar visualizations. The user streak API documentation covers all available fields and query options.
Handling Edge Cases
Production streak systems encounter edge cases that development testing misses. Trophy handles these automatically.
Users crossing time zones should have streaks follow them. If a user flies from California to Japan, their streak window shifts to Japan time. Trophy tracks user timezone, keep their existing streak in tact, and adjusts automatically when you update it.
Midnight action ambiguity needs clear rules. Trophy's event-based architecture processes events in real time meaning that streak computation never fails due to a midnight boundary.
Daylight saving transitions create 23-hour or 25-hour days. Trophy's date math accounts for DST, ensuring users don't lose streaks on transition days due to calendar arithmetic.
Concurrent actions near midnight could double-count or create race conditions. Trophy's event processing ensures each action counts correctly for exactly one day, regardless of concurrency through built-in idempotency.
Freeze consumption timing affects user experience. Trophy applies freezes automatically when users would otherwise lose streaks, transparently maintaining their investment without manual intervention.
These edge cases take weeks to discover and fix when building in-house, the hardest of which are often only discovered after seeing real production traffic patterns. Trophy's production experience for 1M+ users means they're already handled correctly.
Performance Considerations
Trophy's infrastructure is built for scale, but your integration patterns affect performance.
Cache streak data for display in high-traffic areas. Streak counts don't need real-time accuracy. Cache for 30-60 seconds:
const cache = new Map();
async function getCachedStreak(userId: string) {
const cached = cache.get(userId);
if (cached && Date.now() - cached.timestamp < 60000) {
return cached.data;
}
const fresh = await trophy.users.streak(userId);
cache.set(userId, { data: fresh, timestamp: Date.now() });
return fresh;
}
Handle API errors gracefully. Network issues happen. Don't block critical user flows:
try {
const response = await trophy.metrics.event('lessons_completed', {
user: {
id: 'user-123'
},
value: 1
});
} catch (error) {
console.error('Failed to track event:', error);
}
Batch events when possible. If users perform multiple tracked actions in one session, Trophy's batch event API handles batching efficiently.
Trophy handles backend scaling automatically. These client-side patterns optimize your application's performance without affecting Trophy's streak calculations.
Testing Strategies
Streak systems are hard to test because they depend on time. Trophy provides tools for testing without waiting days.
Test with different timezones to ensure fair handling. Create test users in various timezones and verify streak calculations work correctly for each. Trophy's timezone support means your integration code stays simple while handling global complexity.
Test across midnight boundaries by simulating actions at 11:59 PM and 12:01 AM. Verify streak extends correctly and grace periods work as expected. Trophy's APIs work consistently regardless of timing.
Test freeze consumption by having test users miss days. Verify freezes consume correctly and streaks maintain. Trophy's automatic freeze handling should work without special code paths.
Test DST transitions by simulating actions on transition days. These are the hardest edge cases. Trophy handles them, but verify your UI displays correctly during transitions.
Trophy ships with support for multiple environments meaning development teams can test streak edge cases in a safe staging environment before promoting to production.
Build vs. Buy Decision
You now know what a streak feature requires: a data model, timezone math, freeze logic, reminder infrastructure, and the ongoing maintenance to keep it all working. The question is whether to build that stack yourself or call a managed API.
| Dimension | Build It Yourself | Trophy API |
|---|---|---|
| Data model | Design and maintain schema for current_streak, longest_streak, last_active_date | Handled by the API |
| Timezone handling | Build per-user timezone math, including DST edge cases (23- and 25-hour days) | Timezone-aware computation built in |
| Streak freezes and grace periods | Build state for freeze grants, consumption, and limits | Configured in dashboard, applied automatically |
| Reminders | Build cron jobs and push notifications timed to each user's midnight | Timezone-aware emails built in |
| Time to production | 5+ weeks, plus ongoing maintenance | 1 day to 1 week integration |
Building in-house makes sense in specific situations. If your requirements are highly unusual (non-standard streak windows, proprietary scoring logic), you may need full control. If you have dedicated backend capacity for the initial build and the maintenance that follows, the investment can pay off. Outside those cases, a managed API frees your engineering time for the features only your team can build.
Even if your scale is small (under roughly 1,000 users) where the infrastructure complexity is lower and the cost of a managed service may not be justified, you can still use Trophy's platform as its free for up to 1,000 users.
If you want to skip the infrastructure work and ship streaks this week, start with Trophy's developer streaks page.
Understanding User Behavior

Trophy provides analytics showing streak distribution across your user base. Understanding what happens when users lose their streaks helps you design better forgiveness mechanics and communication strategies.
Monitor these patterns:
- Average streak length across all users
- Distribution of streak lengths (how many at 7 days, 30 days, 100+ days)
- Freeze usage patterns (immediate consumption vs. accumulation)
- Restart rates after breaks (do users return after losing streaks?)
Trophy's analytics dashboard shows these metrics. Connecting them to your retention data reveals whether streaks drive sustainable engagement or create pressure that leads to churn.
FAQ
How long does Trophy integration take for streaks?
Basic streak tracking: 1-2 days including user identification with timezones and event tracking. Advanced features like custom freeze logic or complex calendar views: 3-5 days. Compare this to 2-3 months building in-house.
What happens if Trophy's API goes down?
Trophy has uptime SLAs of up to 99.99% but integrations should always be designed to degrade gracefully. Queue events for retry. Show cached streak data. Most teams find Trophy's uptime exceeds what they'd achieve with in-house infrastructure given resource constraints. The event tracking documentation includes reliability guidance.
How do we handle users with unreliable internet?
Trophy's API is designed for reliability, but network issues happen. Implement retry logic with exponential backoff. Queue failed events locally. Trophy's idempotency support prevents duplicate processing when retrying.
Can we customize streak rules?
Trophy supports daily, weekly, and monthly streak frequencies based on any combination of user interaction thresholds. Freeze grants and limits are configurable. This covers most use cases. If you need truly custom streak logic, building in-house gives full control, but validate this need before committing to months of development.
How do we test without waiting days?
Trophy provides test environments where you can safely create test users with varying time zones and traffic patterns to validate Trophy is tracking streaks correctly.
What if we want to change streak settings later?
Trophy's dashboard configuration means changes don't require code deployments. Adjust freeze grants, streak frequency, or which metrics count toward streaks through configuration. Changes apply to future streak periods without affecting users' existing streaks.
Get the latest on gamification
Product updates, best practices, and insights on retention and engagement — delivered straight to your inbox.
The gamification layer for consumer apps
Drop-in gamification features you can ship this sprint. Increase retention and user engagement without sacrificing your roadmap.
Book a call