DEV Community

Sean Maxwell
Sean Maxwell

Posted on

I built a unique ID generator that's ~60% faster than nanoid

jet-id is a tiny, zero-dependency unique ID generator for JavaScript and TypeScript. IDs are 28-character Crockford base32 strings, with optional timestamps so they sort by creation time.

import jetId from 'jet-id';

jetId();       // '9Q8SWWBTY-7NVXM-FT9S6-XB4R3M'
jetId.timed(); // '1KKNTQ2CN-606ZG-8VF48-76B6F3'  (sorts by creation time)
Enter fullscreen mode Exit fullscreen mode

GitHub logo seanpmaxwell / jet-id

An extremely fast unique ID generator for JavaScript and TypeScript, with optional sorting/timestamping.

jet-id

npm CI types dependencies license

An extremely fast unique ID generator for JavaScript and TypeScript, with optional sorting/timestamping.

Quick Glance

import jetId from 'jet-id';

jetId(); // '9Q8SWWBTY-7NVXM-FT9S6-XB4R3M'
Enter fullscreen mode Exit fullscreen mode

Every ID is 25 random characters from the Crockford base32 alphabet, split into groups of 9-5-5-6.

Why jet-id?

  • Main features

    • Optionally timestamped and readable: Generated IDs are 28-character strings (25 Crockford characters plus 3 dashes). The first segment is 9 characters; if you want a timestamp, the first segment encodes the current epoch in Crockford form, so the ID length stays the same.
    • Fast: Faster than nanoid(). See benchmarks.
  • Other perks

    • Small: Only 6.4 kB packed.
    • Strong randomness: 125 random bits. For perspective, UUID v4 has 122.
    • Zero runtime dependencies.
    • Simple API: Just jetId() and jetId.test/jetId.timed/jetId.parseTimed.
    • Universal: Works in Node.js and modern browsers.

Install

npm install jet-id
Enter fullscreen mode Exit fullscreen mode

API

Basic usage

Just call the default import, there…

  • ~83 million IDs/sec, vs. nanoid's ~52 million on my machine
  • 125 random bits from crypto.getRandomValues (UUID v4 has 122)
  • Readable: the Crockford alphabet has no I, L, O, or U to confuse with 1 and 0
  • Small: zero dependencies, 6.4 kB packed
  • Universal: runs in Node.js and modern browsers, and there's a CLI too

How it started

I was looking for an ID generator for a web project (mostly for a database column) that used the Crockford alphabet and had optional timestamping. I couldn't find anything that quite fit, so I debated between wrapping nanoid and writing my own. It seemed like a fun project that probably just involved some simple arithmetic (or so I naively thought), so I wrote my own.

The first version took a morning. Then, out of curiosity, I fed it to Fable 5.1 and GPT Astra, and I was blown away. They introduced me to techniques I didn't know existed, like pooling, lookup tables, and bit-shifting. I spent the next two days passing it back and forth between them, benchmarking every change, rolling back anything that got slower, and adding my own tweaks in between.

What makes it fast

Most of the speed comes from doing expensive work rarely and in bulk:

  • Pooling. IDs are built 256 at a time into a single string, so most calls to jetId() are just a substring. One crypto.getRandomValues call supplies enough randomness for 1,024 IDs.
  • Two characters per lookup. A 1,024-entry table turns 10 random bits into two Crockford characters at once, instead of encoding one character at a time.
  • Four bytes at a time. Each 28-character ID is written as seven 32-bit words through a Uint32Array, dashes included.
  • One string conversion per chunk. The bytes are decoded to a string once (through Buffer in Node, TextDecoder in browsers), and every ID is sliced from it.

Benchmarks

On my MacBook M4 Pro (Node 24), median of 7 samples of at least 500 ms each, after a 500 ms warmup:

Generator Chars Random bits Ops/sec Relative
jetId() 28 125 83,503,288 1.00x
nanoid() 21 126 52,415,897 0.63x
nanoid, Crockford, 25 chars 25 125 47,073,761 0.56x
nanoid, Crockford, 9-5-5-6 28 125 14,441,127 0.17x
crypto.randomUUID() 36 122 9,938,321 0.12x
uuid v4 36 122 8,536,998 0.10x

That's roughly 60% faster than nanoid's defaults, even though jet-id's IDs are longer (28 characters vs. 21). Configured to produce the same 9-5-5-6 format, nanoid is about 6x slower. To be fair, nanoid isn't built for dash-separated output, so part of that gap is the formatting wrapper.

Why 9-5-5-6?

It looks a bit different, but it isn't arbitrary. The first segment is 9 characters because that's how long the current epoch (in milliseconds) is in Crockford base32, which leaves room for the optional timestamp without changing the ID's length. I wanted at least UUID v4's entropy, which takes 25 random characters, and splitting the remaining 16 into 5-5-6 seemed the most readable.

Timestamped IDs

jetId.timed() encodes the current time in the first nine characters, so plain string sorting is creation order. You can also pass your own epoch in milliseconds, and jetId.parseTimed() reads it back out:

jetId.timed(Date.UTC(2015, 2, 14)); // '19GAS9400-...'

const id = jetId.timed();       // '1KKNTQ2CN-606ZG-8VF48-76B6F3'
jetId.parseTimed(id);           // 1773480413589
new Date(jetId.parseTimed(id)); // 2026-03-14T09:26:53.589Z
Enter fullscreen mode Exit fullscreen mode

Two tradeoffs to know about: timestamped IDs spend those first 9 characters on the time, so they carry 80 random bits instead of 125, and IDs created in the same millisecond have no defined order between them.

Validating IDs

jetId.test() checks whether a value is a well-formed ID. It isn't case-sensitive:

jetId.test('avz6yg1rb-47j6r-xgns9-tqw29a'); // true
Enter fullscreen mode Exit fullscreen mode

From the command line

npx jet-id          # one random ID
npx jet-id -c 5     # five, one per line
npx jet-id -t       # one timestamped ID
Enter fullscreen mode Exit fullscreen mode

Feedback welcome

These benchmarks were all done on my local machine using the playground/benchmark.ts script. If they're different for you, or if you know of something faster, feedback is very welcome, especially if there's a flaw in the way I did the benchmarking.

Top comments (2)

Collapse
 
kyisaiah47 profile image
Isaiah Kim

I'm curious how the pool behaves when callers retain IDs from several refill cycles. Does the backing string become a measurable retention cost in a long-running process?

Collapse
 
spmaxwell7 profile image
Sean Maxwell • Edited

Yes, it's a deliberate tradeoff: each ID is a V8 sliced string pointing into a shared ~7 KB chunk of 256 IDs, so retaining one keeps its chunk in memory. For comparison, nanoid uses a 32 KB chunk.