UUID v7, ULID, KSUID — What's the Difference? I Implemented All Five
A side-by-side generator for UUID v4, UUID v7, ULID, NanoID, and KSUID. The point wasn't to ship yet another ID library — it was to understand what each one actually is by writing it.
The standard "please give me a unique ID" answer has split into five. UUID v4 is still everywhere, but UUID v7 is finally standardized, ULID has a loyal following, NanoID is the compact URL-safe choice, and KSUID is what Segment and others reach for when they need high entropy and sortability. Each has subtly different trade-offs, and reading specs didn't help me internalize them. Writing them did.
🔗 Live demo: https://sen.ltd/portfolio/id-generator/
📦 GitHub: https://github.com/sen-ltd/id-generator
The whole thing is about 200 lines of actual ID-generation logic, spread across five files. No dependencies. Let me walk through each and point out the bits I didn't expect until I wrote them.
The one thing all sortable IDs do
If you implement UUID v7, ULID, and KSUID in the same session, you notice they're all doing the same thing:
Write the timestamp first.
That's the entire trick. Everything after that — the specific bit widths, the encoding alphabets, the random suffix lengths — is just engineering taste.
UUID v4: The baseline
export function uuidv4() {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
bytes[6] = (bytes[6] & 0x0f) | 0x40 // version = 4
bytes[8] = (bytes[8] & 0x3f) | 0x80 // variant = 10xx
return bytesToUuidString(bytes)
}
Pure random bytes, with 6 bits stolen for version and variant metadata. Effective randomness: 122 bits. Works everywhere, maximum compatibility. No ordering.
Modern browsers ship crypto.randomUUID(), which does the same thing in C++. Use that in production; implement it yourself to understand what's happening.
UUID v7: RFC 9562, timestamp-first
export function uuidv7(nowMs) {
const ms = nowMs ?? Date.now()
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
// First 48 bits = timestamp (big-endian Unix millis)
bytes[0] = (ms / 2 ** 40) & 0xff
bytes[1] = (ms / 2 ** 32) & 0xff
bytes[2] = (ms / 2 ** 24) & 0xff
bytes[3] = (ms / 2 ** 16) & 0xff
bytes[4] = (ms / 2 ** 8) & 0xff
bytes[5] = ms & 0xff
bytes[6] = 0x70 | (bytes[6] & 0x0f) // version = 7
bytes[8] = 0x80 | (bytes[8] & 0x3f) // variant = 10
return bytesToUuidString(bytes)
}
48 bits of Unix milliseconds, then 12 bits of random (after the version nibble), then 62 bits of random (after the variant bits). Total random bits: 74. That's way less than v4, but the timestamp gives you sortability.
Small numeric subtlety: Date.now() returns milliseconds as a number, and 48 bits (max value ~281 trillion) is still safely within JavaScript's Number.MAX_SAFE_INTEGER (~9 quadrillion). So we can split the timestamp into bytes with arithmetic instead of reaching for BigInt.
ULID: Same structure, shorter string
ULID has almost the same byte layout as UUID v7: 48-bit timestamp + 80-bit random = 128 bits. The difference is the encoding. Instead of hex with dashes (36 chars), it uses Crockford's Base32 (26 chars).
const BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' // no I, L, O, U
function encodeTime(ms) {
const chars = new Array(10)
let n = ms
for (let i = 9; i >= 0; i--) {
chars[i] = BASE32[n % 32]
n = Math.floor(n / 32)
}
return chars.join('')
}
The Crockford alphabet drops the characters people often confuse when handwriting: I/1, L/1, O/0, U/V. A small concession to human error that costs nothing at the bit level.
Why is Base32 26 chars vs hex's 36 chars? Each base32 char carries 5 bits of info, each hex char carries 4. For 128 bits: 128/5 ≈ 26, 128/4 = 32 (plus UUID's 4 dashes = 36). Better information density by switching alphabets.
NanoID: Sacrifice sortability for size
NanoID goes the opposite direction: give up ordering entirely, maximize string density. Default: 21 characters from a 64-symbol URL-safe alphabet (A-Z, a-z, 0-9, _, -).
export function nanoid(size = 21, alphabet = DEFAULT_ALPHABET) {
const mask = (2 << (Math.log(alphabet.length - 1) / Math.LN2)) - 1
const step = Math.ceil((1.6 * mask * size) / alphabet.length)
let id = ''
const bytes = new Uint8Array(step)
while (true) {
crypto.getRandomValues(bytes)
for (let i = 0; i < step; i++) {
const idx = bytes[i] & mask
if (alphabet[idx]) {
id += alphabet[idx]
if (id.length === size) return id
}
}
}
}
Why 21 characters? Because 21 * 6 = 126 bits of entropy, roughly equivalent to UUID v4's 122 bits of real randomness. If you're not going to get ordering anyway, NanoID delivers the same safety in 40% less space.
The mask and step math looks cryptic. What it's doing: when alphabet.length isn't a power of two, simple byte % length introduces bias (some chars would be slightly more likely). The mask trick is unbiased rejection sampling — we generate more bytes than needed, reject the ones out of range, and keep trying until we have enough good chars. step is chosen to minimize the number of retries.
KSUID: Higher entropy sortable
const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
export function ksuid(nowMs) {
const secondsSinceEpoch = Math.floor((nowMs ?? Date.now()) / 1000) - KSUID_EPOCH
// 4 bytes of timestamp + 16 bytes of random = 20 bytes = 160 bits
const all = new Uint8Array(20)
// ... write timestamp, then random ...
// Base62-encode 160 bits into 27 chars via BigInt
let n = 0n
for (const b of all) n = (n << 8n) | BigInt(b)
let out = ''
while (n > 0n) {
const r = Number(n % 62n)
out = BASE62[r] + out
n = n / 62n
}
return out.padStart(27, BASE62[0])
}
KSUID is 32 bits of seconds since 2014 + 128 bits of random. The 2014 epoch shift exists because the full Unix epoch wastes leading bits — nothing ever needs to represent a time before that.
Here's the bug I hit and didn't see coming: the Base62 alphabet has to be in ASCII lexicographic order, which means 0-9 A-Z a-z. My first attempt had A-Z a-z 0-9 because I was thinking about it alphabetically. The tests for sortability failed because string comparison of the generated KSUIDs wasn't preserving numeric order.
For a K-sortable ID, the encoding alphabet itself must be sorted the way strings get compared.
Obvious in retrospect. Would've taken forever to notice without the test.
Effective entropy, compared
The "real" random bits after subtracting version/variant/timestamp fields:
| Format | Total bits | Effective random | Sortable |
|---|---|---|---|
| UUID v4 | 128 | 122 | ❌ |
| UUID v7 | 128 | 74 | ✅ |
| ULID | 128 | 80 | ✅ |
| NanoID | 126 | 126 | ❌ |
| KSUID | 160 | 128 | ✅ |
Two things surprised me:
- UUID v7 has the least effective randomness (74 bits). This is a deliberate choice — 74 bits is more than enough when combined with 1 ms timestamps, because you'd need ~4 billion generations in the same millisecond to hit meaningful collision odds.
- NanoID has the highest (126 bits). Giving up sortability lets it use every single bit for randomness.
Tests
23 cases on node --test. Per format: format-regex, version nibble, variant bits (where applicable), 1000-item uniqueness, 500-item uniqueness within the same timestamp (for sortable IDs), and for timestamp-first formats, literal sortability checks:
test('sortable by creation time', () => {
const a = uuidv7(1_000_000_000_000)
const b = uuidv7(1_000_000_000_500)
const c = uuidv7(1_000_000_001_000)
assert.ok(a < b)
assert.ok(b < c)
})
The KSUID alphabet-ordering bug I mentioned above was caught by exactly this test. Would not have found it manually.
When to use which
After implementing all five, my rule of thumb:
-
crypto.randomUUID()(UUID v4) — the default. If you don't need sortability and don't care about URL length, stop reading. - UUID v7 — you want ordered IDs and your ecosystem expects a UUID shape. Most mature databases now handle UUID v7 well.
- ULID — you want ordered IDs, shorter strings than UUIDs, and don't mind a non-UUID shape.
- NanoID — URL slugs, short share codes. Not for DB primary keys.
- KSUID — you need a lot of random bits and sortability, and 27 chars is acceptable. Segment.io picked this for logging events for a reason.
Closing
Entry #4 in a 100+ portfolio series by SEN LLC. Previous entries:
Feedback welcome.

Top comments (0)