April 22, 2026
· 11 min readHow Big Tech Checks Username Uniqueness at Billion-User Scale
When you type a username and instantly see 'already taken', there's a multi-layer stack doing the work: a probabilistic filter, an in-memory cache, a distributed index, and a load balancer in front of all of them. This post breaks down each data structure, its trade-offs, and how Google, Meta, and Amazon combine them to answer one simple yes/no question in milliseconds across billions of users.

TL;DR
- The "is this username taken?" check looks trivial but becomes a serious system design problem at billion-user scale — a naive
SELECT COUNT(*)on a hot table will crumble. - Big platforms layer four structures: Bloom filters (memory-cheap first filter), Redis/Memcached (hot cache), B+ tree indexes (sorted authoritative store), and tries (for autocomplete and suggestions).
- Bloom filters give a fast "definitely not taken" answer using about 1.2 GB for 1 billion usernames at 1% false positive rate — roughly 16x less memory than storing the raw strings.
- Cassandra, Chrome, and Bigtable all use Bloom filters internally to skip disk reads. Instagram uses PostgreSQL + Cassandra + Memcached for its user data layer.
- The end-to-end flow: global load balancer → regional data center → app server with in-memory Bloom filter → distributed cache → authoritative distributed database.
Why username uniqueness is a real scaling problem
The check feels trivial. The user types a name, you run a query, you respond. At 10,000 users, it is trivial — a SELECT 1 FROM users WHERE username = ? with a unique index is done in a millisecond.
The problem is what happens when you have 2 billion accounts and tens of thousands of signups per second. At that scale:
- Every signup flow hits the username check, often multiple times as the user tries variations.
- Malicious traffic can weaponize the endpoint — availability probes, username enumeration.
- The authoritative username index may not fit cleanly on one machine, so every query becomes a distributed lookup.
- Any cache miss that falls through to the primary database competes with actual writes for the same hot index.
The goal of the architecture we're about to build: keep the happy-path check in sub-millisecond territory, and make sure 99%+ of availability queries never touch the authoritative database at all.
Layer 1: The Redis hash — exact-match cache
The fastest check is the one you answer from RAM without thinking. Redis hashes (HSET, HEXISTS) let you store username → small value pairs under one key, and membership checks are constant time.
# Recently checked or recently taken usernames
HSET usernames:taken "saurabh" 1
HSET usernames:taken "thakurcoder" 1
# Check in O(1)
HEXISTS usernames:taken "bitemong"Where this wins: popular usernames, recently registered accounts, and hot prefixes that get probed repeatedly. If the key is in the hash, you respond in microseconds and never touch the database.
Where it breaks: you can't fit 2 billion usernames in a single Redis instance. Even sharded, Redis is a cache, not a source of truth. It's the first layer, not the only layer.
💡 Tip: Populate this cache on writes (new signup) and on reads (cache miss falls through to DB, result cached with TTL). Don't try to preload it with the whole user table.
Layer 2: Tries — when you need more than yes/no
A trie (prefix tree) stores strings character-by-character along tree paths. Two usernames that share a prefix share the same path up to the point they diverge.
root
|
t
|
h
/ \
a u (thakur...)
|
k (thakur...)Lookups run in O(M) where M is the string length, independent of how many usernames are in the tree. A billion usernames, same lookup cost per query as ten thousand.
The real payoff isn't yes/no — hash maps already win at that. The payoff is:
- Prefix queries: "show me all usernames starting with
thakur" — natural for autocomplete. - Suggestion generation: when
thakurcoderis taken, walk nearby branches to suggestthakurcodes,thakurdev. - Prefix compression: shared prefixes only get stored once.
The catch: raw tries can be memory-hungry because every node carries pointer overhead. Production systems use compressed tries (radix trees) that collapse single-child chains into one node, or they scope the trie to hot data — recent registrations, popular prefixes — rather than the full dataset.
Layer 3: B+ trees — the sorted authoritative index
B+ trees are the workhorse index behind almost every SQL database and most NoSQL ones. They keep keys sorted, support range queries, and stay shallow thanks to very high fanout.
B+ trees have very high fanout — number of pointers to child nodes in a node, typically on the order of 100 or more — which reduces the number of I/O operations required to find an element in the tree. That high fanout is the whole magic: you fit hundreds of keys per disk block, so the tree stays very flat.
How flat, in practice? With a typical order of 100 and a fill factor of 67%, the average node fanout is around 133, so a height-4 B+ tree holds roughly 312 million records. A height-5 tree crosses well past a billion. One reference implementation shows that with a fanout of 100, a 1-billion-row dataset requires only 5 disk seeks per lookup; with fanout 1000, it drops to 3.
So when you hear "B+ tree lookup on a billion rows," the real number is 3 to 5 reads, not 30.
-- The unique index is what makes this fast.
CREATE UNIQUE INDEX idx_users_username ON users (username);
-- Cost: O(log_F N) tree traversal, F ≈ 100-1000.
SELECT 1 FROM users WHERE username = 'bitemong';Why B+ trees and not hashes in the database itself?
| Need | Hash index | B+ tree |
|---|---|---|
| Exact match | ✅ O(1) avg | ✅ O(log F N) |
Range scans (username LIKE 'abc%') |
❌ | ✅ |
| Sorted iteration | ❌ | ✅ |
| Distributable | Harder | Easier (sharded key ranges) |
| Persistent & crash-safe | Complex | Standard |
Systems like Google Cloud Spanner distribute a sorted keyspace backed by B-tree-like structures across many machines, which lets you keep UNIQUE(username) semantics while scaling horizontally.
Layer 4: Bloom filters — the probabilistic first guard
Here is the structure that makes the whole stack cheap to run. A Bloom filter is a bit array plus k hash functions. To add an item, hash it k times, set those bit positions to 1. To check an item, hash it the same way — if any of those bits is 0, the item was definitely never added.
def add(bf, item):
for h in hash_functions:
bf.bits[h(item) % len(bf.bits)] = 1
def maybe_contains(bf, item):
return all(bf.bits[h(item) % len(bf.bits)] == 1
for h in hash_functions)Breaking it down:
- No false negatives. If it says "not in set," that is the truth.
- Bounded false positives. "In set" might be wrong — the bits could have been flipped by other items colliding.
- No stored strings. Just a bit array. That's where the memory magic comes from.
A Bloom filter for 1 billion items with a 1% false positive rate needs only about 1.2 GB of memory — that's 16x less than the roughly 20 GB a hash set of 15-character usernames would require.
That is the number that unlocks the whole architecture. You can hold a filter for every taken username of a billion-user platform in single-digit gigabytes of RAM, cheaply replicated to every application server.
Who uses them? Cassandra, Chrome, and Medium all use Bloom filters. Cassandra puts one on every SSTable and skips the file entirely when the filter says the key is definitely not there. RocksDB's newer Ribbon filter hits a 1% false positive rate at just 7 bits per key. In Cassandra specifically, Bloom filters are maintained per SSTable — each file on disk gets a corresponding filter in memory — and they answer "data definitely does not exist" or "data probably exists," which is exactly the two-state answer the read path needs.
⚠️ Warning: Standard Bloom filters don't support deletion — removing an item could flip bits shared with other members and create false negatives. If you need delete semantics (e.g., handle freed usernames), use a counting Bloom filter or rebuild the filter periodically from the authoritative store.
The data structure comparison
| Structure | Lookup | Memory (1B items) | Best at | Trade-off |
|---|---|---|---|---|
| Redis hash | O(1) | ~30–50 GB | Hot cache, exact match | Expensive to hold all data; single-instance memory ceiling |
| Trie | O(M) | High, unless compressed | Prefix queries, autocomplete | Pointer overhead on sparse prefixes |
| B+ tree | O(log_F N), ~3–5 I/O | Disk-resident, compact | Authoritative index, range scans | Distributed updates are complex |
| Bloom filter | O(k) | ~1.2 GB @ 1% FPR | Skip-the-DB first filter | False positives; no deletions |
No one structure wins. The right answer is a stack where each layer filters traffic before it reaches the next.
How the layers come together
Here's what actually happens when someone types bitemong into a signup form at a platform the size of Instagram or Reddit.
A few things to notice about this flow:
Global load balancing routes the request to the closest regional data center. On AWS, this is typically Amazon Route 53 using latency-based or geolocation routing. The user in Europe lands on an EU cluster, the user in India lands on an ap-south cluster.
Local load balancing inside the data center — NGINX, HAProxy, or AWS ELB — distributes requests across app servers. Each app server holds a copy of the Bloom filter in its own memory, periodically resynchronized from a central source. The filter is not a separate service; it is a small data structure embedded in the application process.
The Bloom filter is the bouncer. It runs before any network hop to a cache or database. On a 1% FPR filter, 99 out of 100 "username not taken" checks return in memory with zero I/O. That is the single biggest cost reduction in the whole system.
The cache catches the rest. Most of the remaining 1% will be hits on hot usernames that are probed repeatedly. Memcached or Redis serves those in microseconds.
The database is the last resort. Only genuine cache misses fall through to the distributed authoritative store. This is the Instagram model in practice — PostgreSQL handles critical transactional data like user authentication, Cassandra handles high-volume scalable workloads, and Memcached sits in front of both with automatic invalidation when writes hit the underlying stores.
Picking which layer to reach for
Use this map when you are designing the check:
| Question you're answering | Right structure |
|---|---|
| "Does this exact username exist?" (hot path) | Bloom filter → Redis → B+ tree |
"Suggest available usernames starting with thakur" |
Trie / radix tree |
| "Get all users whose username starts with a letter for analytics" | B+ tree range scan |
| "Is this probably a registered user, for rate limiting?" | Bloom filter alone |
| "Authoritative 'is this username mine to register now?'" | B+ tree with UNIQUE constraint (never skip this layer) |
Production best practices
- Keep the UNIQUE constraint at the database level. The Bloom filter and cache are performance layers, not correctness layers. The database is the only source of truth.
- Size the Bloom filter for your real load. Aim for a 1% false positive rate as a default. Lower FPR (0.1%) means more memory and roughly a 50% size increase for every halving of the rate.
- Rebuild Bloom filters periodically. Standard Bloom filters don't support deletes. A nightly rebuild from the authoritative store handles freed usernames and keeps the filter from drifting.
- Use a counting Bloom filter if deletes are frequent. Each bit becomes a small counter; decrement on delete. Costs more memory but supports removals correctly.
- Set sensible cache TTLs. A few minutes for "taken" entries (they shouldn't change), shorter for "available" entries so recently taken names are invalidated quickly.
- Rate-limit the endpoint. Uniqueness checks are a prime target for username enumeration attacks. Throttle per-IP and require a CAPTCHA or token after N checks.
- Measure cache-hit and Bloom-filter-reject rates. If your Bloom filter isn't rejecting 90%+ of lookups, it's either too small, too full, or misconfigured.
- Don't skip locking on the actual registration path. Checking availability is one thing; reserving the name is another. Use a transaction with the UNIQUE constraint, and handle the constraint violation as the authoritative "already taken" signal.
When this stack is overkill
Most applications don't need any of this. If you have under a million users and check availability at sub-100 QPS, a single PostgreSQL instance with a unique index on username is not just sufficient — it's the right answer. Adding a Bloom filter, a Redis cache, and a trie in front of that is operational complexity without benefit.
This stack starts paying off when one or more of these is true:
- You have hundreds of millions of users or more.
- Username-check QPS is high enough to be a meaningful fraction of database load.
- You need autocomplete or suggestions built into the signup flow.
- You're building a platform that will be probed or attacked at the signup endpoint.
Otherwise, reach for a UNIQUE constraint, put a modest cache in front if you have to, and come back to this architecture when the numbers justify it.
Conclusion
I think about this stack every time I hit a "username already taken" message and it responds in 50 ms. It's not one clever data structure — it's four of them, arranged so the cheapest ones filter the traffic before the expensive ones ever see it. Bloom filters answer almost everything in RAM. Redis catches the rest. Tries handle suggestions. B+ trees hold the truth.
If you're building your own platform, the lesson isn't "always build this pyramid." It's match the structure to the question you're asking: exact match, prefix match, ordered scan, or "definitely-not-present." Start with a UNIQUE constraint, measure where the pain lands, and add the next layer only when the numbers justify the operational cost. The engineering is in knowing which layer to reach for, not in stacking them all.
FAQ
Why can't I just use a SQL UNIQUE constraint for username checks?
A UNIQUE constraint works perfectly well — up to a point. It enforces correctness via a B-tree index and is the right default for small and medium applications. The problem at billion-user scale is latency and load: every failed signup attempt hits your primary database, and the index itself may not fit cleanly on a single machine. Big platforms keep the UNIQUE constraint as the source of truth but put Bloom filters and caches in front of it so 99%+ of 'is this taken?' queries never touch the DB.
How much memory does a Bloom filter need for 1 billion usernames?
Around 1.2 GB at a 1% false positive rate — that's roughly 16x less than storing the raw strings in a hash set. The exact number depends on the false positive rate you choose: lower FPR means more memory. The key property is that Bloom filters store no actual strings, only bits set by hash functions.
Why do Bloom filters have false positives but no false negatives?
Because of how hashing works. When you add a username, you flip several bits to 1. To check membership, you hash the candidate and look at those same bits. If any bit is 0, the username was definitely never added (no false negatives). If all bits are 1, it might be a real member — or it might be the collateral of other usernames that happened to flip those same bits (false positive). This asymmetry is exactly why Bloom filters are useful as a first filter: a 'no' is trustworthy, a 'yes' just means 'go check the real store'.
Does a trie really save memory, or does it use more?
It depends on overlap. Tries shine when many strings share prefixes — 'alex', 'alexa', 'alexander' all reuse the 'alex' path. If your usernames are mostly unique and random, a trie can actually use more memory than a hash set because of pointer overhead per node. That's why production systems use compressed variants like radix trees, or limit tries to hot data like autocomplete caches for recently searched prefixes.
How deep is a B+ tree index for 1 billion usernames?
Typically 4 to 5 levels, not 30. B+ trees have very high fanout — often 100 or more children per node — so the height grows as log base F of N where F is the fanout. With a fanout of 133 (a common production average), a height-4 tree holds about 312 million records and a height-5 tree holds around 40+ billion. In practice, 1 billion rows means 4–5 disk or memory reads per lookup.
Which database do real companies actually use for username storage?
It varies by platform, but the pattern is usually a relational system (PostgreSQL, MySQL) or a wide-column store (Cassandra, DynamoDB) with a unique index on the username column. Instagram famously uses PostgreSQL for authoritative user data and Cassandra for high-volume non-relational workloads. The database itself uses Bloom filters and B-tree variants internally to make lookups fast.
Why can't I just cache everything in Redis and skip the database?
Memory cost and durability. Storing 2 billion usernames (average 15 chars) as Redis hash entries would cost tens of GB of RAM per replica, plus you'd lose the ordered-lookup and transactional guarantees a real database gives you. Redis is the first line of speed, not the source of truth.