●IOS27 — The iOS 27 public beta arrived in July ahead of a general release expected around September, led by an AI-driven Siri and faster app launches, on iPhone 11 and later●REQ — iOS 27 itself reaches iPhone 11 and later, but most of its AI features require iPhone 15 Pro or newer, so your minimum OS target and your AI feature target are separate calls●BETA — To check compatibility without putting a beta on your daily device, a cloud Mac plus the Xcode 26 Simulator covers the early pass — device-only bugs still need real hardware●TWOCLICK — Rork Max is a Swift app builder aiming to replace Xcode on the web, running from plain-English prompts through on-device testing to a two-click App Store submission●REACH — The Rork Max launch post reportedly passed 8 million views on X and the company says annual revenue doubled within two weeks●EXPO — Standard Rork stays on React Native with Expo, spanning iOS, Android, and web from one codebase, while Rork Max emits pure Swift for the Apple platforms●IOS27 — The iOS 27 public beta arrived in July ahead of a general release expected around September, led by an AI-driven Siri and faster app launches, on iPhone 11 and later●REQ — iOS 27 itself reaches iPhone 11 and later, but most of its AI features require iPhone 15 Pro or newer, so your minimum OS target and your AI feature target are separate calls●BETA — To check compatibility without putting a beta on your daily device, a cloud Mac plus the Xcode 26 Simulator covers the early pass — device-only bugs still need real hardware●TWOCLICK — Rork Max is a Swift app builder aiming to replace Xcode on the web, running from plain-English prompts through on-device testing to a two-click App Store submission●REACH — The Rork Max launch post reportedly passed 8 million views on X and the company says annual revenue doubled within two weeks●EXPO — Standard Rork stays on React Native with Expo, spanning iOS, Android, and web from one codebase, while Rork Max emits pure Swift for the Apple platforms
My generated wallpaper catalog hashed differently on every run — isolating five sources of nondeterminism
The same image folder refused to produce the same catalog.json twice. Directory enumeration order, JSON.stringify key reordering, collation, Unicode normalization, and async completion order — measured one at a time across 1,225 files until the output settled on a single hash.
I had not added a single image. I re-ran npm run catalog on the wallpaper app with the tree in exactly the state of the previous commit. About 18% of the 11,041 lines in catalog.json had moved.
The script is small: walk the asset folder, emit catalog.json. The plan for CI was to guard freshness with git diff --exit-code. Since every run produced a diff, the gate stayed red. Eventually I removed the check and went back to eyeballing generated output in review.
I assumed the timestamp was the culprit and deleted that line. It did not help. What followed was a session of pulling the sources of nondeterminism apart one at a time.
Everything below was measured on Node v22.22.3 / Ubuntu 22.04 (Linux 6.8), against a fixture of 1,225 files (360 numeric IDs, 865 with a category prefix, 25 of them with Japanese names) placed on three different filesystems.
Look at the diff before rewriting anything
Before touching the script, I wanted to know what was actually moving.
Two kinds of change. One line for generatedAt, and a large number of lines where entire items blocks had swapped positions.
The second kind was the awkward one. No values changed. The same objects simply appeared in different places. Semantically the JSON is equivalent and the app behaves identically, which is exactly why nobody had chased it. But a diff gate needs byte equality, not semantic equality.
Here is the script as it stood.
// scripts/gen-catalog.mjs (before)import fsp from "node:fs/promises";import path from "node:path";const SRC = process.argv[2];const OUT = process.argv[3];const catalog = {};const tagSet = new Set();const names = [];// streamed with opendir to keep memory downconst dir = await fsp.opendir(SRC);for await (const ent of dir) { if (ent.isFile() && ent.name.endsWith(".jpg")) names.push(ent.name);}await Promise.all(names.map(async (name) => { const st = await fsp.stat(path.join(SRC, name)); const id = name.replace(/\.jpg$/, ""); const cat = id.includes("_") ? id.split("_")[0] : "legacy"; tagSet.add(cat); catalog[id] = { id, file: name, category: cat, bytes: st.size, width: 1290, height: 2796, aspect: 1290 / 2796, };}));const doc = { generatedAt: new Date().toISOString(), count: Object.keys(catalog).length, tags: [...tagSet].sort((a, b) => a.localeCompare(b)), items: catalog,};await fsp.writeFile(OUT, JSON.stringify(doc, null, 2));
Roughly thirty lines, hiding five separate sources of nondeterminism.
Dropping the timestamp changed nothing
I removed generatedAt first, expecting that to be the end of it.
The test: run the script 20 times against the same directory and count distinct SHA-256 hashes of the output.
Script state
Distinct hashes in 20 runs
As written
20
generatedAt removed
20
Not a single one collapsed. The timestamp was merely the visible cause.
What remained was the shape of the Promise.all call. Pushing into an array inside names.map(async ...) appends in completion order, and stat completion order shifts slightly on every run. Everything downstream reorders with it.
Measured over 200 IDs, 20 runs each:
How results are collected
Distinct outputs in 20 runs
push on completion
20
use the Promise.all return value
1
Promise.all resolves to an array ordered by input position. Returning values instead of pushing removes this source entirely.
At this point repeated runs against the same directory produced one hash. CI still disagreed.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Measured difference between readdir and opendir ordering, including how the same 1,225 files land in a completely different opendir order on a different filesystem (2/1225 positions matched)
✦A step-by-step ablation table, including the stage where removing the timestamp still left 20 distinct hashes out of 20 runs
✦The full deterministic generator plus the normalization guard that makes git diff --exit-code usable as a CI gate
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Enumeration order — readdir is sorted, opendir is not
"Directory order is not guaranteed" is common advice, and I had internalised it. That belief is part of why I had switched to opendir streaming to save memory. Measuring it told the opposite story.
I built the same file set three times, varying only the order in which files were created, then called readdirSync 50 times on each.
assets-A (creation order) : 50 runs → 1 distinct identical to the sorted array
assets-B (shuffled) : 50 runs → 1 distinct identical to the sorted array
assets-C (reverse) : 50 runs → 1 distinct identical to the sorted array
A, B and C agree on every position (1225/1225)
fs.readdir returns sorted results. libuv sorts the scandir output with strcmp before handing it over. That is a byte comparison, so locale does not enter into it — setting LC_ALL to C, C.UTF-8, en_US.UTF-8 or POSIX produced the same bf263d05356f every time.
opendir skips that sort.
readdir : bf263d05356f
opendir : 87268f8b81ea matches the sorted array in 1/1225 positions
opendir, repeated 20 times → 1 distinct (stable within one environment)
Stable within one environment was the trap. Run it locally as many times as you like and it looks perfectly well behaved.
It breaks when the filesystem changes. Same file set, three different filesystems:
Mount
Type
opendir order hash
readdir order hash
/sessions
ext4
87268f8b81ea
bf263d05356f
/
ext4 (different device)
04d38ef7c8d3
bf263d05356f
/dev/shm
tmpfs
7b8e667294a5
bf263d05356f
All three differ. Between the first two, only 2 files out of 1,225 landed in the same position. The ext4 htree orders entries using a hash seed that is per-filesystem, so two ext4 volumes will not agree either.
In other words this was a failure that could never reproduce locally. A fresh clone in CI, a new container layer, a replaced dev machine — the order changes there and the whole artifact reshuffles.
If you want byte-stable output, do not delegate ordering to a library's internals. libuv's sort is an implementation detail, not something the Node documentation promises. Sorting explicitly after you read is the only version that cannot drift.
JSON.stringify reorders integer-like keys
With ordering pinned, the output still refused to match exactly. Next up: object key order.
When a JavaScript object has keys that parse as array indices, those keys ignore insertion order and come out in ascending numeric order.
Insertion was 100, 9, 10, 1, ..., yet the numeric-looking keys sort ascending first and everything else follows in insertion order. "0007" with its leading zero, "-1", and "1.5" are not array indices, so they stay on the string side. There is also a ceiling: anything at or above 4294967295 is treated as a plain string.
const t = {};["4294967294", "4294967295", "4294967296", "5"].forEach((k) => (t[k] = 1));Object.keys(t).join(",");// → 5,4294967294,4294967295,4294967296// 4294967295 and above keep insertion order
What actually hurt here is that this behaviour had been hiding the bug for a long time. Back when every catalog ID was a plain sequence number, insertion order did not matter at all.
Using the real ID set, shuffling insertion order 30 different ways:
ID composition
Count
Distinct outputs across 30 orders
numeric IDs only
360
1
numeric IDs + 1 non-numeric
361
1
numeric IDs + 2 non-numeric
362
2
full catalog (360 numeric + 865 prefixed)
1225
30
With a single non-numeric ID the result is still deterministic, because that one key always lands last. The second one breaks it.
The move to category-prefixed names such as zen_0006 happened more than a year after this script was written. The diff gate started failing the day the naming convention changed, but at the time it read as "the JSON order shifted again," so nobody connected the two.
The fix is small: stop emitting a keyed object, emit an array.
// before: subject to key reorderingitems: { "1": {...}, "zen_0006": {...} }// after: the order you chose is the order you getitems: [ { id: "1", ... }, { id: "zen_0006", ... } ]
If the app needs lookup by ID, build a Map at load time. Map iterates in insertion order and never applies the numeric reshuffle.
Keep collation out of the artifact
Now that ordering is mine to decide, the comparator matters. The script used localeCompare, which is environment-dependent.
Sorting the 1,225 filenames with different comparators, measured against the default sort() (UTF-16 code unit comparison):
Comparator
Positions matching the default
sort() default (UTF-16 code units)
1225 / 1225
localeCompare(undefined)
1225 / 1225
localeCompare("en")
1225 / 1225
localeCompare("ja")
1045 / 1225
Intl.Collator("ja", numeric: true)
686 / 1225
Buffer.compare (UTF-8 bytes)
1225 / 1225
localeCompare("ja") moves 180 positions, because ICU's Japanese collation orders kanji by reading. Sorting a 15-tag list makes it obvious:
ja : 🌸桜 10月 2月 abstract Abstract minimal zen Zen ねこ ネコ ミニマル 猫 夜桜 𠮟咤 α波
en : 🌸桜 10月 2月 abstract Abstract minimal zen Zen α波 ねこ ネコ ミニマル 𠮟咤 夜桜 猫
byte: 10月 2月 Abstract Zen abstract minimal zen α波 ねこ ネコ ミニマル 夜桜 猫 🌸桜 𠮟咤
Under ja, 猫 sorts before 夜桜; under en it sorts after. Two Node builds carrying different ICU versions can disagree on the same data. Pinning artifact order to an ICU version is not a dependency I want to carry for years.
The default sort() has its own edge. It compares UTF-16 code units, so characters outside the BMP disagree with code point order.
UTF-16 code units : 一.jpg 🌸.jpg 𠮟.jpg 﨑.jpg ~.jpg
code point order : 一.jpg 﨑.jpg ~.jpg 🌸.jpg 𠮟.jpg
UTF-8 byte order : 一.jpg 﨑.jpg ~.jpg 🌸.jpg 𠮟.jpg
﨑 and ~ live in U+E000–U+FFFF, above the high surrogate range, so the default sort pushes them behind the emoji. If your filenames include variant kanji, that difference is real.
UTF-8 byte order always agrees with code point order, so Buffer.compare became the comparator for the artifact. Human-friendly ordering belongs to the presentation layer, not to a build product — ship a separate display-order field if you need one.
const byBytes = (a, b) => Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));items.sort((a, b) => byBytes(a.id, b.id));
Unicode normalization could not be fixed inside the generator
The last one was normalization, and it was the only source I could not resolve in the script itself.
The fixture includes five filenames in NFD, with combining marks — the shape you get when names created on macOS travel to a Linux box unchanged.
They look identical, they compare equal under collation, === still says false, and the NFC path cannot be opened. Normalizing only the catalog keys gives you the worst combination: IDs that match and files that cannot be read.
So what about normalizing the ID while keeping the real filename in a file field? I measured that too.
Generation strategy
Linux side (NFD)
macOS side (NFC)
Match
keep the on-disk filename in `file`
522d6a608417
ec0c4adeb6d7
no
derive `file` from the normalized id
ec0c4adeb6d7
ec0c4adeb6d7
yes
As long as the raw filename is carried through, the artifact cannot match across platforms. Obvious in hindsight; I genuinely expected normalizing the keys to be enough.
Normalization turned out to be a repository problem, not a build problem. Reject it upstream instead of absorbing it downstream.
// scripts/check-nfc.mjsimport fs from "node:fs";const bad = [];for (const dir of process.argv.slice(2)) { for (const n of fs.readdirSync(dir)) { if (n !== n.normalize("NFC")) bad.push(`${dir}/${n}`); }}if (bad.length) { console.error(`${bad.length} filenames are not NFC:`); for (const b of bad.slice(0, 5)) console.error(" " + JSON.stringify(b)); process.exit(1);}console.log("all filenames are NFC");
$ node scripts/check-nfc.mjs assets/
5 filenames are not NFC:
"assets/こもれび_0.jpg"
...
exit=1
On the macOS side, confirm git config core.precomposeunicode true. It defaults to true, but repositories carrying old configuration sometimes have it off. Once those five filenames were renamed to NFC, readdir order agreed across both platforms.
The deterministic generator
Here is the version with every fix applied. The numbered comments map to the sources above.
// scripts/gen-catalog.mjsimport fsp from "node:fs/promises";import path from "node:path";const SRC = process.argv[2];const OUT = process.argv[3];// 1) enumerate with readdir, never with streamed opendirconst names = (await fsp.readdir(SRC)).filter((n) => n.endsWith(".jpg"));// 2) preserve input order (use the Promise.all return value)const items = await Promise.all( names.map(async (name) => { const st = await fsp.stat(path.join(SRC, name)); // 3) normalize the key; check-nfc.mjs guarantees the on-disk name upstream const id = name.replace(/\.jpg$/, "").normalize("NFC"); const cat = id.includes("_") ? id.split("_")[0] : "legacy"; return { id, file: `${id}.jpg`, category: cat, bytes: st.size, width: 1290, height: 2796, // 4) fix the precision of floats before serializing aspect: Number((1290 / 2796).toFixed(4)), }; }));// 5) sort explicitly, with a locale-free comparatorconst byBytes = (a, b) => Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));items.sort((a, b) => byBytes(a.id, b.id));const tags = [...new Set(items.map((i) => i.category))].sort(byBytes);// 6) only embed a timestamp when one is supplied from outsideconst epoch = process.env.SOURCE_DATE_EPOCH;const doc = { schema: 2, generatedAt: epoch ? new Date(Number(epoch) * 1000).toISOString() : null, count: items.length, tags, items,};// 7) pin key order and end the file with a newlineconst stable = (obj) => JSON.stringify( obj, (_k, v) => v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.keys(v).sort(byBytes).map((k) => [k, v[k]])) : v, 2 ) + "\n";await fsp.writeFile(OUT, stable(doc));
A note on the float handling. 1290 / 2796 serializes as 0.4613733905579399. That value is IEEE 754 arithmetic and does not vary by environment, but the more digits you ship, the more room downstream consumers have to disagree when parsing, rounding, and re-emitting. Rounding to 0.4614 keeps the artifact readable and sidesteps -0 serializing as 0 and 1e21 serializing as 1e+21.
SOURCE_DATE_EPOCH is the convention borrowed from reproducible builds. Set it to the commit timestamp in CI and leave it unset locally, and it stops interfering with the diff check.
The ablation
Applying the fixes one at a time, measured over 20 runs in one environment and across two filesystems:
State
20 runs, same environment
Across filesystems
original script
20 distinct
differs
+ timestamp removed
20 distinct
differs
+ stopped pushing on completion
1 distinct
differs
+ opendir back to readdir
1 distinct
matches
+ object replaced by array
1 distinct
matches
+ explicit sort / NFC / fixed precision
1 distinct
matches
Row three was the dangerous place to stop. Twenty runs locally, one hash, tests green — and still broken in CI. When someone reports a failure that "doesn't reproduce locally," this is the shape I would look for first.
The last two rows change nothing measurable. They are in because libuv's sort is an implementation detail. If a future version changes it, or someone swaps readdir for a glob library, an explicit sort means the artifact does not move. Six months later, "deterministic by accident" and "deterministic on purpose" are not the same thing at all.
Running the finished version 60 times in a row:
v1 (original) : 60 distinct hashes / 142.5 ms per run
v2 (final) : 1 distinct hash / 137.7 ms per run
Environment variables made no difference.
Environment
10 runs
Hash
default
1 distinct
58977ad47094
LC_ALL=C
1 distinct
58977ad47094
LC_ALL=en_US.UTF-8
1 distinct
58977ad47094
TZ=America/Los_Angeles
1 distinct
58977ad47094
TZ=Asia/Tokyo, LC_ALL=C
1 distinct
58977ad47094
I expected the explicit sort to cost something. It measured slightly faster instead, 142.5 ms down to 137.7 ms — dropping the sequential opendir reads outweighed sorting 1,225 entries. At this scale, determinism is effectively free.
Making the gate work
Only now does the diff gate mean anything.
- name: verify filename normalization run: node scripts/check-nfc.mjs assets- name: regenerate the catalog and require no diff run: | node scripts/gen-catalog.mjs assets public/catalog.json git diff --exit-code -- public/catalog.json
A passing gate now asserts something real: the committed artifact is reproducible from the current assets. A failing gate means either assets were added without regenerating, or the generation logic changed without updating the output — both failures worth hearing about.
Because it no longer produces noise, committing the generated artifact became a workable practice again, and CI builds got shorter since the file is already there.
There is a second payoff. A deterministic artifact hash makes a sound cache key: unchanged content means an unchanged hash, so no pointless CDN purges and no wasted downloads for users. That design simply does not hold with a nondeterministic generator.
Read the diff. Values changing and positions changing point at different layers
Remove the obvious clocks, randomness, and hostnames — then keep going
Check async aggregation. A lot of nondeterminism disappears by turning push into return
Check the enumeration API. opendir and hand-rolled walkers guarantee nothing
Check the output container. Object keys will not stay in the order you inserted them
Detach the comparator from locale. Buffer.compare is the safe choice
Reject non-NFC filenames upstream. The generator cannot fix them
Finally, run the same input N times and count hashes. You are not finished until the count is one
Doing step 8 first would have saved me a day. "The same input produces the same bytes" is a testable property, and because I never tested it, I drifted toward the opposite fix: switching the gate off.
If you commit generated artifacts, determinism is the precondition for that whole design. Operate without it and you end up removing checks one by one, in proportion to how broken it has become.
Thank you for reading. Running your own generator N times and counting the hashes is a good place to start.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.