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)
seanpmaxwell
/
jet-id
An extremely fast unique ID generator for JavaScript and TypeScript, with optional sorting/timestamping.
jet-id
An extremely fast unique ID generator for JavaScript and TypeScript, with optional timestamping and sorting.
Quick start
npm install jet-id
import jetId from 'jet-id';
jetId(); // '9Q8SWWBTY-7NVXM-FT9S6-XB4R3M'
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()andjetId.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 —
randomFillSyncin Node,crypto.getRandomValuesin 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, orUto confuse with1and0 - 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 asubstring. 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
Bufferin Node,TextDecoderin 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
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'
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
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
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)
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.
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.I just did a major version release and now there's
jetId.monowhich includes a monotonic-timestamped idGood 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.
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?
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.