DEV Community

SEN LLC
SEN LLC

Posted on

UUID v7, ULID, KSUID — What's the Difference? I Implemented All Five

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

Screenshot

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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('')
}
Enter fullscreen mode Exit fullscreen mode

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
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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])
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. 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)
})
Enter fullscreen mode Exit fullscreen mode

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)