← Data structures & algorithms
Approximate Membership, with one kind of mistake

Bloom filter

Definitely not, or maybe.

Every time a sign-up form tells you a password is too common, something checked it against a list of passwords attackers try first. Databases such as Cassandra and RocksDB run the same kind of check before they read a file from disk. Most of the time, both only need a fast “definitely not.” Let’s watch a Bloom filter give that answer for a list of common passwords, and see why the one thing it can never do is forget.

TypeScriptGoOne password screen, two implementations.

01 / The idea

Is it on the list?

NIST’s digital identity guidelines say that when someone sets a password, the service must compare it against a blocklist of commonly used, expected, or compromised passwords. Such lists are big. The UK’s National Cyber Security Centre published the 100,000 passwords that turned up most often in breach data collected by Have I Been Pwned. The 47,324 of them that are at least eight characters long take 461 KB as plain text, and every sign-up checks against them, including every check that finds nothing.

You have met this question before, one layer down. Cassandra checks a filter before it reads a data file, to learn whether the row it wants “definitely does not exist” there or “probably exists.” RocksDB can keep one inside each of its files. SQLite, which ships in every Android and iOS device and in Chrome, Firefox, and Safari, added one to its query planner in version 3.38.0. Git can store one for every commit, holding the paths that commit changed, which its documentation says gives significant performance gains for git log -- <path>.

A Bloom filter, named for Burton Bloom, who described it in 1970, is that filter. It is a row of bits that answers “definitely not” or “maybe.” It never stores the passwords, and at a 1% false positive rate it needs about ten bits for each one, however long they are. The price is one kind of mistake: a maybe for a password that was never added. And it can never forget a password it was given.

02 / Name the rule

Hash to a few bits, turn them on, and trust only a no.

The filter is a row of m bits, all off. Each value gets k positions in the row, picked by hashing it. The story uses 32 bits and three positions.

To add a value, turn on all k of its bits. Some may already be on, turned on by another value, and that is fine. To check a value, look at its bits. If any is off, the value was never added, because adding it would have turned that bit on and nothing turns bits off. If all are on, the answer is only maybe: those bits could have been turned on by other values.

The invariant: every value that was added has all of its bits on. So a no is always right. A maybe for a value that was never added is a false positive, and how often that happens depends on how many bits are on. The password screen uses the filter for exactly what it guarantees. A no allows the password straight away. A maybe asks the full list.

Where do three positions come from?Two hashes and double hashing

Each password is hashed twice: FNV-1a over its UTF-8 bytes from two different starting values, each finished with MurmurHash3’s final mix. The mix matters. On its own, FNV-1a’s low bits depend only on the low bits of each byte, and a position mod 32 reads mostly low bits. The i-th position is (h1 + i × h2) mod 32.

sunshine hashes to h1 = 1,488,869,581 and h2 = 115,115,419. Mod 32, 1,488,869,581 is 13. Adding h2 gives 1,603,985,000, which is 8, and adding it again gives 1,719,100,419, which is 3. So sunshine’s bits are 13, 8, and 3.

Two hashes standing in for k is not a shortcut that costs accuracy. Adam Kirsch and Michael Mitzenmacher showed that it works “without any loss in the asymptotic false positive probability.” The measurements in Follow the cost agree.

03 / Follow one operation

A shared bit, a definite no, and a false alarm.

The list starts with five common passwords. sunshine, football, starwars, and superman each turned on three bits, twelve in all. hunter2 is listed too, but at seven characters it is refused before any lookup, so it never needs bits. Then princess joins the list, and three new passwords are checked.

“velvet thunder” is not on the list. Before you watch, predict what the filter will say about it. The animation replays each recorded hash and bit. Try it lets you add and check passwords yourself and watch 32 bits fill up.

Bloom filter

Hash, set bits, and trust only a no.

Hash a password to find its bits.
  1. 0 1
  2. 1 0
  3. 2 0
  4. 3 1
  5. 4 1
  6. 5 0
  7. 6 0
  8. 7 0
  9. 8 1
  10. 9 0
  11. 10 0
  12. 11 1
  13. 12 0
  14. 13 1
  15. 14 0
  16. 15 0
  17. 16 0
  18. 17 0
  19. 18 1
  20. 19 0
  21. 20 0
  22. 21 0
  23. 22 0
  24. 23 0
  25. 24 1
  26. 25 1
  27. 26 0
  28. 27 1
  29. 28 1
  30. 29 1
  31. 30 0
  32. 31 0

No password hashed yet.

12 of 32 bits on · 3 hashes · an unlisted password passes about 5.3%

01/ 05
The setup

Four common passwords, twelve bits on.

sunshine, football, starwars, and superman each turned on three of 32 bits. hunter2 is on the list too, but at seven characters it is refused before any lookup, so it never needs bits.

Reduced motion: choose a scene to see its completed state.

Read this scene

sunshine, football, starwars, and superman each turned on three of 32 bits. hunter2 is on the list too, but at seven characters it is refused before any lookup, so it never needs bits.

sunshine, football, starwars, and superman each turned on three of 32 bits. hunter2 is on the list too, but at seven characters it is refused before any lookup, so it never needs bits.

12 of 32 bits on: 0, 3, 4, 8, 11, 13, 18, 24, 25, 27, 28, 29.

Watch restarts when you return. Step through keeps your selected step. Try it starts a fresh filter each time you open it.

04 / Read the shape

The filter says maybe. The screen decides.

Basic form is the filter alone: the two hashes, the positions, add and check, sizeFor, and saving the bits to bytes and loading them back. In the wild wraps it in PasswordScreen, which owns the sign-up policy. A password under the minimum length is refused first. The filter’s no is final. Only a maybe asks the full list, and the screen counts how often that happened and how often it was a false alarm.

Two details come straight from NIST. Length is counted in Unicode code points, so 日本語パスワード is eight characters, not 24 bytes. And the whole password is compared, not words inside it, so the screen hashes exact strings. The minimum is 8 here. NIST asks for 15 when the password is the only factor, and allows 8 when it is used with another one.

A Bloom filter over a row of bits. Two FNV-1a hashes, finished with MurmurHash3’s mix, pick k positions by double hashing. Adding turns bits on, and a check stops at the first bit that is off. sizeFor turns a count and a target rate into bits and hashes, and a filter can be saved to bytes and loaded back.

TypeScriptReading
blocklist.ts
const MAX_BITS = 2 ** 31;

// Two 32-bit hashes of the value's UTF-8 bytes: FNV-1a from two different starting
// values, each finished with MurmurHash3's final mix. Alone, FNV-1a's low bits depend
// only on the low bits of each byte, and a position mod a small size reads mostly low bits.
export function hashPair(value: string): [number, number] {
	const bytes = new TextEncoder().encode(value);
	return [finish(fnv1a(bytes, 0x811c9dc5)), finish(fnv1a(bytes, 0x050c5d1f))];
}

function fnv1a(bytes: Uint8Array, basis: number): number {
	let hash = basis;
	for (const byte of bytes) {
		hash ^= byte;
		hash = Math.imul(hash, 16777619) >>> 0;
	}
	return hash;
}

function finish(hash: number): number {
	hash ^= hash >>> 16;
	hash = Math.imul(hash, 0x85ebca6b);
	hash ^= hash >>> 13;
	hash = Math.imul(hash, 0xc2b2ae35);
	hash ^= hash >>> 16;
	return hash >>> 0;
}

export type Size = { bits: number; hashes: number };

// Size a filter for about `expectedItems` values at a target false positive rate.
export function sizeFor(expectedItems: number, falsePositiveRate: number): Size {
	if (!Number.isInteger(expectedItems) || expectedItems < 1 || expectedItems > MAX_BITS)
		throw new RangeError('expected items must be an integer from 1 to 2147483648');
	if (!(falsePositiveRate > 0 && falsePositiveRate < 1))
		throw new RangeError('false positive rate must be greater than 0 and less than 1');
	// m = −n ln p / (ln 2)², then k = (m / n) ln 2, the hash count that makes the rate smallest.
	const bits = Math.ceil((-expectedItems * Math.log(falsePositiveRate)) / (Math.LN2 * Math.LN2));
	if (bits > MAX_BITS) throw new RangeError('the filter would need more than 2147483648 bits');
	const hashes = Math.round((bits / expectedItems) * Math.LN2);
	return { bits, hashes: Math.min(32, Math.max(1, hashes)) };
}

export type Step = {
	kind: 'hash' | 'set' | 'test';
	value: string;
	/** hash: the first 32-bit hash. set and test: the bit's position. */
	a: number;
	/** hash: the second hash. set: 1 if the bit was already on. test: 1 if the bit is on. */
	b: number;
};

// A row of bits, all off to start. Adding a value turns on the bits at its positions.
// A value whose positions are all on might have been added; one with any bit off never was.
// Bits are never turned off, because another value may need the same bit.
export class BloomFilter {
	readonly bits: number;
	readonly hashes: number;
	#bytes: Uint8Array;
	#bitsOn = 0;
	#steps: Step[] = [];
	#capture: boolean;

	constructor(bits: number, hashes: number, trace = false) {
		if (!Number.isInteger(bits) || bits < 1 || bits > MAX_BITS)
			throw new RangeError('bits must be an integer from 1 to 2147483648');
		if (!Number.isInteger(hashes) || hashes < 1 || hashes > 32)
			throw new RangeError('hashes must be an integer from 1 to 32');
		this.bits = bits;
		this.hashes = hashes;
		this.#bytes = new Uint8Array(Math.ceil(bits / 8));
		this.#capture = trace;
	}

	// Rebuild a filter from toBytes(), for example one built on a server and shipped to a browser.
	static fromBytes(bits: number, hashes: number, bytes: Uint8Array, trace = false): BloomFilter {
		const filter = new BloomFilter(bits, hashes, trace);
		if (bytes.length !== filter.#bytes.length)
			throw new RangeError('bytes must be exactly ceil(bits / 8) long');
		if (bits % 8 && bytes[bytes.length - 1] >> (bits % 8))
			throw new RangeError('a bit past the last one is set');
		filter.#bytes.set(bytes);
		for (let bit = 0; bit < bits; bit++) if (filter.#isOn(bit)) filter.#bitsOn++;
		return filter;
	}

	get bitsOn(): number {
		return this.#bitsOn;
	}

	// The chance that a value never added finds all its bits on: the fraction on, per hash.
	estimatedFalsePositiveRate(): number {
		return (this.#bitsOn / this.bits) ** this.hashes;
	}

	// Double hashing: the i-th position is (h1 + i × h2) mod bits, so two hashes stand in for k.
	positions(value: string): number[] {
		const [first, second] = hashPair(value);
		return Array.from({ length: this.hashes }, (_, i) => (first + i * second) % this.bits);
	}

	// Turn on every one of the value's bits. True if any was off, so the filter changed.
	add(value: string): boolean {
		let changed = false;
		for (const bit of this.#start(value)) {
			const on = this.#isOn(bit);
			this.#record('set', value, bit, on ? 1 : 0);
			if (on) continue;
			this.#bytes[bit >>> 3] |= 1 << (bit & 7);
			this.#bitsOn++;
			changed = true;
		}
		return changed;
	}

	// False: definitely never added. True: maybe, because other values may have set these bits.
	mightContain(value: string): boolean {
		for (const bit of this.#start(value)) {
			const on = this.#isOn(bit);
			this.#record('test', value, bit, on ? 1 : 0);
			if (!on) return false;
		}
		return true;
	}

	toBytes(): Uint8Array {
		return this.#bytes.slice();
	}

	trace(): Step[] {
		return this.#steps.map((step) => ({ ...step }));
	}

	#start(value: string): number[] {
		this.#steps = [];
		const [first, second] = hashPair(value);
		this.#record('hash', value, first, second);
		return this.positions(value);
	}

	#isOn(bit: number): boolean {
		return (this.#bytes[bit >>> 3] & (1 << (bit & 7))) !== 0;
	}

	#record(kind: Step['kind'], value: string, a: number, b: number): void {
		if (this.#capture) this.#steps.push({ kind, value, a, b });
	}
}
GoAlongside
blocklist.go
const maxBits = 1 << 31

// HashPair returns two 32-bit hashes of the value's UTF-8 bytes: FNV-1a from two
// different starting values, each finished with MurmurHash3's final mix. Alone,
// FNV-1a's low bits depend only on the low bits of each byte, and a position mod a
// small size reads mostly low bits.
func HashPair(value string) (uint32, uint32) {
	return finish(fnv1a(value, 0x811c9dc5)), finish(fnv1a(value, 0x050c5d1f))
}

func fnv1a(value string, basis uint32) uint32 {
	hash := basis
	for i := 0; i < len(value); i++ {
		hash ^= uint32(value[i])
		hash *= 16777619
	}
	return hash
}

func finish(hash uint32) uint32 {
	hash ^= hash >> 16
	hash *= 0x85ebca6b
	hash ^= hash >> 13
	hash *= 0xc2b2ae35
	hash ^= hash >> 16
	return hash
}

// Size is a filter's bit count and hash count.
type Size struct{ Bits, Hashes int }

// SizeFor sizes a filter for about expectedItems values at a target false positive rate.
func SizeFor(expectedItems int, falsePositiveRate float64) (Size, error) {
	if expectedItems < 1 || expectedItems > maxBits {
		return Size{}, errors.New("expected items must be an integer from 1 to 2147483648")
	}
	if !(falsePositiveRate > 0 && falsePositiveRate < 1) {
		return Size{}, errors.New("false positive rate must be greater than 0 and less than 1")
	}
	// m = −n ln p / (ln 2)², then k = (m / n) ln 2, the hash count that makes the rate smallest.
	ln2 := math.Ln2
	bits := math.Ceil(-float64(expectedItems) * math.Log(falsePositiveRate) / (ln2 * ln2))
	if bits > maxBits {
		return Size{}, errors.New("the filter would need more than 2147483648 bits")
	}
	hashes := int(math.Round(bits / float64(expectedItems) * ln2))
	return Size{int(bits), min(32, max(1, hashes))}, nil
}

type Step struct {
	Kind  string `json:"kind"`
	Value string `json:"value"`
	// hash: the first 32-bit hash. set and test: the bit's position.
	A int64 `json:"a"`
	// hash: the second hash. set: 1 if the bit was already on. test: 1 if the bit is on.
	B int64 `json:"b"`
}

// BloomFilter is a row of bits, all off to start. Adding a value turns on the bits
// at its positions. A value whose positions are all on might have been added; one
// with any bit off never was. Bits are never turned off, because another value may
// need the same bit.
type BloomFilter struct {
	bits    int
	hashes  int
	data    []byte
	bitsOn  int
	steps   []Step
	capture bool
}

func NewBloomFilter(bits, hashes int, trace bool) (*BloomFilter, error) {
	if bits < 1 || bits > maxBits {
		return nil, errors.New("bits must be an integer from 1 to 2147483648")
	}
	if hashes < 1 || hashes > 32 {
		return nil, errors.New("hashes must be an integer from 1 to 32")
	}
	return &BloomFilter{bits: bits, hashes: hashes, data: make([]byte, (bits+7)/8), capture: trace}, nil
}

// FromBytes rebuilds a filter from Bytes(), for example one built on a server and
// shipped to a client.
func FromBytes(bits, hashes int, data []byte, trace bool) (*BloomFilter, error) {
	f, err := NewBloomFilter(bits, hashes, trace)
	if err != nil {
		return nil, err
	}
	if len(data) != len(f.data) {
		return nil, errors.New("bytes must be exactly ceil(bits / 8) long")
	}
	if bits%8 != 0 && data[len(data)-1]>>(bits%8) != 0 {
		return nil, errors.New("a bit past the last one is set")
	}
	copy(f.data, data)
	for bit := range bits {
		if f.isOn(bit) {
			f.bitsOn++
		}
	}
	return f, nil
}

func (f *BloomFilter) Bits() int   { return f.bits }
func (f *BloomFilter) Hashes() int { return f.hashes }
func (f *BloomFilter) BitsOn() int { return f.bitsOn }

// EstimatedFalsePositiveRate is the chance that a value never added finds all its
// bits on: the fraction on, per hash.
func (f *BloomFilter) EstimatedFalsePositiveRate() float64 {
	return math.Pow(float64(f.bitsOn)/float64(f.bits), float64(f.hashes))
}

// Positions uses double hashing: the i-th position is (h1 + i × h2) mod bits, so
// two hashes stand in for k.
func (f *BloomFilter) Positions(value string) []int {
	first, second := HashPair(value)
	positions := make([]int, f.hashes)
	for i := range positions {
		positions[i] = int((uint64(first) + uint64(i)*uint64(second)) % uint64(f.bits))
	}
	return positions
}

// Add turns on every one of the value's bits. It reports whether any was off, so
// the filter changed.
func (f *BloomFilter) Add(value string) bool {
	changed := false
	for _, bit := range f.start(value) {
		on := f.isOn(bit)
		f.record("set", value, int64(bit), flag(on))
		if on {
			continue
		}
		f.data[bit>>3] |= 1 << (bit & 7)
		f.bitsOn++
		changed = true
	}
	return changed
}

// MightContain returns false when the value was definitely never added, and true
// for maybe, because other values may have set these bits.
func (f *BloomFilter) MightContain(value string) bool {
	for _, bit := range f.start(value) {
		on := f.isOn(bit)
		f.record("test", value, int64(bit), flag(on))
		if !on {
			return false
		}
	}
	return true
}

func (f *BloomFilter) Bytes() []byte { return append([]byte{}, f.data...) }
func (f *BloomFilter) Trace() []Step { return append([]Step{}, f.steps...) }

func (f *BloomFilter) start(value string) []int {
	f.steps = nil
	first, second := HashPair(value)
	f.record("hash", value, int64(first), int64(second))
	return f.Positions(value)
}

func (f *BloomFilter) isOn(bit int) bool { return f.data[bit>>3]&(1<<(bit&7)) != 0 }

func (f *BloomFilter) record(kind, value string, a, b int64) {
	if f.capture {
		f.steps = append(f.steps, Step{kind, value, a, b})
	}
}

func flag(on bool) int64 {
	if on {
		return 1
	}
	return 0
}
Reading the TypeScriptBits inside bytes

The bits live in a Uint8Array. Bit 27 is in byte 27 >>> 3, which is 3, at mask 1 << (27 & 7), which is 8. toBytes copies that array and fromBytes checks its length before trusting it.

Math.imul and >>> 0 keep both hashes in unsigned 32-bit range. h1 + i × h2 stays below 2⁵³ for up to 32 hashes, so plain numbers compute it exactly. [...password].length counts code points, where password.length would count UTF-16 units.

Reading the GoErrors instead of throws

Positions are computed in uint64, so h1 + i × h2 never wraps. utf8.RuneCountInString counts code points.

NewBloomFilter, FromBytes, SizeFor, and NewPasswordScreen return an error where TypeScript throws. In ScreenOptions, zero means the default, and the error messages match TypeScript’s word for word so both languages report the same results to the shared test cases.

What would I normally use in application code?A package, or the database you already run

Neither language ships one. In TypeScript, the bloom-filters package includes a Bloom filter along with other probabilistic structures. In Go, github.com/bits-and-blooms/bloom/v3 sizes one with bloom.NewWithEstimates(n, fp) and offers Add and Test on byte slices.

Often the filter belongs where the data already is. Redis has a Bloom filter type, created with BF.RESERVE from an error rate and a capacity. Cassandra keeps one per data file and tunes it per table with bloom_filter_fp_chance. Either way, a false positive rate and an expected count are the two numbers you choose.

05 / Try a decision

Why not clear the bits?

Lists change. A password comes off, and leaving its bits on feels untidy, like a record that should have been deleted. Decide what goes wrong before the feedback tells you.

princess comes off the full list. To keep the filter in step, someone clears princess’s bits: 3, 22, and 9. Then a new user picks sunshine, which is still on the list. What happens?

06 / Follow the cost

About ten bits a password, however long the password.

Here is every operation at a glance, with n passwords, m bits, k hashes, and passwords of L bytes. The rest of this section is about the space row, and the rate you pay for it.

Bloom filter password screen: time and extra space per operation
OperationTimeExtra spaceWhat it assumes
Check a password of L bytesO(L + k)O(L)Compute its two hashes, then test at most k bits, stopping at the first that is off. This code computes the pair twice, once for the trace and once for the positions, so FNV-1a runs over the bytes four times: still O(L). TypeScript encodes the password into an L-byte buffer each time; Go reads the string in place.
Add a passwordO(L + k)O(L)The same two hashes, then k bits turned on. The password itself is not kept.
Screen a passwordO(L + k), plus a full-list lookup on maybeO(L)Listed passwords always reach the full list. So does the false positive rate’s share of everything else.
Build from n passwordsO(n(L + k))O(m)One add per password, into m bits fixed when the filter is sized.
Size for n passwords at rate pO(1)—m = −n ln p / (ln 2)², rounded up, and k = (m / n) ln 2, rounded.
Remove a passwordNot supported—A bit may belong to other passwords too. Rebuild the filter from the updated list.
Hold n passwords—O(m), about 1.44 log₂(1/p) bits eachAt 1%, 9.59 bits a password; at 0.1%, 14.38. The length of the passwords does not matter.

In the animation, 14 of 32 bits were on, and each check looked at three of them. A password that was never added finds all three on about (14/32)³ of the time, roughly 8.4%. “velvet thunder” was one of those.

In general, after n values go into m bits with k hashes, a given bit is still off with probability about e−kn/m, so a false positive happens about (1 − e−kn/m)k of the time. The k that makes that smallest is (m / n) ln 2, which leaves about half the bits on. Solving for m gives about 1.44 log₂(1/p) bits per value for a rate p: 9.59 bits at 1%, and every tenfold improvement costs about 4.8 bits more.

Running this lesson’s filter on the NCSC list reproduces it. The 47,324 passwords of eight or more characters take 460,614 bytes as text. Sized for 10%, the filter is 28,351 bytes with 3 hashes, and 10.08% of 200,000 random unlisted passwords passed it. Sized for 1%, it is 56,701 bytes with 7 hashes and 51.8% of its bits on, and 0.99% passed. Sized for 0.1%, it is 85,051 bytes with 10 hashes, and 0.103% passed. Not one listed password was ever missed.

The rate holds only for the count you sized for. The same 1% filter holding twice as many passwords let 15.8% of unlisted ones through to the full list, and at four times, 68.1%. A filter cannot grow in place, because every position depends on m. Size it for the list you expect, and rebuild it when the list outgrows it.

These are bytes and counts, not timings. They assume the hashes spread passwords evenly. Someone who knows the hashes and the size could choose passwords that land on bits already on, but here a false alarm only costs a lookup the full list was always able to answer.

07 / Give it a real job

One filter per list, built from the same snapshot.

In a real service, a job builds the list: it takes the latest breach corpus, drops entries shorter than the minimum, sizes a filter for what is left, and publishes the filter and the full list together. The sign-up endpoint loads the filter into memory. The full list lives where lookups are slow but exact, such as a table with a unique index. The change-password endpoint calls the same screen.

What the screen leaves out is a decision too. It checks one list, while NIST also expects context-specific words, such as the service’s name and the person’s username, which are cheaper to check directly. It does not throttle guessing; NIST requires rate limiting separately. And the filter and the full list must come from the same snapshot. A password added to the list but not to the filter gets a no that is wrong, which is why addListed exists. Removing a password is the other way round: its bits are shared with other passwords, so turning them off could clear a listed password too, and the filter has to be rebuilt from the updated list.

Build UIs?See where a filter already answers for you, and the day you ship one to the browser.

Where it already is in your components

You will rarely find a Bloom filter in a component tree. You meet them one layer down. The sign-up form you already build is the textbook case: under NIST’s guidelines the server screens every new password against a blocklist, and a filter lets it answer most of those checks without reading the list. The database behind your API may be asking the same question before each disk read, the way Cassandra and RocksDB do. And in a large repository, git log -- src/components/Button.tsx can skip every commit whose changed-path filter says no.

What reaches your component is the screen’s answer. That answer should be treated like any other validation result from the server: shown next to the field, announced politely to screen readers, and never trusted from the browser alone.

When you have to own it

Picture a sign-up page that should warn about a common password while the person is still typing, without sending every keystroke of a password to a server. Ship the filter. For the NCSC list at 1%, that is 56.7 KB instead of 461 KB of text, and it reveals nothing that the public list does not. Check each change locally. A no is final, so an unlisted password leaves the page while they type only on a false alarm, about one check in a hundred. A maybe can be that false alarm, so ask the server, cancel the request if the password changes, and warn only when the server confirms. The server screens again on submit, because the check in the browser is a convenience, not a gate.

Loading it has traps of its own. Download it when the password field gets focus, since most visitors never reach it. Show no hint until it arrives, because a missing filter is not a no. Check the length: a cut-off download read as zeros would answer “definitely not listed” for every password, the one mistake a filter must never make. And publish the bit and hash counts beside the bytes, built by the same hash code as the server’s, so a new list never meets an old size.

A password field with the filter already loaded. A no shows nothing, a maybe asks the server with a request it cancels when the password changes, and only a confirmed listing shows a warning.

ReactAlready in your code
PasswordField.tsx
import { useEffect, useId, useState } from 'react';
import type { BloomFilter } from '../blocklist';

type Props = {
	filter: BloomFilter;
	/** Asks the server's full list. Only a maybe from the filter ever calls it. */
	isListed: (password: string, signal: AbortSignal) => Promise<boolean>;
};

export function PasswordField({ filter, isListed }: Props) {
	const id = useId();
	const [password, setPassword] = useState('');
	const [confirmed, setConfirmed] = useState<{ password: string; listed: boolean } | null>(null);

	// A few bit checks, cheap enough to run on every keystroke. The list only holds
	// passwords of 8 or more characters, so shorter ones are never looked up.
	const answer =
		[...password].length < 8 ? 'short' : filter.mightContain(password) ? 'maybe' : 'no';

	useEffect(() => {
		// A no is certain, so most passwords never leave the page while someone types.
		if (answer !== 'maybe') return;
		// A maybe can be a false alarm. Ask, and cancel if the password changes first.
		const controller = new AbortController();
		isListed(password, controller.signal)
			.then((listed) => setConfirmed({ password, listed }))
			.catch(() => {}); // aborted or offline: the server screens again on submit
		return () => controller.abort();
	}, [answer, password, isListed]);

	const listed = answer === 'maybe' && confirmed?.password === password && confirmed.listed;

	return (
		<>
			<label htmlFor={id}>Password</label>
			<input
				id={id}
				type="password"
				autoComplete="new-password"
				value={password}
				onChange={(event) => setPassword(event.target.value)}
				aria-describedby={`${id}-help`}
			/>
			<p id={`${id}-help`} aria-live="polite">
				{answer === 'short' && 'Use at least 8 characters.'}
				{listed && 'This password is on a list of common passwords. Choose another.'}
			</p>
		</>
	);
}

08 / Make the call

Ask what a wrong maybe costs, and what a wrong no would cost.

Reach for a Bloom filter when an exact lookup is expensive, a false yes only costs that lookup, and a false no would be unacceptable. Screening against a blocklist, skipping files that cannot hold a key, and avoiding a trip for something you may not have all fit.

Look elsewhere when the question changes. A list that fits comfortably in memory, or that loses entries often: a hash set, which is exact and removes cleanly. Something stored beside each value: a hash map. Removal without rebuilding: a counting Bloom filter, which keeps a small counter per position at three to four times the space. Less memory for the same rate: RocksDB’s Ribbon filter saves about 30% of the space for three to four times the CPU. How often each value appears, or how many distinct values there are: a count-min sketch or HyperLogLog, the other approximate structures in this catalog. And if nothing exact stands behind the maybe, decide whether the false positive rate is a rate your users can live with.

09 / Take the idea with you

Explain it without saying “Bloom filter.”

“I keep a row of bits. Each password on the list turns on a few of them, picked by its hashes. To check a password, I look at its bits. If any is off, it was never added. If they are all on, it might have been, so I ask the real list. I never turn a bit off, because other passwords may share it.” That describes the mechanism. The name is what you call it in a review.

Before moving on, explain three things without the name: why “tidepool lantern” needed only one bit, why “velvet thunder” needed the full list, and why clearing princess’s bits would let sunshine through. Then find an expensive lookup in your own code that usually comes back empty, and decide what a wrong maybe would cost there.

Connections to follow nextRelated lessons
  • Hash set answers the same question exactly, keeps every value, and removes one by leaving a tombstone.
  • Hash map hashes the same way and keeps a value beside each key.
  • Count-min sketch and HyperLogLog trade exact answers for small, fixed memory in the same spirit: how often, and how many different.