Introduction
I run a site of small browser-only tools. Each one does a single thing, loads no external resources, and sends nothing to a server.
I recently added a UUID generator. Version 4 is a one-liner via crypto.randomUUID(), so there was nothing to build. Version 7 is the interesting one: it is still not in the standard API, and since the site loads no third-party scripts by policy, I had to write it myself.
UUID v7 puts a Unix millisecond timestamp in the leading 48 bits and fills the rest with randomness. That means sorting the strings lexicographically sorts them by creation time, which is the whole point of the version.
The layout
The 128 bits break down like this:
0 48 52 64
+-------------------+-------+-----------+------------------------+
| unix_ts_ms (48) | ver(4)| rand_a(12)| var(2) | rand_b (62) |
+-------------------+-------+-----------+------------------------+
Only two fields are fixed by the spec:
-
version: bits 48–51 must be
0111(7) -
variant: bits 64–65 must be
10(RFC 4122 family)
Everything else can be random. So the job is: pack the timestamp at the front, fill the rest with randomness, then overwrite exactly two spots.
The implementation
Here is the whole thing. We build a 16-byte Uint8Array and fill it front to back.
function uuidv7() {
const bytes = new Uint8Array(16);
// (1) Leading 48 bits = Unix milliseconds, most significant byte first
const ms = BigInt(Date.now());
for (let i = 0; i < 6; i++) {
bytes[i] = Number((ms >> BigInt(8 * (5 - i))) & 0xffn);
}
// (2) Fill the remaining 10 bytes with randomness
crypto.getRandomValues(bytes.subarray(6));
// (3) version = 7: clear the high nibble, then set 0x70
bytes[6] = (bytes[6] & 0x0f) | 0x70;
// (4) variant = 10xxxxxx: clear the top 2 bits, then set 0x80
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
}
bytes is the buffer under construction and ms is the current time in milliseconds. Date.now() needs about 41 bits, so it fits comfortably in the 48-bit field. It overflows some time around the year 10889.
The important part is the ordering of steps (3) and (4). We fill with randomness first, then clear only the target bits, then set the fixed value.
bytes[6] & 0x0f zeroes the high nibble so that | 0x70 can write 0111 into it. Both halves are required. If you skip the mask and write bytes[6] | 0x70, a random byte of 0x8? yields a version nibble of 1111 (15) instead of 7.
What tripped me up
My first version set the version bits before filling in the randomness:
// broken
bytes[6] = 0x70;
crypto.getRandomValues(bytes.subarray(6)); // <- overwrites 0x70
The output still looks like a UUID, so eyeballing the results tells you nothing. It is 36 hex characters with dashes in the right places either way.
I only caught it when I wrote a test asserting the version nibble. Character index 14 of the string is the version digit.
// the broken version passes roughly 1 time in 16
for (let i = 0; i < 1000; i++) {
assert.equal(uuidv7().charAt(14), "7");
}
Because it fails probabilistically, running the test once and seeing it pass makes you think it is fixed. It took a thousand iterations to fail reliably.
The other easy mistake is byte order in the timestamp. Pack it little-endian and the low-order millisecond byte lands at the front, so lexicographic order stops matching time order. That is the only reason to choose v7 in the first place, so getting this backwards silently removes the benefit. Pack most significant byte first.
One caveat worth stating: UUIDs generated within the same millisecond have no guaranteed order among themselves, since the comparison falls through to the random bits. If you need strict monotonicity, you need a counter within the millisecond. For database primary keys it usually does not matter, because millisecond granularity is enough to keep inserts landing at the tail of the B-tree.
The working tool is here. It runs entirely in the browser, so generated IDs are never transmitted anywhere.
https://hashitosystem.com/tools/uuidgen/
Wrapping up
The recipe is three steps: pack the timestamp most significant byte first, fill the rest with randomness, then mask and set exactly two fields.
When you hand-roll a format that fixes bit positions by spec, keep the order fill, mask, set. Setting before filling gives you a bug that only shows up part of the time, which is exactly the kind that survives a test you ran once.
This article is about my own side project. It was written with AI assistance.
Top comments (0)