RORK LABJP
PRICE — Rork Max spans $200 to $1,800 per month, with the upper tiers aimed at heavier builders and teamsFREE — The free tier lands at roughly five prompts per week, enough to try it but not to build on continuouslySHIP — App Store publishing is automated through builds, certificates, and submission, so you can ship an iOS app without a Mac or XcodeSIM — A browser-streamed simulator lets you watch your app run in a real Apple environment from your own browserNATIVE — It reaches HealthKit, ARKit and LiDAR, NFC, Dynamic Island, and Metal 3D — territory React Native cannot touchFUNDING — Rork raised a $15M seed led by Left Lane Capital, announced April 9, 2026, and acquired app builder PaperlinePRICE — Rork Max spans $200 to $1,800 per month, with the upper tiers aimed at heavier builders and teamsFREE — The free tier lands at roughly five prompts per week, enough to try it but not to build on continuouslySHIP — App Store publishing is automated through builds, certificates, and submission, so you can ship an iOS app without a Mac or XcodeSIM — A browser-streamed simulator lets you watch your app run in a real Apple environment from your own browserNATIVE — It reaches HealthKit, ARKit and LiDAR, NFC, Dynamic Island, and Metal 3D — territory React Native cannot touchFUNDING — Rork raised a $15M seed led by Left Lane Capital, announced April 9, 2026, and acquired app builder Paperline
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.js2catalog generationCI5Unicode2

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-06-25
Why Untranslated Strings Leak to Production Every Time You Add a Language — A Catalog and Gap-Detection Design for Rork (Expo) Apps
A design for stopping untranslated strings from leaking into production after you localize a Rork-generated Expo app. Covers a single source-of-truth catalog, CI-based missing/extra key detection, explicit fallback chains, plurals, and pseudolocalization for layout — with implementation.
App Dev2026-08-15
TestFlight External Testing or Phased Release: Where a Solo Developer's Time Actually Pays Off
When you ship a Rork-built app to other people, you can recruit external testers or let a phased release absorb the risk. Here is how I split my limited time between the two, based on where distribution actually stalls and which three areas still deserve pre-release checks.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →