Random UUID v4 makes a poor primary key: values land all over the index, so B-tree inserts scatter across pages and page splits go up. UUID v7 puts a millisecond timestamp in the leading bits (standardized in RFC 9562), so generation order equals sort order and you get locality back.
I needed a UUID generator that runs entirely in the browser, and while building it I implemented v7 by hand instead of pulling in a library. Here is the layout, the implementation, the trap I hit, and when each version is actually the right choice.
The v7 layout
The 128 bits break down like this:
| 48bit unix_ms | 4bit ver(=7) | 12bit rand_a | 2bit variant | 62bit rand_b |
The first 48 bits are the Unix timestamp in milliseconds, big-endian. Then the version (7) and variant bits, and everything else is random. The random part must come from a cryptographic source (crypto.getRandomValues) — not Math.random().
Implementation: putting the bits in the right place
The whole job is "pack the timestamp into the first six bytes, most significant byte first, then overwrite the version and variant nibbles."
function rnd(n){ const a = new Uint8Array(n); crypto.getRandomValues(a); return a; }
function hex(b){ let s=''; for(const x of b) s += ('0'+x.toString(16)).slice(-2); return s; }
function uuidV7() {
const ts = Date.now(); // 48-bit millisecond timestamp
const b = rnd(16);
// 48-bit big-endian millisecond timestamp (top six bytes)
b[0] = (ts / 0x10000000000) & 0xff; // ts >> 40
b[1] = (ts / 0x100000000) & 0xff; // ts >> 32
b[2] = (ts / 0x1000000) & 0xff; // ts >> 24
b[3] = (ts / 0x10000) & 0xff; // ts >> 16
b[4] = (ts / 0x100) & 0xff; // ts >> 8
b[5] = ts & 0xff;
b[6] = (b[6] & 0x0f) | 0x70; // version 7
b[8] = (b[8] & 0x3f) | 0x80; // variant (10xx)
const h = hex(b);
return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`;
}
The detail worth remembering: shift the digits down with division, not with >>. ts >> 40 does not work, because JavaScript's bitwise operators coerce their operands to 32 bits and a 48-bit value gets mangled. Division followed by & 0xff is safe.
For v4, by the way, don't hand-roll anything if the platform gives you the standard API — it is both faster and safer.
function uuidV4() {
if (crypto.randomUUID) return crypto.randomUUID(); // one call where it's supported
const b = rnd(16); b[6] = (b[6]&0x0f)|0x40; b[8] = (b[8]&0x3f)|0x80;
const h = hex(b);
return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`;
}
The trap: ordering is not guaranteed within a millisecond
If two IDs are generated in the same millisecond, their leading 48 bits are identical and everything after that is random — so their relative order is arbitrary. If you assume "generation order == strictly ascending", a burst of IDs will quietly break that assumption.
When you do need strict monotonicity, use the 12-bit rand_a field as a counter: increment it within the same millisecond, and reseed it when the millisecond changes.
let lastMs = 0, seq = 0;
function uuidV7Monotonic() {
const ts = Date.now();
if (ts === lastMs) seq = (seq + 1) & 0x0fff; // same ms: +1 (12 bits)
else { lastMs = ts; seq = rnd(2)[0] & 0x0fff; } // new ms: start from random
// ...store seq in the low 12 bits of b[6..7], overwriting version = 7...
}
If "roughly time-ordered" is good enough for your use case, plain random is fine. The design decision to make up front is simply whether you need strict monotonicity — not which implementation looks cleverer.
Checking it instead of trusting the spec
I measured the following in both Node and the browser rather than assuming the spec held:
- Are the version and variant bits actually set? (
u[14] === '7', andu[19]is one of8,9,a,b.) - Do two IDs generated across a millisecond boundary always sort ascending?
- Generate 1000 IDs inside a single millisecond — does the plain implementation break ascending order, and does the counter version hold?
Reproducing the failure yourself, on purpose, is what makes the edge of the trap visible. "The spec says it should" is not the same as having seen it.
v4 vs v7 vs ULID
| Time-sortable | Locality | Standard | In one line | |
|---|---|---|---|---|
| UUID v4 | No | Low | RFC 9562 | Fully random. Spreads well, hard on indexes |
| UUID v7 | Yes (ms granularity) | High | RFC 9562 | Good primary key. Drops into an existing UUID column |
| ULID | Yes | High | De facto | 26-char Base32. Worth a look if you don't need UUID compatibility |
If you are using UUIDs as primary keys, v7 is worth considering first. In practice the big win is that it fits your existing uuid / UUID column type unchanged — no migration of the column, just of the generator.
The result
I published the generator as a browser tool: it does v4 and v7, with options for count, hyphens, and uppercase. Generation happens in the page and nothing is sent to a server: https://hashitosystem.com/tools/uuidgen/
Wrap-up
v7 is the right fit for "I want roughly time-ordered keys with good locality." The implementation is just splitting the timestamp into bytes with division and overwriting the version/variant nibbles. The one caveat to internalize: ordering within a single millisecond is not guaranteed, so pair it with a counter if you need strict monotonicity. Get that one point right and it is safe to use.
This article is about my own side project. It was written with AI assistance.

Top comments (1)
Great callout on JavaScript’s 32-bit bitwise coercion. The monotonic sketch has three more edge cases worth making explicit before calling it strictly ascending.
First,
rnd(2)[0] & 0x0fffuses only the first byte, so the seed has 8 bits rather than 12; combining both bytes is required. Second,(seq + 1) & 0x0fffsilently wraps after 4096 values in one millisecond and breaks ordering. Third, whenDate.now() < lastMsafter clock rollback, theelsebranch moves the timestamp backward. RFC 9562 recommends checking that each generated UUID is greater than the previous one and either reusing the prior timestamp while advancing the counter or reporting an error on rollback/overflow.There is also a scope question: this module-level state is monotonic only inside one JavaScript realm. Two tabs, workers, processes, or hosts can interleave UUIDs arbitrarily. That is fine for UUID uniqueness with enough random bits, but it is not a global sequencing guarantee.
I’d add tests for counter overflow, frozen time, backward clock jumps, reload/state loss, and concurrent generators. UUIDv7 gives temporal locality; if business correctness needs total order, keep a separate database sequence or event ordering field.