<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Charlie Brinicombe</title>
    <description>The latest articles on DEV Community by Charlie Brinicombe (@charlie_brinicombe).</description>
    <link>https://dev.to/charlie_brinicombe</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3054910%2F517c2750-346b-4a1e-974a-ddef5ae24952.jpg</url>
      <title>DEV Community: Charlie Brinicombe</title>
      <link>https://dev.to/charlie_brinicombe</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/charlie_brinicombe"/>
    <language>en</language>
    <item>
      <title>The Gamification Data Model: How to Structure Streaks, Achievements, Points &amp; Leaderboards</title>
      <dc:creator>Charlie Brinicombe</dc:creator>
      <pubDate>Sun, 19 Apr 2026 22:33:10 +0000</pubDate>
      <link>https://dev.to/charlie_brinicombe/the-gamification-data-model-how-to-structure-streaks-achievements-points-leaderboards-4dm5</link>
      <guid>https://dev.to/charlie_brinicombe/the-gamification-data-model-how-to-structure-streaks-achievements-points-leaderboards-4dm5</guid>
      <description>&lt;p&gt;The individual technical challenges of building gamification features are well documented at this point. Streak logic requires &lt;a href="https://trophy.so/blog/streak-timezone-dst-handling" rel="noopener noreferrer"&gt;timezone-aware calendar evaluation&lt;/a&gt;, not 24-hour arithmetic. Leaderboards at scale require Redis sorted sets, &lt;a href="https://trophy.so/blog/scaling-leaderboards-redis-architecture" rel="noopener noreferrer"&gt;per-segment key management&lt;/a&gt;, and atomic period resets. Achievement &lt;a href="https://trophy.so/blog/how-to-backfill-achievements-existing-users" rel="noopener noreferrer"&gt;backfill&lt;/a&gt; requires idempotent scripts against event history, not denormalized totals. These are solvable problems, and teams who've understood them have a clear path through each one.&lt;/p&gt;

&lt;p&gt;What's less documented is the data model problem that sits underneath all of them. Each feature, solved correctly in isolation, still leaves you with four separate systems — streak state in one table, achievement completions in another, points in a third, leaderboards driven by Redis — connected by glue code.&lt;/p&gt;

&lt;p&gt;The moment you want an achievement that triggers when a user reaches a streak milestone, or a points boost that only applies to a specific user cohort, or a leaderboard that ranks by points balance rather than raw activity, you're writing across those systems. The integration points are where bugs live, where performance degrades, and where the features you didn't build upfront become expensive to add.&lt;/p&gt;

&lt;p&gt;This post describes what a unified gamification data model looks like in practice, starting with the event layer that ties everything together, then the per-feature config and state models that &lt;a href="https://trophy.so" rel="noopener noreferrer"&gt;Trophy&lt;/a&gt; has built and operated at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Event Layer: One Pipeline for Everything
&lt;/h2&gt;

&lt;p&gt;The architectural decision that shapes everything else is where gamification logic lives relative to your event stream. In most custom implementations, features are added incrementally: you build streak logic that fires when a user logs activity, then add achievement checks to the same handler, then wire up points awards, then start a separate cron job for leaderboard updates. The result is a set of interconnected side effects on a single event that's easy to reason about when there are two features and increasingly brittle as the number grows.&lt;/p&gt;

&lt;p&gt;Trophy's model inverts this. Every user interaction flows through a single metric event: a lesson completed, a workout logged, a task checked off. Trophy evaluates that event against all configured gamification features simultaneously and produces a unified response containing the state changes across all of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// One event — Trophy evaluates streaks, achievements, points, and leaderboards&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lessons_completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;America/New_York&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Everything that changed as a result of this single event&lt;/span&gt;
&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentStreak&lt;/span&gt; &lt;span class="c1"&gt;// streak state after this event&lt;/span&gt;
&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;achievements&lt;/span&gt; &lt;span class="c1"&gt;// any achievements unlocked by this event&lt;/span&gt;
&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;points&lt;/span&gt; &lt;span class="c1"&gt;// points awarded by this event, across all systems&lt;/span&gt;
&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;leaderboards&lt;/span&gt; &lt;span class="c1"&gt;// updated leaderboard positions after this event&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response isn't a polling result — it's the transactional outcome of evaluating one event against the full gamification configuration. Nothing is eventually consistent from the application's perspective: the streak has already been evaluated, the achievements have already been checked, the points have already been awarded, and the leaderboard positions have already been updated by the time the response returns.&lt;/p&gt;

&lt;p&gt;This matters for the data model because it means every gamification record is traceable to the originating event. An achievement completion, a points award, a streak extension — each has a foreign key to the metric event that caused it. The event ledger is the canonical source of truth for everything downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaks: Config and Period State
&lt;/h2&gt;

&lt;p&gt;A streak in Trophy is not a counter. It's a series of period records, each with a start date, an end date, a length, and an outcome.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The config layer&lt;/strong&gt; holds everything that determines how a streak is evaluated: frequency (daily, weekly, monthly), the metrics and thresholds that constitute a qualifying action, how multiple metrics are combined (ALL vs OR logic), freeze settings (initial grant, accumulation rate, maximum). This config is what makes it possible to change streak requirements without touching application code — adding a second qualifying metric, adjusting the threshold, switching from daily to weekly — all as config changes that take effect on the next event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The period layer&lt;/strong&gt; is where the history lives. Each streak period is a row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;streak_periods
  user_id
  period_start -- local calendar date in user's timezone
  period_end -- local calendar date in user's timezone
  length -- streak count at close of this period
  outcome -- extended | broken | frozen
  closed_at -- UTC timestamp when the period was finalised
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the record that makes streak restoration a data operation rather than a guess, that powers longest-streak badges without table scans, and that enables the streak history calendar view without storing anything extra.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The freeze ledger&lt;/strong&gt; is a separate table where every grant and every consumption is a row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;streak_freeze_events
  user_id
  event_type -- granted | consumed
  created_at -- UTC timestamp
  reason -- initial_grant | accumulation | manual
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Freeze support isn't a feature you add to this model — it's a natural extension of modelling streak state as events rather than a counter. A freeze consumption is just a period outcome of &lt;code&gt;frozen&lt;/code&gt; with a corresponding &lt;code&gt;consumed&lt;/code&gt; row in the freeze ledger. The balance is always derivable by summing the ledger; there's no separate count to go stale.&lt;/p&gt;

&lt;p&gt;The timezone handling described in &lt;a href="https://trophy.so/blog/streak-timezone-dst-handling?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;Streak Timezone &amp;amp; DST Handling&lt;/a&gt; is baked into the period model at the schema level: &lt;code&gt;period_start&lt;/code&gt; and &lt;code&gt;period_end&lt;/code&gt; are local calendar dates, not UTC timestamps. DST transitions don't affect period boundaries because calendar dates don't have lengths in hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Achievements: Config, Progress, and Completions
&lt;/h2&gt;

&lt;p&gt;Achievement data separates into three distinct concerns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The config layer&lt;/strong&gt; defines what an achievement is and when it triggers. Trophy supports four trigger types — metric threshold, streak length, API call, and composite — and each config row references the relevant entity (a metric key, a streak frequency, prerequisite achievement IDs). Config rows also carry attribute filters: a &lt;code&gt;subject:physics&lt;/code&gt; filter on a metric achievement means the achievement only fires for metric events from users with that attribute, without any application-layer branching.&lt;/p&gt;

&lt;p&gt;Achievement status (inactive, active, locked, archived) is part of config, and the status transition is what drives automatic backdating. When Trophy moves an achievement from inactive to active, it evaluates every existing user's metric totals and streak history against the achievement condition and creates completion records for qualifying users in bulk — the backfill happens automatically, and the &lt;code&gt;achievement.completed&lt;/code&gt; webhook is suppressed for backdated completions to prevent notification floods. The full mechanics of this are covered in &lt;a href="https://trophy.so/blog/how-to-backfill-achievements-existing-users?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;How to Backfill Achievements for Existing Users&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The progress layer&lt;/strong&gt; tracks how far a user is toward each metric achievement's threshold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;achievement_progress
  user_id
  achievement_id
  current_value -- derived from the user's metric total
  threshold -- copied from config at progress record creation
  pct_complete -- precomputed, updated on each metric event
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is updated as a side effect of metric events, not computed on demand. A progress bar query is a single row lookup, not an aggregate against the full event history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The completion layer&lt;/strong&gt; is an append-only ledger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;achievement_completions
  id
  user_id
  achievement_id
  completed_at
  trigger_event_id -- the metric event or API call that caused this
  backdated -- boolean
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Completion records are never updated — they're facts. The &lt;code&gt;trigger_event_id&lt;/code&gt; foreign key means every completion is traceable to the originating event, which makes it possible to audit why a user received an achievement, replay the evaluation for debugging, and correctly handle the idempotent re-runs described in the backfill post.&lt;/p&gt;

&lt;p&gt;Rarity (the percentage of users who have earned each achievement) is maintained as a precomputed field on the config row, updated incrementally as completion records are created rather than computed as an expensive aggregate at display time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Points: Ledger, Triggers, Boosts, and Levels
&lt;/h2&gt;

&lt;p&gt;Points are the most compositionally complex feature because they sit at the intersection of every other feature. Points can be awarded when a metric threshold is reached, when a streak milestone is hit, when an achievement is completed, on a time schedule, or at user signup. The data model has to represent all of these trigger types cleanly and produce an audit-proof award history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The system config layer&lt;/strong&gt; defines each points currency: key, name, display badge, and optional cap. Trophy supports multiple independent systems per app — XP and gems, for example — and each system has its own trigger configuration and ledger. The multi-currency case isn't a special case in the schema; it's just multiple rows in the systems table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trigger config layer&lt;/strong&gt; defines the rules for each system. Each trigger is a row with a type, a reference to the triggering entity (a metric key and threshold, a streak length, an achievement ID, a time interval), a points value, an active/inactive status, and optional user attribute filters. The attribute filters on triggers are what make cohort-specific points rates possible — premium users earn 2× points for lesson completions, free users earn 1× — without any application-layer branching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The award ledger&lt;/strong&gt; is the canonical source of truth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;points_awards
  id
  user_id
  points_system_id
  amount -- net points after boost multiplication
  base_amount -- pre-boost amount, for audit
  boost_multiplier -- the combined multiplier active at award time
  trigger_id -- which trigger config row fired
  source_event_id -- the originating metric event, achievement, etc.
  awarded_at
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;amount&lt;/code&gt; and &lt;code&gt;base_amount&lt;/code&gt; are separate columns because boost audit matters. When a user queries their points history and sees an award of 20 points, the award record shows that the base amount was 10 and the boost multiplier was 2.0. You can always reconstruct what the user saw, which is important for support tickets and for understanding the impact of past boosts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The boost layer&lt;/strong&gt; is a config table with time windows and multiplier values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;points_boosts
  id
  points_system_id
  multiplier
  rounding_mode -- floor | ceil | nearest
  starts_at
  ends_at
  user_attribute_filters -- JSON; null means global
  status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a points award is created, Trophy evaluates every active boost applicable to the user and multiplies them together. The combined multiplier is stored on the award record. Boost stacking (a 2× global boost and a 1.5× personal boost producing a 3× combined multiplier) is a consequence of the multiplicative evaluation rule, not special-case logic.&lt;/p&gt;

&lt;p&gt;Points boosts are the clearest example of a feature the data model either supports structurally or requires a redesign to add. A schema that stores points as a simple total plus a list of events has no natural place for "what multiplier was active when this award was created." Trophy's ledger carries this from the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The levels layer&lt;/strong&gt; stores threshold configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;points_levels
  id
  points_system_id
  key
  name
  threshold
  badge_url
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A user's current level is determined by finding the highest threshold their current balance clears. Trophy evaluates this on every points change and fires a &lt;code&gt;points.level_changed&lt;/code&gt; webhook when the level transitions — the event payload contains both the previous level and the new level, so the application knows exactly what changed without querying current state separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Leaderboards: Config, Segments, and Rank History
&lt;/h2&gt;

&lt;p&gt;The technical complexity of leaderboard infrastructure — Redis sorted set management, segment key explosion, atomic period resets — is covered in &lt;a href="https://trophy.so/blog/scaling-leaderboards-redis-architecture?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;Scaling App Leaderboards: Redis Architecture and Where Trophy Fits&lt;/a&gt;. The data model question is distinct: even if you solve the infrastructure, what do you store and how?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The config layer&lt;/strong&gt; defines each leaderboard: ranking method (metric, points, or streak), period type (perpetual or repeating), participant limit, and breakdown attributes. Breakdown attributes are the config-level representation of segmentation — a &lt;code&gt;city&lt;/code&gt; breakdown attribute means Trophy automatically maintains a separate leaderboard segment for every distinct &lt;code&gt;city&lt;/code&gt; value in the user base. The segments are not explicitly provisioned; they emerge from the attribute values Trophy has seen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rankings layer&lt;/strong&gt; is the live sorted state, maintained in Redis. Every leaderboard has one sorted set per active segment, updated as metric events arrive. The Redis infrastructure and the reasoning for it are detailed in the leaderboard scaling post — the schema point is that this is ephemeral optimized storage, not the record of what happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rank history layer&lt;/strong&gt; is where Trophy diverges sharply from a custom implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;leaderboard_rank_events
  id
  leaderboard_id
  segment_key -- NULL for global, attribute:value for segments
  user_id
  previous_rank
  new_rank
  occurred_at
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every rank change for every participant, in every segment, is a row. This table is what makes &lt;code&gt;leaderboard.rank_changed&lt;/code&gt; webhooks possible without polling: Trophy inserts a row, fires the webhook, and delivers the previous and new rank in the payload. It's also what makes "you were #3 in your city last week" queries possible without reconstructing history from raw events.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The period archive layer&lt;/strong&gt; stores the final rankings when a repeating leaderboard period closes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;leaderboard_period_archives
  id
  leaderboard_id
  segment_key
  period_start
  period_end
  rankings -- JSON array of {userId, rank, value}
  finalised_at
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Period archives are the queryable history of every leaderboard run. The leaderboard finalization process — evaluate all users across all timezones, wait for the last timezone to pass midnight, archive the final state, reset the live sorted set, fire &lt;code&gt;leaderboard.finished&lt;/code&gt; — writes to this table atomically before clearing Redis. If you query a historical leaderboard run through the API, you're reading from this archive, not reconstructing from events.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-Feature References: Where Custom Models Break Down
&lt;/h2&gt;

&lt;p&gt;The individual models above are each technically achievable in a custom implementation. The harder problem is the foreign keys between them.&lt;/p&gt;

&lt;p&gt;A points trigger that fires on streak milestone completion references both the points system config and the streak config. A composite achievement references the completion records of its prerequisite achievements. A leaderboard ranked by points balance references the points award ledger to determine participant scores. A &lt;code&gt;points.level_changed&lt;/code&gt; event needs to fire when a points award causes a threshold crossing, which means the award write and the level evaluation happen in the same transaction.&lt;/p&gt;

&lt;p&gt;In Trophy, these cross-feature references are structural — the foreign keys exist in the schema, and the evaluation logic that traverses them runs inside the same transactional boundary as the originating event. In a custom implementation, the equivalent is glue code: explicit queries from the achievement handler to the streak state table, explicit calls from the points award function to the level check function, explicit webhook dispatches after each state change. Each piece of glue is a place where something can go wrong silently, run in the wrong order, or produce inconsistent state under concurrent updates.&lt;/p&gt;

&lt;p&gt;The features that are hardest to add to a custom implementation later — streak freezes, points boosts, composite achievements, rank-change notifications — are all features that require new cross-feature references. Streak freezes need the freeze ledger to reference streak periods. Points boosts need the award record to reference the active boost config. Composite achievements need a prerequisite graph traversal at evaluation time. Rank-change notifications need the rank history table to be populated atomically with the sorted set update.&lt;/p&gt;

&lt;p&gt;These aren't design oversights — they're features teams typically don't know they want until they've shipped without them. Trophy's schema carries them from the start because they were always part of the model, not added later.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If Trophy stores all this state, what do I own in my own database?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your application data — user records, content, purchases, the things that make your product what it is. Trophy stores the gamification state that's derived from the events you send. The two are linked by user ID. You don't need to replicate gamification state into your own schema — you read it from Trophy's API as needed, or receive it in webhook payloads as it changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does Trophy handle high event volumes without the points and achievement checks becoming a bottleneck?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The evaluation pipeline is designed for throughput, not just correctness. Achievement progress updates are async; the metric event response includes achievement completions that have already crossed the threshold, but progress increments for achievements not yet complete are written behind the response. Points awards, leaderboard updates, and streak evaluations are synchronous in the critical path because they affect the response the application receives. The distinction between what must be consistent on return and what can be eventually consistent is baked into the evaluation order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I have one metric trigger achievements in one system and points in another simultaneously?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. A single metric key can be referenced by multiple achievement configs and multiple points trigger configs, across multiple points systems. When an event arrives, Trophy evaluates all of them. The response contains the union of everything that changed — streak extension, achievement completions, points awards across all applicable systems, leaderboard position updates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if I add a new achievement or a new points trigger to users who already have existing history?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For metric and streak achievements, Trophy backdates automatically when you activate them — any user whose current totals meet the threshold receives the completion. For new points triggers, previously recorded events are not retroactively re-evaluated; the trigger applies from activation forward. If you need to award points to existing users for past activity, the Admin API allows creating point award records directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Go Next
&lt;/h2&gt;

&lt;p&gt;The per-feature technical challenges that motivate this data model are covered in detail across the series: &lt;a href="https://trophy.so/blog/streak-timezone-dst-handling" rel="noopener noreferrer"&gt;Streak Timezone &amp;amp; DST Handling&lt;/a&gt;, &lt;a href="https://trophy.so/blog/scaling-leaderboards-redis-architecture" rel="noopener noreferrer"&gt;Scaling App Leaderboards Beyond Basic Redis&lt;/a&gt;, and &lt;a href="https://trophy.so/blog/how-to-backfill-achievements-existing-users" rel="noopener noreferrer"&gt;How to Backfill Achievements for Existing Users&lt;/a&gt;. The full configuration reference for each feature is in the Trophy documentation: &lt;a href="https://docs.trophy.so/platform/streaks" rel="noopener noreferrer"&gt;Streaks&lt;/a&gt;, &lt;a href="https://docs.trophy.so/platform/achievements" rel="noopener noreferrer"&gt;Achievements&lt;/a&gt;, &lt;a href="https://docs.trophy.so/platform/points" rel="noopener noreferrer"&gt;Points&lt;/a&gt;, and &lt;a href="https://docs.trophy.so/platform/leaderboards" rel="noopener noreferrer"&gt;Leaderboards&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>gamification</category>
      <category>backend</category>
      <category>postgres</category>
      <category>redis</category>
    </item>
    <item>
      <title>Streak Timezone &amp; DST Handling: The Complete Implementation Guide</title>
      <dc:creator>Charlie Brinicombe</dc:creator>
      <pubDate>Sun, 19 Apr 2026 11:38:25 +0000</pubDate>
      <link>https://dev.to/charlie_brinicombe/streak-timezone-dst-handling-the-complete-implementation-guide-3bja</link>
      <guid>https://dev.to/charlie_brinicombe/streak-timezone-dst-handling-the-complete-implementation-guide-3bja</guid>
      <description>&lt;p&gt;Streak bugs from timezone mishandling are the most common category of support tickets for consumer apps with a global user base. A user in Sydney completes their lesson at 11:55 PM local time. Your server is running on UTC, which makes it tomorrow. The streak breaks. The user is correct that they acted within the day. Your code is also technically correct. The problem is that both "days" refer to different things, and there is no graceful failure — the streak just disappears.&lt;/p&gt;

&lt;p&gt;The fix is not complicated in principle, but the implementation has enough sharp edges that most teams get it wrong at least once in production. This post covers the correct approach to UTC storage and local-time evaluation, the two DST cases that will catch you even if you do everything else right, the per-user midnight scheduling problem, and what happens when users travel. At the end, there's a short section on how &lt;a href="https://trophy.so" rel="noopener noreferrer"&gt;Trophy&lt;/a&gt; handles all of it so you understand what you're getting if you use the platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why UTC Streak Logic Breaks
&lt;/h2&gt;

&lt;p&gt;The naive approach to streak evaluation compares UTC timestamps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Broken: compares UTC dates, not the user's local dates&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;didUserActToday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastActivityAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUTCFullYear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;lastActivityAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUTCFullYear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUTCMonth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;lastActivityAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUTCMonth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUTCDate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;lastActivityAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUTCDate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a user in UTC+10, this produces the wrong answer for the last ten hours of every calendar day. Their local "today" started at 2:00 PM yesterday UTC, but &lt;code&gt;getUTCDate()&lt;/code&gt; still says yesterday until midnight UTC. Their activity from 2:00 PM to midnight local time is treated as belonging to the previous day on the server.&lt;/p&gt;

&lt;p&gt;A slightly less naive version checks "within the last 24 hours" rather than comparing calendar dates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Still broken: fixed-duration arithmetic doesn't match calendar days&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;didUserActYesterday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastActivityAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;twentyFourHoursAgo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;lastActivityAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;twentyFourHoursAgo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This version fails on DST transitions, which are covered below, and also fails for any streak logic that needs to match calendar days rather than rolling 24-hour windows.&lt;/p&gt;

&lt;p&gt;The root cause in both cases is the same: streak periods are calendar constructs (a "day" is midnight to midnight in some timezone), and calendar constructs require timezone context to evaluate correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Correct Foundation: Store UTC, Evaluate Locally
&lt;/h2&gt;

&lt;p&gt;All timestamps go into your database as UTC. This is not optional — mixing timezone-local timestamps in storage creates a different class of problems that are harder to fix. The timezone awareness lives at evaluation time, not storage time.&lt;/p&gt;

&lt;p&gt;The correct period boundary check converts both "now" and the last activity timestamp into the user's local calendar date, then compares calendar dates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;toZonedTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;format&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;date-fns-tz&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Get the local calendar date string for a UTC timestamp in a given timezone&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toLocalDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;utcDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ianaTimezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;zoned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toZonedTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;utcDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ianaTimezone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zoned&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;yyyy-MM-dd&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ianaTimezone&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Check whether a user's last activity falls on the local calendar day&lt;/span&gt;
&lt;span class="c1"&gt;// immediately before today — i.e. they completed yesterday's period&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;completedYesterday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;lastActivityAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ianaTimezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;todayLocal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toLocalDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;ianaTimezone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;activityLocal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toLocalDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastActivityAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ianaTimezone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Build yesterday's date string by subtracting one day from today&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;yesterday&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;yesterdayLocal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toLocalDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ianaTimezone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;activityLocal&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;yesterdayLocal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Full streak evaluation: did the user act today or yesterday?&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;evaluateStreak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;lastActivityAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;ianaTimezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;extended&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;maintained&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;broken&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;lastActivityAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;broken&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;todayLocal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toLocalDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;ianaTimezone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;activityLocal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toLocalDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastActivityAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ianaTimezone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;yesterday&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;yesterdayLocal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toLocalDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ianaTimezone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;activityLocal&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;todayLocal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;extended&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;activityLocal&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;yesterdayLocal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;maintained&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;broken&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to note about this pattern. First, the timezone parameter must be an IANA identifier (&lt;code&gt;America/New_York&lt;/code&gt;, &lt;code&gt;Europe/London&lt;/code&gt;, &lt;code&gt;Asia/Tokyo&lt;/code&gt;), not a UTC offset string like &lt;code&gt;+05:30&lt;/code&gt;. UTC offsets don't encode DST rules, which means they'll produce the correct output most of the year and silently wrong output twice a year. Store and use IANA identifiers throughout.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;date-fns-tz&lt;/code&gt; is the recommended library for this in Node.js. &lt;code&gt;Intl.DateTimeFormat&lt;/code&gt; can do the same job but the API is less ergonomic for this specific use case. Avoid &lt;code&gt;moment-timezone&lt;/code&gt; for new code — it is in maintenance mode and the bundle size is significant.&lt;/p&gt;

&lt;h2&gt;
  
  
  DST: The Two Cases That Will Catch You
&lt;/h2&gt;

&lt;p&gt;Daylight saving time creates two categories of non-standard days per year in every region that observes it. Both break 24-hour arithmetic while leaving calendar-day comparison correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spring forward.&lt;/strong&gt; Clocks jump from 2:00 AM to 3:00 AM. The local calendar day is only 23 hours long. A user who was active at 11:00 PM the previous night has 22 hours to repeat their activity before their "today" ends, not 24. Any system that evaluates "has 24 hours passed since last activity" will require action one hour earlier than expected on this day. Users who act at their normal time — say, 10:30 PM — will find that 23 hours has passed since the previous night's activity, which is less than 24, so the check passes. But users who rely on acting during that missing hour (2:00 AM to 3:00 AM, which simply doesn't exist) face a period that is shorter than their normal pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fall back.&lt;/strong&gt; Clocks repeat the 1:00 AM to 2:00 AM hour. The local calendar day is 25 hours long. A 24-hour arithmetic check run at midnight local time will show that only 23 hours have elapsed since the previous midnight, because the local clock lagged UTC by one extra hour during the repeat. If you evaluate "did the user act in the last 24 hours" at midnight on fall-back day, the check passes even if the user acted 25 hours ago.&lt;/p&gt;

&lt;p&gt;Calendar-day string comparison sidesteps both problems completely. &lt;code&gt;'2026-03-29' !== '2026-03-28'&lt;/code&gt; is true regardless of how many hours exist between the two dates. The length of a DST day doesn't affect whether two dates have different local calendar strings.&lt;/p&gt;

&lt;p&gt;The one edge case to test explicitly: an activity that occurs during the repeated hour on fall-back day will produce a local time that appears to occur twice. &lt;code&gt;date-fns-tz&lt;/code&gt; handles this correctly using the IANA timezone data, which encodes the precise UTC offset at each moment. Test this by creating a UTC timestamp for 01:30 AM UTC on a fall-back date and verifying your &lt;code&gt;toLocalDate&lt;/code&gt; function returns the correct local date string for a timezone observing the transition.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Per-User Midnight Evaluation Problem
&lt;/h2&gt;

&lt;p&gt;Most streak systems need to evaluate at some point whether a user's streak should be broken — typically triggered at the end of each day if no activity occurred. The obvious implementation is a cron job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Naive: runs once at UTC midnight, wrong for everyone not in UTC&lt;/span&gt;
&lt;span class="nx"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0 0 * * *&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;evaluateAndBreakStreakIfNeeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This evaluates all users at UTC midnight, which is midnight for users in UTC+0 and noon for users in UTC+12. Those users won't have their streak state updated until twelve hours into their next local day.&lt;/p&gt;

&lt;p&gt;The correct approach is either to evaluate lazily or to schedule per-timezone. Lazy evaluation is simpler and usually sufficient:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Lazy evaluation: check streak state when the user next interacts,&lt;/span&gt;
&lt;span class="c1"&gt;// rather than on a schedule&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleUserActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ianaTimezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;streakState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluateStreak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastActivityAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ianaTimezone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;streakState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;broken&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resetStreak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;streakState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;extended&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incrementStreak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLastActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// 'maintained' — no action needed, streak is still live&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lazy evaluation has one limitation: users who don't open the app won't have their streak broken until their next interaction, which means stored streak values can be stale for inactive users. For display purposes this usually doesn't matter. For analytics and leaderboard data, you may need a scheduled fallback that evaluates stale users once per day, bucketed by timezone.&lt;/p&gt;

&lt;p&gt;The scheduled-per-timezone approach processes users at their local midnight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Group users by timezone and schedule a job for each timezone's midnight&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;schedulePerTimezoneEvaluation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timezones&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDistinctTimezones&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tz&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;timezones&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;midnight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getNextLocalMidnight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// returns a UTC Date&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;midnight&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUsersByTimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;evaluateAndBreakStreakIfNeeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="c1"&gt;// Reschedule for the next local midnight&lt;/span&gt;
      &lt;span class="nf"&gt;scheduleForTimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At scale this becomes a queue problem rather than a cron problem — you're processing millions of per-user evaluation events distributed across 24 hours. The architectural pattern shifts to a job queue (BullMQ, SQS) where each user has a job scheduled for their next local midnight, rescheduled on completion.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Users Travel
&lt;/h2&gt;

&lt;p&gt;A user flying from London to Los Angeles crosses several timezones. Their streak should follow their current location, not lock to origin. Handling this correctly requires two things: updating the stored timezone when it changes, and deciding what to do about the gap.&lt;/p&gt;

&lt;p&gt;The gap is the interesting part. If a user is in London at 11:00 PM (one hour before their streak period ends), boards a flight, and lands in Los Angeles where it's 2:00 PM local time, their "today" just got longer. If you update their timezone on landing, their remaining period is now ten hours rather than one. That's fairer to the user.&lt;/p&gt;

&lt;p&gt;The implementation is straightforward: call &lt;code&gt;evaluateStreak&lt;/code&gt; with the user's current timezone on every interaction, and update the stored timezone when it changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleUserActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;currentTimezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="c1"&gt;// from device or explicit user setting&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Update timezone if it changed — streak evaluation will use the new one&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ianaTimezone&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;currentTimezone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateTimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currentTimezone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ianaTimezone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentTimezone&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;streakState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluateStreak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastActivityAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ianaTimezone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;streakState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;broken&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resetStreak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;streakState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;extended&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incrementStreak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLastActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The international date line is the genuinely tricky case. A user crossing from UTC+12 to UTC-12 skips a full calendar day according to local time. Using calendar-day string comparison as the evaluation method means this creates an apparent two-day gap with no activity, which would break the streak even though the user only missed one local sleep cycle.&lt;/p&gt;

&lt;p&gt;There's no universally correct answer. For most apps, documenting the behaviour and handling support tickets individually is the pragmatic approach. The edge case affects an extremely small number of users.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Trophy Handles This
&lt;/h2&gt;

&lt;p&gt;Trophy's streak logic operates entirely in the user's local timezone. You pass the user's IANA timezone identifier on each metric event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;TrophyApiClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@trophyso/node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TrophyApiClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TROPHY_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lessons_completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;America/Los_Angeles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// IANA identifier from device or user settings&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;tz&lt;/code&gt; field updates Trophy's stored timezone for the user on every event, so timezone changes from travel are handled automatically on the next interaction. Trophy evaluates streak periods using calendar-day comparison in the user's timezone, which means DST transitions — spring forward and fall back — don't break streaks for users in affected regions.&lt;/p&gt;

&lt;p&gt;Once setup, displaying streak data on app load or a profile screen with Trophy is simple, just call the user streak API directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;streak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getStreak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// streak.length — current streak count&lt;/span&gt;
&lt;span class="c1"&gt;// streak.expires — UTC timestamp when the period closes;&lt;/span&gt;
&lt;span class="c1"&gt;// convert to local time to show "expires at 11:59 PM"&lt;/span&gt;
&lt;span class="c1"&gt;// streak.periodStart — start of the current period&lt;/span&gt;
&lt;span class="c1"&gt;// streak.periodEnd — end of the current period&lt;/span&gt;
&lt;span class="c1"&gt;// streak.streakHistory — past periods, use historyPeriods param to control depth&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Current streak: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;streak&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; days`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Expires: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;streak&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expires&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLocaleString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;expires&lt;/code&gt; timestamp is in UTC and represents the end of the user's current streak period in their local timezone. On the client, convert it with &lt;code&gt;toLocaleString()&lt;/code&gt; or your preferred library to show the user a meaningful deadline. This is the primary value to display in streak countdown UI — not a server-computed duration, which would drift from the user's actual local midnight.&lt;/p&gt;

&lt;p&gt;Streak freeze consumption also happens at midnight in the user's local timezone rather than UTC midnight, which is relevant to apps that use freezes as a buffer for missed days. A user whose streak period ends at midnight in Tokyo has their freeze consumed at Tokyo midnight, not London or New York midnight.&lt;/p&gt;

&lt;p&gt;The remaining piece that Trophy handles but is not covered in this post is the streak expiry display: the API returns an &lt;code&gt;expires&lt;/code&gt; timestamp that represents the end of the user's current streak period in UTC, which your frontend converts to local time for display. That's documented in the &lt;a href="https://docs.trophy.so/platform/streaks?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;Trophy Streaks documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3ywdrbs19ovsyqpr02ch.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3ywdrbs19ovsyqpr02ch.png" alt="Streak Timezone &amp;amp; DST Handling: The Complete Implementation Guide" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Timezone-aware streak configuration in Trophy&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Should I store timezone as a UTC offset or an IANA identifier?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Always IANA. A UTC offset like &lt;code&gt;+05:30&lt;/code&gt; is static — it encodes the current offset but not the DST rules that determine when that offset changes. India doesn't observe DST, so &lt;code&gt;+05:30&lt;/code&gt; happens to be unambiguous there, but &lt;code&gt;+10:00&lt;/code&gt; could be AEST or AEDT depending on the time of year. An IANA identifier like &lt;code&gt;Australia/Sydney&lt;/code&gt; encodes the full history of offset changes for that region and is handled correctly by any proper timezone library.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What timezone library should I use in Node.js?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;date-fns-tz&lt;/code&gt; for most projects. It's actively maintained, tree-shakeable, and the API is straightforward for the period boundary calculations described in this post. &lt;code&gt;Luxon&lt;/code&gt; is a good alternative with a more object-oriented API. Avoid &lt;code&gt;moment-timezone&lt;/code&gt; for new projects — it is in maintenance mode and carries significant bundle weight. The &lt;code&gt;Temporal&lt;/code&gt; API is the long-term native solution for JavaScript timezone handling, but browser support is still incomplete as of 2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I get the user's timezone on a mobile app?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;iOS: &lt;code&gt;TimeZone.current.identifier&lt;/code&gt; returns the IANA identifier. Android: &lt;code&gt;TimeZone.getDefault().id&lt;/code&gt; returns the IANA identifier. Web: &lt;code&gt;Intl.DateTimeFormat().resolvedOptions().timeZone&lt;/code&gt; returns the IANA identifier.&lt;/p&gt;

&lt;p&gt;Send this value to your server on each relevant API call. Don't rely on IP geolocation for timezone inference — it's inaccurate for VPN users and doesn't update when users travel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens to a user's streak if they update their timezone mid-day?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With the lazy evaluation pattern described in this post, the update takes effect on the next interaction. If a user changes their timezone and then logs activity in the same session, the new timezone is used for evaluation. There's no retroactive recomputation of the streak based on historical activity in a different timezone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need timezone handling for weekly or monthly streaks?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, for the same reasons. A weekly streak requires the user to act in each calendar week in their local timezone. The week boundary in UTC+12 is 12 hours earlier than in UTC-12. Without per-user timezone evaluation, users in UTC+12 have a shorter competition window every week. The calendar-day comparison pattern extends naturally — replace the daily date string with a week or month string and the logic is identical.&lt;/p&gt;

</description>
      <category>gamification</category>
      <category>backend</category>
      <category>mobile</category>
    </item>
    <item>
      <title>How to Backfill Achievements for Existing Users in Mobile Apps</title>
      <dc:creator>Charlie Brinicombe</dc:creator>
      <pubDate>Sun, 19 Apr 2026 11:14:31 +0000</pubDate>
      <link>https://dev.to/charlie_brinicombe/how-to-backfill-achievements-for-existing-users-in-mobile-apps-1l16</link>
      <guid>https://dev.to/charlie_brinicombe/how-to-backfill-achievements-for-existing-users-in-mobile-apps-1l16</guid>
      <description>&lt;p&gt;You've shipped an achievement system, but your app already had users before you built it. Some of them completed 500 lessons before achievements existed. Others hit a 30-day streak last month with no milestone to mark it. The question is how to retroactively credit the users who already qualify without spamming everyone, without double-crediting on retries, and without locking up your database for an hour.&lt;/p&gt;

&lt;p&gt;The problem is more tractable than it appears, but the details matter. This post covers the data model you need, the three query patterns that cover most achievement types, the production concerns that will catch you if you ignore them, and where &lt;a href="https://trophy.so" rel="noopener noreferrer"&gt;Trophy&lt;/a&gt; removes most of this work for metric and streak-based achievements.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Data Model
&lt;/h2&gt;

&lt;p&gt;Before you write a single backfill query, your schema needs to support it. Three things have to be true.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You need a way to evaluate the qualifying condition from historical data.&lt;/strong&gt; For cumulative achievements ("completed 500 lessons"), this means a table that records individual events — not just a denormalized total. &lt;code&gt;SUM(value) FROM lesson_events WHERE user_id = X&lt;/code&gt; is backfillable. A &lt;code&gt;users.lesson_count&lt;/code&gt; integer that gets incremented in-place is not, unless you can reconstruct the history from somewhere else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You need an achievements table that records completions, not just current state.&lt;/strong&gt; The minimum schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;user_achievements&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;achievement_key&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;completed_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;backdated&lt;/span&gt; &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="c1"&gt;-- Prevents double-crediting on re-runs&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;achievement_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;user_achievements&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;user_achievements&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;achievement_key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;UNIQUE (user_id, achievement_key)&lt;/code&gt; constraint is load-bearing. It means a backfill script can be re-run after a partial failure and the database will reject duplicates, rather than requiring you to track which users were already processed in application code.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;backdated&lt;/code&gt; column is optional but useful for analytics — it lets you distinguish organic completions from retroactive credits when measuring the achievement system's impact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You need to know which achievements a user has already seen.&lt;/strong&gt; Backdating should be silent by default: you credit the user, but you don't immediately fire a notification. The next time they open the app, your client needs to know which completed achievements haven't been surfaced yet. A &lt;code&gt;shown_at&lt;/code&gt; column on &lt;code&gt;user_achievements&lt;/code&gt;, or a separate &lt;code&gt;shown_achievements&lt;/code&gt; table, is the clean way to track this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Backfill Patterns
&lt;/h2&gt;

&lt;p&gt;Most achievements fall into one of three categories. Each has a different query pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cumulative achievements
&lt;/h3&gt;

&lt;p&gt;These are milestones based on a running total: lessons completed, km run, words written, items purchased. The backfill query aggregates your event history and finds users who already qualify.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Step 1: find all users who qualify but haven't been credited yet&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findQualifyingUsers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;achievementKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;metricTable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;metricColumn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
    SELECT e.user_id
    FROM (
      SELECT user_id, SUM(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;metricColumn&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;) AS total
      FROM &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;metricTable&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
      GROUP BY user_id
    ) e
    LEFT JOIN user_achievements ua
      ON ua.user_id = e.user_id
      AND ua.achievement_key = $1
    WHERE e.total &amp;gt;= $2
      AND ua.id IS NULL
  `&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;achievementKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Step 2: credit them in bulk&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;creditUsers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;userIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;achievementKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// INSERT ... ON CONFLICT DO NOTHING makes this safe to re-run&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;placeholders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userIds&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`($&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, $&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, $&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;achievementKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
    INSERT INTO user_achievements (user_id, achievement_key, backdated)
    VALUES &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;placeholders&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
    ON CONFLICT (user_id, achievement_key) DO NOTHING
  `&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For large user bases, batch the INSERT rather than inserting one row at a time. Add a &lt;code&gt;WHERE e.user_id &amp;gt; $cursor&lt;/code&gt; clause to paginate through the qualifying set rather than loading all user IDs into memory at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  One-time action achievements
&lt;/h3&gt;

&lt;p&gt;These cover discrete events: completing onboarding, linking a social account, making a first purchase. The qualifying condition is a row existing somewhere, not a cumulative sum.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;backfillOnboardingAchievement&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Find users who completed onboarding before the achievement existed&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qualifying&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
    SELECT u.id AS user_id
    FROM users u
    LEFT JOIN user_achievements ua
      ON ua.user_id = u.id
      AND ua.achievement_key = 'onboarding-complete'
    WHERE u.onboarding_completed_at IS NOT NULL
      AND ua.id IS NULL
  `&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;qualifying&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No users to backfill&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Backfilling &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;qualifying&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; users`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;placeholders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;qualifying&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`($&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, $&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, $&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;qualifying&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;onboarding-complete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
    INSERT INTO user_achievements (user_id, achievement_key, backdated)
    VALUES &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;placeholders&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
    ON CONFLICT (user_id, achievement_key) DO NOTHING
  `&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Backfill complete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Streak achievements
&lt;/h3&gt;

&lt;p&gt;Streak achievements require a bit more care. A user's current streak at the time of the backfill might not reflect their longest ever streak. If the achievement is "reach a 30-day streak" and a user hit 45 days six months ago but is currently at day 3, querying &lt;code&gt;users.current_streak&lt;/code&gt; would miss them entirely.&lt;/p&gt;

&lt;p&gt;The correct backfill queries against streak history rather than current state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- You need a streak history table, not just current streak length&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;streak_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;streak_length&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;recorded_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Find users whose maximum historical streak meets the threshold&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;se&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;streak_length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;max_streak&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;streak_events&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;se&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;user_achievements&lt;/span&gt; &lt;span class="n"&gt;ua&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;se&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;achievement_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'streak-30-days'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;se&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_streak&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you only store current streak and have no streak history, you cannot backfill streak achievements accurately. The options are to accept the gap (credit users who currently qualify and accept that lapsed high-streakers miss out), or to defer the backfill until enough streak history accumulates in the new table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Concerns
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Order matters for tiered achievements.&lt;/strong&gt; If you have Bronze (100 completions), Silver (500), and Gold (1,000), backfill them in ascending order within each user's processing. Inserting Gold before Bronze works at the database level, but it breaks any downstream logic that expects to see the lower tiers already present.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limit your downstream effects.&lt;/strong&gt; The backfill INSERT is cheap. What's expensive is everything triggered next: achievement emails, XP awards, leaderboard updates. A backfill that credits 50,000 users and each triggers an email queues 50,000 emails in seconds. Either suppress downstream effects entirely for backdated completions using the &lt;code&gt;backdated&lt;/code&gt; flag as a gate, or process them at a controlled rate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't send real-time notifications.&lt;/strong&gt; Achievement notifications land well when they're proximate to the action that earned them. A push notification about a 30-day streak the user hit eight months ago is confusing rather than motivating. Surface backdated completions passively on next app open — a "here's what you've earned" summary — rather than firing the same celebration that new completions trigger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test on a small cohort first.&lt;/strong&gt; Before running across your full user base, scope the backfill to a single test user ID, then a 1% sample. Verify the counts look correct, check that downstream effects aren't misfiring, and confirm the &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt; behaviour holds on a second run. A bad script run against 200,000 users is considerably harder to recover from than one run against 2,000.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Trophy Solves This
&lt;/h2&gt;

&lt;p&gt;If you're using Trophy' &lt;a href="https://trophy.so/features/achievements" rel="noopener noreferrer"&gt;achievements system&lt;/a&gt;, the backfill story for metric and streak achievements is handled automatically. When you activate a metric or streak achievement in Trophy's dashboard, Trophy checks every existing user's stored metric totals and streak history and completes the achievement silently for any user who already qualifies. No script, no query, no production concern about ordering or rate limiting.&lt;/p&gt;

&lt;p&gt;This works because Trophy stores the cumulative metric totals and streak records that the queries above are trying to reconstruct from raw event tables. The qualifying condition evaluation happens inside Trophy rather than in your application code.&lt;/p&gt;

&lt;p&gt;For API achievements — the one-time action type — you still need a script, because the qualifying condition is defined by your application's own data. The script is simpler than the DIY version, since you only need to find qualifying users and call Trophy's completion API for each one. The idempotency key pattern ensures re-runs are safe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;TrophyApiClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@trophyso/node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TrophyApiClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TROPHY_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;backfillAPIAchievement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;achievementKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qualifying&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
    SELECT id FROM users
    WHERE onboarding_completed_at IS NOT NULL
    ORDER BY created_at ASC
  `&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;qualifying&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;achievements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;achievementKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="c1"&gt;// Stable key per user and achievement — re-running returns 202 for&lt;/span&gt;
      &lt;span class="c1"&gt;// users already credited, with no change to their state&lt;/span&gt;
      &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`backfill-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;achievementKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// Brief pause to avoid rate limit pressure on large user bases&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One important detail: Trophy does not fire the &lt;a href="https://docs.trophy.so/webhooks/events/achievements/achievement-completed" rel="noopener noreferrer"&gt;&lt;code&gt;achievement.completed&lt;/code&gt; webhook&lt;/a&gt; for backdated completions, whether backdating happens automatically or through the completion API with an idempotency key. If your downstream effects depend on that webhook, call them explicitly in the script for each user, or suppress them and accept that backdated users skip those flows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzcld3hb1glko9syp296d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzcld3hb1glko9syp296d.png" alt="How to Backfill Achievements for Existing Users (2026)" width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Trophy dashboard showing achievements configuration&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What if I only have denormalized totals, not individual event records?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You can't backfill cumulative achievements accurately without event history. The options are to use the current total as a proxy (credit users whose count now meets the threshold, accepting that churned users who once qualified will be missed), to look for event history in logs or analytics tools, or to start recording individual events now and run the backfill once enough history exists. Going forward, storing event rows rather than only incrementing totals is the right schema choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should backdated achievements trigger XP awards?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It depends on whether XP inflation matters to your economy. Retroactively awarding XP to long-tenured users can distort leaderboards and level distributions if the amounts are significant. A common approach is to award a reduced amount for backdated completions, or to skip XP entirely for achievements completed before a certain date. The &lt;code&gt;backdated&lt;/code&gt; flag on &lt;code&gt;user_achievements&lt;/code&gt; is the clean gate for this logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I recover from a backfill script that fails partway through?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt; pattern makes the script re-runnable from the beginning — users already credited are silently skipped. For very large tables where re-running from scratch is expensive, add cursor-based pagination using the last successfully processed &lt;code&gt;user_id&lt;/code&gt; as a restart point, stored in a checkpoint table or flat file between runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I backfill composite achievements directly?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Composite achievements complete automatically when all prerequisites are met. Backfill the prerequisite achievements first, and any user who then satisfies all prerequisites will have the composite credited automatically as a side effect, whether you're managing the system yourself or using Trophy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I tell users about retroactively credited achievements without it feeling strange?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Avoid real-time push notifications for backdated completions. Surface them on next app open with a summary screen — "You've earned 3 achievements based on your activity" — and let users navigate to them rather than interrupting with a notification about something that happened months ago. The celebration mechanic works best when it's close to the qualifying action, so use it sparingly for credits that aren't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Go Next
&lt;/h2&gt;

&lt;p&gt;For Trophy's full achievement configuration reference — trigger types, status management, and the completion API — see the &lt;a href="https://docs.trophy.so/platform/achievements" rel="noopener noreferrer"&gt;Trophy Achievements documentation&lt;/a&gt;. The idempotency key pattern used in the backfill script is documented in the &lt;a href="https://docs.trophy.so/api-reference/idempotency" rel="noopener noreferrer"&gt;Trophy API idempotency guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For connecting achievement completions to your XP system, &lt;a href="https://trophy.so/blog/how-to-sync-xp-across-devices?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;How to Sync XP Across Devices Without Firebase&lt;/a&gt; covers the server-authoritative model that ties the two together. And for the measurement question of whether backfilling is moving your retention numbers, &lt;a href="https://trophy.so/blog/how-to-measure-points-levels-across-users" rel="noopener noreferrer"&gt;How to Measure Points and Levels Across All Your Users&lt;/a&gt; covers the analytics layer.&lt;/p&gt;

</description>
      <category>gamification</category>
      <category>mobile</category>
      <category>backend</category>
    </item>
    <item>
      <title>How to Measure XP and Levels Across Users</title>
      <dc:creator>Charlie Brinicombe</dc:creator>
      <pubDate>Sun, 19 Apr 2026 10:50:27 +0000</pubDate>
      <link>https://dev.to/charlie_brinicombe/how-to-measure-xp-and-levels-across-users-43em</link>
      <guid>https://dev.to/charlie_brinicombe/how-to-measure-xp-and-levels-across-users-43em</guid>
      <description>&lt;p&gt;Across apps on Trophy's platform, users who complete achievements at the highest difficulty level (where the threshold is 30 to 100 times their average daily activity) retain at 74%. Users completing achievements that require less than one day's average activity retain at 32%. That gap is more than twice the retention rate, and it comes entirely from how the achievement system is calibrated, not from the feature being present or absent.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvcdzdc32rohc08hhn41b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvcdzdc32rohc08hhn41b.png" alt="How to Measure XP and Levels Across All Users (Without a Data Warehouse)" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Source:&lt;/em&gt; &lt;a href="https://trophy.so/?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;&lt;em&gt;Trophy&lt;/em&gt;&lt;/a&gt; &lt;em&gt;platform data, April 2026. 14-day retention rate vs achievement difficulty, where difficulty is defined as the achievement threshold divided by the average daily activity volume.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most teams shipping XP and levels never see a number like that for their own app. They know users have levels — they don't know what percentage of users are stuck at Bronze, whether Silver actually predicts retention, or whether their progression curve is set right. The data exists; it just isn't surfaced anywhere without a data pipeline.&lt;/p&gt;

&lt;p&gt;This post covers the questions worth asking about your &lt;a href="https://trophy.so/features/points?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;points and levels system&lt;/a&gt;, how the typical analytics tool path works and where it creates overhead, and what Trophy surfaces natively without a warehouse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four Questions That Actually Matter
&lt;/h2&gt;

&lt;p&gt;Before reaching for tools, it helps to be precise about what you're trying to learn. Most product teams working on XP and levels are really asking four things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How are my users distributed across levels right now?&lt;/strong&gt; If 80% of users are at Bronze and almost nobody reaches Silver, either the curve is too steep, Silver doesn't offer enough incentive to push for, or the user base isn't sufficiently engaged with the core activity. You can't tell which without the distribution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where is the progression curve breaking down?&lt;/strong&gt; A healthy distribution typically tapers — lots of Bronze, fewer Silver, fewer still Gold. A cliff at a specific level means something is wrong at that transition: the activity required is too high, the reward for reaching it isn't visible enough, or the cohort reaching that point is churning before completing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does reaching a level actually predict retention, or is it decorative?&lt;/strong&gt; This is the question most teams never answer. Levels look good in the product. Whether users who reach Silver retain at meaningfully higher rates than those who don't is a separate question — and the answer determines whether the system is doing any work or just occupying screen real estate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the engagement trend moving in the right direction?&lt;/strong&gt; Levels are a snapshot. Engagement is a trend. A static level distribution that's improving week-on-week is different from one that's flat — and only a time series shows which you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Data Warehouse Path
&lt;/h2&gt;

&lt;p&gt;The standard answer for cross-user analytics is to pipe events into an analytics platform: Mixpanel, Amplitude, BigQuery, Snowflake. This works correctly and makes sense for teams already operating a warehouse for other product analytics. The integration points with Trophy are clean: the &lt;a href="https://docs.trophy.so/webhooks/events/points/points-level-changed" rel="noopener noreferrer"&gt;&lt;code&gt;points.level_changed&lt;/code&gt; webhook&lt;/a&gt; streams level transitions as they happen, and &lt;code&gt;points.changed&lt;/code&gt; streams every XP award.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pipe level changes to your warehouse via webhook handler&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/trophy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;points.level_changed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;previousLevel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newLevel&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Write to your warehouse or analytics platform&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Level Up&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;pointsSystem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;previousLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;previousLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;newLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;pointsTotal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once level events are in your warehouse, you can join them to retention data, build cohort analyses, and answer the questions above with whatever query layer you use. The data model is straightforward: each &lt;code&gt;points.level_changed&lt;/code&gt; event is a row with &lt;code&gt;userId&lt;/code&gt;, &lt;code&gt;fromLevel&lt;/code&gt;, &lt;code&gt;toLevel&lt;/code&gt;, &lt;code&gt;timestamp&lt;/code&gt;, and &lt;code&gt;pointsTotal&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For teams without an existing warehouse, though, standing up a pipeline to answer gamification-specific questions is significant infrastructure overhead. Trophy surfaces the most common answers directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Trophy Surfaces Natively
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Level distribution across all users
&lt;/h3&gt;

&lt;p&gt;The level summary API returns a breakdown of user counts at each level in a points system — a direct answer to "how are my users distributed right now" without any querying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;TrophyApiClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@trophyso/node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TrophyApiClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TROPHY_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Get the count of users at each level for the 'xp' points system&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getLevelDistribution&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;levelSummary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;xp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Returns an array of levels with user counts&lt;/span&gt;
  &lt;span class="c1"&gt;// e.g. [{ key: 'bronze', name: 'Bronze', userCount: 4821 },&lt;/span&gt;
  &lt;span class="c1"&gt;// { key: 'silver', name: 'Silver', userCount: 634 },&lt;/span&gt;
  &lt;span class="c1"&gt;// { key: 'gold', name: 'Gold', userCount: 87 }]&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pull this on a weekly schedule and store the snapshots — even just in a spreadsheet — and you have a time series of level distribution without any pipeline work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Points distribution across all users
&lt;/h3&gt;

&lt;p&gt;The points breakdown API returns users bucketed by points range, which is where the progression curve problems are visible at a finer grain than level labels alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Get a breakdown of users by points range&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getPointsDistribution&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;breakdown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;xp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Returns buckets showing how many users fall in each points range&lt;/span&gt;
  &lt;span class="c1"&gt;// A cliff between two adjacent buckets signals a sticking point&lt;/span&gt;
  &lt;span class="c1"&gt;// in the progression&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;breakdown&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A level distribution showing 80% at Bronze tells you there's a problem. The points breakdown tells you whether those Bronze users are clustered near 0 XP (not engaged at all), near the Silver threshold (almost there but not converting), or spread evenly (disengaged across the board). Each pattern implies a different fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-metric retention analytics
&lt;/h3&gt;

&lt;p&gt;Every metric tracked in Trophy has its own retention dashboard showing the retention rate of users who engaged with that metric versus those who didn't. This is the mechanism for answering "does reaching a level actually predict retention" — not just for XP, but for any interaction you track.&lt;/p&gt;

&lt;p&gt;The retention view compares users who performed the tracked action against those who didn't, across your configured retention window. If lessons completed shows 40% 30-day retention for users who logged at least one lesson versus 15% for those who never did, you have a number — not a hypothesis.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7wzgniglob9l9g5zzb1d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7wzgniglob9l9g5zzb1d.png" alt="How to Measure XP and Levels Across All Users (Without a Data Warehouse)" width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Trophy metric analytics dashboards&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The control ratio — measuring gamification's impact directly
&lt;/h3&gt;

&lt;p&gt;Trophy's built-in A/B testing splits users automatically into a control group (no gamification) and an experimental group (gamification active). Trophy withholds all gamification features — points, achievements, streaks, emails, notifications — from control-group users. The analytics dashboards then compare retention and engagement between the two groups.&lt;/p&gt;

&lt;p&gt;This answers a question that Mixpanel and Amplitude struggle with: not "do users with high XP retain better" (which is confounded — engaged users earn more XP regardless of whether gamification helped) but "does the gamification system itself cause better retention?" The control group is the counterfactual, and Trophy manages the split automatically.&lt;/p&gt;

&lt;p&gt;Configure it from the Integration page in the Trophy dashboard. The &lt;code&gt;control&lt;/code&gt; field returned on user API responses lets you conditionally hide gamification UI from control-group users in your own frontend, keeping the split clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Good Achievement Calibration Looks Like — and How to Read Your Own Data
&lt;/h2&gt;

&lt;p&gt;The platform-wide numbers from Trophy's infrastructure give a useful benchmark. Across all apps on Trophy's platform, retention increases monotonically with achievement difficulty:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Achievement difficulty&lt;/th&gt;
&lt;th&gt;Retention rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&amp;lt;1×&lt;/td&gt;
&lt;td&gt;32%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1×–3×&lt;/td&gt;
&lt;td&gt;35%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3×–10×&lt;/td&gt;
&lt;td&gt;49%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10×–30×&lt;/td&gt;
&lt;td&gt;63%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30×–100×&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Source:&lt;/em&gt; &lt;a href="https://trophy.so/?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;&lt;em&gt;Trophy&lt;/em&gt;&lt;/a&gt; &lt;em&gt;platform data, April 2026. 14-day retention rate vs achievement difficulty, where difficulty is defined as the achievement threshold divided by the average daily activity volume.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The practical implication: if your level summary shows most users clustered at Bronze — which corresponds to low-difficulty achievements — those users are in the 32–35% retention cohort. Moving them into harder achievement territory isn't about making the game more difficult; it's about ensuring the goals are meaningful relative to what users are already doing. An achievement that requires exactly what a user does anyway is no more motivating than no achievement at all.&lt;/p&gt;

&lt;p&gt;How to read your own distribution against this: pull the points breakdown, compare the achievement thresholds you've set to average daily metric values in Trophy's metric analytics, and calculate your own difficulty ratios. If the majority of your completions are happening in the &amp;lt;1x or 1x–3x bucket, there's a calibration problem worth fixing before attributing flat retention to the gamification feature itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring Beyond XP — Tracking Cross-Feature Interactions
&lt;/h2&gt;

&lt;p&gt;Because Trophy's measurement is built on metrics — which track any user interaction, not just gamification actions — you can add purchases, onboarding completions, feature activations, or any other event as a tracked metric. Once an interaction is tracked in Trophy, it gets the same retention and engagement analytics as any gamification metric.&lt;/p&gt;

&lt;p&gt;This means questions like "do users who make a purchase within their first 7 days retain better than those who don't?" are answerable from Trophy's retention dashboard without joining to a separate data source — provided purchases are tracked as a metric. The same applies to any interaction you care about.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft13y3k9w8cgfi7ekjkyt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft13y3k9w8cgfi7ekjkyt.png" alt="How to Measure XP and Levels Across All Users (Without a Data Warehouse)" width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Trophy retention pathway analysis&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The early engagement charts — which Trophy shows specifically for users within their configured activation window — are particularly useful here. They show metric-by-metric engagement for users in their first days, which surfaces which early interactions predict long-term retention and which are noise. That's the data that should drive which achievements and XP triggers you build around.&lt;/p&gt;

&lt;p&gt;A note on scope: Trophy's per-metric retention analysis shows whether users who engaged with a given metric retained better. For questions that require joining multiple data sources outside Trophy — revenue attribution, support ticket correlation, ad spend efficiency — a dedicated analytics warehouse remains the right tool. The &lt;code&gt;points.level_changed&lt;/code&gt; and &lt;code&gt;points.changed&lt;/code&gt; webhooks are the integration points for those flows.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between Trophy's level summary API and querying my own database?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If levels are managed in Trophy, the authoritative user count per level lives in Trophy's infrastructure — not your database. Querying your database only works if you're mirroring Trophy's level state back into your own tables, which requires additional sync logic. The level summary API is a single call against the system of record, with no sync required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I get a time series of level distribution, not just the current state?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The level summary and points breakdown APIs return current state. For a time series, the simplest approach is to schedule a daily or weekly call to both APIs and store the snapshots yourself — even a basic cron job writing to a spreadsheet or database table gives you a trend line without warehouse infrastructure. The &lt;code&gt;points.level_changed&lt;/code&gt; webhook is the event-level alternative if you want full granularity: every level change as it happens, available to stream into whatever store you prefer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does Trophy's control ratio help me measure the impact of XP on retention?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The control ratio assigns a percentage of new users to a group that receives no gamification features. Trophy withholds points, achievements, streak tracking, and all gamification-triggered emails and notifications from these users automatically. The retention and engagement dashboards then compare the two groups, giving you a true causal read on whether gamification is driving retention improvement rather than just correlating with engaged users. Set the ratio from the Integration page; 10–20% in control is typically sufficient for statistical signal without meaningfully reducing the number of users experiencing the full product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can Trophy replace my product analytics tool for measuring engagement?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For gamification-centred engagement questions — is XP driving retention, which achievements correlate with long-term use, where users stall in the progression — Trophy's built-in analytics cover the most important queries without an external tool. For broader product analytics that span multiple features outside Trophy's scope, or for custom SQL queries against your full event history, a dedicated analytics platform remains the right choice. The two aren't mutually exclusive; many teams use Trophy for gamification analytics and a warehouse for everything else, with the Trophy webhooks as the bridge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I know if my XP curve is calibrated correctly?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Pull the points breakdown to see where users cluster relative to your level thresholds. Then compare your achievement thresholds to average daily metric values in Trophy's metric analytics — that ratio is your difficulty score. Completions clustering in the &amp;lt;1x or 1x–3x buckets indicate achievements that are too easy relative to normal usage. The platform benchmark (32% retention at &amp;lt;1x, 74% at 30–100x) is a useful calibration reference: if most of your completions are in the easy buckets, adjusting thresholds upward — or creating a harder tier of achievements — is the highest-leverage change you can make to the system before blaming the feature for flat retention numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Go Next
&lt;/h2&gt;

&lt;p&gt;If you're building an XP feature from scratch and need the implementation walkthrough alongside the measurement layer, &lt;a href="https://trophy.so/blog/how-to-build-an-xp-feature" rel="noopener noreferrer"&gt;How to Build an XP Feature&lt;/a&gt; covers the end-to-end integration. And for the per-user operational question — how a single user's XP stays consistent across their devices — &lt;a href="https://trophy.so/blog/how-to-sync-xp-across-devices" rel="noopener noreferrer"&gt;How to Sync XP Across Devices Without Firebase&lt;/a&gt; covers the server-authoritative model in detail.&lt;/p&gt;

</description>
      <category>gamification</category>
      <category>datascience</category>
      <category>mobile</category>
    </item>
    <item>
      <title>How to Sync XP Across Devices (Without Firebase)</title>
      <dc:creator>Charlie Brinicombe</dc:creator>
      <pubDate>Sat, 18 Apr 2026 23:48:47 +0000</pubDate>
      <link>https://dev.to/charlie_brinicombe/how-to-sync-xp-across-devices-without-firebase-3b1j</link>
      <guid>https://dev.to/charlie_brinicombe/how-to-sync-xp-across-devices-without-firebase-3b1j</guid>
      <description>&lt;p&gt;The instinct when you need XP to appear consistently on a user's phone, tablet, and web app is to reach for a sync layer. Firebase Realtime Database is the most common answer, and it solves exactly that problem, but the need for a sync layer is a consequence of a particular design choice: treating XP as a value that originates on the client and needs to be propagated outward. Change that choice and the sync problem largely disappears.&lt;/p&gt;

&lt;p&gt;This post covers how the Firebase approach actually works, where it creates friction for XP specifically, and how a server-authoritative XP model, where &lt;a href="https://trophy.so" rel="noopener noreferrer"&gt;Trophy&lt;/a&gt; is the single source of truth and devices simply read from it, handles the same use case with less infrastructure and better security guarantees.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Firebase Approach
&lt;/h2&gt;

&lt;p&gt;Firebase Realtime Database handles cross-device consistency through WebSocket connections and a client SDK that maintains a local cache. Each connected client subscribes to a node in the database; when any client writes to that node, Firebase pushes the updated value to all other subscribers in real time.&lt;/p&gt;

&lt;p&gt;For XP, the typical schema stores each user's total under a user-scoped path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/users/{uid}/xp: 1250
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client SDK listens to that node and re-renders the UI whenever the value changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;initializeApp&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;firebase/app&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getDatabase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;runTransaction&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;firebase/database&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;initializeApp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;firebaseConfig&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getDatabase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Listen for XP changes — fires on this device and any other connected device&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;subscribeToXP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onUpdate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;xpRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/xp`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;onValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xpRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;onUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;val&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Award XP for completing a lesson&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;awardXP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;xpRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/xp`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xpRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Firebase also handles offline correctly: if a device loses connectivity, writes are queued locally and flushed when the connection is restored. For a pure "keep this number consistent everywhere" problem, this is a clean solution. The complications are specific to what XP actually needs to do in a gamified product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four Problems the Firebase Model Creates for XP
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Business logic has no safe home
&lt;/h3&gt;

&lt;p&gt;XP isn't a number you increment arbitrarily — it's the output of award rules. A lesson earns 10 XP. Completing a 7-day streak earns 50 XP. Finishing an achievement earns 100 XP. During a promotional campaign, all awards are doubled.&lt;/p&gt;

&lt;p&gt;Firebase is a data store. It has no concept of award rules. Those calculations have to run somewhere: either in client code or in Cloud Functions triggered by database writes.&lt;/p&gt;

&lt;p&gt;Client-side award logic is a security problem. If your app's JavaScript or mobile code decides how much XP to award and writes that value directly to Firebase, any user who can intercept or modify that code can award themselves arbitrary XP. The Firebase security rules are then your only line of defence — and encoding all your award logic in Firebase's declarative rules language is brittle, difficult to test, and easy to get wrong.&lt;/p&gt;

&lt;p&gt;Cloud Functions shift the calculation server-side, which is more defensible, but you've now added a function deployment and invocation layer to what started as a simple data sync question. The logic lives in a Cloud Function, the value lives in Firebase, and you're operating two systems instead of one.&lt;/p&gt;

&lt;h3&gt;
  
  
  No award history
&lt;/h3&gt;

&lt;p&gt;Firebase Realtime Database stores the current state of a node. It does not natively record why that state is what it is — which events fired, when they fired, or what multipliers were active. A user's XP node contains &lt;code&gt;1,250&lt;/code&gt;. What earned those 1,250 points? You don't know without building a separate event log.&lt;/p&gt;

&lt;p&gt;Award history matters more than it seems during initial development. Support tickets ("I completed three lessons and didn't get my XP"), cheat detection ("this user's XP jumped by 5,000 in 30 seconds"), annual Wrapped features ("you earned 14,000 XP this year from 280 lessons") — all of these require a complete, ordered record of every award. If you didn't design that in from day one, reconstructing it later from Firebase is painful.&lt;/p&gt;

&lt;h3&gt;
  
  
  Level and boost logic creates client coordination overhead
&lt;/h3&gt;

&lt;p&gt;Most XP systems aren't just a counter — they have levels (Bronze at 0 XP, Silver at 500 XP, Gold at 2,000 XP) and time-limited boosts (2× XP during a holiday campaign). In the Firebase model, your client code has to know the level thresholds to detect a level-up and trigger the celebration animation. It has to know whether a boost is currently active to display the correct multiplier in the UI. That configuration has to reach every client through some mechanism — another Firebase node, a remote config service, or a hardcoded constant — and stay consistent when it changes.&lt;/p&gt;

&lt;p&gt;This is solvable but it's coordination work. Every additional piece of logic that spans both the client and the Firebase database is something that can drift out of sync.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gaming the system and duplicate awards
&lt;/h3&gt;

&lt;p&gt;Even with server-side award logic in Cloud Functions, a custom Firebase system has no built-in protection against the same action triggering an award more than once. Mobile networks drop requests. Retry logic re-fires them. A user completes a lesson, the request times out on the client, the app retries on reconnect — and if your Cloud Function doesn't explicitly check whether that specific lesson has already been rewarded for that specific user, the award fires twice.&lt;/p&gt;

&lt;p&gt;The standard mitigation is to maintain a separate deduplication log: before processing an award, check whether this action ID has been seen for this user, and if so, skip it. This is straightforward to describe and moderately tedious to build correctly — the check and the award need to happen atomically, the log needs to be queryable by (userId, actionId), and you need a retention policy so it doesn't grow unbounded.&lt;/p&gt;

&lt;p&gt;Without this protection, determined users can also exploit retry behaviour deliberately — triggering network failures at the right moment to manufacture duplicate completions. XP inflation is the common outcome: the total climbs faster than intended, level thresholds lose their meaning, and the progression system breaks down. This is a problem that grows with your user base, rarely surfaces in testing, and is painful to remediate after the fact.&lt;/p&gt;

&lt;h3&gt;
  
  
  The sync problem is downstream of the real problem
&lt;/h3&gt;

&lt;p&gt;When you work through what it would take to make Firebase handle XP correctly — Cloud Functions for award calculations, security rules that encode business logic, a separate event log for history, remote config for level thresholds and boost state — you've built a server-side XP system that happens to use Firebase as its storage layer. At that point you're not saving engineering time compared to building XP server-side directly; you're adding Firebase as an extra dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Server-Authoritative Model
&lt;/h2&gt;

&lt;p&gt;The alternative reframes the problem entirely. Rather than asking "how do I keep XP consistent across devices," ask "what if XP only ever lives in one place, and devices just read from it?"&lt;/p&gt;

&lt;p&gt;In a server-authoritative model, no client ever writes an XP value. The client sends an event describing what the user did ("completed lesson 42"). The server applies the award rules, updates the total, and returns the new state. Every device that wants to display XP reads it from the server. There is no distributed state to synchronise — there is one number in one place.&lt;/p&gt;

&lt;p&gt;A minimal server-authoritative XP implementation without any third-party tooling looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Server-side: award XP and return the new total&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleLessonComplete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;lessonId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;xp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;levelUp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;newLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Award rules live server-side — no client can influence this&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;award&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculateAward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lessonId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// e.g. 10 XP per lesson&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;forUpdate&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newTotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;xp&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;award&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newLevel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolveLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newTotal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;levelUp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newLevel&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;xp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newTotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newLevel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// Write to event log — history is automatic&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;xp_events&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;award&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lesson_complete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;lesson_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lessonId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;total_after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newTotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;xp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newTotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;levelUp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newLevel&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client sends a request to your server, gets back the updated XP and level state, and renders it. No Firebase, no sync layer, no distributed state. The "how does the other device find out" question becomes "how does the other device know to refetch" — a simpler problem that polling or a lightweight push notification handles adequately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trophy's Implementation
&lt;/h2&gt;

&lt;p&gt;Trophy is a purpose-built server-authoritative XP layer. Rather than building the award logic, level system, history, and boost infrastructure yourself, you configure them in Trophy's dashboard and interact via API.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sending activity and reading XP inline
&lt;/h3&gt;

&lt;p&gt;When a user completes an action, send a &lt;a href="https://docs.trophy.so/platform/events" rel="noopener noreferrer"&gt;metric event&lt;/a&gt; to Trophy from your server. The response includes the updated XP total, current level, and (if the action triggered a level change) the new level object. No separate XP fetch required on the active device:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;TrophyApiClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@trophyso/node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TrophyApiClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TROPHY_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleLessonComplete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lessonId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lessons_completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;xpData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;xp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xpData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Total XP after this event&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newTotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;xpData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// xpData.level is only present when the user's level changed&lt;/span&gt;
    &lt;span class="c1"&gt;// Treat its presence as the level-up signal — no extra bookkeeping needed&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xpData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;triggerLevelUpAnimation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xpData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;xpData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;badgeUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;updateXPDisplay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newTotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;xpData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trophy evaluates all configured triggers on every event — metric-based awards, active boosts, caps — and returns the resulting state. Your server code doesn't need to know the award rules; they live in Trophy's configuration meaning they can be changed easily at any time without code changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fetching current XP on app launch or device switch
&lt;/h3&gt;

&lt;p&gt;When a user opens the app on a different device, fetch their current XP state from Trophy. This is the "sync on open" pattern — no persistent connection, no offline cache to manage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadUserXP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;points&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;points&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;xp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Current level object, or null if no levels configured&lt;/span&gt;
    &lt;span class="na"&gt;recentAwards&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;awards&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Recent award history&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call this on app launch, on tab focus, or whenever the user navigates to a profile or progress screen. The value returned is always current — it's a read from Trophy's database, not a local cache.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pushing display updates to other active sessions
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://docs.trophy.so/webhooks/events/points/points-changed" rel="noopener noreferrer"&gt;&lt;code&gt;points.changed&lt;/code&gt; webhook&lt;/a&gt; fires when a user's XP balance changes (at most once per user per points system per minute, to avoid flooding high-frequency apps). Wire this to your own real-time push layer (whatever you already use for notifications) to update any other sessions the user has open:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In your webhook handler&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/trophy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;points.changed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;points&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;xp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Push the updated total to any other active sessions for this user&lt;/span&gt;
      &lt;span class="c1"&gt;// via your existing WebSocket, SSE, or push notification infrastructure&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pushToUserSessions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;xp_updated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;added&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;added&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;points.level_changed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;previousLevel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newLevel&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;xp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Level-up notification to other active sessions&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pushToUserSessions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;level_up&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;previousLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;previousLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;newLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;newLevelBadge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;badgeUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key difference from Firebase's model: Trophy is the source of truth, and your push layer is only responsible for telling other clients to refresh, not for carrying the XP value itself. The client that receives the push does a &lt;code&gt;loadUserXP()&lt;/code&gt; call and renders the current state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Querying award history
&lt;/h3&gt;

&lt;p&gt;The points summary endpoint returns a time-series breakdown of XP awards for a user. No event log to build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getUserXPHistory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pointsEventSummary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;xp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;aggregation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;daily&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-01-01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-04-19&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Returns a daily breakdown of XP awards — useful for&lt;/span&gt;
  &lt;span class="c1"&gt;// Wrapped features, support investigations, or progress graphs&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Preventing duplicate awards with idempotency keys
&lt;/h3&gt;

&lt;p&gt;Trophy's metric event API supports &lt;a href="https://docs.trophy.so/api-reference/idempotency?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;idempotency keys&lt;/a&gt; natively. Pass an &lt;code&gt;idempotencyKey&lt;/code&gt; alongside the event — typically the unique ID of the action being rewarded — and Trophy guarantees the award fires at most once per user per key, regardless of how many times the request is retried.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleLessonComplete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lessonId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lessons_completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// Using the lesson ID ensures this award can only fire once&lt;/span&gt;
    &lt;span class="c1"&gt;// per user per lesson, no matter how many retries occur&lt;/span&gt;
    &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lessonId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// response.idempotentReplayed is true if this key was already seen for this user&lt;/span&gt;
  &lt;span class="c1"&gt;// The response still reflects current state — safe to render regardless&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idempotentReplayed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Duplicate event for lesson &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lessonId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; — no award processed`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When Trophy receives a request with an idempotency key it has already seen for that user and metric, it returns a &lt;code&gt;202 Accepted&lt;/code&gt; response with &lt;code&gt;idempotentReplayed: true&lt;/code&gt;. No metric is incremented, no points are awarded, no achievements are completed — the state is unchanged. The response still reflects the user's current state, so the client can render it safely without needing to branch on whether the event was replayed.&lt;/p&gt;

&lt;p&gt;The key should reflect the granularity of uniqueness you want to enforce. A lesson ID prevents a user from earning XP from the same lesson twice, ever. A session ID would allow multiple completions of the same lesson across different sessions. The choice of idempotency key is the business rule — Trophy enforces whatever you specify.&lt;/p&gt;

&lt;h3&gt;
  
  
  Abstracting XP logic away from code
&lt;/h3&gt;

&lt;p&gt;The difference with Trophy's &lt;a href="https://trophy.so/features/points?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;server-authoritative XP model&lt;/a&gt; is that business logic around how to award XP to users based on interactions lives outside your codebase. This means it can be changed at any point without code changes, so product managers can optimize without bottlenecking developers with small logic tweaks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7roynol68bly7h0156cp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7roynol68bly7h0156cp.png" alt="How to Sync XP Across Devices Without Firebase (2026)" width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Points trigger configuration in Trophy&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Trophy has a system of triggers for different types of XP awards including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Interaction driven triggers e.g. 10XP per flashcard viewed&lt;/li&gt;
&lt;li&gt;Streak triggers e.g. 10XP per day of streak&lt;/li&gt;
&lt;li&gt;Time triggers. e.g. 5XP per hour&lt;/li&gt;
&lt;li&gt;Achievement triggers e.g. 50XP for completing a profile&lt;/li&gt;
&lt;li&gt;One-time triggers e.g. 100XP on sign up&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Additionally, Trophy natively supports setting up levels through the dashboard, allowing easy balance changes and optimizations without code changes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk5kh04d50jmckqsulmkt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk5kh04d50jmckqsulmkt.png" alt="How to Sync XP Across Devices Without Firebase (2026)" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Points levels in the Trophy dashboard&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Finally, Trophy also supports adding time-limited 'boosts' to points logic during key periods such as Christmas, NY and BFCM. This allows product teams to modify poitns logic temporarily around key calendar events, again without creating extra work for developers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You're Not Building
&lt;/h2&gt;

&lt;p&gt;The server-authoritative model with Trophy means the following are handled without custom code: award calculation and rate configuration, level threshold evaluation and assignment, boost multiplier stacking and scheduling, XP cap enforcement, and a complete per-user award event log. Each of those would be engineering work in the Firebase model — either in Cloud Functions, security rules, or additional database tables.&lt;/p&gt;

&lt;p&gt;The one thing Trophy doesn't replace is your real-time push layer for notifying other active sessions. If you already have WebSockets, SSE, or FCM in your stack for other features (chat, notifications, collaborative activity), you use that. If you don't have anything, polling on focus events is a straightforward fallback — &lt;code&gt;loadUserXP()&lt;/code&gt; on &lt;code&gt;visibilitychange&lt;/code&gt; or app foreground keeps every session current without a persistent connection.&lt;/p&gt;

&lt;p&gt;For the full configuration reference including trigger types, level setup, and boost management, see &lt;a href="https://docs.trophy.so/platform/points?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;Trophy's Points documentation&lt;/a&gt;. If you're building out the full XP feature including UI patterns for progress bars and level displays, &lt;a href="https://claude.ai/blog/how-to-build-an-xp-feature?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;How to Build an XP Feature&lt;/a&gt; covers the end-to-end implementation. And if you're also connecting XP to a leaderboard, &lt;a href="https://claude.ai/blog/how-to-build-a-leaderboards-feature?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;How to Build a Leaderboard for Your App&lt;/a&gt; covers how Trophy's points rankings work.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does Trophy push XP updates to other devices in real time?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Trophy fires the &lt;code&gt;points.changed&lt;/code&gt; webhook when a user's XP balance changes, which you can use to trigger a push to other active sessions via your existing real-time infrastructure. Trophy doesn't maintain a persistent WebSocket connection to your clients directly — it notifies your server, and your server notifies the relevant clients. For apps without real-time infrastructure, polling on app foreground or page focus is a practical alternative that keeps sessions current without a persistent connection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if my app needs to work offline — can users earn XP without a connection?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Trophy's metric events are sent from your server, not the client, so offline XP earning requires queuing actions on the client and flushing them to your server when connectivity is restored. The pattern is: client stores completed actions locally, syncs to your server on reconnect, your server sends the batched events to Trophy in order, Trophy computes the awards. This is the same pattern you'd need with any server-authoritative system. The advantage over Firebase is that conflict resolution is trivial — Trophy processes events in the order received and applies award rules consistently, so there's no merge conflict to handle on reconnect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does Trophy handle the level-up moment across devices?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The metric event response includes the &lt;code&gt;level&lt;/code&gt; key in the &lt;code&gt;points&lt;/code&gt; map only when the user's level changed as a result of that event — its presence is the level-up signal, so no comparison logic is needed on the active device. For other sessions, the &lt;code&gt;points.level_changed&lt;/code&gt; webhook fires separately from &lt;code&gt;points.changed&lt;/code&gt;, giving you a clean hook to trigger level-up notifications or animations on any other open sessions without having to diff XP totals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I migrate existing XP data from Firebase to Trophy?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The approach is to backfill Trophy with metric events that reconstruct each user's current XP total, then cut over new activity to Trophy once the backfill is complete. If your Firebase data includes a history of individual awards (separate from the running total), those can be replayed as individual events to preserve the history in Trophy's audit log. If you only have the current totals, you can use Trophy's Admin API to set initial balances directly before going live. Contact Trophy's team for guidance on large-scale migrations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Trophy's points system only for XP, or can it handle multiple currencies like gems or coins?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Trophy supports multiple independent points systems per app. You can configure a separate system for each currency — XP, gems, coins, energy — each with its own triggers, level thresholds, caps, and boost schedules. Each system has its own key (e.g. &lt;code&gt;xp&lt;/code&gt;, &lt;code&gt;gems&lt;/code&gt;), and the metric event response returns updated totals for every system that changed as a result of the event. A single action can simultaneously award XP to one system and deduct energy from another, with both results returned inline in the same response.&lt;/p&gt;

</description>
      <category>gamification</category>
      <category>mobile</category>
      <category>firebase</category>
    </item>
    <item>
      <title>How to Scale a Leaderboard Beyond Basic Redis</title>
      <dc:creator>Charlie Brinicombe</dc:creator>
      <pubDate>Sat, 18 Apr 2026 23:00:59 +0000</pubDate>
      <link>https://dev.to/charlie_brinicombe/how-to-scale-a-leaderboard-beyond-basic-redis-43ee</link>
      <guid>https://dev.to/charlie_brinicombe/how-to-scale-a-leaderboard-beyond-basic-redis-43ee</guid>
      <description>&lt;p&gt;Redis sorted sets are the right foundation for leaderboard ranking. The data structure is genuinely elegant: O(log N) insertions, O(log N + K) range reads, atomic operations, and no lock contention under concurrent writes. Trophy uses Redis under the hood for exactly this reason.&lt;/p&gt;

&lt;p&gt;The question isn't whether to use Redis for ranking, it's how much of the surrounding infrastructure you want to build and maintain yourself. A bare sorted set gets you fast global ranking. It doesn't get you segmentation, time-based resets with historical state, a durability guarantee, user profile joins, or rank-change events. Each of those is a separate engineering project.&lt;/p&gt;

&lt;p&gt;This post covers what each one involves, what Memcached adds to the picture, and how &lt;a href="https://trophy.so" rel="noopener noreferrer"&gt;Trophy&lt;/a&gt; functions as an abstraction layer that keeps the Redis performance profile while handling the surrounding complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Redis Sorted Sets Beat SQL at Scale
&lt;/h2&gt;

&lt;p&gt;The standard starting point for a leaderboard is an &lt;code&gt;ORDER BY&lt;/code&gt; query against a relational database. It works correctly and scales fine until it doesn't — typically somewhere between 50,000 and 500,000 users depending on update frequency. Aa tables grows and update frequency climbs, the sort operation becomes an increasingly expensive full-table pass. Adding an index on the score column helps reads but slows writes. You end up in an arms race between read and write performance that a general-purpose database isn't designed to win.&lt;/p&gt;

&lt;p&gt;Redis sorted sets sidestep this entirely. Under the hood, each sorted set is implemented as a skip list — a probabilistic data structure that maintains sorted order on every write rather than sorting at read time. The consequence is that &lt;code&gt;ZADD&lt;/code&gt;, &lt;code&gt;ZRANK&lt;/code&gt;, and &lt;code&gt;ZREVRANGE&lt;/code&gt; are all O(log N) regardless of set size, and range reads are O(log N + K) where K is the number of results returned. Fetching the top 10 users from a set of 10 million takes the same time as fetching from a set of 10,000 — the sort cost is paid incrementally on every write, not in a single expensive query at read time.&lt;/p&gt;

&lt;p&gt;A basic TypeScript implementation using &lt;code&gt;ioredis&lt;/code&gt; demonstrates the core pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Redis&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ioredis&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REDIS_URL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Update a user's score — O(log N)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;recordActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zincrby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;leaderboard:weekly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Fetch the top 10 — O(log N + 10)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getTopTen&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zrevrangebyscore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;leaderboard:weekly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;+inf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-inf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WITHSCORES&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LIMIT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="mi"&gt;10&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Get a specific user's rank — O(log N)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getUserRank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zrevrank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;leaderboard:weekly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Convert 0-indexed to 1-indexed&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is correct, fast, and handles concurrent writes safely. Redis processes commands sequentially through its single-threaded event loop, so two users updating scores simultaneously never corrupt the sorted set — no SELECT-then-UPDATE race condition, no transaction isolation level to worry about.&lt;/p&gt;

&lt;p&gt;At this point, the competing guidance is accurate: Redis sorted sets are the right data structure. The complications start when you extend beyond a single global list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five Places Raw Redis Gets Complicated
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Segmentation key explosion
&lt;/h3&gt;

&lt;p&gt;A single global leaderboard maps to one sorted set key. The moment you add segmentation (city-level boards, skill-tier boards, friend groups, gym-specific rankings) you're managing one sorted set per segment. A mid-sized fitness app with 50 cities, 3 skill tiers, and 4 activity types has 600 sorted sets to maintain. Every score update potentially touches multiple sets. At 10,000 daily active users averaging two activities each, that's 20,000 write operations touching up to 600 keys per operation in the worst case.&lt;/p&gt;

&lt;p&gt;To keep things maintainable as this scales, you need a consistent naming scheme (&lt;code&gt;leaderboard:{type}:{segmentValue}&lt;/code&gt;), logic to determine which keys a given user belongs to on every write, a way to enumerate all segments when querying, and a cleanup strategy for segments that become empty. None of this is a Redis limitation, it's just engineering work that scales with the number of segment dimensions you add.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// What segmented writes look like at the application layer&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;recordActivitySegmented&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;userCity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;userSkillTier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;activityType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;`leaderboard:global`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`leaderboard:city:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userCity&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`leaderboard:tier:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userSkillTier&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`leaderboard:activity:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;activityType&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`leaderboard:city:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userCity&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:tier:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userSkillTier&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="c1"&gt;// Pipeline all writes — but you're still managing N keys per user&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zincrby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is manageable at 5 segments. At 600, key management becomes a significant operational concern.&lt;/p&gt;

&lt;h3&gt;
  
  
  Time-based resets and historical state
&lt;/h3&gt;

&lt;p&gt;Resetting a weekly leaderboard at period boundaries sounds simple. It isn't. The naive approach (deleting the key and starting fresh) loses the previous period's final rankings before you've had a chance to announce winners or archive results for later analysis. The standard pattern is to snapshot the old set first, then reset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resetWeeklyLeaderboard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;weekKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;archiveKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`leaderboard:weekly:archive:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;weekKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Snapshot the current set before reset&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zunionstore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;archiveKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;leaderboard:weekly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;archiveKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Keep 90 days&lt;/span&gt;

  &lt;span class="c1"&gt;// Delete the live set to start fresh&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;del&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;leaderboard:weekly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This introduces a race condition: if a score update arrives between the &lt;code&gt;ZUNIONSTORE&lt;/code&gt; and &lt;code&gt;DEL&lt;/code&gt;, that update is in the archive but not in the new live set. The correct approach uses a Lua script to make the operation atomic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resetScript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
  local archiveKey = KEYS[2]
  local liveKey = KEYS[1]
  redis.call('ZUNIONSTORE', archiveKey, 1, liveKey)
  redis.call('EXPIRE', archiveKey, ARGV[1])
  redis.call('DEL', liveKey)
  return 1
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;atomicReset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;weekKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;resetScript&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;leaderboard:weekly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`leaderboard:weekly:archive:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;weekKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now add timezone handling. A weekly leaderboard ending Sunday midnight needs to end at Sunday midnight &lt;em&gt;in each user's local timezone&lt;/em&gt;, not UTC. You can't reset the global set at a single UTC timestamp without disadvantaging users in later timezones. The correct architecture requires either per-user timezone tracking with per-user period offsets, or a finalisation window that holds the period open until all timezones have passed the boundary, typically 12 to 14 hours after the UTC deadline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Persistence and durability
&lt;/h3&gt;

&lt;p&gt;Redis is an in-memory store. By default, a Redis restart loses all data. For a leaderboard, that means losing all rankings since the last snapshot. Production deployments need to choose between RDB (periodic snapshots, faster but lossy) and AOF (append-only log, slower but durable) persistence modes, or a combination. Misconfiguring this — or failing to account for it at all — means a Redis node restart can silently wipe weeks of ranking data.&lt;/p&gt;

&lt;p&gt;This isn't a hard problem to solve, but it's a configuration and operational concern that needs deliberate attention. A managed Redis service (ElastiCache, Redis Cloud, Upstash) handles this for you at the infrastructure level, but you still need to understand the durability model to know what you're getting.&lt;/p&gt;

&lt;h3&gt;
  
  
  User data joins
&lt;/h3&gt;

&lt;p&gt;Redis stores user IDs and scores. It knows nothing about display names, avatars, or any other user attributes needed to render a leaderboard. Every leaderboard read requires a join between Redis (for ranks and scores) and your primary database (for user metadata).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getLeaderboardWithProfiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Step 1: Get ranked user IDs from Redis&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zrevrangebyscore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;leaderboard:weekly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;+inf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-inf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WITHSCORES&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LIMIT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 2: Fetch user profiles from your database&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;profiles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT id, name, avatar_url FROM users WHERE id = ANY($1)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userIds&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;profileMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 3: Merge&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;userIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profileMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;avatarUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profileMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;avatar_url&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two round trips on every leaderboard render. For read-heavy leaderboards, this pattern drives significant database load. The standard mitigation is to cache the merged result — which then introduces cache invalidation complexity on top of the Redis and database layers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rank-change event blindness
&lt;/h3&gt;

&lt;p&gt;Redis has no concept of a user's rank changing because &lt;em&gt;someone else&lt;/em&gt; scored more. When User A's score update pushes User B from rank 9 to rank 10, Redis processes User A's &lt;code&gt;ZINCRBY&lt;/code&gt; correctly — but nothing notifies User B. Building a rank-change notification system on top of Redis requires polling (expensive), a separate event stream (significant architecture), or a post-write rank comparison (latency and consistency trade-offs). This is one of the more underappreciated pieces of leaderboard infrastructure, and it's entirely absent from the Redis documentation and most tutorials.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Memcached Adds — and Doesn't
&lt;/h2&gt;

&lt;p&gt;Memcached is a simple key-value cache with no sorted data structure primitive. It cannot implement leaderboard ranking natively — there's no Memcached equivalent of &lt;code&gt;ZADD&lt;/code&gt; or &lt;code&gt;ZRANK&lt;/code&gt;. Where Memcached appears in leaderboard architectures, it's as a read cache layer in front of Redis or a database: pre-computed leaderboard snapshots are stored in Memcached with a short TTL and served from there to reduce Redis or database read load.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Typical Memcached caching layer in front of Redis&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getCachedLeaderboard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ttlSeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;memcached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getLeaderboardFromRedis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;memcached&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;ttlSeconds&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern makes sense at very high read volumes — if thousands of users are viewing the leaderboard simultaneously and fresh-to-the-second accuracy isn't required, serving from Memcached reduces Redis round trips. But it doesn't solve segmentation, time-based resets, persistence, user data joins, or rank-change events. It adds a caching layer on top of the existing Redis problems, along with its own staleness and invalidation considerations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trophy's Architecture
&lt;/h2&gt;

&lt;p&gt;Trophy uses Redis sorted sets for real-time ranking — the same data structure and the same O(log N) performance profile. The difference is that Trophy handles the surrounding infrastructure as a managed layer, so the problems above don't land in your codebase.&lt;/p&gt;

&lt;p&gt;The architecture sits across three components:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redis&lt;/strong&gt; handles real-time ranking for all active leaderboard periods. Sorted sets are maintained per leaderboard per period per segment. Segmentation key management, atomic period resets, and the Lua scripts required for safe transitions are internal to Trophy's infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PostgreSQL&lt;/strong&gt; materialises ranking data for durability and historical queries. Completed leaderboard periods are persisted to Postgres, which means historical runs are queryable without relying on Redis TTL management or archived keys. It also means a Redis node restart doesn't wipe live ranking data — writes to Redis are backed by Postgres persistence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An event pipeline&lt;/strong&gt; sits on top of both. When a user's rank changes — including when their rank shifts because another user scored more — Trophy detects the transition and fires a &lt;code&gt;leaderboard.rank_changed&lt;/code&gt; webhook. Period boundaries trigger &lt;code&gt;leaderboard.finished&lt;/code&gt; and &lt;code&gt;leaderboard.started&lt;/code&gt; events. These are the re-engagement hooks that raw Redis can't emit.&lt;/p&gt;

&lt;p&gt;From the application side, the integration is three API calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;TrophyApiClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@trophyso/node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TrophyApiClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TROPHY_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 1. Identify users with segmentation attributes — Trophy manages key routing internally&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;identify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sam Okafor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;london&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;skill_level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;intermediate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Send activity — Trophy writes to all relevant sorted sets atomically&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;workouts_completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Europe/London&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Rank positions for all relevant leaderboards come back inline&lt;/span&gt;
&lt;span class="c1"&gt;// No separate ranking query, no user data join required on your side&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;leaderboards&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Fetch a segmented leaderboard for display&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;londonBoard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;leaderboards&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;weekly-workouts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;userAttributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;city:london&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Historical runs are available without any archival work on your end&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lastWeek&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;leaderboards&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;weekly-workouts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;userAttributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;city:london&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-04-07&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Period start date of the previous week&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rank-change webhook requires a handler in your application, but the detection and delivery are handled by Trophy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Webhook handler — Trophy fires this when any user's rank shifts&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/trophy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;leaderboard.rank_changed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;leaderboardKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;previousRank&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;previousRank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// User has dropped — re-engagement trigger&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendPushNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You've been overtaken&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`You've dropped to rank &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; on the &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;leaderboardKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; leaderboard this week.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;previousRank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// User has climbed — positive reinforcement&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendPushNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You moved up&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`You're now ranked #&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; this week. Keep going.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;leaderboard.finished&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Period has closed — announce winners, trigger recap emails&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;leaderboardKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;topRankings&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendWeeklyRecapEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topRankings&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rankings configuration and rest conditions are all managed through the Trophy dashboard, meaning all members of the product team, including non-technical colleagues, can be involved in the build.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fym8eoekq9ogl024xng66.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fym8eoekq9ogl024xng66.png" alt="How to Scale a Leaderboard Beyond Basic Redis (2026)" width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Trophy dashboard repeating leaderboard configuration&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Teams can also make use of Trophy's built-in leaderboard analytics dashboards which help understand leaderboard activity, balance and competitiveness.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvm65f5gvzgt3jjilju1d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvm65f5gvzgt3jjilju1d.png" alt="How to Scale a Leaderboard Beyond Basic Redis (2026)" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Leaderboard analytics in the Trophy dashboard&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What You're Actually Trading Off
&lt;/h2&gt;

&lt;p&gt;Running Redis leaderboard infrastructure yourself means owning the following, in addition to the core sorted set operations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Key schema management&lt;/strong&gt; — naming convention, enumeration, and cleanup for all segment keys&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomic reset logic&lt;/strong&gt; — Lua scripts for safe period transitions without race conditions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistence configuration&lt;/strong&gt; — RDB vs AOF trade-offs, backup strategy, recovery procedures&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timezone-aware period management&lt;/strong&gt; — per-user timezone tracking and finalisation windows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Historical state archival&lt;/strong&gt; — TTL management or database snapshots for past period queries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User profile join layer&lt;/strong&gt; — round trips between Redis and your database on every render, plus caching strategy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rank-change event pipeline&lt;/strong&gt; — polling or event streaming to detect rank transitions from third-party score updates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis cluster management&lt;/strong&gt; — sharding decisions, replica configuration, monitoring if you reach 10M+ member sets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read caching layer&lt;/strong&gt; — Memcached or application-level cache for high-volume read scenarios&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are insurmountable. Senior engineers build all of them regularly. The honest question is whether any of that work is the part of your product that differentiates you from competitors. If the answer is no (and for most consumer apps it is) Trophy provides the same Redis performance with that list already handled.&lt;/p&gt;

&lt;p&gt;For a more detailed look at the segmentation design decisions and what breakdown attributes to use for your app category, &lt;a href="https://trophy.so/blog/how-strava-uses-segmented-leaderboards-to-drive-engagement" rel="noopener noreferrer"&gt;How Strava Uses Segmented Leaderboards to Drive Engagement&lt;/a&gt; covers the psychology and configuration in more depth. For the full implementation walkthrough from first event to live rankings, see &lt;a href="https://trophy.so/blog/how-to-build-a-leaderboards-feature" rel="noopener noreferrer"&gt;How to Build a Leaderboard for Your App&lt;/a&gt;, and the official Trophy &lt;a href="https://docs.trophy.so/platform/leaderboards" rel="noopener noreferrer"&gt;leaderboard documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does Trophy actually use Redis, or is that a simplification?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Trophy uses Redis sorted sets for real-time ranking — the same data structure described in this post. The O(log N) performance characteristics apply. The difference is that Trophy manages the Redis infrastructure, key schema, persistence configuration, and operational concerns. You interact with a REST API; the sorted set operations happen internally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the performance difference between querying Trophy versus querying Redis directly?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A direct Redis &lt;code&gt;ZRANK&lt;/code&gt; or &lt;code&gt;ZREVRANGE&lt;/code&gt; call is a sub-millisecond in-memory operation — as fast as it gets. Trophy adds one HTTP round trip to that, typically 20–50ms depending on region. For leaderboard display (which is a UI interaction, not a hot path) this is imperceptible. For real-time ranking updates where you want the user's updated position returned inline (e.g., immediately after a score update), Trophy returns rank data in the event response within 200-300ms, eliminating the need for a separate ranking query after the write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I cache Trophy's leaderboard responses?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For display use cases, yes. Caching for 30–60 seconds on your side reduces API calls and is consistent with standard leaderboard UX — users don't need rankings accurate to the millisecond when viewing a leaderboard screen. Trophy returns fresh data on every request; the caching decision is yours. For real-time feedback immediately after a user's own activity (showing their updated rank), use the rank data returned directly in the metric event response rather than polling the leaderboard endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens to my leaderboard if Trophy experiences downtime?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Design your integration to degrade gracefully — queue metric events for retry and serve cached leaderboard data during any outage. This is the same resilience pattern you'd apply to any external service and the same pattern you'd need to implement for a managed Redis provider. Trophy's 99.9% uptime SLA and infrastructure redundancy are detailed on the &lt;a href="https://status.trophy.so" rel="noopener noreferrer"&gt;status page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At what scale does building Redis leaderboards in-house become worth it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The threshold is usually centered around unusual requirements that managed platforms don't support i.e. custom ranking algorithms, proprietary tie-breaking logic, or integration constraints specific to your stack. However for most apps with standard leaderboard requirements, the build-in-house path typically costs more in upfront engineering time and ongoing maintenance than it saves in platform fees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Go Next
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://docs.trophy.so/platform/leaderboards" rel="noopener noreferrer"&gt;Trophy Leaderboards documentation&lt;/a&gt; covers the full configuration reference: leaderboard types, ranking methods, breakdown attribute setup, and the webhook event schema for &lt;code&gt;leaderboard.rank_changed&lt;/code&gt; and &lt;code&gt;leaderboard.finished&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For the implementation walkthrough — from first metric event to a live segmented leaderboard with rank-change notifications — see &lt;a href="https://trophy.so/blog/how-to-build-a-leaderboards-feature" rel="noopener noreferrer"&gt;How to Build a Leaderboard for Your App&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The segmentation design decisions (when to split, what attributes to use, how small to make groups) are covered in &lt;a href="https://trophy.so/blog/how-strava-uses-segmented-leaderboards-to-drive-engagement" rel="noopener noreferrer"&gt;How Strava Uses Segmented Leaderboards to Drive Engagement&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>gamification</category>
      <category>webdev</category>
      <category>mobile</category>
      <category>redis</category>
    </item>
    <item>
      <title>Streak Reminder Push Notifications: Prevention First</title>
      <dc:creator>Charlie Brinicombe</dc:creator>
      <pubDate>Sat, 18 Apr 2026 12:43:56 +0000</pubDate>
      <link>https://dev.to/charlie_brinicombe/streak-reminder-push-notifications-prevention-first-2ih7</link>
      <guid>https://dev.to/charlie_brinicombe/streak-reminder-push-notifications-prevention-first-2ih7</guid>
      <description>&lt;p&gt;Across Trophy's platform, only 0.9% of users return to start a new streak after losing a 2-3 day streak. That number climbs to 9.1% at 31-60 days and that trend dictates where push notification effort actually pays off.&lt;/p&gt;

&lt;p&gt;This post covers why recovery-focused push strategies underperform, how Trophy's push primitives handle the parts that break when teams build them from scratch, and the decisions behind the defaults.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1q5meajofycyhti51but.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1q5meajofycyhti51but.png" alt="Streak Reminder Push Notifications: Prevention First" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Length of streak that was lost (days)&lt;/th&gt;
&lt;th&gt;Return rate (%)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2-3&lt;/td&gt;
&lt;td&gt;0.90&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4-7&lt;/td&gt;
&lt;td&gt;1.42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8-14&lt;/td&gt;
&lt;td&gt;1.54&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15-30&lt;/td&gt;
&lt;td&gt;2.49&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;31-60&lt;/td&gt;
&lt;td&gt;9.09&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Source:&lt;/em&gt; &lt;a href="https://trophy.so" rel="noopener noreferrer"&gt;&lt;em&gt;Trophy&lt;/em&gt;&lt;/a&gt; &lt;em&gt;platform data, April 2026. Return rate is the percentage of users who started a new streak at any point after losing their previous one, segmented by the length of the streak they lost.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The actionable read of this table is that users who lose short streaks are, in practice, lost users. Recovery notifications sent to this cohort are an expensive way to move the 0.9% number slightly. Users who have invested more than 30 days in a streak are a different story: the return rate rises 3x between the 15-30 and 31-60 buckets. The operational implication is that push effort compounds most when it prevents loss on high-streak users, not when it tries to recover it on low-streak ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive approach
&lt;/h2&gt;

&lt;p&gt;The default path when building streak reminder push notifications is to wire up Firebase Admin SDK (or direct APNs) behind a cron job that queries for users whose streak is about to expire.&lt;/p&gt;

&lt;p&gt;Most teams don't have the time-series user interaction data required to calculate streaks properly, but for those who do the code would look something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;firebase-admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initializeApp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;serviceAccount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Runs on a cron at 20:00 UTC&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;atRiskUsers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
  SELECT u.id, u.device_tokens, s.current_streak_length
  FROM users u
  JOIN streaks s ON s.user_id = u.id
  WHERE s.current_streak_length &amp;gt; 0
    AND s.last_extended_at &amp;lt; NOW() - INTERVAL '20 hours'
`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;atRiskUsers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;device_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;messaging&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Don't lose your streak!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`You're on a &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current_streak_length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-day streak. Open the app to keep it going.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This code handles FCM only. For iOS you either add a separate APNs integration with its own SDK, auth flow, and error handling, or you standardise on Expo Push Service and accept its abstraction constraints. Either path starts a second maintenance stream.&lt;/p&gt;

&lt;p&gt;The same shape of problem applies here as it did for &lt;a href="https://trophy.so/blog/streak-reminder-emails" rel="noopener noreferrer"&gt;streak reminder emails&lt;/a&gt;: a cron-plus-query-plus-send pipeline looks tractable on day one and then accumulates edge cases. The difference is that push is a more interruptive surface than email, which means the penalty for sending the wrong message at the wrong moment is proportionally higher. Users who lose trust in your push notifications disable them, and once they're disabled you have no way to earn that channel back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the naive approach breaks in production
&lt;/h2&gt;

&lt;p&gt;The failure modes cluster into five categories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Platform fragmentation between APNs and FCM.&lt;/strong&gt; iOS and Android use different services with different auth models, different error codes, and different rate-limit behaviours. Every feature you build is two implementations in parallel: token management, batch sends, delivery retries, content formatting. Expo Push Service abstracts most of this, but only if you standardise on it at the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Device token lifecycle.&lt;/strong&gt; Push tokens rotate when users reinstall the app, switch phones, or in some cases on OS updates. If your server doesn't listen for &lt;code&gt;Unregistered&lt;/code&gt; (APNs) or &lt;code&gt;UNREGISTERED&lt;/code&gt; (FCM) errors and clean up stale tokens, you keep sending to dead addresses indefinitely. Dead addresses waste quota and count against your sender reputation on some platforms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Race conditions around extension.&lt;/strong&gt; A user opens the app at 7:59 PM local time, extends their streak, and closes the app. The cron job runs at 8:00 PM UTC, finds them as at-risk (the read replica was stale), and fires a push that says "don't lose your streak." On a mobile surface, that reads as the app being confused about what the user just did. This is exactly the kind of error that makes users turn push off for your app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting and batching.&lt;/strong&gt; APNs and FCM both impose rate limits on bulk sends. Handling batch endpoints, back-off on 429 responses, and retry queues correctly is its own engineering track, and getting it wrong means silent delivery failures that you only notice through low engagement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Content staleness.&lt;/strong&gt; Push notification payloads are prepared at send time but delivered with some latency. A message that said "you have 3 hours left" can arrive when the user has 2 hours left, or none. For notifications that reference freeze state or progress toward an achievement, the window between payload construction and user-visible delivery becomes a correctness problem rather than a cosmetic one.&lt;/p&gt;

&lt;p&gt;Each of these is solvable. The question is whether the team building the core product should be spending its time on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trophy approach
&lt;/h2&gt;

&lt;p&gt;Trophy's push notifications are a built-in type, configured through the dashboard and dispatched based on the user state Trophy already tracks. Sending streak reminder pushes reduces to three pieces of work: pick a channel, identify users with their device tokens, and activate the streak template.&lt;/p&gt;

&lt;p&gt;On the channel side, Trophy supports Apple Push Notification Service, Firebase Cloud Messaging, and Expo Push Service. For apps without an existing investment in APNs or FCM, Expo is the lowest-friction choice because it handles platform fragmentation automatically and means you don't maintain two sets of credentials. More detail on the full &lt;a href="https://trophy.so/features/email-push?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;email and push feature set&lt;/a&gt; is on the features page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk92jylxbpuzlg2oz0oyq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk92jylxbpuzlg2oz0oyq.png" alt="Streak Reminder Push Notifications: Prevention First" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Device tokens get associated with each user the same way timezone does, inline on metric events or via explicit identify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;TrophyApiClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@trophy/sdk-node&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TrophyApiClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TROPHY_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flashcards-flipped&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;deviceTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deviceTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// array of push tokens across devices&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client-side token capture depends on your stack. For Expo, it's a single call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-notifications&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getExpoPushTokenAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For native iOS or Android, you capture the token through your own APNs or FCM integration and pass it into Trophy through the identify call above. Either way, the Trophy user record now knows where to send.&lt;/p&gt;

&lt;p&gt;User preferences are managed through a dedicated preferences API rather than a single opt-out flag. This matters because "I want streak reminders by email but not push" is a common preference that a binary opt-out can't express:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updatePreferences&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user-123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;streak_reminder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;push&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// push only&lt;/span&gt;
    &lt;span class="na"&gt;achievement_completed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;push&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// both&lt;/span&gt;
    &lt;span class="na"&gt;recap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// email only&lt;/span&gt;
    &lt;span class="na"&gt;reactivation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="c1"&gt;// disabled&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Activate the streak template in the dashboard and the dispatch logic Trophy has already built takes over: timezone-aware scheduling, state checking at send time, and device-level coordination are all handled server-side.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2k3t76ttenyx7hevujko.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2k3t76ttenyx7hevujko.png" alt="Streak Reminder Push Notifications: Prevention First" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The decisions behind the default
&lt;/h2&gt;

&lt;p&gt;A few defaults encode opinions about what makes streak reminder pushes work, and they're worth explaining.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Streak pushes fire with reference to the user's local day-end, not UTC.&lt;/strong&gt; The &lt;code&gt;tz&lt;/code&gt; associated with each user drives every scheduling decision, including when "approaching end of day" actually means. A user who moves timezones gets correct reminders as soon as the &lt;code&gt;tz&lt;/code&gt; is updated on the next identify call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Push content is aware of freeze state.&lt;/strong&gt; Users who have freezes available average longer streaks: 17.19 days on daily streaks with freezes, compared to 11.62 days without. That population is exactly the high-value cohort where push effort compounds, which means telling them to panic about a streak that a freeze will automatically protect is the fastest way to teach them your notifications don't know what's going on. Trophy's template system exposes freeze state, so the reminder can adjust tone accordingly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reactivation is a separate notification type from streak reminders.&lt;/strong&gt; Once a streak is lost, the question isn't "extend your streak" but "come back and start a new one," and the return rate data above argues for sending that message selectively. Trophy's reactivation notifications are a distinct type with their own template, which means you can enable them for your long-streak cohort without firing them indiscriminately. The broader pattern of &lt;a href="https://trophy.so/blog/what-happens-when-users-lose-streaks" rel="noopener noreferrer"&gt;what happens when users lose streaks&lt;/a&gt; covers the full reactivation design space.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timing draws on the same weekday pattern as email.&lt;/strong&gt; We covered this in &lt;a href="https://trophy.so/blog/streak-reminder-emails?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;our streak reminder emails post&lt;/a&gt;: Friday is the peak day for daily streak loss across Trophy's platform, with a share roughly twice what random distribution would predict. Push notifications face the same distribution but with tighter delivery constraints, since pushes are more time-sensitive than email. The practical effect is that the late-afternoon window on Friday is where the highest-leverage streak pushes land.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do streak reminder pushes fire for users who don't have freezes available?&lt;/strong&gt; Yes. The send decision is based on streak state, specifically whether the streak is at risk of expiring in the user's local timezone, not on freeze availability. Freezes change the copy and tone of the push rather than whether it fires. For users with freezes, the reminder can position the freeze as a backup; for users without, the reminder is more direct about the immediate risk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I send push notifications for achievement completions as well as streaks?&lt;/strong&gt; Yes. Trophy supports four notification types: achievement_completed, recap, reactivation, and streak_reminder. Each has its own template and its own per-channel preference. You can enable all four, or any subset, per user via the preferences API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if a user has multiple devices?&lt;/strong&gt; Device tokens are stored as an array on the user record. A push notification for that user is dispatched across the associated tokens, so the user sees the notification on each device they've enabled push on. When a token expires or is rejected by APNs or FCM, Trophy handles the error response and removes the stale token from the user record.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which channel should I use if I'm starting from scratch?&lt;/strong&gt; For cross-platform apps built in Expo or React Native, use Expo Push Service. It's one integration instead of two and handles the APNs and FCM routing automatically. For native iOS or Android apps with existing push infrastructure, use APNs and FCM directly. The integration work is similar to what you've already done for other notification types, and Trophy supports both channels natively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I let users opt in to push reminders during onboarding?&lt;/strong&gt; The update preferences API accepts an array of channels per notification type, so enabling push specifically on streak reminders is one call. Combined with the OS-level permission prompt from your client SDK, this lets you build a preference flow where users see exactly what they're opting into and can toggle individual types rather than a single on/off switch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about users who signed up through the web and don't have device tokens?&lt;/strong&gt; Users without device tokens won't receive pushes. There's nowhere to send them. For these users, Trophy falls back to email if email is enabled on the notification type in their preferences. This is why most apps configure streak_reminder with both &lt;code&gt;email&lt;/code&gt; and &lt;code&gt;push&lt;/code&gt; as default channels: the channel the user actually receives depends on what they've granted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The return rate data at the top of this post is the short version of a longer argument: push notifications are leverage, and leverage compounds on users who are already engaged. A streak reminder sent to a user on a 3-day streak recovers the streak 0.9% of the time. The same reminder sent to a user on a 60-day streak recovers it at closer to 100%. Treating those two cohorts with the same urgency, copy, and cadence is the single most common push strategy failure in gamified apps, and the one with the clearest data correction.&lt;/p&gt;

&lt;p&gt;For the full set of Trophy's push notification configuration options, the &lt;a href="https://docs.trophy.so/platform/push-notifications" rel="noopener noreferrer"&gt;push notifications platform documentation&lt;/a&gt; covers every setting referenced here.&lt;/p&gt;

</description>
      <category>gamification</category>
    </item>
    <item>
      <title>Streak Reminder Emails: The Timing That Drives Retention</title>
      <dc:creator>Charlie Brinicombe</dc:creator>
      <pubDate>Sat, 18 Apr 2026 12:18:00 +0000</pubDate>
      <link>https://dev.to/charlie_brinicombe/streak-reminder-emails-the-timing-that-drives-retention-14bi</link>
      <guid>https://dev.to/charlie_brinicombe/streak-reminder-emails-the-timing-that-drives-retention-14bi</guid>
      <description>&lt;p&gt;Across Trophy's platform, 25.35% of all daily streak losses happen on Fridays. That's a single-day concentration nearly twice what you'd expect from random distribution. It's also the clearest signal in our data for why streak reminder emails live or die on three specific decisions: when they fire, who gets them, and what they know about the user's current state.&lt;/p&gt;

&lt;p&gt;This post covers the parts that go wrong when teams build streak reminders in-house, the Trophy primitives that handle them natively, and the decisions behind the defaults.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwf8ph9o9hw672zi2i7xl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwf8ph9o9hw672zi2i7xl.png" alt="Streak Reminder Emails: The Timing That Drives Retention" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Weekday&lt;/th&gt;
&lt;th&gt;Loss share (%)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Monday&lt;/td&gt;
&lt;td&gt;7.07&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tuesday&lt;/td&gt;
&lt;td&gt;12.04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wednesday&lt;/td&gt;
&lt;td&gt;17.74&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thursday&lt;/td&gt;
&lt;td&gt;13.91&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Friday&lt;/td&gt;
&lt;td&gt;25.35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saturday&lt;/td&gt;
&lt;td&gt;19.07&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sunday&lt;/td&gt;
&lt;td&gt;4.83&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Source:&lt;/em&gt; &lt;a href="https://trophy.so/?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;&lt;em&gt;Trophy&lt;/em&gt;&lt;/a&gt; &lt;em&gt;platform data, April 2026. Distribution of daily streak losses by weekday, across all apps on Trophy's platform. Calculated on users with active daily streaks longer than three days at the time of loss.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive approach
&lt;/h2&gt;

&lt;p&gt;The default instinct when building streak reminder emails is to stitch together three things: a database query that finds users with active streaks at risk of expiring, a scheduled job that runs on a fixed cadence, and an email service that delivers the actual message.&lt;/p&gt;

&lt;p&gt;A lot of teams don't have the time-series user interaction data required to calculate streaks. But for those who do, in code that typically looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Runs on a cron at 20:00 UTC&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;atRiskUsers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
  SELECT u.id, u.email, u.timezone, s.current_streak_length
  FROM users u
  JOIN streaks s ON s.user_id = u.id
  WHERE s.current_streak_length &amp;gt; 0
    AND s.last_extended_at &amp;lt; NOW() - INTERVAL '20 hours'
`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;atRiskUsers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sendgrid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reminders@yourapp.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;templateId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;d-streak-reminder&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dynamicTemplateData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;streakLength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current_streak_length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compressed like this, it looks tractable: one join, one loop, one send. The query gets more complex in production, the schedule gets more nuanced, and the template gets more conditional, but the shape stays roughly the same.&lt;/p&gt;

&lt;p&gt;What this shape doesn't handle is everything that makes the difference between a streak reminder that works and one that actively reduces retention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the naive approach breaks in production
&lt;/h2&gt;

&lt;p&gt;The failure modes cluster into four categories, all of which we see regularly when customers migrate to Trophy from in-house systems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Race conditions around extension.&lt;/strong&gt; A user opens the app at 7:59 PM local time, does the thing that extends their streak, closes the app. The cron job runs at 8:00 PM UTC, queries for at-risk users, finds them (because the &lt;code&gt;last_extended_at&lt;/code&gt; field was still stale in the read replica), and sends a reminder for a streak they already extended. The user opens the email, sees "Don't lose your streak!", and loses trust in the product at the same moment the reminder was meant to reinforce it. This happens more often than teams expect, because the window between extension and send is exactly when users are most active.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timezone drift.&lt;/strong&gt; A user identifies as &lt;code&gt;Europe/London&lt;/code&gt;, then flies to Tokyo for two weeks. The reminder logic is still using the stored London timezone, so they're getting reminders at 4 AM local. The streak logic is also still using London midnight as the rollover point, which means from the user's perspective the streak resets at a seemingly random time during the afternoon. Neither problem is technically unsolvable, but the solution requires knowing when a user's effective timezone has changed, and re-syncing it on every session is work most teams never get around to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reminder fatigue and the Friday cliff.&lt;/strong&gt; A reminder that fires daily at the same UTC moment regardless of user context teaches users to ignore it. More usefully: daily streak losses cluster hard toward the end of the working week (see the weekday distribution above), and a one-size-fits-all reminder schedule misses that concentration entirely. Friday users need the reminder earlier and harder than Tuesday users. Users who have freezes available don't need an emergency reminder at all; they need a gentler nudge, because their streak is actually protected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deliverability and domain management.&lt;/strong&gt; Streak reminders are transactional enough that they need reliable delivery but frequent enough that they can damage sender reputation if mishandled. SPF, DKIM, DMARC, bounce handling, list hygiene: these are full-time concerns for teams that send meaningful volume, and they're inherited the moment you decide to send any emails at all.&lt;/p&gt;

&lt;p&gt;Each of these is solvable with enough engineering time. The question is whether solving them is the best use of the team that's supposed to be building the core product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trophy approach
&lt;/h2&gt;

&lt;p&gt;In Trophy, sending streak reminder emails is two pieces of work: identify the user with their timezone, and activate the template. Trophy handles all streak calculations automatically. All timezones and all edge cases are covered.&lt;/p&gt;

&lt;p&gt;Identification happens wherever you're already tracking user activity. If you're incrementing a metric when the user completes their core action, you can identify them inline on the same call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;TrophyApiClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@trophy/sdk-node&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TrophyApiClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TROPHY_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// On any user action that should extend a streak, send event to Trophy, which automatically tracks streaks&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trophy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flashcards-flipped&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// IANA identifier, e.g. "Europe/London"&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timezone is the key piece. Trophy uses it for streak calculations, leaderboard rankings, and the send timing of every email type. On the browser side, fetching the current timezone is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timezone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;resolvedOptions&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Capture that on each session and pass it into the user object on the next metric event. Trophy handles the rest server-side: tracking which users have active streaks at risk, in which timezones, with which freeze counts available automatically. When the reminder logic fires, it fires per-user and in the user's local clock.&lt;/p&gt;

&lt;p&gt;The email template itself is designed in the Trophy dashboard. The block-based editor supports conditional rendering (show different content based on user state), smart blocks for things like current streak length and recent history, and email variables for personalisation in both the subject line and body.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fobj9inuqbk6kz04nzbu4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fobj9inuqbk6kz04nzbu4.png" alt="Streak Reminder Emails: The Timing That Drives Retention" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Activate the template, and the send logic Trophy has already built takes over.&lt;/p&gt;

&lt;p&gt;The comparison with the naive code is deliberate. The engineering work that made the naive approach brittle — the timezone handling, the race condition prevention, the freeze awareness, the deliverability setup — is all still happening. It's happening inside Trophy, refined across every customer app where it runs, and maintained by a team whose full-time job is the streak engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decisions behind the default
&lt;/h2&gt;

&lt;p&gt;A few defaults are worth explaining, because they encode opinions about what makes streak reminders work that would otherwise need to be reverse-engineered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Daily streak reminders fire four hours before the end of the day in the user's timezone.&lt;/strong&gt; Early enough to give the user real time to act, late enough that the reminder feels relevant rather than premature. The four-hour window has held up across enough app categories to make it the default. It's also configurable: the dashboard exposes the send timing directly, so teams with genuinely different rhythms (for example, apps where most usage happens in the morning) can override it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weekly streak reminders fire on Friday morning.&lt;/strong&gt; This is the direct response to the Friday cliff in the platform data. Weekly-streak users need to know before the weekend whether they need to act, which means the reminder has to land on a day they're still checking work-adjacent email. Sending on Saturday, the next most likely loss day, is already too late for the cohort that forgets until Monday.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reminders skip users who have already extended.&lt;/strong&gt; Trophy's send logic checks current streak state at send time, not at query time. A user whose streak was extended twenty minutes before the reminder was scheduled will not receive it, because the state check happens after the template is prepared, not before. This eliminates the race-condition failure mode entirely. The cost of the additional check is borne by Trophy, not by the customer's infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Users in unknown timezones default to Eastern Time.&lt;/strong&gt; When the &lt;code&gt;tz&lt;/code&gt; field isn't set on a user, Trophy defaults to Eastern Time for send scheduling. This is a pragmatic default rather than a correct one. The correct approach is to identify every user with their actual timezone, which the Intl API snippet above makes approximately free on the first session after sign-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What timezone does Trophy use if I haven't identified the user with one?&lt;/strong&gt; When the &lt;code&gt;tz&lt;/code&gt; field isn't provided on a user, Trophy defaults to Eastern Time for both streak calculations and email send timing. This is a pragmatic default rather than a correct one. The correct approach is to identify every user with their actual timezone, which can be pulled from the browser with one line of JavaScript and passed in on the first metric event for that user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I send reminder emails only to a specific user segment?&lt;/strong&gt; Yes. Every email template in Trophy can be scoped to users with specific custom attribute values. If you've defined a &lt;code&gt;plan&lt;/code&gt; attribute with values &lt;code&gt;free&lt;/code&gt; and &lt;code&gt;pro&lt;/code&gt;, you can create two different streak reminder templates (one for each plan), and each will only send to users matching that attribute. This is the right place to handle segmentation rather than filtering at the database level, because it also means the segmentation logic stays in one place as the template evolves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens when users turn off email notifications?&lt;/strong&gt; Trophy respects a per-user &lt;code&gt;subscribeToEmails&lt;/code&gt; flag, and also exposes a user preferences API for more granular controls. When a user opts out through a preference center in your app, setting that flag through the SDK removes them from all email types until they opt back in. The check happens at send time, so there's no risk of a reminder going out in the window between the opt-out and the next sync.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I customise the email content and subject line?&lt;/strong&gt; The email builder in the Trophy dashboard supports full template design: copy, subject line, smart blocks for streak history and progress charts, conditional blocks for showing different content based on user state, and email variables for personalisation. Subject lines support the same variables as the body, which is useful for making the subject line specific to the user's current streak length or time since last activity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What domain do reminders send from?&lt;/strong&gt; Trophy supports sending from your own domain out of the box. For production use, DNS verification lets you configure a full sending subdomain with DKIM and Return Path records, which gives you the benefit of your existing domain reputation and the best deliverability. Single Sender Verification is available for faster setup during development, which verifies a single email address without requiring DNS changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can reminder emails include in-app deep links?&lt;/strong&gt; Yes. The button block in the email builder accepts a URL, and that URL can include any user-specific parameters you want, including attribute values passed through as variables. A common pattern is to link the main call-to-action button to a deep link that opens the app directly on the relevant screen, with the user's current streak length and expiry time encoded as query parameters for in-app display.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Streak reminder emails are the last 5% of the streak system, not a separate infrastructure concern. If the streak logic underneath them knows each user's timezone, freeze state, and extension status in real time, the reminder itself becomes a toggle and a template. If it doesn't, the reminder becomes an ongoing maintenance burden that competes with the core product for engineering time, and one that breaks in ways that erode the trust the streak was designed to build.&lt;/p&gt;

&lt;p&gt;For the full set of Trophy's streaks APIs and email configuration options, the &lt;a href="https://docs.trophy.so/platform/streaks" rel="noopener noreferrer"&gt;streaks platform documentation&lt;/a&gt; and &lt;a href="https://docs.trophy.so/platform/emails" rel="noopener noreferrer"&gt;emails platform documentation&lt;/a&gt; cover every setting referenced here.&lt;/p&gt;

</description>
      <category>gamification</category>
    </item>
    <item>
      <title>Guess what day most people lose their streak?</title>
      <dc:creator>Charlie Brinicombe</dc:creator>
      <pubDate>Wed, 15 Apr 2026 09:45:08 +0000</pubDate>
      <link>https://dev.to/charlie_brinicombe/guess-what-day-most-people-lose-their-streak-34h8</link>
      <guid>https://dev.to/charlie_brinicombe/guess-what-day-most-people-lose-their-streak-34h8</guid>
      <description>&lt;p&gt;Trophy is now powering over 24M streaks which is kind of crazy to think about considering we only launched 1.0 here in January this year.&lt;/p&gt;

&lt;p&gt;One of the parts I find most interesting about building horizontal infrastructure is that as you scale and power more and more products you get to see insights that most teams building in isolation will only see a part of, and you can use those insights to make the the infrastructure better for everyone.&lt;/p&gt;

&lt;p&gt;For example, because we power streaks for so many users, &lt;a href="https://trophy.so" rel="noopener noreferrer"&gt;Trophy&lt;/a&gt; can tell that 25% of all streaks are lost on a Friday, closely followed by Saturday (19%) and then Wednesday (18%).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1uoc2hmdzm66v45xo412.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1uoc2hmdzm66v45xo412.png" alt="Trophy streak loss data" width="800" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is across all products Trophy powers, but we can use this to back up insights for each products specific use case and start powering advanced features for them.&lt;/p&gt;

&lt;p&gt;The most obvious next step from here is to start working on improving our streak infrastructure to personalize streaks reminders for each user, including sending targeted reminders based on when each user is most likely to lose their streak.&lt;/p&gt;

&lt;p&gt;Lot's more insights like this to follow!&lt;/p&gt;

&lt;p&gt;Happy building!&lt;/p&gt;

&lt;p&gt;Charlie&lt;/p&gt;

</description>
      <category>resources</category>
      <category>gamification</category>
      <category>datascience</category>
      <category>mobile</category>
    </item>
    <item>
      <title>How We Built SaaS Calculators in Next.js (And Kept Them Shareable)</title>
      <dc:creator>Charlie Brinicombe</dc:creator>
      <pubDate>Wed, 25 Mar 2026 15:51:13 +0000</pubDate>
      <link>https://dev.to/charlie_brinicombe/how-we-built-saas-calculators-in-nextjs-and-kept-them-shareable-66o</link>
      <guid>https://dev.to/charlie_brinicombe/how-we-built-saas-calculators-in-nextjs-and-kept-them-shareable-66o</guid>
      <description>&lt;p&gt;When we built our &lt;a href="https://trophy.so/calculators" rel="noopener noreferrer"&gt;calculator suite&lt;/a&gt;, we wanted more than "a form that outputs a number."&lt;/p&gt;

&lt;p&gt;We wanted calculators that were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fast and SEO-friendly,&lt;/li&gt;
&lt;li&gt;easy to extend,&lt;/li&gt;
&lt;li&gt;mathematically auditable,&lt;/li&gt;
&lt;li&gt;and shareable via URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post breaks down the architecture, design patterns, and trade-offs behind that implementation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Motivation (and a bit about Trophy)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://trophy.so" rel="noopener noreferrer"&gt;Trophy&lt;/a&gt; is a product aimed at helping teams make better decisions about growth and retention. In our space, the same few questions come up constantly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What does our churn imply about retention over time?&lt;/li&gt;
&lt;li&gt;If we reduce churn, what’s the &lt;em&gt;revenue impact&lt;/em&gt; over the next 6–12 months?&lt;/li&gt;
&lt;li&gt;Given ARPU and churn, what’s a reasonable LTV and customer lifespan?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We could have answered those with spreadsheets, PDFs, or one-off blog posts but those formats don’t travel well inside a team. We wanted something that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;turns “back-of-napkin” math into an interactive tool&lt;/strong&gt;,
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;makes assumptions explicit&lt;/strong&gt;,
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;produces a link you can drop into Slack/Notion&lt;/strong&gt;, and
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;holds up technically&lt;/strong&gt; (fast load, predictable state).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s why we built calculators as a first-class part of the web app: not just for lead-gen, but as a reusable, composable surface we can iterate on as the product evolves.&lt;/p&gt;




&lt;h2&gt;
  
  
  Anatomy of a Calculator Page (and why each section exists)
&lt;/h2&gt;

&lt;p&gt;One thing we learned quickly: the calculation itself is only a small part of the user journey.&lt;/p&gt;

&lt;p&gt;Each page section has a distinct job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Header + description: establishes context fast ("what this calculator answers") so users know they’re in the right place before entering data.&lt;/li&gt;
&lt;li&gt;Formula block: adds transparency and trust, especially for technical readers who want to validate assumptions before using outputs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftv5oyxfkuaox1f6pvd1k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftv5oyxfkuaox1f6pvd1k.png" alt="Header and formula section" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Input controls: collect the minimum viable assumptions needed to compute a meaningful result without overwhelming the user.&lt;/li&gt;
&lt;li&gt;Primary result card(s): surface the key answer immediately (e.g. churn, retention, LTV), with lightweight interpretive context.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9pqqmoyyw7mnc1ft99ey.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9pqqmoyyw7mnc1ft99ey.png" alt="Primary result section" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Impact chart section: translates one-off outputs into a forward-looking narrative ("what changes over 3, 6, 12 months"), which is where decision-making usually happens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvqps4xwveigeo9rkxhyy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvqps4xwveigeo9rkxhyy.png" alt="Revenue impact section" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Share section: turns a result into a portable artifact via URL, so teams can discuss the same scenario in Slack/Notion/email.&lt;/li&gt;
&lt;li&gt;Related calculators: supports natural next questions (e.g. from churn to LTV), increasing usefulness and reducing dead-ends.&lt;/li&gt;
&lt;li&gt;FAQ + CTA: FAQ handles objections and clarification; CTA gives users a clear next step after insight.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In practice, this structure helped us balance three goals simultaneously: educational content, interactive analysis, and team communication.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tech Stack at a Glance
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js App Router&lt;/strong&gt; for server/client component boundaries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React + TypeScript&lt;/strong&gt; for predictable UI and typed state&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind CSS&lt;/strong&gt; for consistent composable styling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recharts&lt;/strong&gt; for chart rendering&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL query params&lt;/strong&gt; as the source of truth for shareable results&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  1) Server-First Page Composition
&lt;/h2&gt;

&lt;p&gt;A key architectural choice: keep route pages as &lt;strong&gt;server components&lt;/strong&gt;, and pass &lt;code&gt;searchParams&lt;/code&gt; down.&lt;/p&gt;

&lt;p&gt;That gives us:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;better SSR/SEO behavior,&lt;/li&gt;
&lt;li&gt;cleaner hydration boundaries,&lt;/li&gt;
&lt;li&gt;no &lt;code&gt;useSearchParams()&lt;/code&gt; at the page level (which can force Suspense/client boundaries).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example page composition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Calculator&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./calculator&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;ChurnImpactChart&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./churn-impact-chart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;CalculatorPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;createCalculatorMetadata&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/components/calculators&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createCalculatorMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ChurnRateCalculatorPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CalculatorPage&lt;/span&gt;
      &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;initialSearchParams&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;calculator&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Calculator&lt;/span&gt; &lt;span class="na"&gt;initialSearchParams&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;impactChart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ChurnImpactChart&lt;/span&gt; &lt;span class="na"&gt;initialSearchParams&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a clean "orchestration layer" pattern: the page wires dependencies together, while feature components do domain work.&lt;/p&gt;




&lt;h2&gt;
  
  
  2) URL-Driven State via a Dedicated Hook
&lt;/h2&gt;

&lt;p&gt;Instead of each calculator calling &lt;code&gt;useSearchParams()&lt;/code&gt; directly, we introduced one shared adapter hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useCalculatorStateFromParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;initialSearchParams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SearchParamsInput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;UseCalculatorStateReturn&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;usePathname&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRouter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;initialSearchParams&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;parseParamsToState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;setState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Partial&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CalculatorState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;paramsToSearchString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scroll&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setState&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why this pattern works
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single source of truth&lt;/strong&gt; for query parsing/serialization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No duplicated URL sync logic&lt;/strong&gt; across calculators.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share-by-default UX&lt;/strong&gt;: every calculated result can be copied as a link.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is effectively an adapter around Next routing primitives with a calculator-focused API.&lt;/p&gt;




&lt;h2&gt;
  
  
  3) Config-Driven Page Metadata and Content
&lt;/h2&gt;

&lt;p&gt;We model each calculator with a typed &lt;code&gt;CalculatorConfig&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CalculatorConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nl"&gt;formula&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
    &lt;span class="nx"&gt;intro&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nl"&gt;calculatorSectionTitle&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
    &lt;span class="nx"&gt;ctaTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
    &lt;span class="nx"&gt;ctaDescription&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then metadata generation becomes deterministic and reusable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createCalculatorMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CalculatorConfig&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Metadata&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://trophy.so/calculators/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reduces drift between SEO tags and on-page content, while making it easier to add new calculators safely.&lt;/p&gt;




&lt;h2&gt;
  
  
  4) Composition Over Monoliths
&lt;/h2&gt;

&lt;p&gt;The shared shell (&lt;code&gt;CalculatorPage&lt;/code&gt;) receives three key inputs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;config&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;calculator&lt;/code&gt; node&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;impactChart&lt;/code&gt; node
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CalculatorPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;initialSearchParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;calculator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;impactChart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CalculatorLayout&lt;/span&gt; &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CalculatorShell&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;calculator&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;CalculatorShell&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;impactChart&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CalculatorShareSection&lt;/span&gt;
        &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;initialSearchParams&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;initialSearchParams&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;hideUntilCalculated&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;CalculatorLayout&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a pragmatic slot-based composition pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The layout remains consistent across calculators.&lt;/li&gt;
&lt;li&gt;Each domain calculator can evolve independently.&lt;/li&gt;
&lt;li&gt;Shared behavior (share section, formula, related calculators, CTA) stays centralized.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  5) Domain Math Lives in Pure Functions
&lt;/h2&gt;

&lt;p&gt;UI is stateful and interactive; math should be deterministic and testable.&lt;/p&gt;

&lt;p&gt;So the formulas sit in standalone utility modules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateChurnDecayData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;startingUsers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;period&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;churnPercent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;maxDays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clamped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;churnPercent&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;survivalPerPeriod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;clamped&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;clamped&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;users&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;period&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;maxDays&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;periodsElapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;period&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;survival&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;survivalPerPeriod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;periodsElapsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;users&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startingUsers&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;survival&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;points&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Benefits
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;easier unit testing,&lt;/li&gt;
&lt;li&gt;less coupling to React render cycles,&lt;/li&gt;
&lt;li&gt;clearer auditing for stakeholders who care about formula correctness.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  6) Two-Layer Validation Strategy
&lt;/h2&gt;

&lt;p&gt;Each calculator validates in two places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Realtime input validity&lt;/strong&gt; (&lt;code&gt;isValid&lt;/code&gt;) to disable calculate actions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Action-time validation&lt;/strong&gt; inside &lt;code&gt;handleCalculate&lt;/code&gt; for safety.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isValid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startNum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;remainingNum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;startNum&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;remainingNum&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;remainingNum&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;startNum&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleCalculate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;shareReady&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;churn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculateChurn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;setResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;churn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;startingUsers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;remainingUsers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;churnRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;churn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;shareReady&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This avoids accidental invalid URL state and keeps result cards/charts consistent.&lt;/p&gt;




&lt;h2&gt;
  
  
  7) Performance and UX Choices
&lt;/h2&gt;

&lt;p&gt;Some implementation details that made a big difference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;useMemo&lt;/code&gt; for chart data and derived values (half-life, domains).&lt;/li&gt;
&lt;li&gt;route updates via &lt;code&gt;router.replace&lt;/code&gt; with &lt;code&gt;{ scroll: false }&lt;/code&gt; to avoid janky navigation.&lt;/li&gt;
&lt;li&gt;fixed, typed period options (&lt;code&gt;1 | 7 | 30&lt;/code&gt;) to simplify math and UI consistency.&lt;/li&gt;
&lt;li&gt;responsive chart containers (&lt;code&gt;overflow-x-auto&lt;/code&gt; + minimum widths) for small screens.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  8) Design Patterns We Reused Across Calculators
&lt;/h2&gt;

&lt;p&gt;The biggest win was pattern consistency:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Template + Slots&lt;/strong&gt;: one page shell, pluggable calculator + chart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adapter Hook&lt;/strong&gt;: one URL-state bridge for all calculators.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pure Domain Modules&lt;/strong&gt;: math separated from rendering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config-Driven Metadata&lt;/strong&gt;: SEO/content generated from typed config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature Flags in Query&lt;/strong&gt;: share readiness and parameterized results.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These patterns made adding new calculators substantially faster after the first one.&lt;/p&gt;




&lt;h2&gt;
  
  
  9) What We’d Improve Next
&lt;/h2&gt;

&lt;p&gt;If we were extending this further, we’d likely add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;schema-based query parsing/validation (e.g. zod/valibot) for stricter runtime guarantees,&lt;/li&gt;
&lt;li&gt;analytics events at "calculate" and "share" boundaries,&lt;/li&gt;
&lt;li&gt;optional server-side persistence for comparison history,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;benchmarking&lt;/strong&gt; (e.g. “you’re in the 60th percentile for churn in fitness apps”) with clear cohort definitions and data provenance,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;downloadable reports&lt;/strong&gt; (PDF/CSV) that bundle inputs, assumptions, charts, and a narrative summary,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;shareable team reports&lt;/strong&gt; (invite colleagues, comments, pinned scenarios) so calculators become collaborative artifacts—not just individual tools,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;scenario management&lt;/strong&gt; (save multiple parameter sets, compare side-by-side, and track deltas over time),&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;permissions + workspace context&lt;/strong&gt; so sharing can be public links, private links, or org-only depending on the audience.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;The core idea is simple: &lt;strong&gt;treat calculators as a product surface, not a throwaway widget&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;By combining server-first composition, URL-based state, and pure domain math, we ended up with calculators that are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;maintainable,&lt;/li&gt;
&lt;li&gt;predictable,&lt;/li&gt;
&lt;li&gt;and genuinely useful to share in real workflows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re building anything similar, start with these two constraints:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every result should be reproducible from the URL.&lt;/li&gt;
&lt;li&gt;Every formula should live outside the component tree.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything else gets easier from there.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>startup</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How to Build an Achievements Feature</title>
      <dc:creator>Charlie Brinicombe</dc:creator>
      <pubDate>Fri, 17 Oct 2025 15:21:22 +0000</pubDate>
      <link>https://dev.to/charlie_brinicombe/how-to-build-an-achievements-feature-5dd2</link>
      <guid>https://dev.to/charlie_brinicombe/how-to-build-an-achievements-feature-5dd2</guid>
      <description>&lt;p&gt;You want achievements. Track user progress. Award badges for milestones. Display completion status. Seems straightforward. Three weeks later, you're debugging why some users didn't get achievements they qualified for, handling backdating for new achievements, and optimizing queries that check completion criteria for every user action.&lt;/p&gt;

&lt;p&gt;Achievement systems appear simple until you implement them at scale. The core concept (recognize milestone completion) hides complexity. When do you check if achievements are complete? How do you handle users who qualified before the achievement existed? What about achievements that require checking historical data across multiple metrics?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://trophy.so/?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;Trophy&lt;/a&gt; handles &lt;a href="https://trophy.so/features/achievements?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;achievement systems&lt;/a&gt; including completion logic, progress tracking, and backdating. The &lt;a href="https://docs.trophy.so/guides/how-to-build-an-achievements-feature?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;complete implementation guide&lt;/a&gt; walks through the full process. Integration takes 1 day to 1 week. But understanding what building from scratch involves helps you make informed build versus buy decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Points
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Technical challenges in achievement system implementation&lt;/li&gt;
&lt;li&gt;Completion checking patterns that scale&lt;/li&gt;
&lt;li&gt;Backdating strategies for fairness&lt;/li&gt;
&lt;li&gt;Integration examples with Trophy's API&lt;/li&gt;
&lt;li&gt;Build versus buy considerations for achievement features&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Technical Reality
&lt;/h2&gt;

&lt;p&gt;Before building achievement systems, understand the problems beyond simple milestone tracking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Completion checking logic&lt;/strong&gt; needs efficiency at scale. Checking every achievement on every user action kills performance. You need smart triggering that only checks relevant achievements. Building this trigger system takes time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Progress tracking&lt;/strong&gt; for multi-step achievements adds complexity. "Complete 100 tasks" needs counting. Showing users "45/100" requires storing partial progress. Updating this efficiently as users act requires careful database design.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.trophy.so/platform/achievements?ref=trophy.ghost.io#backdating-achievements" rel="noopener noreferrer"&gt;&lt;strong&gt;Backdating&lt;/strong&gt;&lt;/a&gt; ensures fairness when you add new achievements. Users who qualified before the achievement existed should complete it automatically. Scanning historical data for every user takes time. Getting this right without overloading your database is tricky.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Badge hosting and delivery&lt;/strong&gt; seems minor but adds infrastructure. Where do badge images live? How do you serve them efficiently? What formats work across platforms? These details accumulate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rarity calculations&lt;/strong&gt; show how many users completed each achievement. This requires counting completions across your entire user base and keeping these counts updated as more users complete achievements.&lt;/p&gt;

&lt;p&gt;Building production-ready achievement systems typically takes 3-5 weeks including completion logic, progress tracking, and backdating. Trophy's infrastructure handles these problems, reducing implementation to integration work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Patterns
&lt;/h2&gt;

&lt;p&gt;If building in-house, these patterns avoid common mistakes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Event-based checking&lt;/strong&gt; triggers achievement evaluation only when relevant events occur. Store achievement triggers. When events arrive, check only achievements that could complete based on that event type. This scales better than checking all achievements on every action.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incremental progress updates&lt;/strong&gt; maintain partial completion state. Store "tasks completed: 45" instead of recalculating from history on every check. Update incrementally as users act. Trophy uses this pattern for efficient progress tracking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Async backdating&lt;/strong&gt; processes achievement qualification in background jobs. When you create an achievement, queue a job that scans historical data and awards to qualified users. Don't block achievement creation on backdating completion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Denormalized completion state&lt;/strong&gt; stores which users completed which achievements in fast-access storage. Recompute from history only when needed. Trophy maintains completion state with millisecond query latency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trigger mapping&lt;/strong&gt; links achievement requirements to specific events. Achievement requires metric X reaching value Y, so only check it when metric X events arrive. Trophy's configuration system includes this trigger mapping automatically.&lt;/p&gt;

&lt;p&gt;Trophy implements these patterns. You configure achievement criteria. Trophy handles the infrastructure complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Estimate
&lt;/h2&gt;

&lt;p&gt;Here's realistic timeline for building achievements in-house:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 1: Basic implementation.&lt;/strong&gt; Create achievements. Track completions. Display badges. Works in development with simple achievements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 2: Progress tracking.&lt;/strong&gt; Store partial progress for multi-step achievements. Update progress efficiently. Show users their advancement toward incomplete achievements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 3: Completion logic.&lt;/strong&gt; Build trigger system that checks relevant achievements on user actions. Optimize to avoid checking irrelevant achievements. Handle achievement criteria complexity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 4: Backdating and edge cases.&lt;/strong&gt; Implement fair backdating for new achievements. Handle concurrent completion attempts. Test with realistic data volumes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ongoing: Maintenance and expansion.&lt;/strong&gt; New achievements need adding regularly. Badge management continues. Performance tuning as usage scales.&lt;/p&gt;

&lt;p&gt;That's 4+ weeks of engineering time plus ongoing maintenance. Trophy's infrastructure eliminates this timeline, reducing implementation to 1 day to 1 week of integration work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building with Trophy
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://trophy.so/?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;Trophy's&lt;/a&gt; integration is faster because achievement infrastructure already exists. Here's what implementation looks like.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create Achievements
&lt;/h3&gt;

&lt;p&gt;In Trophy's dashboard, create achievements with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Name and description&lt;/li&gt;
&lt;li&gt;Badge image (Trophy hosts this for you)&lt;/li&gt;
&lt;li&gt;Trigger type (metric-based, API-based, or streak-based)&lt;/li&gt;
&lt;li&gt;Completion criteria&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For metric achievements, specify which metric and what threshold completes the achievement. Trophy automatically tracks progress and awards completion when users reach the threshold.&lt;/p&gt;

&lt;p&gt;Managing achievements online means future additions or changes happen in the dashboard and not in your codebase, preventing back and forth changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Track User Actions
&lt;/h3&gt;

&lt;p&gt;Send events to Trophy when users perform achievement-relevant actions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { TrophyApiClient } from '@trophyso/node';

const trophy = new TrophyApiClient(process.env.TROPHY_API_KEY);

// When user completes a task
const response = await trophy.metrics.event('tasks_completed', {
  user: {
    id: 'user-123'
  },
  value: 1
});

// Check if user completed any achievements
if (response.achievements &amp;amp;&amp;amp; response.achievements.length &amp;gt; 0) {
  response.achievements.forEach(achievement =&amp;gt; {
    console.log(`Unlocked: ${achievement.name}`);
    console.log(`Badge: ${achievement.badgeUrl}`);
    showAchievementNotification(achievement);
  });
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trophy processes events and automatically checks if any achievements should complete. The response includes newly completed achievements for immediate user feedback.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Display User Achievements
&lt;/h3&gt;

&lt;p&gt;Fetch achievements a user has completed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Get user's completed achievements
const achievements = await trophy.users.achievements('user-123', {
  includeIncomplete: 'false' // Only show completed
});

achievements.forEach(achievement =&amp;gt; {
  console.log({
    name: achievement.name,
    description: achievement.description,
    badgeUrl: achievement.badgeUrl,
    completedAt: achievement.achievedAt,
    rarity: achievement.rarity // Percentage of users who completed it
  });
});

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trophy returns achievement data including badges, completion timestamps, and rarity statistics. The &lt;a href="https://docs.trophy.so/api-reference/endpoints/users/get-a-users-completed-achievements?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;achievements API documentation&lt;/a&gt; covers all available fields.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Show Achievement Progress
&lt;/h3&gt;

&lt;p&gt;For incomplete achievements, show progress toward completion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Get all achievements including incomplete ones with progress
const allAchievements = await trophy.users.achievements('user-123', {
  includeIncomplete: 'true'
});

allAchievements.forEach(achievement =&amp;gt; {
  if (achievement.achievedAt) {
    // Completed
    console.log(`✓ ${achievement.name}`);
  } else {
    // Incomplete - show progress if available
    if (achievement.progress) {
      const percent = (achievement.progress.current / achievement.progress.target) * 100;
      console.log(`${achievement.name}: ${percent}% complete`);
    }
  }
});

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trophy tracks progress for metric-based achievements automatically. Users see how close they are to completion.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Complete API Achievements
&lt;/h3&gt;

&lt;p&gt;For achievements that can't be automatically tracked via metrics (completing onboarding, linking accounts, etc.), use the complete achievement API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// When user completes onboarding
await trophy.achievements.complete('user-123', 'onboarding_complete');

// Trophy marks it as complete and returns achievement data
const result = await trophy.achievements.complete('user-123', 'onboarding_complete');

if (result.achievement) {
  console.log(`Completed: ${result.achievement.name}`);
  showAchievementNotification(result.achievement);
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you control over when achievements complete for events Trophy can't track automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Achievement Types and Strategies
&lt;/h2&gt;

&lt;p&gt;Different achievement structures serve different product goals. Understanding &lt;a href="https://trophy.so/blog/when-your-app-needs-an-achievements-feature?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;when your app needs achievements&lt;/a&gt; helps you design effective systems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metric achievements&lt;/strong&gt; track cumulative actions. "Complete 100 tasks" or "View 50 lessons." These recognize consistent usage and progress toward mastery. Trophy's metric system tracks these automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Streak achievements&lt;/strong&gt; recognize consistency. "Maintain a 30-day streak" celebrates sustained engagement. Trophy's streak tracking makes these simple to implement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API achievements&lt;/strong&gt; recognize specific one-time events. "Complete onboarding" or "Link social account." You trigger these manually when appropriate events occur.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tiered achievements&lt;/strong&gt; create progression. Bronze (10 tasks), silver (50 tasks), gold (100 tasks). Users see clear advancement path. Trophy's metric thresholds support tiered structures naturally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hidden achievements&lt;/strong&gt; surprise users through discovery. Don't show them until completed.&lt;/p&gt;

&lt;p&gt;Mix achievement types to serve different user motivations and engagement patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing Achievement Structures
&lt;/h2&gt;

&lt;p&gt;Effective achievement design requires understanding user psychology and product goals. &lt;a href="https://trophy.so/blog/designing-achievements-for-optimal-user-engagement?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;Designing achievements for optimal engagement&lt;/a&gt; explores strategic frameworks for achievement creation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accessibility balance&lt;/strong&gt; matters. Some achievements should be easy (first task completed) for quick wins. Others should be challenging (1,000 tasks completed) for long-term goals. Trophy's analytics show completion rates helping you tune difficulty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Progressive revelation&lt;/strong&gt; prevents overwhelming new users. Show basic achievements upfront. Reveal advanced achievements as users progress. Trophy's achievement system supports controlling visibility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Meaningful milestones&lt;/strong&gt; align with user goals. Achievements should recognize progress users care about, not arbitrary metrics. Trophy's flexible metric system lets you track what matters for your product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rarity indicators&lt;/strong&gt; show prestige. "Only 5% of users have completed this" motivates completion. Trophy automatically calculates and displays rarity statistics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backdating Logic
&lt;/h2&gt;

&lt;p&gt;When you create new achievements, users who already qualified should complete them automatically. Trophy handles this through backdating.&lt;/p&gt;

&lt;p&gt;When you activate an achievement in Trophy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Trophy scans historical data for users who meet completion criteria&lt;/li&gt;
&lt;li&gt;Awards the achievement to qualified users with original completion timestamp&lt;/li&gt;
&lt;li&gt;Updates completion counts and rarity statistics&lt;/li&gt;
&lt;li&gt;Processes in background without blocking achievement activation&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This ensures fairness without requiring manual intervention. Users who completed 100 tasks before that achievement existed get credit when you launch it.&lt;/p&gt;

&lt;p&gt;Your code doesn't change. Trophy handles backdating automatically based on achievement configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notification Strategy
&lt;/h2&gt;

&lt;p&gt;Achievement completions need communication without overwhelming users. Trophy's &lt;a href="https://docs.trophy.so/platform/emails?ref=trophy.ghost.io#achievement-emails" rel="noopener noreferrer"&gt;email system&lt;/a&gt; handles notification timing.&lt;/p&gt;

&lt;p&gt;Configure achievement emails in Trophy's dashboard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When to send (immediately, batched, specific times)&lt;/li&gt;
&lt;li&gt;Email content and design&lt;/li&gt;
&lt;li&gt;Which achievements trigger emails&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Trophy sends notifications automatically when users complete achievements. No manual email logic needed in your code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Trophy handles notifications automatically
// Your code just tracks events
await trophy.metrics.event('tasks_completed', {
  user: { id: 'user-123' },
  value: 1
});

// If user completes achievement, Trophy sends configured emails

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Performance Considerations
&lt;/h2&gt;

&lt;p&gt;Trophy's infrastructure is built for scale, but your integration patterns affect performance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check achievements server-side only&lt;/strong&gt;. Don't query achievement status on every page load. Cache achievement data and refresh periodically or after user actions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache completion state&lt;/strong&gt; for display-heavy scenarios. Achievement lists don't need real-time accuracy. Cache for 5-10 minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const cache = new Map();

async function getCachedAchievements(userId: string) {
  const cached = cache.get(userId);
  if (cached &amp;amp;&amp;amp; Date.now() - cached.timestamp &amp;lt; 300000) {
    return cached.data;
  }

  const fresh = await trophy.users.achievements(userId);
  cache.set(userId, { data: fresh, timestamp: Date.now() });
  return fresh;
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trophy handles backend scaling. These client-side patterns optimize your application's performance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Analytics and Tuning
&lt;/h2&gt;

&lt;p&gt;Trophy provides analytics for achievement system health.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Completion rates&lt;/strong&gt; show whether achievements are appropriately difficult. Trophy's dashboard shows what percentage of users complete each achievement. Aim for variety: some easy (60%+), some moderate (30-60%), some difficult (5-30%).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Completion velocity&lt;/strong&gt; reveals how long achievements take. If most users complete an achievement within days of starting, it might be too easy. If it takes months on average, consider whether it's appropriately challenging or frustratingly difficult.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rarity distribution&lt;/strong&gt; shows whether you have enough aspirational achievements. If all achievements have 40-60% completion, you might need harder goals for engaged users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Abandonment patterns&lt;/strong&gt; indicate where users give up. If users progress halfway toward an achievement then stop, the difficulty curve might be wrong or the achievement might not be compelling.&lt;/p&gt;

&lt;p&gt;Use these insights to refine achievement thresholds and create new achievements that fill gaps. Trophy's dashboard configuration makes adjustments quick without code changes.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>mobile</category>
      <category>gamification</category>
    </item>
    <item>
      <title>How to Build an Energy Feature</title>
      <dc:creator>Charlie Brinicombe</dc:creator>
      <pubDate>Fri, 17 Oct 2025 11:47:21 +0000</pubDate>
      <link>https://dev.to/charlie_brinicombe/how-to-build-an-energy-feature-73i</link>
      <guid>https://dev.to/charlie_brinicombe/how-to-build-an-energy-feature-73i</guid>
      <description>&lt;p&gt;Your want to add an energy feature to your platform. Users consume energy for actions. Energy regenerates over time. Cap it at a maximum. Seems like simple arithmetic. Three weeks later, you're debugging regeneration timing, handling edge cases around maximum caps, and dealing with race conditions when users perform rapid actions.&lt;/p&gt;

&lt;p&gt;Energy systems appear straightforward until you implement them at scale. The core concept (limited resource that regenerates) hides complexity. When does regeneration happen? What if users act while at zero energy? How do you handle time zones for regeneration timing? What prevents users from gaming the system?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://trophy.so/?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;Trophy&lt;/a&gt; handles &lt;a href="https://trophy.so/features/points?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;energy systems&lt;/a&gt; including regeneration, consumption, and metering. The &lt;a href="https://docs.trophy.so/guides/how-to-build-an-energy-feature?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;complete implementation guide&lt;/a&gt; walks through the full process. Integration takes 1 day to 1 week. But understanding what building from scratch involves helps you make informed build versus buy decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Points
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Technical challenges in energy system implementation&lt;/li&gt;
&lt;li&gt;Regeneration patterns and timing considerations&lt;/li&gt;
&lt;li&gt;Consumption triggers and usage metering&lt;/li&gt;
&lt;li&gt;Integration examples with Trophy's API&lt;/li&gt;
&lt;li&gt;Build versus buy considerations for energy features&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Technical Reality
&lt;/h2&gt;

&lt;p&gt;Before building energy systems, understand the problems beyond simple counters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time-based regeneration&lt;/strong&gt; needs careful implementation. Energy regenerates hourly or daily, but when exactly? Server-scheduled jobs create spiky regeneration patterns. Per-user timers scale poorly. Event-driven regeneration requires complex triggering. Getting this right takes time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Maximum cap handling&lt;/strong&gt; prevents infinite accumulation. Users hit the cap and stop regenerating. But what if regeneration happens while they're at cap? Do they lose potential energy? How do you communicate this clearly? Edge cases around caps create complexity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consumption timing&lt;/strong&gt; affects user experience. Immediate energy deduction prevents spam but blocks users at zero. Deferred consumption allows actions but creates debt states. Partial consumption for partial actions adds more complexity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Race conditions&lt;/strong&gt; happen when users perform rapid actions. Two actions at 5 energy each when user has 8 energy total. Which succeeds? Do they both check balance simultaneously and both succeed, overdrawing the account? Proper locking prevents this but adds latency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time zone handling&lt;/strong&gt; for regeneration schedules requires per-user logic. Daily regeneration at midnight means different times for different users. Trophy handles &lt;a href="https://trophy.so/blog/handling-time-zones-gamification?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;time zones automatically&lt;/a&gt;, but building this yourself means complex timezone math.&lt;/p&gt;

&lt;p&gt;Building production-ready energy systems typically takes 3-6 months including regeneration logic, consumption triggers, and edge cases. Trophy's infrastructure handles these problems, reducing implementation to integration work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Patterns
&lt;/h2&gt;

&lt;p&gt;If building in-house, these patterns avoid common mistakes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Event-based consumption&lt;/strong&gt; separates action tracking from energy deduction. Store user actions as events. Process energy changes asynchronously. This prevents blocking user actions on energy calculations but requires careful consistency management.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scheduled regeneration jobs&lt;/strong&gt; grant energy at fixed intervals. Cron jobs that run hourly or daily and grant energy to eligible users. This pattern is simple but creates load spikes. Trophy uses distributed scheduling that spreads regeneration load evenly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lazy regeneration&lt;/strong&gt; computes energy only when queried. Store last check time. When user requests their balance, calculate regeneration since last check and update. This avoids scheduled jobs but complicates balance queries. Trophy uses hybrid approach with cached totals and lazy updates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Optimistic locking&lt;/strong&gt; prevents race conditions. Check energy balance, attempt consumption, verify balance didn't change during operation. If it changed, retry. This ensures consistency without blocking concurrent operations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Denormalized balances&lt;/strong&gt; for query performance. Maintain current energy in fast storage (Redis). Recompute from event history only when needed. Trophy caches current balances with millisecond query latency.&lt;/p&gt;

&lt;p&gt;Trophy implements these patterns. You configure regeneration rules and consumption triggers. Trophy handles the infrastructure complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Estimate
&lt;/h2&gt;

&lt;p&gt;Here's realistic timeline for building energy systems in-house:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 1-2: Basic implementation.&lt;/strong&gt; Track energy balance. Deduct for actions. Display totals. Works in development with simple cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 3-4: Regeneration logic.&lt;/strong&gt; Implement time-based regeneration with scheduled jobs. Handle maximum caps. Make regeneration work across user sessions and time zones. Test edge cases around timing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 5-6: Consumption triggers.&lt;/strong&gt; Build system for deducting energy based on different actions. Implement variable consumption amounts. Handle insufficient energy cases gracefully.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 7: Concurrency and edge cases.&lt;/strong&gt; Prevent race conditions. Handle rapid actions correctly. Test regeneration at scale. Fix performance issues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ongoing: Maintenance and tuning.&lt;/strong&gt; As usage patterns change, regeneration rates need adjustment. New actions need consumption rules. This work continues indefinitely.&lt;/p&gt;

&lt;p&gt;That's 7+ weeks of engineering time plus ongoing maintenance. Trophy's infrastructure eliminates this timeline, reducing implementation to 1 day to 1 week of integration work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building with Trophy
&lt;/h2&gt;

&lt;p&gt;Trophy's integration is faster because energy infrastructure already exists. Here's what implementation looks like.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create Energy System
&lt;/h3&gt;

&lt;p&gt;In Trophy's dashboard, create a points system called "Energy" (or your preferred name). Configure the maximum energy cap users can have. Trophy supports any maximum up to your requirements.&lt;/p&gt;

&lt;p&gt;Energy systems are just points systems with specific regeneration and consumption rules. Trophy's flexible points infrastructure supports both XP-style accumulation and energy-style &lt;a href="https://docs.trophy.so/platform/points?ref=trophy.ghost.io#metering" rel="noopener noreferrer"&gt;metering&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Configure Regeneration Triggers
&lt;/h3&gt;

&lt;p&gt;Set up how users gain energy over time. In Trophy's dashboard, create time-based triggers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hourly regeneration&lt;/strong&gt; : Grant X energy every N hours&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Award 1 energy every hour&lt;/li&gt;
&lt;li&gt;Award 10 energy every 6 hours&lt;/li&gt;
&lt;li&gt;Maximum caps prevent energy exceeding limits&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Daily regeneration&lt;/strong&gt; : Grant energy once per day&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Award 20 energy at midnight user time&lt;/li&gt;
&lt;li&gt;Award 50 energy every 24 hours&lt;/li&gt;
&lt;li&gt;Trophy handles timezone timing automatically&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Trophy's trigger system grants energy automatically based on your configuration. Users receive energy without manual processing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Configure Consumption Triggers
&lt;/h3&gt;

&lt;p&gt;Set up how users spend energy. Create negative-value triggers for actions that consume energy:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Action-based consumption&lt;/strong&gt; : Deduct energy when users perform specific actions&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deduct 1 energy per lesson viewed&lt;/li&gt;
&lt;li&gt;Deduct 5 energy per workout started&lt;/li&gt;
&lt;li&gt;Deduct 10 energy per premium feature access&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Configure these through Trophy's dashboard as negative point awards. When users perform tracked actions, Trophy automatically deducts configured energy amounts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Track Energy-Consuming Actions
&lt;/h3&gt;

&lt;p&gt;Send events for actions that consume energy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { TrophyApiClient } from '@trophyso/node';

const trophy = new TrophyApiClient(process.env.TROPHY_API_KEY);

// When user views a lesson (consumes 1 energy)
const response = await trophy.metrics.event('lesson_viewed', {
  user: {
    id: 'user-123'
  },
  value: 1 // 1 lesson viewed
});

// Check remaining energy
if (response.points?.energy) {
  const remaining = response.points.energy.total;
  const consumed = Math.abs(response.points.energy.added); // Will be negative
  console.log(`Consumed ${consumed} energy. ${remaining} remaining.`);
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trophy processes the event and automatically deducts energy based on trigger configuration. The response includes updated energy balance for immediate display.&lt;/p&gt;

&lt;p&gt;Changes to energy consumption logic can be made in the Trophy dashboard, preventing back and forth code changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Check Energy Before Actions
&lt;/h3&gt;

&lt;p&gt;Before allowing energy-consuming actions, check if user has sufficient energy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Check user's current energy
const energy = await trophy.users.points('user-123', 'energy');

if (energy.total &amp;gt; 0) {
  // User has energy, allow action
  await performAction();

  // Track the action (consumes energy)
  await trophy.metrics.event('lesson_viewed', {
    user: { id: 'user-123' },
    value: 1
  });
} else {
  // User has no energy, prevent action or show paywall
  showInsufficientEnergyMessage();
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern prevents users from attempting actions they can't afford. The energy check happens before action processing, providing clear feedback.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Display Energy Status
&lt;/h3&gt;

&lt;p&gt;Show users their current energy and when it regenerates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Get detailed energy information
const energy = await trophy.users.points('user-123', 'energy', {
  awards: 5 // Last 5 energy changes
});

console.log({
  current: energy.total,
  maximum: energy.maximum,
  recentChanges: energy.awards.map(award =&amp;gt; ({
    amount: award.points,
    trigger: award.trigger.name,
    time: award.timestamp
  }))
});

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trophy returns current energy, maximum cap, and recent energy changes. Use this data for UI showing energy status and regeneration timing. The &lt;a href="https://docs.trophy.so/api-reference/endpoints/users/get-a-users-points?ref=trophy.ghost.io" rel="noopener noreferrer"&gt;points API documentation&lt;/a&gt; covers all available fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regeneration Strategies
&lt;/h2&gt;

&lt;p&gt;Different regeneration patterns serve different product goals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Constant regeneration&lt;/strong&gt; grants energy at fixed intervals regardless of usage. Users get 1 energy per hour even if they're at maximum. Simple but can waste potential energy when users hit caps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regeneration until cap&lt;/strong&gt; stops when users reach maximum. Trophy's default behavior. Users don't waste regeneration but might feel pressure to spend energy before hitting cap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Overflow to storage&lt;/strong&gt; lets excess regeneration accumulate in separate pool. Complex but prevents waste. Trophy supports this through secondary points systems that have different caps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Activity-based regeneration&lt;/strong&gt; grants energy for specific actions beyond time. Complete a challenge, gain energy. This creates positive feedback loops where engagement grants resources for more engagement.&lt;/p&gt;

&lt;p&gt;Trophy's time-based triggers handle first two patterns natively. Configure in dashboard without code. More complex patterns use multiple points systems or custom trigger logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consumption Patterns
&lt;/h2&gt;

&lt;p&gt;How you consume energy affects gameplay and user psychology.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixed consumption&lt;/strong&gt; deducts the same amount for all instances of an action. 1 energy per lesson. Simple and predictable. Users understand cost clearly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Variable consumption&lt;/strong&gt; charges different amounts for different actions or contexts. 1 energy for basic lesson, 5 energy for advanced lesson. Creates strategic choice about energy spending.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scaling consumption&lt;/strong&gt; increases cost based on usage. First 5 lessons cost 1 energy each, next 5 cost 2 each. Encourages moderation and prevents grinding. Trophy implements through threshold-based triggers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partial refunds&lt;/strong&gt; return energy if actions don't complete. User starts lesson (spends energy) but quits (refunds energy). Requires custom logic to track partial completions and issue refunds as positive point awards.&lt;/p&gt;

&lt;p&gt;Trophy's trigger flexibility supports all these patterns. Configure consumption amounts through dashboard. Adjust based on player behavior without code changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preventing Energy Gaming
&lt;/h2&gt;

&lt;p&gt;Energy systems create incentives to game. Design prevents exploitation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Maximum caps&lt;/strong&gt; prevent infinite accumulation. Trophy's configurable maximum prevents users from stockpiling unlimited energy for later use. Choose caps based on intended session length.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting&lt;/strong&gt; beyond energy. If users can refresh energy artificially (time zone changes, system clock manipulation), add detection and rate limits. Trophy's server-side processing prevents client-side time manipulation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consumption verification&lt;/strong&gt; ensures actions actually completed before deducting energy. Deduct energy only after verifying action succeeded. Trophy's event-based model supports this through proper event sequencing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Account-level tracking&lt;/strong&gt; prevents multi-account farming. If users create multiple accounts to bypass energy limits, implement account-level detection. Trophy tracks per-user; your authentication layer handles account limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Display and Communication
&lt;/h2&gt;

&lt;p&gt;How you present energy affects user experience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clear cost indicators&lt;/strong&gt; before actions. "This will cost 5 energy" prevents surprise when users can't afford actions. Trophy's balance checking enables this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regeneration timing&lt;/strong&gt; transparency. "Energy regenerates in 2 hours" or "Full energy at 8 PM" gives users planning information. Trophy's time-based triggers have predictable schedules you can communicate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Friendly empty states&lt;/strong&gt;. "You're out of energy! It regenerates 1 per hour." explains situation without being punitive. Frame energy as pacing mechanic, not punishment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Progress toward regeneration&lt;/strong&gt;. "Energy: 3/10 (regenerating...)" shows both current state and that progress continues. Trophy's balance provides current amount; you track maximum for display.&lt;/p&gt;

&lt;h2&gt;
  
  
  Economic Tuning
&lt;/h2&gt;

&lt;p&gt;Energy systems need careful tuning to feel fair without killing engagement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regeneration rate&lt;/strong&gt; determines session frequency. Fast regeneration (hourly) encourages frequent short sessions. Slow regeneration (daily) encourages longer, less frequent sessions. Trophy makes rate adjustments through dashboard configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Maximum cap&lt;/strong&gt; determines session length. Cap of 10 with consumption of 1 per action allows 10 actions per session. Cap of 100 allows longer sessions but slower regeneration to full. Trophy's configurable cap lets you test different values.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consumption amounts&lt;/strong&gt; relative to regeneration define gameplay pace. If regeneration grants 20 energy daily and average session consumes 15, users can play daily with buffer. Trophy's analytics show consumption patterns informing tuning.&lt;/p&gt;

&lt;p&gt;Monitor average energy levels across users. If most users sit at maximum constantly, regeneration is too generous or consumption too low. If most users sit at zero, consumption is too high or regeneration too slow. Trophy's analytics dashboard shows energy distribution.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>gamification</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
