System Design ·

Bloom Filters, Count-Min Sketch, and HyperLogLog: Probabilistic Data Structures for Production Systems

When exact answers cost too much memory or too many round trips, probabilistic data structures trade a small, tunable error rate for massive space and speed savings. This article covers how Bloom filters, Count-Min Sketch, and HyperLogLog work internally, how to implement them in TypeScript, and how to tune them for real workloads.

Bloom Filters, Count-Min Sketch, and HyperLogLog: Probabilistic Data Structures for Production Systems

Every system design decision eventually runs into a wall where the exact answer is too expensive. You want to know whether a user has already seen a notification, but storing every seen ID at scale costs hundreds of gigabytes. You want to count unique visitors per day, but keeping a full set of visitor IDs in memory means your analytics layer consumes as much RAM as your database.

Probabilistic data structures solve this by trading a bounded, configurable error rate for dramatic reductions in memory and lookup latency. The three covered here are the most practically useful: Bloom filters for membership testing, Count-Min Sketch for frequency estimation, and HyperLogLog for cardinality estimation. Each has well-understood mathematical error bounds. Knowing how they work internally is what lets you tune them correctly and reason about failure modes.

Bloom Filters

How It Works

A Bloom filter is a bit array of size m, initially all zeros. You choose k hash functions, each mapping an input to a position in [0, m).

Insert: For element x, compute h1(x) through hk(x) and set those k bits to 1.

Query: Check those same k positions. If all are 1, the element is “probably in” the set. If any is 0, it is definitely not in the set.

False positives occur when all k positions happen to be set by prior insertions of other elements. False negatives cannot occur: insertion always sets bits; queries check those exact bits.

class BloomFilter {
  private readonly bits: Uint8Array;
  private readonly m: number;
  private readonly k: number;

  constructor(expectedItems: number, falsePositiveRate: number) {
    // Optimal m: -(n * ln(p)) / (ln(2)^2)
    this.m = Math.ceil(
      -(expectedItems * Math.log(falsePositiveRate)) / (Math.LN2 * Math.LN2)
    );
    // Optimal k: (m/n) * ln(2)
    this.k = Math.max(1, Math.round((this.m / expectedItems) * Math.LN2));
    this.bits = new Uint8Array(Math.ceil(this.m / 8));
  }

  add(item: string): void {
    for (let i = 0; i < this.k; i++) {
      const pos = this.hash(item, i) % this.m;
      this.bits[Math.floor(pos / 8)] |= 1 << pos % 8;
    }
  }

  has(item: string): boolean {
    for (let i = 0; i < this.k; i++) {
      const pos = this.hash(item, i) % this.m;
      if ((this.bits[Math.floor(pos / 8)] & (1 << pos % 8)) === 0) return false;
    }
    return true;
  }

  // Double hashing avoids needing k independent hash functions
  private hash(item: string, seed: number): number {
    const h1 = this.fnv1a(item);
    const h2 = this.fnv1a(item + "\0" + seed);
    return Math.abs(h1 + seed * h2);
  }

  private fnv1a(input: string): number {
    let hash = 2166136261;
    for (let i = 0; i < input.length; i++) {
      hash ^= input.charCodeAt(i);
      hash = (hash * 16777619) >>> 0;
    }
    return hash;
  }
}

Tuning

Given expected item count n and target false positive rate p:

m = -(n * ln(p)) / (ln(2))^2
k = (m / n) * ln(2)

For 1 million items at 1% false positive rate: m is roughly 9.6 million bits (1.2 MB) and k is 7. Dropping to 0.1% costs about 50% more memory. Halving the false positive rate means 50% more memory, roughly.

The most common production mistake: sizing for n and ignoring growth. At 2x expected capacity, expect roughly 4x the configured false positive rate. Either cap inserts and rotate filters on a schedule, or monitor the insertion count and alert before degradation.

Production Use Cases

Notification deduplication: Check a per-user Bloom filter before sending a push notification. If has(notificationId) returns true, skip. Fall back to the database only on a positive result, then add to the filter after a confirmed send. A 1% false positive rate means 1% of notifications get incorrectly skipped. Tune to your business tolerance.

Cache pre-check: A Bloom filter of all cached keys lets you skip a cache round trip on guaranteed misses. A negative result means certain miss; go directly to the database. A positive result still requires the cache lookup due to false positives.

Message pipeline deduplication: At-least-once delivery produces duplicates. A Bloom filter at the consumer side handles the common case cheaply, falling back to an exact check only on positives.

Count-Min Sketch

How It Works

A Count-Min Sketch is a 2D array of counters with d rows and w columns. Each row has its own hash function mapping elements to [0, w).

Update: For element x with count c, for each row i, increment sketch[i][hash_i(x)] by c.

Query: Return min(sketch[i][hash_i(x)]) over all rows. Collisions can only inflate counts, never deflate them, so the minimum across rows is the best estimate.

class CountMinSketch {
  private readonly table: number[][];
  private readonly d: number;
  private readonly w: number;
  private readonly seeds: number[];

  constructor(epsilon: number, delta: number) {
    // w = ceil(e / epsilon), d = ceil(ln(1 / delta))
    this.w = Math.ceil(Math.E / epsilon);
    this.d = Math.ceil(Math.log(1 / delta));
    this.seeds = Array.from({ length: this.d }, (_, i) => i * 2654435761);
    this.table = Array.from({ length: this.d }, () => new Array(this.w).fill(0));
  }

  update(item: string, count = 1): void {
    for (let i = 0; i < this.d; i++) {
      const col = this.hash(item, this.seeds[i]) % this.w;
      this.table[i][col] += count;
    }
  }

  estimate(item: string): number {
    let min = Infinity;
    for (let i = 0; i < this.d; i++) {
      const col = this.hash(item, this.seeds[i]) % this.w;
      min = Math.min(min, this.table[i][col]);
    }
    return min;
  }

  private hash(item: string, seed: number): number {
    let h = seed;
    for (let i = 0; i < item.length; i++) {
      h = Math.imul(h ^ item.charCodeAt(i), 0x9e3779b9);
      h ^= h >>> 16;
    }
    return Math.abs(h);
  }
}

Error Bounds

The estimate exceeds the true count by at most epsilon * N with probability at least 1 - delta, where N is total stream weight. For 0.1% error with 99% confidence: w = ceil(2.718 / 0.001) = 2719, d = ceil(ln(100)) ≈ 5. That is 13,595 counters total. For a stream of 100 million events, the worst-case over-estimate for any element is 100,000.

Production Use Cases

Sliding window rate limiting: Two sketches per key (current window and previous). Weight the previous window by the remaining fraction of the current period to get a smooth sliding window count.

class SketchRateLimiter {
  private current: CountMinSketch;
  private previous: CountMinSketch;
  private windowStart: number;
  private readonly windowMs: number;

  constructor(epsilon: number, delta: number, windowMs: number) {
    this.current = new CountMinSketch(epsilon, delta);
    this.previous = new CountMinSketch(epsilon, delta);
    this.windowStart = Date.now();
    this.windowMs = windowMs;
  }

  isAllowed(key: string, limit: number): boolean {
    this.rotateIfNeeded();
    const elapsed = Date.now() - this.windowStart;
    const weight = 1 - elapsed / this.windowMs;
    const estimate =
      this.current.estimate(key) +
      Math.floor(this.previous.estimate(key) * weight);
    if (estimate >= limit) return false;
    this.current.update(key);
    return true;
  }

  private rotateIfNeeded(): void {
    if (Date.now() - this.windowStart >= this.windowMs) {
      this.previous = this.current;
      this.current = new CountMinSketch(0.001, 0.01);
      this.windowStart = Date.now();
    }
  }
}

Top-k heavy hitters: Pair a Count-Min Sketch with a min-heap of size k. After each update, check if the estimated frequency belongs in the top-k and evict the minimum if so. This gives approximate top-k without tracking every distinct element.

Analytics event counting: Count clicks or feature usage per user in constant memory. The sketch uses d * w counters regardless of user count.

HyperLogLog

How It Works

The algorithm exploits a property of uniform hash functions: in a stream of uniformly distributed values, the probability of a value starting with at least k leading zeros is 2^(-k). If the maximum run of leading zeros observed is k, the stream likely contains around 2^k distinct elements.

This single-register estimate has high variance. HyperLogLog reduces variance by splitting elements across m = 2^b registers using the first b bits of each hash as a bucket index, then taking the harmonic mean across all registers with a bias correction.

class HyperLogLog {
  private readonly m: number;
  private readonly b: number;
  private readonly registers: Uint8Array;
  private readonly alphaMM: number;

  constructor(b: number) {
    if (b < 4 || b > 16) throw new Error("b must be between 4 and 16");
    this.b = b;
    this.m = 1 << b;
    this.registers = new Uint8Array(this.m);
    const alpha = b >= 6
      ? 0.7213 / (1 + 1.079 / this.m)
      : b === 5 ? 0.697 : 0.673;
    this.alphaMM = alpha * this.m * this.m;
  }

  add(item: string): void {
    const hash = this.hash32(item);
    const index = hash >>> (32 - this.b);
    const w = (hash << this.b) | ((1 << this.b) - 1);
    const rank = Math.clz32(w) + 1;
    this.registers[index] = Math.max(this.registers[index], rank);
  }

  estimate(): number {
    let sum = 0;
    for (let i = 0; i < this.m; i++) sum += Math.pow(2, -this.registers[i]);
    let e = this.alphaMM / sum;

    // Small range correction
    if (e <= 2.5 * this.m) {
      const zeros = this.registers.filter(r => r === 0).length;
      if (zeros > 0) e = this.m * Math.log(this.m / zeros);
    }
    // Large range correction
    if (e > (1 / 30) * 4294967296) {
      e = -4294967296 * Math.log(1 - e / 4294967296);
    }
    return Math.round(e);
  }

  merge(other: HyperLogLog): void {
    if (this.m !== other.m) throw new Error("Precision mismatch");
    for (let i = 0; i < this.m; i++) {
      this.registers[i] = Math.max(this.registers[i], other.registers[i]);
    }
  }

  private hash32(input: string): number {
    let h1 = 0xdeadbeef, h2 = 0x41c6ce57;
    for (let i = 0; i < input.length; i++) {
      const c = input.charCodeAt(i);
      h1 = Math.imul(h1 ^ c, 0x9e3779b9);
      h2 = Math.imul(h2 ^ c, 0x5f4a7c15);
    }
    return (h1 ^ h2) >>> 0;
  }
}

Tuning Precision

The standard error is 1.04 / sqrt(m):

bRegistersMemoryStandard Error
101,024~1 KB3.25%
124,096~4 KB1.63%
1416,384~16 KB0.81%
1665,536~64 KB0.41%

b = 14 is the right default for most analytics use cases. The mergeability is what makes HLL practical at scale: maintain per-shard HLLs, merge at query time. A month of daily HLLs for 10,000 properties at b = 12 is 30 × 10,000 × 4 KB = 1.2 GB, versus the gigabytes required for exact sets. For weekly or monthly uniques, merge the daily registers with element-wise max.

Redis Integration

All three structures work with Redis, enabling shared state across service instances without each process maintaining its own copy.

import { createClient } from "redis";

const redis = createClient();
await redis.connect();

// Bloom filter (requires Redis Stack or RedisBloom module)
await redis.bf.add("seen:user:123", "notif:abc");
const exists = await redis.bf.exists("seen:user:123", "notif:xyz");

// Count-Min Sketch
await redis.cms.initByProb("req-counts", 0.001, 0.01);
await redis.cms.incrBy("req-counts", [{ item: "10.0.0.1", increment: 1 }]);
const [count] = await redis.cms.query("req-counts", "10.0.0.1");

// HyperLogLog (built into every Redis version, no module needed)
await redis.pfAdd("visitors:2026-03-24", "user:456");
const unique = await redis.pfCount("visitors:2026-03-24");

// Weekly unique count by merging daily keys
const weekly = await redis.pfCount(
  "visitors:2026-03-18", "visitors:2026-03-19",
  "visitors:2026-03-20", "visitors:2026-03-21",
  "visitors:2026-03-22", "visitors:2026-03-23",
  "visitors:2026-03-24"
);

PFADD and PFCOUNT are built into every Redis version. Bloom filters and Count-Min Sketch require Redis Stack. If your managed Redis does not support modules, run the structures in application memory and checkpoint to durable storage periodically.

Tradeoffs

StructureMemoryError TypeError BoundDeletionsMerge
Bloom FilterO(n) bitsFalse positives onlyConfigurable pNoYes (bitwise OR)
Count-Min SketchO(d x w) countersOver-counts onlyepsilon x N, confidence 1 - deltaNoYes (element-wise sum)
HyperLogLogO(2^b) bytesRelative error1.04 / sqrt(m)NoYes (register max)
Exact hash setO(n)NoneNoneYesYes
Exact counter mapO(n)NoneNoneYesYes

The no-deletion constraint catches teams off guard. Bloom filter bits are shared across elements, so clearing a bit might create false negatives for others. For deletion support, use a Counting Bloom Filter: replace each bit with a 4-bit counter, increment on insert, decrement on delete. Memory cost is 4x, but deletions work correctly.

Production Considerations

Filter rotation: Bloom filter false positive rate degrades as fill factor grows. Run two filters in parallel (current and previous). Insert into both, query both, and retire the older one on a schedule. This bounds fill factor regardless of insert volume.

Persistence: These structures reset on process restart. Serialize to durable storage between restarts if your use case requires correctness across deployments. A Bloom filter serializes to m/8 bytes; a Count-Min Sketch to d * w * sizeof(counter).

Monitoring: Track insertion count against n for Bloom filters and alert at 150% fill. For Count-Min Sketch, track total stream weight to compute the absolute error ceiling. For HyperLogLog, the error is a fixed relative rate; anomalous cardinality spikes usually point to upstream data problems, not sketch issues.

Hash function choice: MurmurHash3 and xxHash are solid defaults. FNV-1a is simpler and fast enough for most workloads. Never use MD5 or SHA-1 here.

The Core Insight

These are not approximation hacks. They are the correct engineering choice when the acceptable error rate is well-defined and exact computation is prohibitive. The key discipline is making the error bound explicit: document what false positive rate or over-estimation percentage your system can tolerate, configure the structure to meet it, and monitor that it stays within bounds as traffic changes.

Simple enough to implement from scratch in a few hours. That exercise is worth doing, because you will understand the failure modes before they hit you in production.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.