---
title: How to Build a Daily Streak in Firebase and Supabase (and When to Use a Streak API Instead)
canonical_url: "https://trophy.so/blog/build-daily-streak-firebase-supabase"
description: "Copy-paste Firebase and Supabase code to build a daily streak yourself, plus an honest, data-backed look at when a managed streak API is worth it instead."
last_updated: "2026-09-14T14:27:30.000+00:00"
---

# How to Build a Daily Streak in Firebase and Supabase (and When to Use a Streak API Instead)

## Do You Need a Streak API to Add a Daily Streak?

You can add a daily streak yourself by storing two things per user: the last-active date and a running count. On each qualifying action, check whether the new day is the next calendar day (add one), the same day (do nothing), or a later day (reset to one). Building it yourself is fine for one simple streak, but a [streak tracking API](https://trophy.so/developers/streaks) becomes worth it once you need correct timezones, daylight saving, grace periods, freezes, and anti-cheat at scale.

This guide teaches the do-it-yourself path first. You get working Firestore and Postgres code you can copy. Then we cover the edge cases that break most homemade streaks, and we say plainly when a streak API earns its place.

## What a Daily Streak Actually Requires

A daily streak counts the consecutive days a user completes a qualifying action, like finishing a lesson or logging a workout. Miss a day and the count goes back to zero.

The data model is small. You need `current_streak` (the count now), `longest_streak` (the best count so far), and `last_active_date` (a calendar date, not a timestamp).

Every update makes a three-way decision. Same day means no change. Next calendar day means add one. A gap of two or more days means reset to one.

One rule prevents most bugs: compare calendar dates in the user's timezone, never raw timestamps. A timestamp is an exact moment, like `2026-09-12T23:55:00Z`. A calendar date is just the day, like `2026-09-12`. Streaks care about the day, so store and compare the date.

## Build a Daily Streak in Firebase and Firestore

In Firestore, keep one document per user at `users/{uid}` with `current_streak`, `longest_streak`, and `last_active_date`. Each qualifying action updates that document.

Use a transaction for the update. A transaction is a read-then-write that runs as one atomic step, so two devices cannot both read the old count and both add one. Firestore [retries the whole transaction](https://firebase.google.com/docs/firestore/manage-data/transactions) when it detects a concurrent edit: in the case of a concurrent edit, Cloud Firestore runs the entire transaction again. Reads must run before writes, and the transaction function can run more than once, so keep it free of side effects.

```js
import { getFirestore, FieldValue } from "firebase-admin/firestore";

const db = getFirestore();

// Turn "now" into the user's local calendar date, e.g. "2026-09-12".
function localDate(date, tz) {
  return new Intl.DateTimeFormat("en-CA", { timeZone: tz }).format(date);
}

// Move a YYYY-MM-DD string by n days. Noon UTC keeps it safe across DST.
function addDays(ymd, n) {
  const d = new Date(ymd + "T12:00:00Z");
  d.setUTCDate(d.getUTCDate() + n);
  return d.toISOString().slice(0, 10);
}

async function recordStreak(uid, tz) {
  const ref = db.collection("users").doc(uid);
  return db.runTransaction(async (tx) => {
    const snap = await tx.get(ref);
    const data = snap.data() || { current_streak: 0, longest_streak: 0, last_active_date: null };
    const today = localDate(new Date(), tz);

    if (data.last_active_date === today) return data.current_streak; // same day, no change

    const yesterday = addDays(today, -1);
    const next = data.last_active_date === yesterday ? data.current_streak + 1 : 1;

    tx.set(ref, {
      current_streak: next,
      longest_streak: Math.max(next, data.longest_streak || 0),
      last_active_date: today,
      updated_at: FieldValue.serverTimestamp(),
    }, { merge: true });

    return next;
  });
}
```

The code reads the document, computes today's date in the user's timezone, and writes the new count. `FieldValue.serverTimestamp()` records the write time using the server clock, not the device clock. It uses a plain `+ 1` here because the count depends on the value it just read inside the transaction.

If you ever need to bump a counter without reading it first, [FieldValue.increment()](https://googleapis.dev/nodejs/firestore/latest/FieldValue.html) tells the server to increment the field's current value by the given value. For streak logic you still need the read, because the next count depends on whether yesterday was active.

## Build a Daily Streak in Supabase and Postgres

In Supabase, store one row per user in a `user_streaks` table. Then put the decision logic in a [Postgres database function](https://supabase.com/docs/guides/database/functions). Postgres functions live in the database and are called from your app with `rpc()`.

```sql
create table user_streaks (
  user_id uuid primary key,
  current_streak int not null default 0,
  longest_streak int not null default 0,
  last_active_date date
);

create or replace function update_streak(p_user_id uuid, p_tz text)
returns int
language plpgsql
as $$
declare
  today date := (now() at time zone p_tz)::date;
  last_date date;
  cur int;
  next int;
begin
  select last_active_date, current_streak into last_date, cur
  from user_streaks where user_id = p_user_id for update;

  if last_date = today then
    return cur;                         -- same day, no change
  elsif last_date = today - interval '1 day' then
    next := coalesce(cur, 0) + 1;       -- next day, continue
  else
    next := 1;                          -- gap, reset
  end if;

  insert into user_streaks (user_id, current_streak, longest_streak, last_active_date)
  values (p_user_id, next, next, today)
  on conflict (user_id) do update
    set current_streak = next,
        longest_streak = greatest(user_streaks.longest_streak, next),
        last_active_date = today;

  return next;
end;
$$;
```

The function is written in plpgsql, the procedural language Postgres uses for functions. The date logic does the timezone work for you. `now() at time zone p_tz` converts the current moment to the user's local time, and `::date` truncates it to a calendar day. That is the same idea as `date_trunc('day', ...)`, just shorter.

The `for update` clause locks the row while the function runs, so two calls cannot both increment. The `on conflict` clause handles the first-ever action and every later one in a single statement.

Call the function from your app with `rpc()`, which runs a database function by name:

```js
const { data } = await supabase.rpc("update_streak", {
  p_user_id: userId,
  p_tz: "America/New_York",
});
```

You also need to reset streaks for users who miss a day. Schedule a daily job with [Supabase Cron](https://supabase.com/docs/guides/cron), a Postgres module that schedules recurring jobs with cron syntax and is built on pg\_cron. Cron syntax is a five-field schedule string that says when to run.

```sql
select cron.schedule('expire-streaks', '0 * * * *', $$
  update user_streaks
  set current_streak = 0
  where last_active_date < ((now() at time zone 'UTC')::date - interval '1 day')
$$);
```

## The Hard Parts: Timezones, DST, Grace Periods, Freezes, and Idempotency

The data model is easy. The edge cases are where do-it-yourself streaks break, and they cause bugs users notice and complain about. Our [deeper build walkthrough](https://trophy.so/blog/how-to-build-a-streaks-feature) covers these in more detail, but here is what each one costs you.

### Timezones and the Midnight Problem

A user who acts at 11:55 PM and again at 12:05 AM is on two different calendar days. A user who flies from New York to London changes timezone mid-streak. Both cases break code that assumes one global clock.

Store the write time in UTC, but decide the streak day using the user's own timezone. Use an IANA timezone name like `America/New_York`, which is the standard tz-database string that carries the region's rules. Truncating a timestamp without a timezone defaults to UTC. As Firebase notes, when it [truncates on a time zone](https://firebase.google.com/docs/firestore/pipelines/functions/timestamp-functions), if timezone is not provided, truncation will be based on UTC calendar boundaries, which is wrong for every user who is not on UTC.

### Daylight Saving Time

Daylight saving time (DST) is when the local clock shifts by an hour twice a year. On those days a local day is 23 or 25 hours, not 24.

Any code that adds a fixed 86,400 seconds to find "the next day" will drift on DST days and eventually count wrong. The fix is in the code above: compare calendar-date strings in the user's timezone instead of subtracting timestamps. A date string like `2026-09-12` has no hours to drift.

Follow this [full guide on handling timezone and DST edge cases in a streaks feature](https://trophy.so/blog/streak-timezone-dst-handling) for more information.

### Grace Periods and Streak Freezes

A grace period is a short window past midnight, often a few hours, where a late action still counts for the previous day. It forgives the user who logs in at 12:10 AM.

[Streak freezes](https://trophy.so/features/streaks) are saved skip days a user can spend so one missed day does not reset the count. Freezes need real rules: how they are earned, how many a user can hold, and how one gets consumed on a missed day. You also need a scheduled job to apply them before the reset runs. Trophy's own platform data shows the payoff: apps that use freezes keep users on about 17-day streaks past the 7-day mark, versus about 11 days without freezes.

### Idempotency: Stop Users From Inflating Their Streak

Never trust the device clock to decide the day, because a user can change it to fake activity. Use the server timestamp instead. Then make the update idempotent, which means running it twice for the same day has the same effect as running it once.

The simplest way is a dedupe table with a unique key on user plus local date. Insert first; if the row already exists, today already counted. This also absorbs retries and double taps for free.

```sql
create table streak_events (
  user_id uuid not null,
  local_date date not null,
  primary key (user_id, local_date)
);
-- Insert first; if the row already exists, today already counted.
insert into streak_events (user_id, local_date)
values (:user_id, :today)
on conflict do nothing;
```

## Firebase vs Supabase for Streaks: Which Is Simpler?

Firestore is simplest if you are already on Firebase and want per-document atomic increments with little server code. Watch your read and write costs as event volume grows, because every streak update is billed.

Supabase and Postgres are simplest if you want the logic in one SQL function, easy date math with `date_trunc`, and built-in cron for expiry. Both options still leave you to own timezone, DST, grace, freeze, and anti-cheat logic yourself.

## When to Build vs When to Use a Streak API

Build it yourself when the streak is a single, mostly cosmetic feature on one platform, and it is low-stakes if it is occasionally wrong. The code above is enough, and adding a dependency would be overkill.

Use a streak API when you need several streak types, correct behavior across many timezones and DST, freezes and grace periods, reminders, analytics, and millions of events. That is a lot of moving parts to build and maintain. By [Trophy's own buy vs. build analysis](https://trophy.so/buy-vs-build), a correct streak in-house is typically a multi-week effort, while integrating a streak API takes 1 day to 1 week.

| Capability | Build it yourself | Streak API (Trophy) |
| --- | --- | --- |
| Timezone/DST handling | You write and test per-user timezone and DST logic | Each user is evaluated in their own local IANA timezone |
| Grace periods | Custom window logic and a scheduled job | Configurable |
| Streak freezes | Earn, cap, and consume rules plus a job to apply them | Built in |
| Anti-cheat/idempotency | Your own dedupe table and server-time checks | Handled server-side |
| Reminders & analytics | Separate systems to build and wire up | Included |
| Maintenance cost | Ongoing, on your team | Managed for you |
| Time to ship | Weeks | 1 day to 1 week |

Trophy evaluates each user in their own local timezone rather than one shared UTC boundary, and it ships SDKs in seven languages: Node.js, Go, Java, .NET, PHP, Python, and Ruby. If you are still deciding whether a streak fits your product at all, read [when your app needs a streak](https://trophy.so/blog/when-your-app-needs-streak-feature) first.

## FAQ

### Do I Need a Streak API to Add a Daily Streak?

No. A simple daily streak is a few fields plus date logic you can build yourself, and an API pays off once timezones, freezes, and scale get involved.

### How Do I Store and Increment a Daily Streak?

Keep `current_streak`, `longest_streak`, and `last_active_date`, then on each action add one when the new day is the next calendar day, do nothing on the same day, and reset on a gap.

### How Do I Stop Users From Gaming or Inflating Their Streak?

Decide the day from the server timestamp rather than the device clock, and make the update idempotent with a unique key on user plus local date.

### How Do I Handle Users in Different Timezones and DST?

Store write times in UTC but evaluate the streak day in each user's IANA timezone, and compare calendar-date strings instead of subtracting timestamps.

### What Is a Streak Freeze and How Do I Implement One?

A freeze is a saved skip day; grant a capped number, consume one automatically on a missed day, and apply it with a scheduled job.

### Firebase or Supabase for Streaks, Which Is Simpler?

Firestore if you want atomic per-document increments with little backend, and Supabase if you want the logic in one Postgres function with built-in cron.

### How Much Engineering Time Does Building vs Integrating Cost?

By Trophy's own estimate, building a correct streak in-house is usually a multi-week effort, while integrating a streak API takes 1 day to 1 week.
