System Design with Param · Shorts study notes · 04

Bloom filter: check 1 billion usernames in about 1 GB

“You have 1 billion usernames. On every keystroke, the signup page asks whether a name is free. How do you answer without hammering the database, and without keeping every name in RAM?”

A Bloom filter gives up a little certainty to save a huge amount of memory. This page starts with why the obvious answers hurt, then builds the filter bit by bit, works out its size, and covers what goes wrong in production. Every term is explained the first time it appears.

0 · Words used on this page

Quick glossary

TermWhat it means
Set membershipThe question “is this item in the collection?” Yes or no, nothing more. A Bloom filter answers only this. It cannot give the item back or list what's inside.
Bit arrayA long row of bits, each 0 or 1. 8 bits take 1 byte, so 8 billion bits take 1 GB.
Hash functionA function that turns any input into a number that looks random. The same input always gives the same number. Here we use that number as a position in the bit array. Bloom filters use fast non-cryptographic hashes such as MurmurHash or xxHash.
False positiveThe filter says “maybe present” for an item that was never added.
False negativeThe filter says “not present” for an item that was added. A correctly used Bloom filter never does this.
n, m, k, pn = items you add. m = bits in the array. k = hash functions per item. p = false positive rate.
Cache penetrationRequests for keys that don't exist anywhere. The cache never holds them, so every request falls through to the database.
LSM tree / SSTableA storage design used by Cassandra and RocksDB. Writes collect in memory, then flush to immutable sorted files called SSTables. One read may have to look through many of these files.
1 · The problem

Most checks are for names nobody has

A user typing param_codes fires a check for p, pa, par… (debouncing, meaning waiting for a typing pause, cuts this down but doesn't remove it). Multiply by every signup, and this becomes a high-volume read.

Notice what the question needs. You don't want the user's row. You only want one bit of information: does this name exist?

IMPThe shape that suits a Bloom filter: a large set, an expensive lookup, and many lookups where the answer is “no”. A filter pays off by avoiding those “no” lookups.

2 · The half answers

Why the obvious options hurt

① Query the database, with an index

SELECT 1 FROM users WHERE username = ? with a unique index is correct, and a single lookup is fast. The problem is volume: every check costs a connection and index page reads, and uncached pages mean disk reads. This traffic competes with the queries that actually run your product. Keep the unique index anyway, because it's the final authority.

② Put every username in a Redis set

Fast and exact, but it stores every name. We measured this: 1 million 17-character names in a Redis set took 72 bytes each (Redis 7.0.11). The name is only 17 of those bytes. The rest is hash table overhead. Scaled to 1 billion names, that's about 72 GB of RAM, plus replicas.

③ Cache the answers

Each name someone tries gets cached with a short lifetime. Popular names benefit, but people type endless unique variations, so hit rates are low on exactly the traffic you wanted to absorb. Memory also grows with every attempt.

What we want: almost exact, and far smaller than the data. The trick is to stop storing the names at all.

3 · Must-know

How a Bloom filter works

Burton Bloom published this in 1970. There are only three steps.

Start

Create a bit array of m bits, all 0. Choose k hash functions.

Add “alice”

Hash “alice” k times to get k positions, say 3, 11 and 25. Set those bits to 1. The name itself is not stored anywhere, which is why this is so small.

Check “bob”

Hash “bob” the same way and look at those positions.

  • Any bit is 0: bob was definitely never added. Adding him would have set that bit.
  • All bits are 1: bob is probably present. Those bits may have been set by other names, so you must confirm.
Is “bob”free? Bloom filterin memory Definitely not presentAvailable, no DB call Maybe presentAsk DB, the exact answer a bit is 0 all bits 1

IMP“No” is a guarantee. “Yes” is only a maybe. The filter never replaces the database. It only stops most “no” traffic from reaching it.

Interactive

Bit simulator

A tiny filter: 32 bits and 3 hash functions. Add a few names, then check others. Press “Find a false positive” to watch the filter get one wrong.

Start by adding a name.
Added: noneBits set: 0 / 32

The app keeps the list of added names only to label this demo. A real filter stores nothing but the bits. A real one is also much larger: 32 bits is deliberately small so collisions show up quickly.

4 · Must-know

Why it can say “maybe”, but never miss

5 · Must-know

How big, and how many hashes?

Choose how many items you expect (n) and what false positive rate you can accept (p). Two formulas give the rest:

bits needed      m = -n · ln(p) / (ln 2)²
hash functions   k = (m / n) · ln 2
false positives  p ≈ (1 - e^(-k·n/m))^k

Worked example: 1 billion usernames at 1%

  • m = 1,000,000,000 × 4.605 / 0.4805 ≈ 9.585 billion bits, which is 9.585 bits per name.
  • Divide by 8 to get bytes: about 1.2 GB.
  • k = 9.585 × 0.693 ≈ 7 hash functions.
  • Compared with the Redis set's 72 GB estimate, that's about 60× smaller, and name length stops mattering.
False positive rateBits per itemHash functions1 billion items
1%9.58571.2 GB
0.1%14.378101.8 GB
0.01%19.170142.4 GB

IMPAbout 4.8 extra bits per item cuts false positives by 10×. Size depends on the item count and the error rate, not on how long the items are.

We ran it: 1 million real inserts, 1 million checks for absent names

Setup (9.585 bits per item unless noted)False positivesBits set
k = 7, filled to plan1.01%51.8%
14.378 bits per item, k = 100.10%50.1%
k = 1 (too few hashes)9.92%9.9%
k = 20 (too many hashes)7.05%87.6%
k = 7, 2× the planned items15.75%76.8%
k = 7, 5× the planned items83.16%97.4%

False negatives in every run: 0. Node.js, positions from split MD5 hashes. These match the formula to within about 0.02 percentage points.

Look at the “bits set” column. The best filter is about half 1s and half 0s. Below that, memory is wasted. Above that, too many checks find only 1s. And overfilling is not a gentle failure: double the items and 1% becomes nearly 16%.

Interactive

Sizing simulator

Plan a filter, then drag “actually inserted” past 100% and watch the false positive rate climb. The DB meter assumes every check is for an absent name.

Bloom filter size
Redis set size (estimate)
False positives now
Absent-name checks reaching DB
Hash functions: Bits per planned item:

Redis set estimate = items × (55 + name length) bytes, fitted to our one measurement of 72 bytes at 17 characters. Treat it as a rough figure: the real overhead changes with encoding and table size.

6 · Code

The whole thing fits in about 20 lines

import { createHash } from "node:crypto";

class BloomFilter {
  constructor(n, p) {
    this.m = Math.ceil(-n * Math.log(p) / Math.LN2 ** 2);
    this.k = Math.round((this.m / n) * Math.LN2);
    this.bits = new Uint8Array(Math.ceil(this.m / 8));
  }
  *positions(item) {
    const d = createHash("md5").update(item).digest();
    const h1 = d.readUInt32LE(0), h2 = (d.readUInt32LE(4) | 1) >>> 0;
    for (let i = 0; i < this.k; i++) yield (h1 + i * h2) % this.m;
  }
  add(item) {
    for (const p of this.positions(item)) this.bits[p >> 3] |= 1 << (p & 7);
  }
  mightContain(item) {
    for (const p of this.positions(item))
      if (!(this.bits[p >> 3] & (1 << (p & 7)))) return false;
    return true;
  }
}

const usernames = new BloomFilter(1_000_000, 0.01);
usernames.add("alice");
usernames.mightContain("alice"); // true
usernames.mightContain("bob");   // false, almost always

You don't need 7 separate hash functions

Compute two hash values and derive the rest: position_i = h1 + i × h2. Kirsch and Mitzenmacher showed this keeps the same false positive rate asymptotically. That's the loop above. | 1 forces h2 to be odd, so it can't be 0 and repeat one position. >>> 0 is needed because | in JavaScript returns a signed number, and a negative position would silently break the filter. MD5 keeps this demo free of dependencies. In production, use a faster non-cryptographic hash such as xxHash or MurmurHash.

In production, Redis has it ready

BF.RESERVE usernames 0.01 1000000000
BF.ADD usernames "alice"
BF.EXISTS usernames "bob"

These commands ship with Redis Open Source 8 and later. Older versions need the RedisBloom module. Guava's BloomFilter is a common in-process choice for Java.

7 · Where it runs for real

Three uses worth knowing

IMPDatabases: Cassandra and RocksDB

In an LSM tree, one key could be in any of many SSTable files, and searching them all means disk reads. So each file gets its own filter in memory. If the filter says “not here”, the database skips that file. RocksDB's NewBloomFilterPolicy(10) uses about 10 bits per key for about 1% false positives. Cassandra's bloom_filter_fp_chance defaults to 0.01 (0.1 with leveled compaction). This is the classic senior-level answer, and it's probably where you already rely on a Bloom filter without knowing it.

Cache penetration protection

An attacker requests random product IDs that don't exist. The cache misses every time, and the database takes every hit. Caching “not found” doesn't help much, because each random ID is new and fills the cache with junk. Put a filter of all valid IDs in front: a “no” returns 404 straight away, and the database is never touched.

Akamai: don't cache something seen only once

Akamai studied one cluster over two days. 74% of about 400 million objects were requested exactly once, so caching them wasted disk. Their rule: cache an object only on its second request, using a Bloom filter to remember what's been seen. In a test on about 47 production servers, byte hit rate rose from about 74% to 83% and disk writes fell 44%.

And a counter-example: Medium

Medium Engineering wrote in 2025 that they made a filtering step cheaper by removing Bloom filters in favour of direct per-user database queries. A Bloom filter is a tool for a specific shape of problem, not an automatic upgrade.

8 · Production depth

What breaks after launch

IMP“No false negatives” depends on your write path

The guarantee holds only for items that actually reached the filter. Suppose the DB insert succeeds, then the app crashes before updating the filter. Now the filter says “definitely not present” for a real row. For usernames, the user sees “available” and signup fails. For cache penetration protection, a real product returns 404.

Fix: add to the filter first, then write to the DB. If the DB write fails, you only have a few extra stale bits, which means a tiny extra false positive rate. The wrong order creates missing data errors. The right order creates a small harmless cost.

Always keep the unique constraint

Two people can see “available” for the same name in the same second. The filter can't settle that. A unique index in the database decides the winner, and the loser gets a “just taken” error.

An empty or lost filter must fail open

A freshly restarted empty filter answers “definitely not present” for everything. If you trust it, every lookup is wrong. Persist the filter (Redis RDB/AOF, a snapshot to S3), and if it's not fully loaded, send all checks to the database. The same applies when a Redis call times out.

Rebuilding without losing writes

Build a new filter from a table scan while the old one keeps serving. New signups must go to both filters from before the scan begins, otherwise anything created during the scan is missing. When the scan finishes, swap them. Rebuilds are also how you size up and clear out deleted items.

Deletes: three real options

  • Ignore them and rebuild periodically. Deleted names become false positives, the DB check still gives the right answer. Simplest, and usually enough.
  • Counting Bloom filter. Replace each bit with a small counter: add increments, delete decrements. It supports deletes but uses several times the memory.
  • Cuckoo filter. Stores small fingerprints and supports deletes, with similar space at low error rates. You may only delete items you actually added, and inserts can fail when it's very full.

Growth: size for the future

A fixed filter can't be resized, because it no longer knows the items and can't rehash them. Options: size for several years up front, rebuild when it fills, or use a scalable Bloom filter, which stacks a new larger filter when one fills up. Redis does this by default, but each extra filter makes checks slower. Monitor the fill level: alert when bits set goes well past 50%.

Normalize before hashing

Param, param and param produce unrelated hash values. If your uniqueness rule ignores case, lowercase and trim before you add and before you check, using the same rule in every service. Otherwise you get false negatives.

In-process or shared?

  • In-process (Guava, a byte array): no network hop, but each server holds its own copy, and copies drift unless every server receives every write.
  • Shared (Redis): one source of truth, but each check is a network call. It's still far cheaper than the database, and it adds a dependency that needs a fail-open path.

Deep extras

  • Merging: two filters with the same m, k and hashes can be combined by OR-ing their bits. The result is the same as adding all items to one filter.
  • CPU cache misses: 7 random positions in a 1 GB array can mean 7 cache misses. RocksDB keeps a key's probes within one cache line, trading a slightly higher error rate for speed.
  • Attacks: if attackers know your hash and seed, they can craft inputs that set many bits or always return “maybe”. Use a secret-seeded hash when the input is attacker-controlled.
  • Newer filters: RocksDB's Ribbon filter uses about 30% less space for the same error rate, at 3–4× the build CPU.

When not to use one

  • Most lookups find something. Then nearly every check returns “maybe” and still goes to the DB. The filter adds work and saves little.
  • The set is small. A plain hash set is simple and exact.
  • A false “yes” is costly and can't be confirmed. Without a source of truth behind it, the filter wrongly rejects about 1 in 100 free items.
  • You need the data. It can't list items or return values.
9 · Interview traps

Answers that sound right but aren't

“A Bloom filter tells you whether a username exists.”

Only half. It can prove a name does not exist. “Maybe” still needs the database.

“It can give false positives and false negatives.”

False positives only. Bits never go back to 0, so an added item always finds all its bits set, unless your code skipped the add or deleted bits.

“To delete, set that item's bits back to 0.”

Those bits may be shared with other items, so this creates false negatives. Use a counting or cuckoo filter, or rebuild.

“More hash functions always means better accuracy.”

Too many fill the array. In our run at the same memory, k = 7 gave 1.01%, while k = 20 gave 7.05%.

“A longer username needs a bigger filter.”

Item length doesn't matter. Size depends only on item count and target error rate.

“Once it's created, the error rate stays at 1%.”

The 1% applies only at the planned item count. At 2× the items we measured 15.75%.

“With the filter, we can drop the database's unique index.”

Never. Two concurrent signups can both pass the check. Only the unique index closes that race.

10 · Self-quiz

Check yourself (tap to reveal)

1. How much memory for 500 million items at 1% false positives?

500,000,000 × 9.585 bits ≈ 4.79 billion bits. Divide by 8: about 600 MB, with k = 7.

2. The filter says “not present” for a row that exists in the DB. List two possible causes.

The app wrote to the DB but never updated the filter (crash, or wrong write order), or the filter restarted empty. Different normalization between add and check, or a rebuild that missed writes, are also valid answers.

3. False positives have risen from 1% to 15% over a year. What happened, and what's the fix?

The filter now holds about 2× the planned items. Rebuild it larger from the database, and alert on the fill level so that it doesn't happen again.

4. 95% of lookups in your system find an existing row. Should you add a Bloom filter?

Probably not. It only saves lookups where the answer is “no”, and that's just 5% of traffic here, while all the others pay for an extra check.

5. Why do Cassandra and RocksDB keep a filter per SSTable file?

A key could be in any file, and files are immutable. A small in-memory filter avoids a disk read on most files that don't hold the key. Immutability also means the filter never needs a delete.

11 · For the short

Pick the points to keep

Ticks are saved in this browser. Press “Copy my picks” and paste them in chat.

Sources