Blog Product analytics and replay

Cohort retention: how the numbers are counted

A retention chart looks simple: rows of people who started, columns of how many came back. But "came back on day 1" can mean the next calendar day or 24 hours later, and "started" can mean first ever or first this month. Tools make different choices, so the same data gives different numbers.

This post explains exactly what ZipLogger counts, with a run of the real counting code, and then how saved cohorts fit in.

Open a retention report

Go to Events and click Retention in the view switcher. There are three inputs:

  • Cohort event: the event that puts someone in a cohort, for example signup.
  • Return event: the event that counts as coming back. If you leave it empty, any event counts.
  • weekly or daily.

Click Compute. Each row is a cohort, labelled with the day or week it started, then Users, then one column per period: W0 to W7 for weekly, D0 to D13 for daily. Each cell is a percentage of that row's users. Hover a cell for the raw count, for example "1 of 2".

Below the table it says how many events were examined, and how many were skipped for having no user id or anonymous id.

The definition, step by step

This is what the code does (RetentionGrid in the event store, with the same rules in the ClickHouse query):

  1. A person is the userId. If an event has no userId, the anonymousId is used instead. The two are not merged here. Someone who browsed anonymously and then signed up can count as two people.
  2. Entry is a person's first cohort event inside the report's time window. It is not their first cohort event ever.
  3. The cohort row is the UTC calendar day of that entry, or for weekly reports the week that starts on Monday, UTC.
  4. A return is a return event that comes strictly after the entry. With signup as both cohort and return event, the signup itself does not count as coming back.
  5. The period of a return is how many calendar days or weeks separate its bucket from the entry's bucket. D1 means "did something on the next UTC calendar day".
  6. Each column is counted separately. A person can count in W1 and W3 but not in W2. A cell does not mean "still around by then".

Periods that have not finished yet never show a final percentage, so this week's cohort does not look like everyone left. An open period with nobody back shows a dot (·). One where some people are already back shows the count so far with a star, such as 3*. A row whose day or week is only partly inside the window is flagged, because it holds fewer people than a full one.

A run on real code

We fed a small stream through the same RetentionGrid class the event store uses: daily buckets, signup as the cohort event, any event as a return, four periods.

WhoEvents (UTC)
asignup Mon 21 Sep 10:00, page_view Mon 15:00, Tue 09:00, Thu 12:00
bsignup Mon 21 Sep 23:30, page_view Tue 00:15
csignup Tue 22 Sep 11:00
anonymous visitor (anon_7f3)signup Tue 22 Sep 12:00, page_view Wed 08:00
no ids at allpage_view Wed 09:00

The output:

RETENTION daily, cohortEvent=signup, returnEvent=any, periods=4
  2026-09-21 users=2 returned=[1,2,0,1]
  2026-09-22 users=2 returned=[0,1,0,0]
  2026-09-23 users=0 returned=[0,0,0,0]
  2026-09-24 users=0 returned=[0,0,0,0]
  ignoredWithoutIdentity=1

So the Sep 21 row reads 50%, 100%, 0%, 50%. Notice:

  • b counts toward D1 after 45 minutes. They signed up at 23:30 and came back at 00:15, which is the next UTC calendar day. Buckets are calendar days, not rolling 24-hour windows.
  • a counts in D0 because they came back the same day after signing up.
  • The anonymous visitor is a person here. Retention falls back to the anonymous id, so device-level retention works before anyone signs in. Funnels do not do this.
  • Empty days still get a row. Sep 23 and 24 had no signups, and they show up as rows with zero users instead of disappearing. A chart with missing rows would look better than it is.
  • The event with no ids was skipped and reported, not dropped without a word.

Calendar buckets and what they mean for you

Because buckets are UTC calendar days and Monday-start UTC weeks, a few things follow:

  • Someone who signs up late in the day gets a very short D0 and an easy D1. Someone who signs up just after midnight gets the opposite.
  • W0 covers the rest of the entry week, which is anything from seven days to a few minutes.
  • There is no time zone setting.

If you compare these numbers with a tool that uses rolling 24-hour periods, expect D1 to differ.

The time window

The report picks its own window from the granularity. The UI does not use the page's time range picker. Weekly covers the last 8 weeks, with 8 periods. Daily covers the last 14 days, with 14 periods. The window starts at "now minus 8 weeks", which is usually mid-week, so the oldest row can hold only part of a week's signups.

The API (GET /api/v1/events/retention) takes cohortEvent, returnEvent, granularity, periods (up to 12 weeks or 30 days), and from and to. The UI has no control for the number of periods.

Because entry means "first cohort event in the window", the choice of cohort event matters:

  • A one-time event like signup gives you new-user cohorts.
  • A repeated event like page_view gives you "first seen in this window". A user who has been around for a year enters on their first page view in the window, as if they were new.

The same goes for the return event. Left empty, anything counts, including background events and the $identify event that identify() records. Pick an event that means real use, such as report_viewed.

Saved cohorts

A cohort is a saved audience: everyone who did an event in the last N days, optionally narrowed by property conditions. Click Cohorts… on the Events toolbar. The form takes:

  • a name, for example Recent buyers;
  • the event, for example purchase;
  • last N days, from 1 to 90;
  • optional conditions in name:op:value form, separated by spaces: plan:eq:business amount:gt:100. The operators are eq, neq, contains, startsWith, endsWith, gt, gte, lt and lte.

Click Create. Each cohort's row has a count button that shows how many users it matches right now. A + after the number means the cohort has more than 10,000 members and counting stopped there. On OpenSearch a query limited to such a cohort is refused, and a process report refuses an audience that large on any store. A workspace can have up to 50 cohorts. Creating and deleting them needs the Editor role.

A cohort stores its definition, not a list of people. Membership is worked out again on every query, so it never goes stale. It also means "purchased in the last 30 days" is always counted back from now, not from the dates of the report you apply it to.

A condition that does not parse, for example plan=business without the :op: part, is dropped without an error, and the cohort then matches more people than you meant. Check the count after you create one.

What you can do with a cohort

Pick a cohort in the Events toolbar, where it says All users by default. The Overview, Explore and Retention views all narrow to its members. With Retention, that answers questions like "do business-plan buyers come back more than everyone else?" Compute once with All users, once with the cohort, and compare the rows.

Two things change when a cohort is applied:

  • Only identified users count. Cohort members are user ids, so the anonymous-id fallback no longer applies.
  • The cohort filters, it does not define entry. The report still enters people on the cohort event you typed into the Retention form. The saved cohort only decides which users are looked at.

Over the API, ?cohort=<id> works on the event search, histogram, overview, terms, funnel, retention, property values and CSV export endpoints. The dashboard funnel widget has no cohort picker. A process report's Audience setting is how a cohort reaches a funnel in the UI.

Limits

  • A scan limit. Retention reads every matching event in the window. Past the server's limit you get the real count and a suggestion: "Narrow the time range, set a return event, or add a filter." Setting a return event helps most, because then only two event names are read.
  • No identity merging in the counts. An identity link from identify() does not join an anonymous person to their account here.
  • No rolling periods and no time zones. UTC calendar buckets only.

For where people drop out on the way to signing up, see the funnel analysis post. The events API and SDK calls are in the product analytics docs. Or create a free workspace, send a signup event, and open Events → Retention.

Funnel drop-off: who left and what they did next

Build a saved checkout process in ZipLogger, open the exact people who dropped at a step, and see the next thing each of them did. Limits included.

Funnel analysis: sequential vs presence funnels

Build a conversion funnel from tracked events, see why sequential and presence modes give different numbers, and trace a drop-off to the errors behind it.

Identify anonymous users and follow their journey

Link a visitor's anonymous events to their account with identify(), then read their profile and session timeline with the errors and replay beside it.

Try it on your own logs

There is a free plan and no sales call. Install an SDK, send a few log lines, and search them.