Infrastructure
Design Count Unique Active Users
01
The problem in one page
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.
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
Requirements
- 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.
- 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.
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
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.
| Quantity | Working | Result |
|---|---|---|
| Events per day | 60M users x 40 events | 2.4B |
| Average ingest rate | 2.4B / 86,400 s | about 28K / s |
| Peak ingest rate | 3.2x average at the evening peak | about 90K / s |
| Raw event volume | 2.4B x about 250 B | about 600 GB a day |
| One dense HLL sketch | 16,384 registers x 6 bits | 12 KB |
| Standard error at that size | 1.04 / sqrt(16,384) | about 0.81% |
| Sketches per day | 2,400 segments x 48 half-hour buckets | about 115K, mostly sparse |
| Sketch storage per day | 115K, about 3 KB each on average with sparse encoding | about 350 MB |
| Daily rollups for 2 years | 2,400 x 730 x 12 KB worst case | about 21 GB |
| Exact daily set, top-level only | 60M ids as a compressed bitmap | about 8 to 10 MB per day |
| Year-long chart query | 365 daily sketches merged per point | about 4 MB read, a few ms of CPU |
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.
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
API design
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
System overview
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"] --> QCollectors 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.
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.
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.
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
Deep dive: why sketches beat exact sets here
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.
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:
Weekly users for the week starting Wednesday is the merge of 336 half-hour sketches. Nothing was precomputed for that window.
Shift which 48 buckets make up "a day" and merge. Every timezone with a 30-minute offset gets a correct calendar day.
Forty stream workers each hold partial sketches for the same bucket. The store merges them on write, and order does not matter.
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
| Approach | Cost | Fits |
|---|---|---|
| Hash set of ids | about 30 to 60 bytes per id; a monthly set of 180M ids is many gigabytes | Only small segments or one-off analysis |
| Compressed bitmap | needs dense integer ids; 60M users fits in roughly 8 to 10 MB | Exact DAU for top-level metrics, finance reporting |
| HyperLogLog, p = 14 | 12 KB dense, often far less sparse; about 0.8% standard error | Every segment and window on dashboards |
| Theta sketch | larger, around 30 to 100 KB for similar error; supports intersection and difference | Retention 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.
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
Key decisions and what they cost
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 correctOne 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 teamThe cheapest sketch that merges handles every dashboard. A small set of theta sketches covers the questions that need intersection.
Right tool per questionOne 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 queriesLate 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 intentSimple, 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 chartsAll 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 joinsPerfectly 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 stitching08
Ways it breaks
Ways it breaks
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.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.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.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 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.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
Talking through it in an interview
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.
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.
Lead with mergeability. It is what makes arbitrary windows, timezones and parallel workers cheap. Without it, sketches would just be a compression trick.
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.
Get time right. Discuss event time versus arrival time, late mobile events, and why half-hour buckets support timezones like India's.
Point out free deduplication. Idempotent adds mean retries and replays do not inflate counts, which simplifies the failure story.
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
How the system grows
Store events, run COUNT(DISTINCT user_id) per day at night. Exact, simple and fine while the table fits on one machine.
Call PFADD per event into a daily key and use PFCOUNT across keys for weeks. Live numbers appear, queries become instant.
Batched adds from a stream processor replace per-event Redis calls. Half-hour buckets, segments and a durable sketch store arrive.
A batch job rebuilds each day from the lake, adds exact bitmaps for finance metrics and theta sketches for retention.
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
References and further study
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.
