# How Username Availability Checks Answer in Milliseconds

You type a username, stop for a moment, and a red line says the name is taken. Two free suggestions appear underneath. There is no spinner and no page load. The question is how a system with millions of accounts answers that quickly, and the answer is less magic than it looks.

This post does not claim to describe how Gmail works internally. It builds a standard architecture instead: a canonical key, a Bloom filter, a Redis cache and a PostgreSQL unique index. Everything below runs as real code against Redis 7 and PostgreSQL 16, with tests, and the numbers are measured rather than quoted.

The short version is that no layer is clever on its own. Each layer exists to avoid the cost of the next one, and only the last layer is allowed to be the truth. The availability check is advisory; the unique index decides whether a signup succeeds. Here is the path one check takes.

![Mermaid Diagram](https://mermaid.ink/img/pako:eNpdkk-P0zAQxb_KMKciudKycMoB1L8CCSTYFoRUc5g6k8bU8US222y26XdHSUsX9mi_N795z_IJjeSMGRZOGlNSSLCeaw8AMNloXLe19Tuo6RA5wujt3R1UEXLeysEbfq3xF4zH72G60WjIi7eGnH3ikadqUC-kaW_qrD-Ss3kHPzcaH_g3mwSNTSUQBKYo_oX_6p6dNE6dSAWFdYlDBhW1W4ZEe_YfNJ4vM7NhRqMXBba4esFGMIcQ2CeNHSw3GidHso62jmFE-dFGCe1z0Atk4HcwP_Uxcxsz2Htp_MuN88Hccuxg3T9Vr95IF9FLB4uTxq8S0y7w6tvnDII0wI82pviMWvyDuv_LUtBQqCCVDIZMyTf24sZe3v_f6Nq6IBcZaok22SODBMjZceIcyBg5-NQ3RoUVh4psjtkJU8lV_w1yLujgEqrLzQ8KtkfH3lOIT0uqrGsxwzHVteNxbGPiSsHUWb__QmY1nJfikwKNK94Jw_dPGhU8yFaSKPjI7sjJGlIwCZacgkg-jiMHW6AalqzsU5_lzbv6Ec9nhdvdTJwEzPBVU9rEeP4DqTfkRA?type=png)

A Bloom-filter negative is a fast answer only while the filter is known to contain every committed username. A stale or incompletely rebuilt filter can miss a real account. The unique index still prevents duplicate signups, but the check can briefly show “available” when the name has already been claimed. The maintenance section below covers that trade-off.

## Step 1: Decide what “the same name” means

Before any lookup, turn what the person typed into one canonical string, and use only that string everywhere: in the filter, in the cache and in the database. Google's help page on Gmail addresses [says dots do not matter](https://support.google.com/mail/answer/7436150), and that anyone who tries to register a dotted version of an existing username gets an error saying the username is already taken. That is a normalization rule applied before the lookup.

The version below folds Unicode compatibility forms with NFKC, lowercases, enforces length and shape, and blocks reserved names. It rejects look-alike letters from other scripts instead of trying to map them. A Cyrillic letter that looks like a Latin `a` fails validation. So does the dotted Turkish capital I, because lowercasing it leaves a combining mark that the shape check refuses.

```typescript
// letters and digits, with single . _ - separators between them
const SHAPE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;

/**
 * Turn whatever the user typed into the one string that is stored, hashed,
 * cached and compared. Every layer (Bloom filter, Redis, Postgres) must see
 * this value and never the raw input.
 */
export function canonicalize(raw: string, opts: CanonicalizeOptions = {}): Canonical {
  const { minLength = 3, maxLength = 30, ignoreDots = false, reserved = DEFAULT_RESERVED } = opts;

  // NFKC folds compatibility forms (full-width letters, ligatures) into plain ones
  let s = raw.normalize('NFKC').trim().toLowerCase();
  if (ignoreDots) s = s.replaceAll('.', '');

  if (s.length === 0) return { ok: false, reason: 'empty' };
  if (s.length < minLength) return { ok: false, reason: 'too_short' };
  if (s.length > maxLength) return { ok: false, reason: 'too_long' };
  if (!SHAPE.test(s)) return { ok: false, reason: 'invalid_chars' };
  if (reserved.has(s)) return { ok: false, reason: 'reserved' };
  return { ok: true, value: s };
}
```

Ignoring dots is a product decision, so it is off by default here (`ignoreDots`). If you want Gmail's rule, switch it on so the dots are removed before the unique index sees the name, because the index can only compare what you hand it.

## Step 2: Let the PostgreSQL unique index be the truth

PostgreSQL enforces uniqueness through an index. Its documentation says only B-tree indexes can be declared unique, and that defining a unique constraint creates the index that enforces it ([PostgreSQL docs, Unique Indexes](https://www.postgresql.org/docs/current/indexes-unique.html)). The table needs no extra lookup structure. Keep what the person typed in one column, the canonical form in another, and put the constraint on the canonical column.

```sql
create table if not exists users (
  id                 bigint generated always as identity primary key,
  username           text        not null,  -- exactly as the person typed it
  username_canonical text        not null,  -- what we compare on
  created_at         timestamptz not null default now(),
  constraint users_username_canonical_key unique (username_canonical)
);
```

How fast is that lookup? I loaded one million rows into PostgreSQL 16.15 and ran `EXPLAIN ANALYZE` on one name. The plan was an index-only scan with zero heap fetches and four shared buffer hits, and execution took 0.043 ms (0.046 ms in a second run). The unique index for one million rows is 38.9 MB. At this size, the database is not the slow part.

```text
Index Only Scan using users_username_canonical_key on users (actual time=0.025..0.026 rows=1 loops=1)
  Index Cond: (username_canonical = 'u424242'::text)
  Heap Fetches: 0
  Buffers: shared hit=4
Planning Time: 0.057 ms
Execution Time: 0.043 ms
```

So why put anything in front of it? Because a sign-up page asks this question every time typing pauses, for every visitor, and most of the names asked about are free. Each of those checks still costs a network hop, a pooled connection and an index probe. The next layers exist to answer the common case without that hop.

## Step 3: A Bloom filter answers “definitely free” cheaply

A Bloom filter is a bit array plus _k_ hash functions. To add a name you set _k_ bits. To test a name you read the same _k_ bits: if any is zero the name was not added to that filter, and if all are one it probably was. Burton Bloom described the structure in [1970](https://doi.org/10.1145/362686.362692). A correctly populated and updated Bloom filter has no false negatives, so “no” is certain and “yes” means maybe ([Wikipedia, Bloom filter](https://en.wikipedia.org/wiki/Bloom_filter)).

That guarantee is about the data structure, not the whole application. If a process crashes after a database commit but before the filter is updated, or if a rebuild misses a concurrent registration, the live filter can be stale and return a negative for a name that exists. The unique index still protects signup correctness, but the preflight availability answer can be wrong until the filter is repaired. A production service must decide whether to accept that short advisory inconsistency or use a stronger update/recovery strategy.

Sizing needs two inputs: how many names you expect and how many false “maybes” you accept. The [Redis BF.RESERVE documentation](https://redis.io/docs/latest/commands/bf.reserve/) lists the results. A 1% error rate needs 7 hash functions and 9.585 bits per item, 0.1% needs 10 and 14.378, and 0.01% needs 14 and 19.170. My sizing function reproduces all three in a unit test. It rounds the hash count up, which is how the documented counts come out.

```typescript
export function bloomParams(expectedItems: number, falsePositiveRate: number): BloomParams {
  const bits = Math.ceil((-expectedItems * Math.log(falsePositiveRate)) / (Math.LN2 * Math.LN2));
  const hashes = Math.max(1, Math.ceil((bits / expectedItems) * Math.LN2));
  if (bits >= 2 ** 32) throw new Error('Redis bitmaps are limited to 2^32 bits');
  return { bits, hashes };
}
```

Instead of _k_ separate hash functions, I use double hashing. One SHA-256 call is split into two 32-bit numbers, `h1` and `h2`, and bit _i_ is `h1 + i × h2`, modulo the filter size. Wikipedia's article notes that independence between the hash functions can be relaxed with a negligible increase in false-positive rate, and cites enhanced double hashing as one way to derive the _k_ indices.

```typescript
export function bloomIndexes(item: string, { bits, hashes }: BloomParams): number[] {
  const digest = createHash('sha256').update(item).digest();
  const h1 = digest.readUInt32LE(0);
  const h2 = (digest.readUInt32LE(4) | 1) >>> 0;
  const out = new Array<number>(hashes);
  for (let i = 0; i < hashes; i++) out[i] = (h1 + i * h2) % bits;
  return out;
}
```

Redis documents Bloom-filter commands of its own (`BF.RESERVE`, `BF.ADD`, `BF.EXISTS`). I stored the filter as a plain bitmap with `GETBIT` and `SETBIT` instead, so it runs on any Redis, and I ran it on 7.0.15 without any module. All _k_ reads go out in one pipeline, which is one network round trip. Redis refuses a bit offset of 2^32 or more (I checked on 7.0.15), so the sizing function throws before you can build a filter that large.

```typescript
export class RedisBloom {
  readonly redis: Redis;
  readonly key: string;
  readonly params: BloomParams;

  constructor(redis: Redis, key: string, params: BloomParams) {
    this.redis = redis;
    this.key = key;
    this.params = params;
  }

  async has(item: string): Promise<boolean> {
    const pipe = this.redis.pipeline();
    for (const i of bloomIndexes(item, this.params)) pipe.getbit(this.key, i);
    const replies = await pipe.exec();
    return (replies ?? []).every(([err, bit]) => {
      if (err) throw err;
      return bit === 1;
    });
  }

  async add(item: string): Promise<void> {
    const pipe = this.redis.pipeline();
    for (const i of bloomIndexes(item, this.params)) pipe.setbit(this.key, i, 1);
    const replies = await pipe.exec();
    for (const [err] of replies ?? []) if (err) throw err;
  }
}
```

At one million names and a 1% target, the bit array is 1,198,133 bytes, and Redis reports 1,310,776 bytes for the stored key. A Redis set holding the same million names reports 47,588,720 bytes, which is 36.3 times more. I filled an in-process copy of the filter to its design capacity and probed one million names that were never added: 1.01% came back “maybe”, and 51.8% of the bits were set.

Pushing a second million names into the same filter, twice its design capacity, raised the false-positive rate to 15.8%. Remember that number when you size one. An undersized filter does not fail loudly. It quietly sends more traffic to the database. Redis's own filter adds sub-filters when it reaches capacity, and [its documentation](https://redis.io/docs/latest/commands/bf.reserve/) says performance degrades linearly with the number of sub-filters.

### Do you need the filter at all?

Here is the part that surprised me. My bitmap Bloom filter in Redis was not faster than a plain Redis set. `SISMEMBER` is [O(1)](https://redis.io/docs/latest/commands/sismember/), and over loopback its median was 0.076 to 0.093 ms across two runs, against 0.126 to 0.148 ms for the seven pipelined `GETBIT` calls. The filter buys memory savings, not raw speed.

Memory is the real gain. At 9.585 bits per name, a hundred million names is about 120 MB of bits. The in-process version of the same filter, which the tests and benchmark use, probed in 3.4 to 4.5 microseconds per name including the SHA-256 call. A copy like that can sit inside each app server and answer the common case with no network hop at all.

It also has a cost: every server needs a way to refresh its copy from a shared source. For that reason the code here keeps one shared copy in Redis. My advice for a young platform is to build the canonical key, the unique index, the guarded `INSERT`, the debounce and the rate limit first (steps 1, 2, 5, 8 and 9). The measured lookup above is already about 0.2 ms over loopback. Add the filter when database lookups show up in your metrics.

## Step 4: Cache “taken”, never “free”

Cache only the answer “taken”. A name that is free now can be claimed a second from now, so a cached “free” is a lie waiting to happen. A taken name stays taken until the account is deleted, which makes it safe to cache with an expiry as long as deletion invalidates the key. The cache earns its place on popular names that the filter correctly reports as maybe taken, because it spares the database from being asked about them again.

```typescript
export class RedisTakenCache {
  readonly redis: Redis;
  readonly ttlSeconds: number;

  constructor(redis: Redis, ttlSeconds = 24 * 60 * 60) {
    this.redis = redis;
    this.ttlSeconds = ttlSeconds;
  }

  async isTaken(canonical: string): Promise<boolean> {
    return (await this.redis.exists(`taken:${canonical}`)) === 1;
  }

  async markTaken(canonical: string): Promise<void> {
    await this.redis.set(`taken:${canonical}`, '1', 'EX', this.ttlSeconds);
  }
}
```

Here is the check itself. A filter negative returns immediately only under the assumption that the filter is current. Otherwise the cache is asked, then PostgreSQL, and a hit in PostgreSQL warms the cache. Released names can remain cached as taken until the expiry, which is 24 hours by default here, so delete the cache key when an account is deleted if you want the name back sooner.

```typescript
/** The fast, advisory answer shown while the person types. */
async check(raw: string): Promise<CheckResult> {
  const c = canonicalize(raw, this.norm);
  if (!c.ok) return { available: false, via: 'validation', reason: c.reason };

  // A negative is definitive only while the filter is known to be current.
  if (!(await this.deps.bloom.has(c.value))) return { available: true, via: 'bloom' };

  // A positive is only "maybe". Try the cache, then settle it in Postgres.
  if (await this.deps.cache.isTaken(c.value)) {
    return { available: false, via: 'cache', reason: 'taken' };
  }
  if (await this.deps.store.exists(c.value)) {
    await this.deps.cache.markTaken(c.value);
    return { available: false, via: 'db', reason: 'taken' };
  }
  return { available: true, via: 'db' }; // Bloom false positive, or a deleted account
}
```

On loopback, a free name was answered in a median of 0.13 to 0.14 ms, and 4,943 of 5,000 were settled by the filter alone. The other 57, or 1.14%, were false positives that went on to PostgreSQL and came back free. A taken name with a cold cache took 0.59 to 0.61 ms at the median, because it makes a filter call, a cache call, a database call and a cache write. The same names again, from the warm cache, took 0.22 to 0.24 ms.

These timings describe a healthy, already-populated filter. If the filter is missing entries because of a failed update or an incomplete rebuild, a negative may incorrectly show “available”. Signup remains safe because the database unique index is authoritative, but the UI may be misleading until the filter is repaired.

## Step 5: Claiming the name, where the check stops mattering

Everything so far is advisory. Between the moment two people both see “available” and the moment they submit, the answer can change. The only safe way to claim a name is an `INSERT` that the unique index is allowed to refuse.

![Mermaid Diagram](https://mermaid.ink/img/pako:eNqlkUGP2jAQhf_KdM4O2pV6yiFSAqWLRANd0z1xmZghsXDs1HZgdxH_vQrZSqVIvfQ4872Z98Y-o3I7xhQD_-zZKp5pqj21WwsA0JGPWumObIQcKIDUtU36DvJ7XvzJi3sur5z9kf09XA9w7UKsPcvvy1GQJ1kmU1ANqwNYPllqeSQySbIsT4GOpA1V5qNd_HuguBv4cAh91er4W5ll6xQWpfzyvIHJZAKrEqarcr5cTDcwW0G52jwtyq-jep2MGx7Bu9Nf2ZRyvY2gPFPk3U3C_zd8GAzD7XGRDmwFqMa5wEDWxYY9CmzZt6R3mJ4xNtwOv73jPfUmohg7L-T18Cph0OydjXNqtXnDFBPqOsNJeAuRWwGF0fbwjZS81nNno4AtSq4dw4_FFgU8u8pFJ-CJzZGjViQg95qMgEA2JIG93qO4mkj9PmR5_Ny94uUisKqnzjiPKX46NToyXn4BH6DP7A?type=png)

PostgreSQL's `INSERT` with `ON CONFLICT DO NOTHING` turns that refusal into an ordinary result: zero rows returned means someone else got there first ([PostgreSQL docs, INSERT](https://www.postgresql.org/docs/current/sql-insert.html)). The same page warns that inserts into a table with unique indexes can block while a concurrent session is working on a matching value, so a second sign-up may wait for the first.

```typescript
/** The real arbiter. Returns the new id, or null if the unique index said no. */
async insert(display: string, canonical: string): Promise<number | null> {
  const r = await this.pool.query<{ id: string }>(
    `insert into users (username, username_canonical)
     values ($1, $2)
     on conflict (username_canonical) do nothing
     returning id`,
    [display, canonical],
  );
  return r.rowCount === 1 ? Number(r.rows[0].id) : null;
}
```

The service wraps that call. On success it teaches the filter and the cache. On a lost race it must do the same: the winning request may have raced with a stale or incomplete filter, so marking only the Redis cache is not enough. A cache hit is never consulted when the Bloom filter says “definitely free”.

```typescript
/** The authoritative answer. The unique index decides, never the filter. */
async register(raw: string): Promise<RegisterResult> {
  const c = canonicalize(raw, this.norm);
  if (!c.ok) return { ok: false, reason: c.reason };

  const id = await this.deps.store.insert(raw.trim(), c.value);
  if (id === null) {
    // The name exists. Update both derived indexes, not just the cache.
    await Promise.all([
      this.deps.bloom.add(c.value),
      this.deps.cache.markTaken(c.value),
    ]);
    return { ok: false, reason: 'taken' };
  }

  // The row is committed. Keep both derived indexes in step with it.
  await Promise.all([
    this.deps.bloom.add(c.value),
    this.deps.cache.markTaken(c.value),
  ]);
  return { ok: true, id };
}
```

There is an important reliability caveat in this simplified implementation: the SQL insert can commit and a subsequent Redis operation can fail. The account then exists even if the request reports an error. In production, do not assume a Redis write can roll back a committed database row, and do not silently swallow failures. A common solution is a transactional outbox: write a durable index-maintenance event in the same database transaction as the account change, then have a worker retry the Redis updates until they succeed. A scheduled rebuild is a useful safety net, but it is not a substitute for durable retry if you need quick recovery.

I tested it by firing 50 simultaneous sign-ups for one name through a pool of 20 connections. Exactly one succeeded, 49 were told the name was taken, and the table held one row. Different spellings that normalize to the same name, such as a full-width version of the letters, lose the same way.

If you would rather not store a canonical column, a unique index on `lower(username)` also works, and `ON CONFLICT` accepts an index expression as its target. Every query must then repeat the expression exactly, and any normalization beyond lowercase has to live in SQL.

## Step 6: Keep the filter honest

Each layer above the database is allowed to be wrong, but only in a direction the next layer catches. A Bloom filter cannot delete, so a deleted account leaves a ghost “maybe taken” that PostgreSQL clears up on the next check. My test deletes an account, clears the cache, and confirms the name comes back as available through the database path.

The dangerous direction is a filter missing a name that exists. This can happen if the process dies after the commit and before the filter update, if Redis rejects an update, or if a rebuild overlooks a concurrent registration. The person may see “available”, submit, and then have the unique index refuse the signup. It is an annoying moment for the user but never creates a duplicate account. The test deletes the whole filter to simulate a missing entry, confirms the stale answer, confirms the database refusal, then repairs the filter.

Repair is a scan of the unique index in keyset pages, written into a side key and swapped in with `RENAME`, which replaces the live key in one step. Run it at boot before treating Bloom negatives as authoritative, and on a schedule if you can afford the read load.

The simple swap below has a gap that the code does not close. A name registered while the scan is running is added to the old key, so it is missing from the new one after the swap. The unique index still refuses a duplicate, and the next rebuild picks the name up. To close the gap, write new names to both keys while a rebuild is running, or use a versioned rebuild process that captures changes occurring during the scan.

```typescript
/** Repair step: re-add every committed username. Run on boot and on a schedule. */
export async function rebuildBloom(store: UserStore, target: Pick<Bloom, 'add'>): Promise<number> {
  let n = 0;
  for await (const batch of store.scanCanonical()) {
    await Promise.all(batch.map((name) => target.add(name)));
    n += batch.length;
  }
  return n;
}
```

```typescript
const next = new RedisBloom(redis, `${BLOOM_KEY}:next`, bloom.params);
const n = await rebuildBloom(store, next);
assert.equal(n, 2);
await redis.rename(`${BLOOM_KEY}:next`, BLOOM_KEY);
```

If you need real deletion, counting Bloom filters and cuckoo filters support it. Wikipedia's article says counting filters use three to four times the space of a static one, and describes cuckoo filters as supporting deletion with better locality of reference ([Wikipedia, Bloom filter](https://en.wikipedia.org/wiki/Bloom_filter)).

## Step 7: Suggestions are the same lookup in bulk

The row of suggestions under the error is the same problem asked in bulk. Build a handful of candidates from the typed name. Accept the ones the filter says are certainly free, subject to the same filter-freshness caveat. Send the rest to PostgreSQL in a single query with `= ANY(...)`, so one round trip covers every candidate. How any particular site picks its candidates is its own business. Mine appends a random number below 100, 1,000 or 10,000.

```typescript
/** Suggestions are the same lookup in bulk: build candidates, filter, one query. */
async suggest(raw: string, count = 3): Promise<string[]> {
  const c = canonicalize(raw, this.norm);
  if (!c.ok) return [];

  const max = this.norm.maxLength ?? 30;
  const candidates = new Set<string>();
  for (let attempts = 0; candidates.size < count * 4 && attempts < count * 40; attempts++) {
    const digits = String(Math.floor(this.random() * 10 ** (2 + Math.floor(this.random() * 3))));
    const name = `${c.value}${digits}`;
    if (name.length <= max && canonicalize(name, this.norm).ok) candidates.add(name);
  }

  const list = [...candidates];
  const maybeTaken: string[] = [];
  const free: string[] = [];
  for (const name of list) {
    if (await this.deps.bloom.has(name)) maybeTaken.push(name);
    else free.push(name);
  }
  const reallyTaken = await this.deps.store.takenAmong(maybeTaken);
  const stillFree = maybeTaken.filter((n) => !reallyTaken.has(n));
  return [...free, ...stillFree].slice(0, count);
}
```

```typescript
/** Many probes, one round trip. Used for suggestions. */
async takenAmong(candidates: string[]): Promise<Set<string>> {
  if (candidates.length === 0) return new Set();
  const r = await this.pool.query<{ username_canonical: string }>(
    'select username_canonical from users where username_canonical = any($1::text[])',
    [candidates],
  );
  return new Set(r.rows.map((row) => row.username_canonical));
}
```

The test registers the first three suggestions, then asks again with the same random seed. The second set shares no name with the first.

## Step 8: Make the client feel instant

What the person feels is mostly not the server. Typing fires events faster than any server should be asked, so the browser waits for a pause (300 ms here), sends one request, and aborts the previous one if a new keystroke arrives. Without the abort, a slow reply for “adit” can land after the reply for “aditya” and show the wrong verdict.

```typescript
export function createAvailabilityChecker(opts: CheckerOptions) {
  const { fetcher, onResult, onError, delayMs = 300 } = opts;
  let timer: ReturnType<typeof setTimeout> | undefined;
  let inFlight: AbortController | undefined;

  return {
    input(name: string): void {
      clearTimeout(timer);
      inFlight?.abort();
      if (name.trim() === '') return;

      timer = setTimeout(async () => {
        const mine = new AbortController();
        inFlight = mine;
        try {
          const res = await fetcher(`/api/username/check?name=${encodeURIComponent(name)}`, {
            signal: mine.signal,
          });
          const body = await res.json();
          if (!mine.signal.aborted) onResult({ name, ...body });
        } catch (err) {
          if (!mine.signal.aborted) onError?.(err);
        }
      }, delayMs);
    },
  };
}
```

The test drives this with fake timers. Five keystrokes 100 ms apart produce one request, a slow response for an earlier name is dropped after a newer keystroke, and an empty box never calls the server. Server work in my measurements was about 0.1 to 0.6 ms at the median on loopback, so on a real connection the network round trip and the debounce dominate. I did not measure a real network.

## Step 9: Protect the endpoint, because it leaks accounts

A name-availability endpoint tells anyone who asks which names have accounts. The OWASP Web Security Testing Guide chapter on [account enumeration](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/03-Identity_Management_Testing/04-Testing_for_Account_Enumeration_and_Guessable_User_Account) notes that response time can reveal whether a user exists, especially when a request triggers extra work such as sending a reset email. It also says the application should answer in the same manner for every failed authentication.

A sign-up check reveals taken names by design, so the controls are different. Give each client a budget, and expose only what the form needs. For flows that must not reveal accounts, such as login, password reset, or sign-up by email, return the same message whether or not the account exists and do the real work asynchronously.

The limiter is a fixed-window counter. `SET NX EX` and `INCR` run inside one `MULTI`, so a crash cannot leave a counter without an expiry. A fixed window allows a short burst across a window boundary, which is acceptable for a form like this. Behind a proxy or CDN, `req.ip` is the proxy's address unless Express is told to trust it, so check that before relying on the key.

```typescript
export async function allow(
  redis: Redis,
  key: string,
  limit: number,
  windowSeconds: number,
): Promise<boolean> {
  const replies = await redis.multi().set(key, 0, 'EX', windowSeconds, 'NX').incr(key).exec();
  const count = replies?.[1]?.[1];
  if (typeof count !== 'number') throw new Error('rate limit counter failed');
  return count <= limit;
}
```

The NestJS controller applies the budget and returns only `available` and a reason. Which layer answered stays on the server.

```typescript
@Get('check')
async check(
  @Query('name') name: string | undefined,
  @Req() req: Request,
  @Res({ passthrough: true }) res: Response,
) {
  // The check is an enumeration oracle by design, so it gets a budget per client.
  if (!(await allow(this.redis, `rl:username-check:${req.ip}`, 30, 60))) {
    res.status(429);
    return { error: 'slow_down' };
  }

  const result = await this.service.check(name ?? '');
  // Say what the form needs. Which layer answered stays on the server.
  return result.available ? { available: true } : { available: false, reason: result.reason };
}
```

The endpoint tests run through NestJS against real Redis and PostgreSQL. The 31st and 32nd request inside a minute return 429, and a taken-name response contains no hint of which layer answered.

## What I measured

All numbers come from the benchmark script in the companion code, run twice on one Ubuntu 24.04 sandbox with Node 22.22, Redis 7.0.15 and PostgreSQL 16.15. Latencies are medians of sequential calls over loopback, with hot caches, one connection and no other load. Where two values appear, they are the two runs.

| Measurement | Result |
| --- | --- |
| False-positive rate at design capacity (1M names, 1% target) | 1.01% |
| Bits set at design capacity | 51.8% |
| False-positive rate at twice the design capacity | 15.8% |
| Redis memory for the filter, 1M names | 1,310,776 bytes |
| Redis memory for a set of the same 1M names | 47,588,720 bytes (36.3 times more) |
| In-process filter probe, SHA-256 included | 3.4 to 4.5 microseconds |
| Redis SISMEMBER median | 0.076 to 0.093 ms |
| Seven pipelined GETBIT calls median | 0.126 to 0.148 ms |
| PostgreSQL unique index size, 1M rows | 38,920,192 bytes |
| PostgreSQL execution time, one index-only lookup | 0.043 to 0.046 ms |
| PostgreSQL lookup median, name exists / name absent | 0.18 to 0.20 ms / 0.18 ms |
| check(), free name, median / p99 | 0.13 to 0.14 ms / 0.91 to 0.97 ms |
| check(), taken name, cold cache, median / p99 | 0.59 to 0.61 ms / 2.2 to 2.7 ms |
| check(), taken name, warm cache, median / p99 | 0.22 to 0.24 ms / 0.44 to 0.66 ms |
| Free names settled by the filter alone | 4,943 of 5,000 (98.9%) |

This is one machine and one million rows, not a global service. It says nothing about how Gmail works, and it does not test billions of rows, many app servers, or a network between the layers. At that scale you would expect to partition names by a hash of the canonical form, keep a filter per partition, and refresh in-process copies from a shared source. Those are design directions, not results from this work.

## Run it yourself

I ran everything on Node 22.22, Redis 7.0.15 and PostgreSQL 16.15. The unit tests use Node's built-in TypeScript support, and the NestJS tests add `tsx` because decorators need it. The tests flush Redis databases 5 to 8 and create and drop their own PostgreSQL databases, so point them at a throwaway instance. They read `PGPORT`, `PGUSER` and `REDIS_PORT`, with standard defaults.

```bash
npm install
npm test            # unit and integration tests
npm run test:nest   # NestJS endpoint tests
npm run bench       # the benchmark behind the numbers above
```

## The one rule

Every layer in front of PostgreSQL is a shortcut that may be wrong, and each is allowed to be wrong only in a way the next layer can catch. The unique index is the only layer allowed to refuse a signup for good. The Bloom filter can reduce traffic, the cache can save repeated lookups, and the browser can make the response feel instant, but they all need maintenance and clear failure behavior. Build the unique index first, measure it, and add the other layers when your own numbers ask for them.

---

*Published via [ZyVOP](https://zyvop.com/how-username-availability-checks-answer-in-milliseconds-y8dys?utm_source=hashnode&utm_medium=crosspost&utm_campaign=syndication) — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium & Hashnode in 1 click.*
