◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 31 days left. If your app calls it, check the migration target●SDK 58 ISSUE — Early reports include Android widgets not refreshing on a schedule and Stripe linking statically●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 31 days left. If your app calls it, check the migration target●SDK 58 ISSUE — Early reports include Android widgets not refreshing on a schedule and Stripe linking statically●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/App Dev
◇ App Dev/2026-07-31Advanced

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.

build reproducibilityNode.jscatalog generationCI7Unicode

✦ Premium Article

The diff was 1,984 lines long.

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.

$ node scripts/gen-catalog.mjs assets/ public/catalog.json
$ cp public/catalog.json /tmp/a1.json
$ node scripts/gen-catalog.mjs assets/ public/catalog.json
$ diff /tmp/a1.json public/catalog.json | wc -l
1984
$ wc -l /tmp/a1.json
11041 /tmp/a1.json

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 down
const 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 stateDistinct hashes in 20 runs
As written20
generatedAt removed20

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 collectedDistinct outputs in 20 runs
push on completion20
use the Promise.all return value1

Promise.all resolves to an array ordered by input position. Returning values instead of pushing removes this source entirely.

// depends on completion order
const items = [];
await Promise.all(names.map(async (n) => { const st = await fsp.stat(n); items.push(mk(n, st)); }));
 
// preserves input order
const items = await Promise.all(names.map(async (n) => { const st = await fsp.stat(n); return mk(n, st); }));

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.

or
Unlock all articles with Membership →
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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

◇ App Dev2026-07-29
The same wallpaper appeared twice in my catalog — measuring perceptual hash thresholds across 324 images
Four approaches to image catalog deduplication measured end to end: SHA-256, dHash, a hue signature, and a block signature. dHash alone scored 0.9% precision. Here is why perceptual hashes collapse on smooth images, and how the ground truth turned out to be the thing that was wrong.
◇ App Dev2026-10-29
An Image Requested Once Before Its Upload Stayed a 404 at the Edge. I Split the Retention Rules by Status Code and Made Upload and Purge a Single Step
A new wallpaper stayed missing in some regions because the CDN kept a 404, fetched once before the upload, as long as it kept the image itself. Per-status retention, a two-request diagnosis, and pairing upload with purge, tested in Node.
◇ App Dev2026-10-28
"Project is incompatible with this version of Expo Go": Three Places to Check Before Blaming Your Project
When Expo Go says your project needs a newer Expo Go, there are three things to compare before you delete node_modules: the project's SDK, the SDK the app supports, and, on Android, a real mix-up in Expo Go 56.0.1. Here is the order I check them in.