Skip to Content
core-conceptsHow It Works

Last Updated: 3/9/2026


How It Works

Understanding Nano ID’s internal architecture helps you use it effectively and debug issues.

High-Level Overview

Nano ID generates random IDs through three core steps:

  1. Generate random bytes using hardware random generator
  2. Map bytes to alphabet using bitwise operations
  3. Return ID string of requested length
import { nanoid } from 'nanoid' nanoid() // "V1StGXR8_Z5jdHi6B-myT" // ↑ // 21 characters from urlAlphabet (A-Za-z0-9_-)

Random Pool Architecture

Nano ID uses a pooled random generation strategy to minimize expensive system calls.

The Problem

Calling the hardware random generator (like crypto.getRandomValues()) is expensive:

// Expensive: one system call per ID for (let i = 0; i < 1000; i++) { crypto.getRandomValues(new Uint8Array(21)) // 1000 system calls }

The Solution: Random Pool

Nano ID generates a large pool of random bytes upfront, then consumes them:

// Efficient: one system call for many IDs const pool = crypto.getRandomValues(new Uint8Array(21 * 128)) // 1 system call for (let i = 0; i < 1000; i++) { // Consume from pool (no system calls) }

Implementation

const POOL_SIZE_MULTIPLIER = 128 let pool, poolOffset function fillPool(bytes) { if (!pool || pool.length < bytes) { // Initial allocation: 128x requested size pool = Buffer.allocUnsafe(bytes * POOL_SIZE_MULTIPLIER) crypto.getRandomValues(pool) poolOffset = 0 } else if (poolOffset + bytes > pool.length) { // Pool exhausted: refill crypto.getRandomValues(pool) poolOffset = 0 } poolOffset += bytes }

Key benefits:

  • 128 IDs generated per system call
  • Automatic refill when pool exhausted
  • Memory-efficient (reuses buffer)

Alphabet Mapping

Nano ID maps random bytes (0-255) to alphabet characters using a bitmask.

The Default Alphabet

const urlAlphabet = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict' // ↑ 64 characters (2^6)

The alphabet is carefully ordered for optimal compression:

  • Gzip/Brotli references: 'use, andom, rict' appear in many files
  • Brotli dictionary: 26T, 1983, 40px, bush, jack, mind, very, wolf

Mapping Algorithm

Since the alphabet has 64 characters (2^6), we use a 6-bit mask:

export function nanoid(size = 21) { fillPool(size) let id = '' for (let i = poolOffset - size; i < poolOffset; i++) { // Mask byte to 0-63 range (6 bits) id += urlAlphabet[pool[i] & 63] // ↑ // Binary: 0b00111111 } return id }

Why & 63 works:

Random byte: 11010110 (214 in decimal) Mask (63): 00111111 (63 in decimal) -------- Result: 00010110 (22 in decimal) Maps to urlAlphabet[22] = '8'

This ensures:

  • Uniform distribution: All characters have equal probability
  • No waste: Every random byte becomes a character
  • Fast: Bitwise AND is extremely fast

Custom Alphabet Algorithm

For custom alphabets, the algorithm is more complex because alphabet sizes aren’t always powers of 2.

The Challenge

With a 30-character alphabet:

const alphabet = '0123456789ABCDEFGHIJKLMNOPQR' // 30 chars

We can’t use a simple bitmask because 30 isn’t a power of 2.

The Solution: Rejection Sampling

export function customAlphabet(alphabet, size = 21) { // Find smallest power of 2 that exceeds alphabet size let mask = (2 << (31 - Math.clz32((alphabet.length - 1) | 1))) - 1 // For 30 chars: mask = 31 (0b00011111) // Calculate how many random bytes to request let step = Math.ceil((1.6 * mask * size) / alphabet.length) return (customSize = size) => { let id = '' while (true) { let bytes = random(step) let i = step while (i--) { // Map byte to alphabet, reject if out of range id += alphabet[bytes[i] & mask] || '' // ↑ // Returns '' if index >= alphabet.length if (id.length >= customSize) return id } } } }

How it works:

  1. Mask calculation: Find smallest power of 2 ≥ alphabet size

    • 30 chars → mask = 31 (2^5 - 1)
  2. Rejection sampling: Discard bytes that map beyond alphabet

    • Byte 28 & 31 = 28 → alphabet[28] ✅
    • Byte 30 & 31 = 30 → alphabet[30] = undefined → reject ❌
  3. Redundancy factor: Request 1.6x bytes to minimize rejections

    • Magic number 1.6 optimized through benchmarking

Why Not Modulo?

A common mistake is using modulo:

// WRONG: Creates bias id += alphabet[randomByte % alphabet.length]

This creates non-uniform distribution:

Alphabet size: 30 Random bytes: 0-255 0-29: 9 occurrences each (0, 30, 60, 90, 120, 150, 180, 210, 240) 30-255: 8 occurrences each Result: First 30 chars are 12.5% more likely!

Rejection sampling ensures perfect uniformity.

Browser vs Node.js

Nano ID has two implementations optimized for each environment.

Node.js (index.js)

import { webcrypto as crypto } from 'node:crypto' // Uses pooled random generation function fillPool(bytes) { pool = Buffer.allocUnsafe(bytes * 128) crypto.getRandomValues(pool) }

Optimizations:

  • Buffer pooling (128x multiplier)
  • Reuses allocated memory
  • Batch system calls

Browser (index.browser.js)

// Direct crypto API, no pooling export let nanoid = (size = 21) => { let id = '' let bytes = crypto.getRandomValues(new Uint8Array(size)) while (size--) { id += urlAlphabet[bytes[size] & 63] } return id }

Why no pooling?

  • Browsers already optimize crypto.getRandomValues()
  • Memory pooling less beneficial in browser context
  • Simpler code = smaller bundle size

Module Resolution

Bundlers automatically select the right version:

// package.json { "browser": { "./index.js": "./index.browser.js" }, "react-native": { "./index.js": "./index.browser.js" } }

Performance Characteristics

Time Complexity

  • nanoid(): O(n) where n = size
  • customAlphabet(): O(n × r) where r = rejection rate
    • Worst case: r ≈ 2 for alphabet size 129
    • Best case: r = 1 for power-of-2 alphabets

Space Complexity

  • Node.js: O(128n) for pool (amortized O(1) per ID)
  • Browser: O(n) per ID

Benchmark Results

nanoid() 3,693,964 ops/sec customAlphabet() 2,799,255 ops/sec (24% slower due to rejection) nanoid/non-secure 2,226,483 ops/sec (no crypto overhead)

Security Properties

Unpredictability

  • Node.js: Uses OS-level CSPRNG (Cryptographically Secure PRNG)
  • Browser: Uses Web Crypto API (hardware-backed when available)
  • No seed: Cannot predict future IDs from past IDs

Uniformity

Every character has exactly equal probability:

P(char) = 1 / alphabet.length For urlAlphabet (64 chars): P(char) = 1/64 = 0.015625 (exactly)

See Security for detailed analysis.

What’s Next?