DEV Community

Sean Maxwell
Sean Maxwell

Posted on Edited 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. There's also a strictly ordered variant for when sorting by the millisecond isn't enough.

import jetid from 'jet-id';

jetid();       // '9Q8SWWBTY-7NVXM-FT9S6-XB4R3M'
jetid.timed(); // '1KKNTQ2CN-606ZG-8VF48-76B6F3'  (sorts by creation time)
jetid.mono();    // '1M308A0DM-PR0000-422PKHWX-THHP6B3G-DPQCJ4GCE'  (sorts within the millisecond too)
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 license

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

Quick start

npm install jet-id
Enter fullscreen mode Exit fullscreen mode
import jetId from 'jet-id';

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

A plain ID contains 25 random Crockford base32 characters, grouped as 9-5-5-6. Including the three dashes, each ID is 28 characters long.

· · ·

Table of Contents

· · ·

Why jet-id?

  • Fast: Faster than nanoid() in the included benchmarks.
  • Small: 6.9 kB packed, with zero runtime dependencies.
  • TypeScript-ready: Includes type declarations.
  • Portable: Works in Node.js and modern browsers.
  • Flexible: Choose random IDs, timestamped IDs, strictly ordered IDs, or secret keys.
  • Strong randomness: jetId() and jetId.mono() provide 125 random bits. For comparison, UUID v4 has 122.
  • Simple API: Generate IDs, validate their…
  • ~81 million IDs/sec, vs. nanoid's ~53 million on my machine
  • 125 random bits from the platform CSPRNG — randomFillSync in Node, crypto.getRandomValues in browsers (UUID v4 has 122)
  • Strictly ordered when you need it: jetid.mono() orders IDs within a single millisecond without giving up any of those 125 bits
  • Readable: the Crockford alphabet has no I, L, O, or U to confuse with 1 and 0
  • Small: zero dependencies, 6.9 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 call to the CSPRNG 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.
  • A cached timestamp. For timed IDs, the nine-character timestamp is split into a six-character prefix that only changes every 32 seconds and a three-character suffix that changes each millisecond. The prefix is cached, so most calls re-encode three characters, not nine.

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 81,127,524 1.00x
nanoid() 21 126 52,583,004 0.65x
nanoid, Crockford, 25 chars 25 125 46,942,586 0.58x
nanoid, Crockford, 9-5-5-6 28 125 15,589,642 0.19x
jetid.mono() 44 125 13,035,960 0.16x
crypto.randomUUID() 36 122 9,964,004 0.12x
ulid (monotonic) 26 80 3,637,167 274.9
uuid v4 36 122 8,577,377 0.11x

That's roughly 50% 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 5x slower. To be fair, nanoid isn't built for dash-separated output, so part of that gap is the formatting wrapper.

jetid.mono() is the slowest of jet-id's own generators because it reads the high-resolution clock and encodes a sequence number on every call, rather than slicing a pre-built pool. It's still faster than either UUID option and ULID's monotonic option.

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.timed.parse() reads it back out:

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

const id = jetid.timed();         // '1KKNTQ2CN-606ZG-8VF48-76B6F3'
jetid.timed.parse(id);            // 1773480413589
new Date(jetid.timed.parse(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. If that second one matters to you, keep reading.

Strictly ordered IDs

jetid.mono() is for when "sorts by creation time" has to mean exactly — every ID sorts strictly after the one before it, even within a single millisecond.

import jetid from 'jet-id';

jetid.mono(); // '1M308A0DM-PR0000-422PKHWX-THHP6B3G-DPQCJ4GCE'
jetid.mono(); // '1M308A0DM-WR0000-1ZS1A6M5-NE6RKRHN-X9F480HCB'
jetid.mono(); // '1M308A0DM-X50000-XJ1ZENAH-MVX225N0-MZ0BTD8YB'
Enter fullscreen mode Exit fullscreen mode

It's 44 characters in a 9-6-8-8-9 layout: the 9-character timestamp as before, then 6 characters of ordering key (10 bits of sub-millisecond time from performance.now() plus a 20-bit counter for ties), then 25 random characters.

That's why it's longer. Ordering costs 15 characters, and I didn't want to pay for them out of the randomness. At 40 characters the random tail keeps the full 125 bits, so jetid.mono() is exactly as collision-resistant as jetid().

Two caveats. It takes no epoch argument — the ordering guarantee only holds against time it reads itself. And the guarantee is per process: the counter lives in module state, so IDs from separate workers or machines aren't ordered against each other.

jetid.mono.test() and jetid.mono.parse() work like their jetid counterparts; parse returns { epoch, fraction, counter }. Use epoch for dates. The other two are an ordering key, not a finer clock — browsers coarsen performance.now(), so on Firefox and Safari counter does nearly all the work.

Validating IDs

jetid.test() checks whether a value is a well-formed ID. It isn't case-sensitive, and it accepts anything, returning false rather than throwing:

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

jetid.mono.test() does the same for the 44-character format. Neither accepts the other's IDs.

From the command line

npx jet-id               # one random ID
npx jet-id -c 5          # five, one per line
npx jet-id -t timed      # one timestamped ID
npx jet-id -t mono -c 10  # ten strictly ordered IDs
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 (6)

Collapse
 
mihai_leanzero profile image
Mihai Perdum •

Nice work on the pooling and lookup-table stack. One question on jetId.timed(): since same-millisecond IDs have no defined order between them, did you consider a monotonic tie-break like ulid's monotonicFactory, which increments the low-order random bits by 1 within the same ms instead of drawing fresh randomness? Might be worth it for anyone using this as a sort key rather than just a unique key, even if it means those particular same-ms IDs come out more predictable than the rest.

Collapse
 
spmaxwell7 profile image
Sean Maxwell •

Ah, thanks for mentioning that. That had crossed my mind at one point, but the added complexity plus losing more characters from the entropy portion to the timestamping portion didn't seem worth it. Although, maybe I could add an extra function (e.g. preciseTimed) which could bring the timestamp within milliseconds.

Collapse
 
spmaxwell7 profile image
Sean Maxwell • • Edited

I just did a major version release and now there's jetId.mono which includes a monotonic-timestamped id

Collapse
 
mihai_leanzero profile image
Mihai Perdum •

Good to hear, jetId.mono closes the exact gap I was poking at. Glad the monotonic tie-break ended up worth shipping rather than staying a maybe.

Collapse
 
kyisaiah47 profile image
kyisaiah47 •

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.