← System design

Infrastructure

Design Count Unique Active Users

Every product review starts with the same chart: how many people used us today, this week, this month. Counting events is easy. Counting distinct people across billions of events, sliced by country and platform, over any date range is not, because uniqueness does not add up: Monday's users plus Tuesday's users is not the two-day number. The design hinges on a data structure that can be merged instead of summed.
Core: mergeable HyperLogLog sketches per time bucket2.4B activity events a dayUnder 1% error, 12 KB per sketchAny window, any timezone

01

The problem in one page

01

The problem in one page

You run analytics for a company with several apps and about 180 million monthly users. Leadership, product teams and the investor deck all want the same family of numbers: DAU, WAU, MAU, the ratio between them, and the same figures broken down by country, platform and app. Product managers also want ad hoc windows ("unique users between the launch and the outage") and a live view of today that is no more than a minute behind.

The trap is that distinct counts are not additive. If 40 million people used the app on Monday and 41 million on Tuesday, the two-day figure is somewhere between 41 and 81 million, depending on overlap. You cannot store one integer per day and add them up. Storing the full list of user ids for every day and every segment works, but the storage and the cost of unioning large sets for every chart grows quickly.

So the question is really: what is the smallest thing you can store per bucket that still lets you union buckets later? The answer most production systems reach is a probabilistic sketch, with an exact batch path for the handful of numbers that must be precise.

What makes it hard

Duplicates arrive constantly (retries, multiple devices, app restarts), events show up hours late from phones that were offline, and the same person may be anonymous on Monday and signed in on Tuesday. Every one of those can inflate the count if the design ignores it.

02

Requirements

02

Requirements

Functional
  • Report unique active users for day, week, 28-day and calendar month windows, plus any custom range.
  • Break every metric down by app, platform, country and app version, alone or combined.
  • Show today's count updating live, at most 60 seconds behind real events.
  • Return a year of daily values for trend charts in one call.
  • Support a viewer's timezone, so "today" in Tokyo and "today" in São Paulo are different windows.
  • Answer "how many people did both A and B" for a small number of retention and overlap questions.
Non-Functional
  • Handle 2.4 billion events a day with peaks near 90,000 per second.
  • Dashboard queries return in under 200 ms at p95, even for a year-long range.
  • Estimated counts stay within 1% of the truth at the 95% level; finance-grade monthly figures are exact.
  • Retries and replays never inflate a count: the same user counted twice still counts once.
  • Events up to 72 hours late still land in the correct day.
  • Storage for sketches stays in the tens of gigabytes, not terabytes.
Clarify the definition of "active"

An app open, a heartbeat, or a meaningful action? Agree on which event types qualify and which identifier counts as a person (account id, device id, or a stitched identity). Changing the definition later breaks every historical chart, so it belongs in the first five minutes.

03

Sizing the numbers

03

Sizing the numbers

Take 180M monthly users, about 60M on an average day, each sending around 40 qualifying events a day. Segments are 3 apps, 4 platforms and about 200 countries, so up to 2,400 fine-grained combinations, though many are tiny.

QuantityWorkingResult
Events per day60M users x 40 events2.4B
Average ingest rate2.4B / 86,400 sabout 28K / s
Peak ingest rate3.2x average at the evening peakabout 90K / s
Raw event volume2.4B x about 250 Babout 600 GB a day
One dense HLL sketch16,384 registers x 6 bits12 KB
Standard error at that size1.04 / sqrt(16,384)about 0.81%
Sketches per day2,400 segments x 48 half-hour bucketsabout 115K, mostly sparse
Sketch storage per day115K, about 3 KB each on average with sparse encodingabout 350 MB
Daily rollups for 2 years2,400 x 730 x 12 KB worst caseabout 21 GB
Exact daily set, top-level only60M ids as a compressed bitmapabout 8 to 10 MB per day
Year-long chart query365 daily sketches merged per pointabout 4 MB read, a few ms of CPU
The headline

A full year of daily unique counts for every segment fits in about 21 GB. Storing exact id lists for the same segments would run to terabytes and turn every month-long query into a large set union.

Why half-hour buckets

Days are not the same in every timezone. India is UTC+5:30, so hourly buckets cannot assemble an Indian calendar day. Thirty-minute buckets can for nearly every zone; the rare 45-minute offsets get an approximation flag.

04

API design

04

API design

GET/v1/active-users?window=dau&from=2026-09-01&to=2026-09-30&tz=Asia/Kolkata&app=main&group=platformSeries of unique counts
{ "window": "dau", "tz": "Asia/Kolkata", "method": "hll_p14", "relative_error_95": 0.016, "series": [ { "date": "2026-09-01", "ios": 11840221, "android": 27310456, "web": 4022918 } ], "complete_through": "2026-09-30T23:59:59+05:30", "exact": false // true only after the nightly exact job has closed the day }
GET/v1/active-users/unique?from=2026-09-14T10:00Z&to=2026-09-16T22:30Z&country=BROne number for a custom range
{ "unique": 3904117, "low": 3841000, "high": 3967000, "buckets_merged": 110 }
GET/v1/active-users/live?app=mainToday so far, refreshed every 15 s
{ "today": 38211904, "as_of": "2026-10-01T14:02:45Z", "lag_seconds": 22 }
POST/v1/events/batchIngest from SDKs and servers
// up to 500 events per call, gzip encoded { "events": [ { "event_id": "01J9ZK3Q7M", // client-generated, for raw-store dedupe "user_id": "u_77310", "device_id": "d_4f2e", "type": "screen_view", "client_ts": "2026-10-01T13:58:02Z", "platform": "android", "app_version": "8.14.0" } ] } // 202 Accepted once written to the queue

Two details are worth calling out. Every response says whether the number is an estimate and gives its error band, so nobody pastes a sketch estimate into a board report as if it were exact. And ingest returns 202 as soon as the event is in the queue; counting happens later and never blocks the client.

05

System overview

05

System overview

Streaming path, batch correction and queryDiagram
flowchart LR
	SDK["App SDKs and servers"] --> COL["Collectors"]
	COL --> K[["Kafka: activity, keyed by user"]]
	K --> ID["Identity resolver"]
	ID --> SP["Stream job: add to sketches"]
	SP --> HOT[("Redis: recent sketches")]
	SP --> SK[("Sketch store: bucket x segment")]
	K --> LAKE[("Raw events in the lake")]
	LAKE --> BJ["Nightly batch job"]
	BJ --> SK
	BJ --> EX[("Exact daily bitmaps")]
	Q["Query service"] --> HOT
	Q --> SK
	Q --> EX
	D["Dashboards and API users"] --> Q

Collectors accept batches, validate them and write to a Kafka topic keyed by user id. Keying by user keeps each person's events on one partition, which lets downstream jobs deduplicate and stitch identities with local state instead of a shared lookup on every event.

A stream processor (Flink, or Kafka Streams at smaller sizes) reads the topic, drops non-qualifying event types, resolves the person id, and adds that id to the HLL sketch for the right half-hour bucket and every segment the event belongs to. It keeps sketches in memory and flushes changed ones every 10 seconds to Redis for the live view and to a durable sketch store, a wide-column table keyed by (metric, segment, bucket_start).

In parallel, raw events land in the data lake. A nightly batch job rebuilds the previous day's sketches from the lake, which absorbs late events and any stream bugs, and computes exact daily sets for the top-level metrics as compressed bitmaps. The query service never counts events. It fetches the buckets a window needs, merges them, and reads one estimate off the merged sketch.

Sketches are the unit of storage

Every bucket and segment pair stores one sketch. A query for any window is "fetch N sketches, merge, estimate", so a new window shape never needs a new pipeline.

Two clocks

Buckets use the event's own timestamp, clamped to a sane range, so a phone that reconnects tomorrow still adds to yesterday. A watermark marks when a bucket is considered settled.

Rollups for speed

A daily sketch is the merge of its 48 buckets and is stored separately, so a year-long chart reads 365 sketches instead of 17,520.

06

Deep dive: why sketches beat exact sets here

06

Deep dive: why sketches beat exact sets here

The core idea

Instead of remembering who was active, remember the rarest-looking hash value you have seen. Rare patterns only show up when many distinct inputs have passed through, so their rarity tells you roughly how many there were.

How HyperLogLog counts

Hash each person id to 64 uniformly random-looking bits. In a set of random numbers, about half start with a 0, a quarter with 00, an eighth with 000. If the longest run of leading zeros you have seen is 20, you have probably seen something like 220, about a million, distinct values. One such observation is very noisy, so HyperLogLog splits the stream into many registers using the first few bits of the hash, keeps the longest run seen in each, and combines them with a harmonic mean, which keeps a single unlucky register from skewing the result.

With 214 registers of 6 bits each the sketch is 12 KB and the standard error is about 0.81%, which means 95% of estimates land within about 1.6% of the truth. Doubling the registers cuts the error by a factor of only about 1.4, so p = 14 is a common sweet spot. Small segments use a sparse encoding that stores only non-zero registers, so a country with 3,000 users costs a few hundred bytes, not 12 KB. At very low counts the algorithm switches to a different estimator (linear counting) that is accurate for small sets.

# adding one person to a sketch with m = 2^14 registers h = hash64(person_id) # same hash function forever idx = h >> (64 - 14) # top 14 bits choose a register rest = h << 14 # remaining 50 bits rank = leading_zeros(rest) + 1 # 1 to 51 reg[idx] = max(reg[idx], rank) # idempotent: adding twice changes nothing # merging two sketches, for any windows or segments merged[i] = max(a[i], b[i]) for i in range(m)

Merging is the feature that matters

Two sketches built with the same hash and register count merge by taking the larger value in each register. The result is exactly the sketch you would have got by feeding both streams into one. That single property powers the whole design:

Any window

Weekly users for the week starting Wednesday is the merge of 336 half-hour sketches. Nothing was precomputed for that window.

Any timezone

Shift which 48 buckets make up "a day" and merge. Every timezone with a 30-minute offset gets a correct calendar day.

Any parallelism

Forty stream workers each hold partial sketches for the same bucket. The store merges them on write, and order does not matter.

Free deduplication

A user counted twice by a retry or a replayed checkpoint sets the same register to the same value. Duplicates cannot inflate a sketch.

Exact sets, and where they still win

ApproachCostFits
Hash set of idsabout 30 to 60 bytes per id; a monthly set of 180M ids is many gigabytesOnly small segments or one-off analysis
Compressed bitmapneeds dense integer ids; 60M users fits in roughly 8 to 10 MBExact DAU for top-level metrics, finance reporting
HyperLogLog, p = 1412 KB dense, often far less sparse; about 0.8% standard errorEvery segment and window on dashboards
Theta sketchlarger, around 30 to 100 KB for similar error; supports intersection and differenceRetention and overlap questions

HyperLogLog has one big weakness: it supports union but not intersection. You can estimate "used both iOS and web this week" by inclusion and exclusion, but the errors of the three terms compound, and for a small overlap the answer can be meaningless. For the handful of retention metrics that need set operations, keep a theta sketch alongside. Theta sketches keep the smallest hash values seen, which makes intersection and difference straightforward at the price of more memory.

For the top-line numbers that finance signs off on, run an exact path: map each account to a dense integer once, and store each day's active users as a Roaring bitmap. A month is the OR of thirty bitmaps, which takes milliseconds. This is affordable for a few metrics, not for 2,400 segments.

Late events, identity and bots

Phones batch events while offline and send them hours later. The stream job places each event by its client timestamp, clamped to between 72 hours in the past and five minutes in the future to stop broken device clocks from creating activity in 1970 or next year. Because adding to a sketch is idempotent and order-free, a late event simply updates an older bucket, and the nightly rebuild catches anything the stream missed.

Identity needs a decision. If a person browses anonymously on the web and then signs in, counting both the device id and the account id makes them two people. The identity resolver keeps a map from device to account and counts the account once known. Merges discovered later are reconciled only in the nightly job; the live number is allowed to be slightly high, and the API labels it so.

Finally, filter automated traffic before counting. A crawler cycling through fresh device ids can add hundreds of thousands of "users" in an hour. Apply rules on request rate, user agent and server-side signals in the collectors, and keep a separate sketch of filtered traffic so analysts can see what was removed.

A rule you must never break

Pick the hash function and register count once and freeze them. Sketches built with different hashes cannot be merged, so a change silently splits history in two. If you must change, rebuild every stored sketch from raw events first.

07

Key decisions and what they cost

07

Key decisions and what they cost

Chosen
Stream for freshness, nightly batch to correct

The stream job gives a live number within a minute; the nightly job rebuilds yesterday from raw events and closes it, including exact top-line counts. You maintain two code paths that must share the same counting rules.

Fresh and correct
Alternative
Stream only, with replay for fixes

One code path, and fixes mean replaying the topic from a point in time. Works well if Kafka retains enough history and the stream job is trusted for finance numbers. Exact counts still need a batch store somewhere.

Good with a mature team
Chosen
HyperLogLog everywhere, theta for overlaps

The cheapest sketch that merges handles every dashboard. A small set of theta sketches covers the questions that need intersection.

Right tool per question
Alternative
Theta sketches for everything

One sketch type that supports all set operations. Several times the storage per bucket and slower merges, paid on every chart, though only a few queries need it.

Overpays for rare queries
Chosen
Event time with a 72-hour window

Late mobile events land in the right day, and charts stay stable after the window closes. The live number for "today" keeps climbing slightly as stragglers arrive.

Matches user intent
Alternative
Processing time

Simple, with no watermarks. But a network outage that delays events moves activity into the wrong day, which shows up as a fake dip and a fake spike.

Distorts the charts
Chosen
Partition the topic by user id

All of one person's events reach the same worker, so dedupe and identity stitching run on local state. Very heavy users or bots can create a hot partition, which the bot filter mostly handles.

Local state, cheap joins
Alternative
Round-robin partitions

Perfectly even load. Every worker sees every user, so identity lookups need a remote store on each event, and sketches from all workers must be merged per bucket.

Fine without identity stitching

08

Ways it breaks

08

Ways it breaks

🕳️
Events are dropped quietly

A collector bug or a mobile SDK release that stops sending a qualifying event type makes DAU fall 4%. Nothing errors; the chart just looks like a bad week, and someone writes a memo about it.

Reconcile input volume per app and platform against the previous week at the same hour, alert on drops beyond a threshold, and compare stream counts with the nightly batch every morning.
🔁
The stream job restarts from a checkpoint

A worker crash replays ten minutes of events. In a counter-based design that would double count.

With sketches, replay is harmless because adds are idempotent. Use checkpointed offsets anyway so the flush to the store is consistent with what was read.
🧊
Redis loses the recent sketches

The live view goes blank or shows tiny numbers after a cache restart.

Redis is only a cache. Rebuild today's live sketch by merging the durable buckets written so far, and show "rebuilding" rather than a misleading low number.
🕰️
Device clocks are wrong

A batch of phones with a broken date reports activity in the year 2000 or tomorrow, creating spikes in odd buckets.

Clamp client time to the accepted window, fall back to server receive time outside it, and count clamped events in a metric that alerts when it jumps.
🤖
A bot inflates the user count

A scripted client creates a fresh device id per request and adds 900,000 "users" to one country in an afternoon.

Filter in the collectors on rate and fingerprint signals, keep the filtered stream as its own sketch, and let the nightly job apply improved rules retroactively.
🧮
Someone changes the hash seed

A library upgrade changes the default seed. New sketches no longer line up with old ones, and monthly numbers spanning the change come out near double.

Pin the hash and seed in code, store the sketch format version with every sketch, and refuse to merge mismatched versions.

09

Talking through it in an interview

09

Talking through it in an interview

1

Start with why the obvious answer fails. Show that daily unique counts cannot be summed. That one example motivates sketches and sets you apart from candidates who reach for COUNT(DISTINCT) on a giant table.

2

Explain HyperLogLog in two sentences. Leading zeros of a hash estimate how many distinct items you have seen; many registers and a harmonic mean make it stable. Then quote the size and error.

3

Lead with mergeability. It is what makes arbitrary windows, timezones and parallel workers cheap. Without it, sketches would just be a compression trick.

4

Admit the intersection weakness. Mention theta sketches for overlap questions and exact bitmaps for finance numbers. Knowing the limits of your tool is part of the answer.

5

Get time right. Discuss event time versus arrival time, late mobile events, and why half-hour buckets support timezones like India's.

6

Point out free deduplication. Idempotent adds mean retries and replays do not inflate counts, which simplifies the failure story.

7

Label estimates. Return the error band and an exact flag. Interviewers like candidates who think about how numbers are used, not just how they are computed.

10

How the system grows

11

How the system grows

Stage 1: under 1M daily users
One Postgres table and a cron job

Store events, run COUNT(DISTINCT user_id) per day at night. Exact, simple and fine while the table fits on one machine.

Stage 2: up to about 10M
Redis HyperLogLog per day

Call PFADD per event into a daily key and use PFCOUNT across keys for weeks. Live numbers appear, queries become instant.

Stage 3: up to about 60M
Kafka, a stream job and bucketed sketches

Batched adds from a stream processor replace per-event Redis calls. Half-hour buckets, segments and a durable sketch store arrive.

Stage 4: up to a few hundred million
Nightly correction and exact top-line counts

A batch job rebuilds each day from the lake, adds exact bitmaps for finance metrics and theta sketches for retention.

Stage 5: several regions
Regional pipelines, global merges

Each region builds its own sketches and ships them to a global store, where merging regional sketches gives the worldwide count without moving raw events across borders.

Watch and read

Try next

Free to read · better with landed

Whiteboard this with landed

landed runs it as a live system design round on the whiteboard and grades your trade-offs.