RORK LABJP
SWIFTUI — New Rork projects are no longer Expo. iPhone is Swift and SwiftUI, Android is Kotlin and Jetpack Compose, Web is React. Existing Expo projects still build and shipEXPO GO — On iOS, Expo Go now needs the same account signed in on both the terminal and the app before a QR code will launch anything. Android and development builds are unaffectedNOV 1 — Forty-eight days until the extension deadline for Google Play's target API 36 requirement. After November 2, non-compliant apps stop reaching new devicesSIGSEGV — Expo Go dies silently on some Samsung devices while expo-doctor reports a clean 20 out of 20. When every check is green, the adb log is where to look nextNEW — Is that API key sitting inside your app? When to use Rork environment variables and when to reach for a Supabase Edge FunctionCREDITS — How far does it go, the promise that AI-side errors do not cost credits? Log what a day of asking for the same fix three times actually consumes, and the line starts to showSWIFTUI — New Rork projects are no longer Expo. iPhone is Swift and SwiftUI, Android is Kotlin and Jetpack Compose, Web is React. Existing Expo projects still build and shipEXPO GO — On iOS, Expo Go now needs the same account signed in on both the terminal and the app before a QR code will launch anything. Android and development builds are unaffectedNOV 1 — Forty-eight days until the extension deadline for Google Play's target API 36 requirement. After November 2, non-compliant apps stop reaching new devicesSIGSEGV — Expo Go dies silently on some Samsung devices while expo-doctor reports a clean 20 out of 20. When every check is green, the adb log is where to look nextNEW — Is that API key sitting inside your app? When to use Rork environment variables and when to reach for a Supabase Edge FunctionCREDITS — How far does it go, the promise that AI-side errors do not cost credits? Log what a day of asking for the same fix three times actually consumes, and the line starts to show
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 generationCI7Unicode2

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 $15 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-09-10
Your First EAS Workflow in a Rork Repo, and the Alert That Goes Missing Exactly When You Need It
Putting two files into .eas/workflows in a repo exported from Rork, and why a notification wired with needs stays silent on exactly the nights it fails — with the output of a small local checker I actually ran.
📚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