Skip to main content

Command Palette

Search for a command to run...

Building a Streak for Gamification Architecture

Published
12 min read
Building  a Streak for Gamification Architecture
S

I'm a passionate tech enthusiast eagerly awaiting my next opportunity to make contribution to the world of technology. Hungry for knowledge, I consider myself a perpetual learner, constantly exploring and honing my skills in the vast realm of tech and software.

Ever wondered how Duolingo or your favourite fitness app keeps track of your daily streak?

At its core, a streak is just a gamified version of attendance. Apps are designed in a way that gently pressures you to keep coming back every day. Over time, that small pressure turns into habit. Duolingo is probably the best example of this — people often open the app just to make sure their streak doesn’t break.

In this blog, we’ll break down how a user streak is actually maintained, what kind of edge cases you need to think about, and how you can store and manage streaks cleanly in a production environment.


1. What Triggers a Streak?

A streak is never calculated automatically. There is always some trigger , an action or event that tells the system the user was active.

In most applications, streaks are triggered in one of two ways.

I. Login-Based Streaks

This is the simplest approach.

Here, the streak is calculated based on a basic action like user login. If a user logs in within a 24-hour window, the streak flag is triggered and the streak logic runs.

This approach is commonly used in social media or productivity apps where just opening the app counts as engagement.

II. Task-Based Streaks

This is the more meaningful and widely used approach.

Apps like Duolingo or fitness apps calculate streaks only after a user completes a specific task — for example:

  • Completing a lesson

  • Logging a workout

  • Submitting a habit

Task submission is treated as an event. Once the event occurs, the date on which it happened is marked on the calendar. This ensures streaks are tied to actual value-driven actions, not just app opens.


2. When Does a Streak Break?

A streak represents an action performed continuously without a day gap.

To keep things simple, we track continuity using two logical flags:

  • Flag 1 → represents the previous day (last event date)

  • Flag 2 → represents the current day (today)

Based on these two flags, the outcome is determined.

Possible Scenarios

  • Flag 1 triggered + Flag 2 triggered Streak continues and is incremented.

  • Flag 1 not triggered + Flag 2 triggered A new streak starts and the streak count becomes 1.

  • Flag 1 triggered + Flag 2 not triggered If the date difference is greater than one day, the streak is lost.

  • Flag 1 not triggered + Flag 2 not triggered Streak remains 0.

This simple comparison handles most streak logic without unnecessary complexity.


The Catch: Streak Freeze

There is one important safety net : Streak Freeze.

A streak freeze allows users to miss a day without losing their streak. Each freeze point gives the user permission to skip one day.

The rules are simple:

  • If the user misses a day and has freeze points available, the streak remains intact and one freeze is consumed.

  • If the user misses a day and has no freeze points left, the streak breaks.

This feature significantly improves retention by removing the “one mistake and everything is gone” feeling.


Storing Streaks in the Database

Once the logic is clear, the next step is storage.

The cleanest and most scalable approach is to use two collections:

  • userStreak

  • streakCalendar


userStreak Collection

This collection keeps track of the current streak state for every user.

It stores:

  • Current streak count

  • Maximum streak achieved

  • Available freeze points

  • Key timestamps

Important Fields

  • lastEventDate Stores the date on which the streak-triggering event last occurred.

  • updatedAt Stores the exact timestamp when the document was last modified.

updatedAt is updated on every event, but lastEventDate is updated only once per day.

Example Document

{
  "userId": "user_id",
  "streakCount": 1,
  "maxStreak": 1,
  "lastEventDate": "2026-02-10",
  "createdAt": "2026-02-10T07:07:30.652Z",
  "updatedAt": "2026-02-10T07:07:30.652Z"
}

streakCalendar Collection

This collection acts as a historical log of all days on which a user performed the required action.

Each user has only one document per day in this collection.

Example Document

{
  "userId": "userId",
  "date": "2026-02-03",
  "createdAt": "2026-02-03T12:10:33.714Z",
  "source": "streak"
}

This data is useful for:

  • Calendar views

  • Analytics

  • Debugging streak issues


Streak Manager

All streak logic lives inside a dedicated file — streakManager.js.

This file is responsible for:

  • Comparing dates

  • Incrementing or resetting streaks

  • Consuming freeze points

  • Ensuring idempotency


Date Utilities

We rely on UTC-based date comparison to avoid timezone bugs.

const today = new Date().toISOString().slice(0, 10);
const now = new Date();

const isSameDay = (d1, d2) =>
  d1.getUTCFullYear() === d2.getUTCFullYear() &&
  d1.getUTCMonth() === d2.getUTCMonth() &&
  d1.getUTCDate() === d2.getUTCDate();

const isYesterday = (lastDate, today) => {
  const yesterday = new Date(today);
  yesterday.setUTCDate(today.getUTCDate() - 1);
  return isSameDay(lastDate, yesterday);
};

Reset-Only Mode

Sometimes we just want to check if a streak should be reset, without incrementing it. This happens when loading the user profile or validating inactivity.

if (shouldAffectStreak === true) {
  const userStreak = await collection.findOne({ userId });
  if (!userStreak || !userStreak.lastEventDate) return;

  const lastDate = new Date(userStreak.lastEventDate);
  const diffInDays = Math.floor(
    (now - lastDate) / (1000 * 60 * 60 * 24)
  );

  if (diffInDays > 1) {
    await collection.updateOne(
      { userId },
      {
        $set: {
          streakCount: 0,
          updatedAt: now
        }
      }
    );
  }
  return;
}

Normal Streak Flow

This logic runs after a successful event or task completion.

const handleStreakEvent = async ({ userId, shouldAffectStreak }) => {
  const mongo = await getMongoClient();
  const collection = mongo.db().collection("userStreaks");

  if (shouldAffectStreak === false) {
    const streakDoc = await collection.findOne({ userId });

    await markStreakCalendarDay({ userId, date: today });

New User

if (!streakDoc) {
  await collection.insertOne({
    userId,
    streakCount: 1,
    maxStreak: 1,
    lastEventDate: today,
    createdAt: now,
    updatedAt: now
  });

  return { status: "CREATED" };
}

Same Day Event

if (isSameDay(new Date(streakDoc.lastEventDate), new Date(today))) {
  return { status: "SAME_DAY_IGNORE" };
}

Consecutive Day

if (isYesterday(new Date(streakDoc.lastEventDate), new Date(today))) {
  const newStreak = streakDoc.streakCount + 1;

  await collection.updateOne(
    { userId },
    {
      $set: {
        streakCount: newStreak,
        maxStreak: Math.max(streakDoc.maxStreak, newStreak),
        lastEventDate: today,
        updatedAt: now
      }
    }
  );

  return { status: "INCREMENTED" };
}

Freeze Consumption or Reset

if (currentDoc?.availableFreez > 0) {
  await collection.updateOne(
    { userId },
    {
      $inc: { availableFreez: -1 },
      $set: { lastEventDate: today, updatedAt: now }
    }
  );

  return { status: "FREEZE_CONSUMED" };
}

await collection.updateOne(
  { userId },
  {
    $set: {
      streakCount: 1,
      lastEventDate: today,
      updatedAt: now
    }
  }
);

return { status: "RESET" };

How This Fits Into the App Flow

This logic is used as middleware inside the application.

Event happens
     ↓
Mark calendar day (idempotent)
     ↓
Fetch userStreak
     ↓
Compare dates
     ↓
Increment | Reset | Ignore
  • When fetching the user profile → pass shouldAffectStreak = true

  • When submitting a task/event → pass shouldAffectStreak = false

This keeps the logic flexible and predictable.


streak-calendar.js

This utility safely marks a user as active for a given day. It is idempotent and safe to call multiple times.

const markStreakCalendarDay = async ({
  userId,
  date,
  source = "streak"
}) => {
  const mongo = await getMongoClient();
  const collection = mongo.db().collection("streakCalender");

  try {
    await collection.updateOne(
      { userId, date },
      {
        $setOnInsert: {
          userId,
          date,
          source,
          createdAt: new Date(),
        }
      },
      { upsert: true }
    );

    return { marked: true };
  } catch (err) {
    if (err.code === 11000) {
      return { marked: false };
    }
    throw err;
  }
};

Fetching Calendar Streak Data: Efficient Windowed Queries

After collecting users' streak data, how are we going to send it to represent on the calendar? One challenge that comes is about the size of the list returning with all the number of days and dates the user has performed the activity.

This is a very crucial step as in any application where you have maintained streaks, you need to represent the streaks on the calendar. And to do so, the most efficient way is to fetch the data in pieces, as fetching huge data at a time which you are not going to show on screen is not the right call.

The Windowed Query Pattern

The best practice would be fetching the streak data in a period window, like:

  • Weekly data (7 days) - for mobile calendar views

  • Monthly data (30-31 days) - for desktop calendar widgets

  • Quarterly data (90 days) - for analytics dashboards and year-in-review features

This approach helps the application side to also map the dates quickly and reduces the non-essential data to be shared via response body.

Why Windowed Queries Matter

Performance Benefits:

  • Reduces network payload size (less bandwidth consumption)

  • Faster JSON parsing on client side

  • Lower memory footprint in mobile apps

  • Enables progressive loading (load current week, then expand if needed)

Database Optimization:

  • Indexed date range queries are extremely fast

  • Less data transfer from database to application server

  • Allows for efficient cleanup of old records

Implementation: 7-Day Streak Window

Here's a production-ready example for fetching the last 7 days of user streak data:

const { getMongoClient } = require("../../../utils/mongoDb/mongo-client");

const getUserLast7DaysStreak = async (event) => {
  console.log("event received at get-user-last-7-days-streak :::", event);

  try {
    const mongoClient = await getMongoClient();
    const userId = event?.user?.userId;

    // Calculate 7-day window (today + previous 6 days = 7 total)
    const now = new Date();
    const sevenDaysAgo = new Date();
    sevenDaysAgo.setUTCDate(now.getUTCDate() - 6);

    console.log("DATE RANGE :::", sevenDaysAgo, now);

    //  Cleanup old calendar records (data retention policy)
    await mongoClient
      .db()
      .collection("streakCalender")
      .deleteMany({
        userId,
        createdAt: { $lt: sevenDaysAgo }
      });

    // Fetch calendar records within the window
    const records = await mongoClient
      .db()
      .collection("streakCalender")
      .find({
        userId,
        createdAt: {
          $gte: sevenDaysAgo,  // Greater than or equal to 7 days ago
          $lte: now             // Less than or equal to now
        }
      })
      .project({ _id: 0, createdAt: 1, source: 1 })  // Only fetch needed fields
      .sort({ createdAt: 1 })  // Ascending chronological order
      .toArray();

    // Fetch current streak stats
    const streakCount = await mongoClient
      .db()
      .collection("userStreaks")
      .findOne({ userId });

    // Transform records into client-friendly format
    const activity = records.map(r => ({
      date: r.createdAt.toISOString().slice(0, 10),  // YYYY-MM-DD
      day: r.createdAt.getUTCDate(),                 // Day of month (1-31)
      timestamp: r.createdAt
    }));

    return {
      statusCode: 200,
      body: JSON.stringify({
        range: {
          from: sevenDaysAgo.toISOString(),
          to: now.toISOString()
        },
        totalActiveDays: streakCount?.streakCount || 0,
        maxStreak: streakCount?.maxStreak || 0,
        activity
      })
    };

  } catch (error) {
    console.log("error at get-user-last-7-days-streak ", error);
    return {
      statusCode: error.statusCode || 500,
      body: JSON.stringify({
        message: error.message,
        error: error.error || "INTERNAL_SERVER_ERROR"
      })
    };
  }
};

module.exports = {
  getUserLast7DaysStreak,
};

Key Implementation Details

Date Range Calculation:

const now = new Date();
const sevenDaysAgo = new Date();
sevenDaysAgo.setUTCDate(now.getUTCDate() - 6);  // -6 because today counts as day 1

Projection Optimization:

.project({ _id: 0, createdAt: 1, source: 1 })

Only fetches createdAt and source fields, ignoring _id. This reduces payload size by ~30-40%.

Data Retention Policy: The optional deleteMany call implements automatic cleanup of old records. This prevents the streakCalendar collection from growing indefinitely:

await collection.deleteMany({
  userId,
  createdAt: { $lt: sevenDaysAgo }
});

Response Structure:

{
  "range": {
    "from": "2026-02-03T00:00:00.000Z",
    "to": "2026-02-10T12:30:45.123Z"
  },
  "totalActiveDays": 5,
  "maxStreak": 23,
  "activity": [
    {
      "date": "2026-02-03",
      "day": 3,
      "timestamp": "2026-02-03T12:10:33.714Z"
    },
    {
      "date": "2026-02-05",
      "day": 5,
      "timestamp": "2026-02-05T08:22:11.432Z"
    }
  ]
}

Implementation: 90-Day Quarterly Window

For analytics dashboards, trends analysis, and year-in-review features:

const getUserLast90DaysStreak = async (event) => {
  console.log("event received at get-user-last-90-days-streak :::", event);

  try {
    const mongoClient = await getMongoClient();
    const userId = event?.user?.userId;

    // Calculate 90-day window (today + previous 89 days = 90 total)
    const now = new Date();
    const ninetyDaysAgo = new Date();
    ninetyDaysAgo.setUTCDate(now.getUTCDate() - 89);

    console.log("QUARTERLY DATE RANGE :::", ninetyDaysAgo, now);

    // Fetch calendar records for the last 90 days
    const records = await mongoClient
      .db()
      .collection("streakCalender")
      .find({
        userId,
        createdAt: {
          $gte: ninetyDaysAgo,
          $lte: now
        }
      })
      .project({ _id: 0, createdAt: 1, source: 1 })
      .sort({ createdAt: 1 })
      .toArray();

    // Fetch current streak stats
    const streakStats = await mongoClient
      .db()
      .collection("userStreaks")
      .findOne({ userId });

    // Transform activity data with monthly grouping
    const activity = records.map(r => {
      const date = new Date(r.createdAt);
      return {
        date: date.toISOString().slice(0, 10),
        day: date.getUTCDate(),
        month: date.getUTCMonth() + 1,  // 1-12
        monthName: date.toLocaleString('default', { month: 'short' }),
        weekNumber: Math.ceil((date - ninetyDaysAgo) / (7 * 24 * 60 * 60 * 1000)),
        timestamp: r.createdAt,
        source: r.source
      };
    });

    // Group by month for quarterly analytics
    const monthlyBreakdown = activity.reduce((acc, day) => {
      const monthKey = `${day.month}-${day.monthName}`;
      if (!acc[monthKey]) {
        acc[monthKey] = { month: day.monthName, count: 0, days: [] };
      }
      acc[monthKey].count++;
      acc[monthKey].days.push(day.date);
      return acc;
    }, {});

    // Calculate quarterly statistics
    const activeDaysThisQuarter = activity.length;
    const completionRate = ((activeDaysThisQuarter / 90) * 100).toFixed(1);
    const averagePerMonth = (activeDaysThisQuarter / 3).toFixed(1);

    return {
      statusCode: 200,
      body: JSON.stringify({
        range: {
          from: ninetyDaysAgo.toISOString(),
          to: now.toISOString(),
          days: 90
        },
        stats: {
          currentStreak: streakStats?.streakCount || 0,
          maxStreak: streakStats?.maxStreak || 0,
          activeDaysThisQuarter,
          completionRate: parseFloat(completionRate),
          averagePerMonth: parseFloat(averagePerMonth),
          totalPossibleDays: 90
        },
        monthlyBreakdown: Object.values(monthlyBreakdown),
        activity  // Full day-by-day data for detailed view
      })
    };

  } catch (error) {
    console.log("error at get-user-last-90-days-streak ", error);
    return {
      statusCode: error.statusCode || 500,
      body: JSON.stringify({
        message: error.message,
        error: error.error || "INTERNAL_SERVER_ERROR"
      })
    };
  }
};

module.exports = {
  getUserLast90DaysStreak,
};

Quarterly Query Template

Simple MongoDB Query:

// Last 90 days (quarter)
const ninetyDaysAgo = new Date();
ninetyDaysAgo.setUTCDate(new Date().getUTCDate() - 89);

db.streakCalendar.find({
  userId: "user_id",
  createdAt: {
    $gte: ninetyDaysAgo,
    $lte: new Date()
  }
})
.project({ _id: 0, createdAt: 1, source: 1 })
.sort({ createdAt: 1 });

Aggregation Pipeline for Monthly Breakdown:

db.streakCalendar.aggregate([
  {
    $match: {
      userId: "user_id",
      createdAt: {
        $gte: ninetyDaysAgo,
        $lte: new Date()
      }
    }
  },
  {
    $group: {
      _id: { 
        year: { $year: "$createdAt" },
        month: { $month: "$createdAt" }
      },
      count: { $sum: 1 },
      days: { $push: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } } }
    }
  },
  {
    $sort: { "_id.year": 1, "_id.month": 1 }
  }
]);

Enhanced Response for Quarterly View:

{
  "range": {
    "from": "2025-11-12T00:00:00.000Z",
    "to": "2026-02-10T12:30:45.123Z",
    "days": 90
  },
  "stats": {
    "currentStreak": 5,
    "maxStreak": 23,
    "activeDaysThisQuarter": 68,
    "completionRate": 75.6,
    "averagePerMonth": 22.7,
    "totalPossibleDays": 90
  },
  "monthlyBreakdown": [
    {
      "month": "Nov",
      "count": 22,
      "days": ["2025-11-12", "2025-11-13", ...]
    },
    {
      "month": "Dec",
      "count": 25,
      "days": ["2025-12-01", "2025-12-02", ...]
    },
    {
      "month": "Jan",
      "count": 21,
      "days": ["2026-01-01", "2026-01-03", ...]
    }
  ],
  "activity": [...]
}

Query Performance Optimization

Essential Indexes

// Compound index for efficient date range queries
db.streakCalendar.createIndex({ userId: 1, createdAt: 1 });

// Index for cleanup operations
db.streakCalendar.createIndex({ createdAt: 1 });

// Partial index for active users only (optional optimization)
db.streakCalendar.createIndex(
  { userId: 1, createdAt: 1 },
  { 
    partialFilterExpression: { 
      createdAt: { $gte: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000) } 
    } 
  }
);

Query Comparison Table

WindowDaysUse CaseAvg DocsResponse Size
Weekly7Mobile calendar~5-7~1 KB
Monthly30Desktop calendar~20-25~3 KB
Quarterly90Analytics dashboard~60-70~8 KB

Client-Side Integration Pattern

Progressive Loading Strategy:

// 1. Load weekly data immediately (fast, small payload)
const weekData = await fetchLast7DaysStreak();
renderCalendar(weekData);

// 2. User clicks "Show Month" → fetch monthly data
const monthData = await fetchLast30DaysStreak();
expandCalendar(monthData);

// 3. User navigates to analytics → fetch quarterly data
const quarterData = await fetchLast90DaysStreak();
renderAnalytics(quarterData);

This windowed approach ensures optimal performance, reduces server costs, and provides a snappy user experience across all device types and network conditions.

Final Thoughts

A streak system isn’t so complicated , it’s just some date math and discipline. What makes it production-ready is how well you handle edge cases, idempotency, and missed days. More than edge cases, all matters is how are you willing to cater the user data once system or users scale. When done right, streaks become one of the strongest retention mechanisms in any product simple, predictable, and surprisingly powerful.