RORK LABJP
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 laterREQ — 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 callsBETA — 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 hardwareTWOCLICK — 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 submissionREACH — The Rork Max launch post reportedly passed 8 million views on X and the company says annual revenue doubled within two weeksEXPO — 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 platformsIOS27 — 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 laterREQ — 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 callsBETA — 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 hardwareTWOCLICK — 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 submissionREACH — The Rork Max launch post reportedly passed 8 million views on X and the company says annual revenue doubled within two weeksEXPO — 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
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 generationCI5Unicode

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-07-18
Walk Through an App Store Submission Without Writing Code — Sticker Packs as a Dress Rehearsal
An iMessage sticker pack is one of the few things you can ship to the App Store with zero code. It cannot make money — and that constraint is exactly what makes it a clean rehearsal for the submission process.
📚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 →