Counting Unique Active Users at Scale

A system design interview guide to counting distinct identifiers — daily and monthly active users, unique ad reach — across billions of events, when keeping the exact set of every id in memory is no longer affordable.

Student edition · start with the guarantee

Estimate distinct active users cheaply and understand why merging sketches is different from adding daily counts.

Correctness contract: Compatible sketch union reproduces the sketch of the combined ID set; the resulting cardinality estimate remains approximate and cannot answer individual membership.

Basic HTTP, databases, and programming are assumed. Specialized terms are explained below. Named products motivate the problem; this is an educational design, not a claim about their current internals. Capacity numbers are illustrative decimal payload estimates, not benchmarks. Add indexes, protocol overhead, replicas, headroom, and failure capacity before provisioning.

"How many distinct users did we see today?" sounds like a one-line question, and at small scale it is: drop every user id into a set and read off its size. The trouble starts when "today" means tens of billions of events and the answer has to cover dozens of audiences, time windows, and breakdowns at once. A set that remembers every id it has ever seen grows in lockstep with the number of distinct ids, and at a billion ids that set is gigabytes — per metric, per window. The interesting design problem is not the counting itself but how to count distinctly without paying for memory that scales with the answer. This guide builds up from the naive set to probabilistic cardinality estimation, the property that makes it composable across shards and time, and the architecture that ties it together.

Contents

  1. The Distinct-Count Problem
  2. Requirements and Clarifying Questions
  3. Back-of-the-Envelope Estimation
  4. Why an Exact Set Blows Up
  5. HyperLogLog
  6. Mergeability
  7. Accuracy and Error
  8. Exact vs Approximate
  9. Real Tools
  10. API and Data Model
  11. Architecture
  12. Failure Modes
  13. Tradeoffs and Alternatives
  14. A 45-Minute Interview Plan
  15. Common Mistakes
  16. Summary
  17. Check Yourself
  18. Key Terms
  19. Concepts to understand first
  20. Worked example
  21. Runnable Python model

Concepts to understand first

Each definition includes a concrete example and the condition under which the mechanism is useful. You can read this guide without opening another guide.

HyperLogLog

A fixed-size sketch for estimating distinct values. Hashed IDs select registers; each register retains the largest observed leading-zero rank. A bias-corrected harmonic estimate turns those extrema into a cardinality estimate.

Example. Two compatible sketches merge by element-wise maximum. That produces exactly the sketch of the union, while its final count remains approximate. At 16,384 packed six-bit registers, the relative standard error is about 0.81%.

Limit. Error is statistical, not a hard per-dataset bound. You cannot remove an individual ID or ask membership. Python lists do not actually pack registers into 12 KiB.

Cardinality

The number of distinct identities a store must track. A metric series is its metric name plus the full label combination. The product of label-value counts is an upper bound when not every combination occurs.

Example. 100 hosts × 20 routes × 5 status codes can produce up to 10,000 series. Adding a unique request_id can make every request create a new series.

Limit. Hashing every ID to a distinct digest does not reduce cardinality or guarantee anonymity. Use bounded categories, explicit limits, or intentional fixed-bucket aggregation.

Event time and watermarks

Event time is when the producer says an event happened; processing time is when a consumer handles it. A watermark is the processor's policy-driven estimate of event-time progress, used to emit and eventually clean up window state.

Example. An event for 12:00 arrives at 12:07. A 5-minute allowed-lateness policy may update its window, divert it to correction, or reject it according to the API contract.

Limit. Watermarks are not proof all earlier events arrived. Idle inputs, skew, and unbounded delay require explicit policies; later corrections must propagate to rollups and reports.

Offsets and checkpoints

An offset is a position in one log partition. A checkpoint is a recoverable, consistent record of input positions and processing state. On restart, state must correspond exactly to the input that is considered processed.

Example. If a batch increments a total and crashes before saving its offset, replay increments twice. Commit the total, dedup state, and partition position together, or use a compatible transactional/idempotent sink.

Limit. Saving an offset after a write avoids that specific loss window but can still duplicate; framework checkpoints alone do not make arbitrary external effects transactional.

Deduplication

Recognizing multiple deliveries of one logical event using a stable identity. The same logical event must keep its ID across retries, and claiming the ID must be atomic with the counted or stored effect.

Example. Events e1,e2,e1 should yield a count of 2. An exact seen set plus the increment in one transaction makes the second e1 a no-op.

Limit. A TTL limits the dedup horizon; retries after expiry may count again. Approximate filters may drop new events, and changing the ID or routing a retry to another local seen set defeats dedup.

1. The Distinct-Count Problem

The core operation is cardinality: given a stream of identifiers with arbitrary repeats, report how many different ones appeared. This is fundamentally harder than a plain count, because a plain count just needs a running integer — you never have to remember which ids you have already seen. Distinct counting does: to avoid counting the same user twice, something has to recognise that this id has shown up before.

The metrics that ride on this operation are everywhere in a consumer product:

Two things make this expensive at scale. First, the stream is enormous and unbounded, so any approach that touches every event has to be cheap per event. Second, you rarely want a single global number; you want the count for many overlapping audiences and windows, and each one is its own distinct-count problem. A solution that costs gigabytes for one metric does not survive being multiplied by hundreds of slices.

The reframing for an interview: you are not asked to count, you are asked to count distinctly — and the cost of distinctness is memory that, done naively, scales with the answer. Every technique below is about breaking that link so memory stays flat while the count grows.

2. Requirements and Clarifying Questions

"Count unique users" sounds like one number. In practice it is thousands of numbers, and the questions below decide whether an exact design is even possible.

FunctionalNon-functional
Ingest activity events with a user id and dimensions.Handle about 870,000 events per second at peak.
Return distinct users for any metric, slice, and time range.Relative error about 1% or better.
Combine counts across shards and windows without double counting.Query latency under 100 ms; storage small enough to keep years of history.

3. Back-of-the-Envelope Estimation

Assume 500 million daily active users, each producing about 50 events a day, 1,000 slices, and a 3× peak.

StepWorkingResult
Events per day500 M users × 50 events25 B /day
Events per second (average)25 B ÷ 86,400 s≈ 289,000 /s
Events per second (peak)289,000 × 3≈ 868,000 /s
Exact set, one day, global500 M ids × 8 bytes (ids only, no overhead)4 GB
Exact sets, 1,000 slices1,000 × 10 M avg users × 8 B≈ 80 GB /day
HLL sketches, 1,000 slices1,000 × 12 KB (p = 14)≈ 12 MB /day
Rolling 30-day MAU readmerge 30 daily sketches × 12 KB≈ 360 KB
Two years of daily sketches12 MB × 730 days≈ 9 GB

The exact design needs tens of gigabytes per day before hash-table overhead (often 2–4× more), and a 30-day MAU would need a 30-way set union. The sketch design keeps two years of history in the space of one day of exact sets.

4. Why an Exact Set Blows Up

The exact answer is conceptually trivial: maintain a hash set, insert every id, and the cardinality is the set's size. It is also correct to the last unit. The problem is purely one of resources, and it shows up the moment the number of distinct ids gets large.

Each distinct id you insert occupies space in the set — the id itself plus the hash-table overhead around it. That space is paid per distinct id, so the set's footprint grows linearly with cardinality. At a million ids it is a few tens of megabytes and nobody notices. At a billion distinct ids it is many gigabytes for a single counter, and you do not have one counter — you have one per metric, per window, per breakdown. The memory bill is the product of all of those, and it grows without bound as your audience and the number of slices grow.

Exact set memory grows with cardinality while the sketch stays fixed-size
An exact set stores every distinct id, so its memory climbs with the cardinality — from manageable at a million ids to many gigabytes at a billion. A probabilistic sketch occupies the same small, fixed footprint (about 12 KB for 16,384 registers) no matter how many distinct ids it has summarised.

You could push the exact set onto disk or into a distributed store, but that trades a memory problem for a throughput and latency problem: every event now involves a lookup and possible write against external storage, and the storage itself still grows with cardinality. For a high-rate stream feeding many metrics, the exact set is the wrong tool — not because it is incorrect, but because its cost is tied to exactly the quantity you are trying to measure.

The key insight that unlocks a cheaper approach is that most consumers of these numbers do not need the exact figure. Whether daily actives are 41,802,119 or 41,800,000 changes no decision; a count that is within a percent or two, computed in a fixed 12 KB, is far more valuable than an exact one that costs gigabytes. That tolerance is what probabilistic cardinality estimation trades on.

5. HyperLogLog

HyperLogLog (HLL) is the standard answer to large-scale distinct counting. It estimates cardinality from a tiny, fixed-size summary — a few kilobytes that can stand in for billions of distinct ids — by exploiting a statistical property of hashed values rather than remembering the ids themselves.

The intuition is a coin-flip argument. If you hash an id into a uniformly random bit string, then the number of leading zeros in that string behaves like flipping a coin and counting how many heads you get in a row before the first tail. Seeing a run of k leading zeros is roughly a one-in-2k event. So if, across all the ids you have hashed, the longest run of leading zeros you have ever observed is k, that is evidence you have probably seen on the order of 2k distinct values. Crucially, repeats do not move this estimate: hashing the same id again produces the same bit string and the same leading-zero count, so duplicates are silently absorbed — which is exactly the distinctness property we need.

A single longest-run observation is a wildly noisy estimator, so HLL splits the work across many independent registers (also called buckets) and averages them. Each id is hashed once; the first few bits of the hash pick which register the id belongs to, and the remaining bits supply the leading-zero count for that register. Each register keeps only the maximum leading-zero count it has ever seen — a small integer, a handful of bits. The final estimate combines all the registers using a harmonic mean (with a bias correction), which damps the influence of any one unusually long run and gives a stable answer.

Intuition: coin flips

If a friend tells you the longest run of heads they flipped in a row was 10, you would guess they flipped about 210 ≈ 1,000 times. One friend is a noisy witness, so HLL asks 16,384 friends (registers) and combines their answers.

An id is hashed, split into index bits and value bits, and recorded in a register
Each id is hashed once. The first p bits select one of 2^p registers; the remaining bits give the position of the first set bit, i.e. one plus the leading-zero count. The register keeps the maximum such value it has seen. The cardinality estimate is a bias-corrected harmonic mean across all registers — more leading zeros seen in a bucket implies more distinct ids likely hashed into it.
# add an id to the sketch
function hll_add(registers, id):
  h    = hash64(id)                     # uniform 64-bit value
  idx  = h >> (64 - p)                  # first p bits -> register index
  rest = h & ((1 << (64 - p)) - 1)      # remaining bits
  rank = leading_zeros(rest, width=64-p) + 1  # zeros counted inside the 64-p bit field
  registers[idx] = max(registers[idx], rank)   # keep the longest run

# estimate the cardinality from the registers
function hll_estimate(registers):
  m   = len(registers)                  # 2^p buckets
  raw = alpha(m) * m*m / sum(2 ** -r for r in registers)  # harmonic mean
  return bias_correct(raw, registers)   # small/large-range fixes

The headline result is the memory story. With 2,048 registers (p = 11) of 6 bits each — 1.5 KB total — HLL estimates cardinalities up into the billions with a standard error of about 2.3%. Redis uses 16,384 registers (12 KB) for about 0.81%. That footprint is fixed: it does not grow as the count grows, which is precisely the link that the exact set could not break.

Try it: a 30-line HyperLogLog

This runnable Python sketch uses p = 14 like Redis. Set A adds every id twice to show that duplicates change nothing. B overlaps A by half, and the union is the element-wise max.

import hashlib
import math

P = 14                      # 2^14 = 16384 registers (what Redis uses)
M = 1 << P

def h64(x):
    return int.from_bytes(hashlib.blake2b(str(x).encode(), digest_size=8).digest(), "big")

def add(reg, item):
    h = h64(item)
    idx = h >> (64 - P)                 # first P bits pick the register
    rest = h & ((1 << (64 - P)) - 1)    # the other 64-P bits
    rank = (64 - P) - rest.bit_length() + 1   # leading zeros in 64-P bits, plus 1
    reg[idx] = max(reg[idx], rank)

def estimate(reg):
    alpha = 0.7213 / (1 + 1.079 / M)
    raw = alpha * M * M / sum(2.0 ** -r for r in reg)
    zeros = reg.count(0)
    if raw <= 2.5 * M and zeros:        # small range: linear counting
        return M * math.log(M / zeros)
    return raw

a, b = [0] * M, [0] * M
for i in range(1_000_000):
    add(a, f"user{i}")
    add(a, f"user{i}")                  # duplicates change nothing
for i in range(500_000, 1_500_000):
    add(b, f"user{i}")
union = [max(x, y) for x, y in zip(a, b)]
for name, reg, true in [("A", a, 1_000_000), ("B", b, 1_000_000), ("A∪B", union, 1_500_000)]:
    est = estimate(reg)
    print(f"{name:4} true={true:>9,} est={est:>11,.0f} err={100*(est-true)/true:+.2f}%")
print(f"memory: {M} registers x 6 bits = {M*6//8:,} bytes")

Output:

A    true=1,000,000 est=    995,579 err=-0.44%
B    true=1,000,000 est=    983,043 err=-1.70%
A∪B  true=1,500,000 est=  1,476,860 err=-1.54%
memory: 16384 registers x 6 bits = 12,288 bytes

These are observed seeded errors, not an HLL hard bound. The 0.81% asymptotic relative standard error at p=14 permits occasional larger misses; normal-like confidence approximations are not a guarantee for each dataset.

The whole trick is that HLL never stores ids. It stores a fixed array of small "longest run of zeros" counters, and infers cardinality from how extreme those runs are. Memory is a function of the chosen precision, not of how many distinct ids you have actually seen.

6. Mergeability

The property that makes HLL a system-design building block rather than just a clever counter is that two sketches can be merged into one that represents the union of their inputs — exactly and losslessly, with no double counting and no need to revisit the original ids.

Because each register simply holds the maximum leading-zero run seen for the ids that hashed into it, merging two sketches is element-wise maximum: for each register position, take the larger of the two values. The resulting sketch is identical to one you would have built by feeding it every id from both inputs. This works only when the sketches share the same hash function and the same number of registers, which is why those parameters are fixed and standardised up front.

Sketch A31420521Sketch B23124116max(A, B)33424526
Merging is element-wise max. Each coloured cell in A or B is the value that wins. The result is exactly the sketch you would get by adding every id from both inputs to one sketch.
# union of two sketches = element-wise max of registers
function hll_merge(a, b):
  return [max(a[i], b[i]) for i in range(len(a))]

# DAU over a week = merge of seven daily sketches, then estimate
weekly = reduce(hll_merge, [daily[d] for d in last_7_days])
dau_week = hll_estimate(weekly)

Mergeability buys two things that the architecture leans on heavily:

This is the property that an exact set cannot match cheaply: unioning two large sets means materialising both and deduplicating across them, whereas merging two sketches is a fixed-size, order-independent maximum. It is also what makes distinct counting feel like ordinary aggregation — the sketch behaves like a number you can add up, except the "addition" is a set union.

7. Accuracy and Error

HLL is an estimator, and its error is a tunable parameter rather than an accident. The single knob is the number of registers, set by the precision p (the sketch has 2p registers). More registers mean more independent observations to average, which means a tighter estimate — at the cost of a larger, though still tiny, sketch.

The relationship is clean: with m = 2p registers, the standard error is about 1.04 / √m. Doubling the registers shrinks the error by about thirty percent. The commonly cited operating point — around 1.5 KB and roughly two percent error — is just one choice on this curve; you can spend a few more kilobytes to push the error well under a percent if a metric warrants it.

ConcernWhat it means in practice
Tunable errorPick precision p to trade memory for accuracy. Standard error ≈ 1.04/√(2^p); more registers, tighter estimate, slightly bigger sketch.
Relative, not absoluteThe error is a percentage of the true count, so the absolute miss grows with cardinality — fine for large numbers, less ideal for tiny ones.
Small-range behaviourAt very low cardinalities the raw estimate is biased; implementations switch to a more exact mode (e.g. linear counting) below a threshold.
No per-id queriesA sketch answers "how many distinct?" but cannot tell you whether a specific id is in the set — it forgets the ids by design.
Weak at intersectionsMerge gives unions. An intersection must be estimated as |A| + |B| − |A ∪ B|, and the errors of all three add up (see the box below).
Precision pRegisters mMemory (6-bit registers)Standard error 1.04/√m
112,0481.5 KB2.30%
124,0963 KB1.63%
14 (Redis)16,38412 KB0.81%
1665,53648 KB0.41%

"Standard error" is one standard deviation. About two thirds of estimates land within it, and about 95% land within twice it, so an occasional miss of 2× the quoted figure is normal.

Intersections break HLL

Say campaigns A and B each reach 10 million users, and 100,000 people saw both. The union is 19.9 million. At p = 14 its estimate has a standard error of 0.81% × 19.9M ≈ 161,000 — bigger than the 100,000 overlap you are trying to find. Inclusion-exclusion (|A| + |B| − |A∪B|) can then give a negative number. For overlap questions use an exact join, a larger sketch, or a sketch built for set operations such as Theta sketches (Apache DataSketches).

The practical consequence is that HLL is excellent for the large, fuzzy numbers that drive product and business decisions, and a poor fit when you need exactness or per-element membership. Knowing which regime you are in is the judgement call.

8. Exact vs Approximate

Choosing between an exact set and a probabilistic sketch is a question about requirements, not cleverness. The deciding factors are how large the cardinality gets, how much error the consumer can tolerate, and whether anything downstream needs to ask about individual ids.

A common hybrid is to keep exact counts for small or high-stakes slices and switch to HLL once a slice's cardinality crosses a threshold where the exact set's memory stops being free. Some systems even start a counter as an exact set and promote it to a sketch automatically when it grows past a configured size, getting precision for small audiences and bounded memory for large ones.

9. Real Tools

Redis exposes PFADD, PFCOUNT, and PFMERGE. Commands take explicit key names; PFMERGE dst shard* does not expand a wildcard in Redis. Discover keys with a safe application-maintained list or bounded SCAN, then pass explicit sources. Redis Cluster requires the relevant multi-key command's keys to share a slot (use a deliberate hash tag), or merge exported sketches in an application/backend that supports cross-shard aggregation.

PFADD {dau}:day:shard1 u1 u2 u3
PFADD {dau}:day:shard2 u3 u4
PFMERGE {dau}:day {dau}:day:shard1 {dau}:day:shard2
PFCOUNT {dau}:day

Trino's approx_distinct and Spark's approx_count_distinct expose approximate cardinality with configurable error. Vendor approximate-distinct algorithms vary: do not assume every warehouse uses identical HLL parameters or supports byte-compatible merges. Persist algorithm, precision, hash/normalization version, and window metadata with stored sketches.

10. API and Data Model

# write: activity events (batched, via Kafka)
{ "user_id": "u_8421", "ts": "2026-09-25T14:03:07Z", "country": "BR", "platform": "ios" }

# read: distinct users for a metric, slice and range
GET /v1/uniques?metric=active&slice=country:BR&from=2026-09-01&to=2026-09-30
  → { "estimate": 41802119, "std_error_pct": 0.81, "sketches_merged": 30 }
TableKeyValueNotes
sketch_hour(metric, slice, hour)HLL bytes, p = 14Kept a few days for "last N hours" queries.
sketch_day(metric, slice, day)HLL bytes, p = 14Whole retained windows can merge into a larger range. A partial-hour/day boundary requires finer retained sketches or raw/exact boundary handling; one whole-day sketch cannot isolate part of that day.
count_day(metric, slice, day)estimate (int)Optional cache of the single-day number for fast charts.
Store the sketch, not only the number

You cannot add two daily DAU numbers to get a two-day count, because people active on both days would be counted twice. You can merge two daily sketches. Store the sketch bytes so every future range is still answerable.

11. Architecture

Putting the pieces together, a unique-user counting service is a pipeline that turns a firehose of events into a small, mergeable sketch per metric and window, and serves estimates by reading or merging those sketches. The shape follows directly from the two properties established above: a sketch costs fixed memory, and sketches merge.

Ingest, route by shard, per-shard HLL, periodic merge, store sketch, query
Events flow in carrying an id; each is routed to a shard by hashing the id, and the shard updates its local HLL sketch. A periodic job merges per-shard sketches into a single sketch per metric and time window, which is persisted. Reads estimate cardinality from a stored sketch (or merge several windows on the fly), e.g. via PFCOUNT.

Tracing the flow:

# ingest path: cheap per-event update on the owning shard
function on_event(evt):
  shard = pick_shard(evt.user_id)              # hash(id) % N
  for window in windows_for(evt.timestamp):    # e.g. this hour, this day
    sketch = shard.sketch(evt.metric, window)
    hll_add(sketch.registers, evt.user_id)     # hash + max, O(1)

# periodic merge: collapse per-shard sketches, then persist
function consolidate(metric, window):
  merged = reduce(hll_merge, [s.sketch(metric, window) for s in shards])
  store.put(key(metric, window), merged)       # keep the sketch itself

# read path: estimate, merging windows on the fly if needed
function distinct_count(metric, windows):
  sketches = [store.get(key(metric, w)) for w in windows]
  return hll_estimate(reduce(hll_merge, sketches))

12. Failure Modes

FailureWhat happensMitigation
Shard crashes before its sketch is persistedThat shard's ids for the window are missing.Checkpoint sketches with Kafka offsets and replay. Re-adding ids is harmless because HLL is idempotent.
Duplicate delivery (at-least-once)Same id added twice.Nothing to do: max() makes repeats free. This is a big operational win of HLL.
Two sketches built with different p or hashMerge is meaningless.Store p and hash version in the sketch header; refuse to merge mismatches.
Late events for a closed dayDay undercounts.Keep the day sketch writable for a grace period; adds are idempotent so re-opening is safe.
Bot traffic with random idsCardinality inflates.Filter invalid traffic before the sketch; a sketch cannot remove an id once added.

13. Tradeoffs and Alternatives

RepresentationUseful operationsLimits
Exact setMembership, union, intersection, deletion.Memory depends on identities and runtime/container overhead.
Dense bitmapExact set operations over a bounded integer universe.Universe bits may be huge; compressed formats have data-dependent overhead.
HyperLogLogApproximate distinct count; exact compatible sketch union.No individual membership/deletion. Statistical error; sketch precision/encoding must match.
Theta/set-operation sketchesApproximate union/intersection/difference with documented accuracy.Accuracy and memory depend on parameters and result size.

The 12 KiB HLL figure is packed six-bit register payload, not Python list RAM. Standard error is not a hard maximum, nor an exact 95% guarantee for every dataset; normal-like confidence statements are asymptotic approximations. Larger time ranges are answerable only at the granularity of retained sketches; a partial-day interval cannot be reconstructed from a whole-day sketch alone.

Worked example: overlapping daily audiences

Day A contains {u1,u2,u3}; day B contains {u3,u4}.

Worked example, in orderCount days separately: Exact DAUs are 3 and 2. Adding the numbers gives 5, double-counting u3.. Merge identities or sketches: The exact union is {u1,u2,u3,u4}, size 4. Compatible HLL register-wise max creates the same sketch as ingesting that union.. Handle duplicate deliveries: Adding u3 repeatedly does not change its maximum register rank. This is sketch idempotency, not proof ingestion never loses an event.. Ask about intersections: An HLL union does not recover the individual IDs. Inclusion–exclusion combines noisy estimates and can be useless for a small overlap; use exact sets or a sketch designed for set operations.1Count days separately2Merge identities or sketches3Handle duplicate deliveries4Ask about intersections
Read top to bottom. The table below explains the state and guarantee at each step.
StepState, calculation, and reasoning
1. Count days separatelyExact DAUs are 3 and 2. Adding the numbers gives 5, double-counting u3.
2. Merge identities or sketchesThe exact union is {u1,u2,u3,u4}, size 4. Compatible HLL register-wise max creates the same sketch as ingesting that union.
3. Handle duplicate deliveriesAdding u3 repeatedly does not change its maximum register rank. This is sketch idempotency, not proof ingestion never loses an event.
4. Ask about intersectionsAn HLL union does not recover the individual IDs. Inclusion–exclusion combines noisy estimates and can be useless for a small overlap; use exact sets or a sketch designed for set operations.
Check your understanding: Does 'lossless merge' mean an exact user count?

No. It means no extra information loss beyond the sketch representation: the merged registers are identical to one union sketch. Its estimated count still has statistical error.

Run a small, testable model

Download concepts.py. It uses only the Python 3.10+ standard library. From the directory where you saved it, run:

python3 concepts.py hll
python3 concepts.py test

The model uses packed one-byte registers for a transparent HLL implementation and reports observed error separately from expected standard error.

The program contains deterministic assertions and failure traces. It is a learning model, not a production service: it does not implement network consensus, distributed durability, authentication, or third-party delivery. Read its contracts and try changing an input before extending it.

Primary references for these concepts

14. A 45-Minute Interview Plan

MinutesWhat to doWhat to say or draw
0–5ClarifyWindows, slices, error tolerance, freshness, membership.
5–10Estimate~870K events/s; exact sets are tens of GB/day; sketches are MB/day.
10–15Naive designExact set per slice per day. Show why it fails for MAU and many slices.
15–30HLL deep diveHash, register index, leading zeros, harmonic mean, 1.04/√m, merge by max.
30–40ArchitectureKafka → per-shard sketches → merge → sketch store by (metric, slice, window) → merge on read.
40–45LimitsIntersections, small counts, billing stays exact, bot filtering, failure handling.

15. Common Mistakes

16. Summary

Counting unique users at scale is the problem of measuring cardinality without paying memory proportional to the answer. The design is a small set of decisions that reinforce one another:

ConcernMechanism
What are we actually computing?Cardinality — the number of distinct ids in a stream — for metrics like DAU, MAU, and unique reach.
Why not just use a set?An exact set costs memory proportional to the distinct count — gigabytes at a billion ids, multiplied across every metric and window.
How do we count distinctly in fixed memory?HyperLogLog: hash each id, track the longest leading-zero run per register, estimate via a bias-corrected harmonic mean.
How do we roll up across shards and time?Mergeability — the union of two sketches is the element-wise max of their registers, exact and order-independent.
How accurate is it?Tunable: standard error ≈ 1.04/√(registers); 1.5 KB gives about 2.3%, 12 KB about 0.81%.
When should we stay exact?Small cardinalities, audited or billed numbers, or when per-id membership is required.
What do we build on?Redis PFADD/PFCOUNT/PFMERGE, Presto approx_distinct, Spark approx_count_distinct — HLL is built in.
How is it wired together?Event stream → per-shard sketch → periodic merge → stored sketch per metric and window → estimate on read.
The recurring theme: trade a sliver of accuracy for a fixed, tiny memory footprint and the ability to compose. Once a distinct count is a mergeable kilobyte instead of a growing set, counting unique users becomes ordinary aggregation that scales with your audience instead of fighting it.

17. Check Yourself

  1. You need about 1% standard error. What is the smallest standard precision p, and how much memory does it use?
    Answer

    1.04/√m ≤ 0.01 needs m ≥ 10,816, so p = 14 (m = 16,384). That is 12 KB with 6-bit registers and gives 0.81%. p = 13 gives 1.15%, just over the target.

  2. Monday's DAU is 40M and Tuesday's is 42M. Can you say the two-day unique count is 82M?
    Answer

    No. Users active on both days would be counted twice. The true answer is between 42M and 82M. Merge the two daily sketches and estimate the union.

  3. Why does adding the same user id a million times not change an HLL sketch?
    Answer

    The same id always hashes to the same register and the same rank. max(r, r) = r, so the register is unchanged after the first add.

  4. Doubling the number of registers reduces the error by how much?
    Answer

    By a factor of √2, so the error drops by about 29%. To halve the error you need 4× the registers.

  5. Campaigns A and B each reach 10M users with 100K overlap. Why is HLL a bad way to measure the overlap?
    Answer

    The union (19.9M) has a standard error of about 161K at p = 14, which is larger than the 100K overlap. Inclusion-exclusion inherits that error, so the answer is mostly noise.

  6. Your sketches are written by consumers that can replay Kafka after a crash. Do you need exactly-once processing?
    Answer

    No. HLL adds are idempotent, so replaying the same events leaves the sketch unchanged. At-least-once delivery is enough.

18. Key Terms

Cardinality
The number of distinct identities a store must track. A metric series is its metric name plus the full label combination. The product of label-value counts is an upper bound when not every combination occurs. Example and limits above.
DAU / MAU
Daily / monthly active users: distinct users active in a day / 30 days.
Register
One small counter in an HLL sketch holding the max rank seen.
Precision p
Number of hash bits used to pick a register; m = 2p.
Rank
Position of the first 1-bit in the remaining hash bits (leading zeros + 1).
Harmonic mean
An average that damps outliers: n ÷ ∑(1/x).
Standard error
One standard deviation of the estimate, as a fraction of the true value.
Linear counting
Small-range estimator using the fraction of empty registers.
Merge
Element-wise max of two sketches; gives the sketch of the union.
Theta sketch
A sketch family that supports union, intersection and difference.

Further reading


⌂