●SEED — Rork closed a $15M seed round led by Left Lane Capital, a sign of how much capital is flowing into prompt-to-mobile-app tooling●PAPER — Rork acquired the app builder Paperline and says it will stay acquisitive, largely as a way to bring in engineering talent●MAXSWIFT — Rork Max is a separate line that generates native Swift rather than React Native, covering iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●SCALE — Rork reports roughly 743,000 monthly visits and 85% growth, putting no-code app builders squarely into production-tool territory●EXPOAI — Expo pairs Claude Code, Expo Skills, Expo MCP, Argent, and AI-readable docs to hand agents the framework context that general-purpose models lack●AGENT — Expo Agent runs in the browser, letting you generate and modify an app from prompts while working directly against a project or repository●SEED — Rork closed a $15M seed round led by Left Lane Capital, a sign of how much capital is flowing into prompt-to-mobile-app tooling●PAPER — Rork acquired the app builder Paperline and says it will stay acquisitive, largely as a way to bring in engineering talent●MAXSWIFT — Rork Max is a separate line that generates native Swift rather than React Native, covering iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●SCALE — Rork reports roughly 743,000 monthly visits and 85% growth, putting no-code app builders squarely into production-tool territory●EXPOAI — Expo pairs Claude Code, Expo Skills, Expo MCP, Argent, and AI-readable docs to hand agents the framework context that general-purpose models lack●AGENT — Expo Agent runs in the browser, letting you generate and modify an app from prompts while working directly against a project or repository
Every Experiment Kept Landing on the Same Devices: Hash Choice and Salt Position, Measured Across a Million IDs
On-device experiment assignment looked fine in isolation, then collapsed the moment two experiments ran side by side. Here is what one million IDs revealed about four hash functions, why the culprit was the concatenation order rather than hash quality, and the implementation I settled on, with the measured numbers.
Two experiments in the same week: one on paywall copy, one shortening onboarding from three screens to two. Both shipped at a 10% exposure.
A week later I stopped scrolling through the numbers. More than six in ten devices enrolled in the shortened onboarding were also seeing the paywall copy variant. The two experiments had nothing to do with each other. At 10% and 10%, roughly 1% of all devices should have landed in both.
My first suspect was the device IDs. My second was hash quality. Both were wrong. The cause sat in the line that builds the assignment key — the order in which two strings get concatenated, which no code review ever stops.
It took me a full day to see it, so here are the numbers from one million IDs and the implementation I ended up with, for anyone circling the same drain.
Why assignment happens on the device at all
The wallpaper apps I run as an indie developer earn through AdMob and subscriptions, and I have not stood up a server purely for experiments. An assignment service adds availability, latency, and cost all at once. Long before an App Store submission, that is a place I would rather stay lean.
So assignment resolves entirely on device. Four properties matter:
Deterministic — the same device sees the same variant on every launch
Independent across experiments — being in one experiment must not change the odds of being in another
Sticky through ramp-ups — going from 10% to 20% must not push anyone back out
Synchronously resolvable — no await on the launch path, variant decided before the first paint
The fourth sounds like a nicety. It is not. A paywall headline that renders in the default copy and swaps on the next frame reads as a bug to the person holding the phone.
The candidates: naive character-code sum, the 31-based polynomial hash (the shape of Java's String.hashCode), 32-bit FNV-1a, and the first four bytes of SHA-256.
First, uniformity in isolation. One million UUID v4 values, hash(salt + ':' + id) % 100 into a hundred buckets, then a chi-squared statistic and the extremes.
// bench.mjs — run with Node.js 22import { createHash, randomUUID } from 'node:crypto';const N = 1_000_000;const ids = new Array(N);for (let i = 0; i < N; i++) ids[i] = randomUUID();function sumChars(s) { let h = 0; for (let i = 0; i < s.length; i++) h += s.charCodeAt(i); return h >>> 0;}function javaHash(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0; return h >>> 0;}function fnv1a(s) { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); // plain * overflows past 2^53 and loses bits } return h >>> 0;}function sha256t(s) { return createHash('sha256').update(s).digest().readUInt32BE(0);}function uniformity(fn, salt) { const B = 100; const counts = new Int32Array(B); const t0 = process.hrtime.bigint(); for (let i = 0; i < N; i++) counts[fn(salt + ':' + ids[i]) % B]++; const t1 = process.hrtime.bigint(); const expected = N / B; let chi2 = 0; for (let b = 0; b < B; b++) { const d = counts[b] - expected; chi2 += (d * d) / expected; } return { ms: Number(t1 - t0) / 1e6, chi2, min: Math.min(...counts), max: Math.max(...counts), };}
With 99 degrees of freedom, the upper 1% critical value is 135.8. Above that, uniformity is hard to defend.
Hash
Time for 1M
Chi-squared
Min bucket
Max bucket
Verdict
Character-code sum
529.6 ms
26,013.0
7,650
12,483
Fails
31-based polynomial (javaHash)
535.9 ms
103.0
9,780
10,262
Passes
FNV-1a 32-bit
536.0 ms
89.3
9,705
10,320
Passes
SHA-256, first 4 bytes
2,834.4 ms
113.0
9,813
10,287
Passes
Expected count per bucket is 10,000. The character sum misses by up to 24.83% and drops out here — UUIDs draw from sixteen hex characters plus hyphens, so the reachable sums cluster tightly in the middle.
The other three all passed at this stage. The one I had been shipping was the 31-based polynomial, chosen because it adds no dependency.
✦
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 data showing a 31-based polynomial hash passing uniformity yet reaching P(B|A)=65.35% and a 7.239% triple-enrolment rate
✦The condition under which moving the salt to the end flips the overlap to a perfectly exclusive 0.00%, and the algebra behind it
✦A final-mix step that lands FNV-1a at P(B|A)=10.00%, 0.496µs per call, and 100% stickiness through a ramp-up
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.
The hash that passed uniformity broke the moment a second experiment appeared
The mistake was looking at one experiment at a time.
Assign experiment A and experiment B at 10% each over the same devices and count how many land in both. Under independence that is 1.00% of all devices, or 10.00% of the devices already in A.
function overlap(fn, saltA, saltB) { let a = 0, both = 0; for (let i = 0; i < N; i++) { const inA = fn(saltA + ids[i]) % 100 < 10; if (inA) { a++; if (fn(saltB + ids[i]) % 100 < 10) both++; } } return { aPct: (a / N) * 100, conditional: (both / a) * 100 };}
Scheme
Exposure of A
P(B|A)
Gap vs expected
No salt at all (hash the ID only)
9.99%
100.00%
+90.00pt
Character sum, salt in front
8.45%
77.94%
+67.94pt
31-based polynomial, salt in front
10.03%
65.20%
+55.20pt
FNV-1a, salt in front
9.99%
10.32%
+0.32pt
SHA-256, salt in front
10.02%
9.85%
-0.15pt
Forgetting the salt entirely gives 100%, no surprise. Row three is the surprise. The polynomial hash that sailed through uniformity at 103.0 correlates at 65.20% across two experiments — matching the "six in ten" I had seen in production almost exactly.
Adding a third experiment widened the gap. Three experiments at 10% each should put 0.100% of devices in all three.
Hash
In all three experiments
Expected under independence
31-based polynomial
7.239%
0.100%
FNV-1a
0.095%
0.100%
Seventy-two times over. Without meaning to, I had manufactured a permanent test cohort — a group absorbing every change at once. Shortened onboarding, altered paywall copy, and a third unrelated feature, all aimed at the same people. Beyond contaminating the readings, it concentrated every disruption on a small set of users, which bothered me more than the statistics did.
The culprit was the concatenation order, not the hash
This is where I sat still the longest.
A 31-based polynomial hash returns, for a string c₀c₁…c_{n-1}:
h(s) = Σ c_i · 31^(n-1-i) (mod 2^32)
Build the key as salt + ':' + id and the ID length is constant, because it is always a UUID. The salt therefore always gets multiplied by the same 31^37, and the ID part is simply added on top:
Subtract, and h(id) cancels. What remains is a constant that does not depend on the device at all. Collecting the differences over 20,000 devices:
const diffs = new Set();for (let i = 0; i < 20000; i++) { const d = (javaHash('exp_paywall_v2:' + ids[i]) - javaHash('exp_onboard_v3:' + ids[i])) >>> 0; diffs.add(d);}console.log(diffs.size); // → 1
One distinct value across all 20,000 devices: 3716953708. Run the same loop with FNV-1a and you get 19,998 distinct differences.
Experiment A and experiment B were the same number line, shifted sideways by a constant. Where that shift happens to land decides whether the overlap comes out at 65% or 90%. Nothing about randomness quality was at fault; a hash that mixes characters purely by addition is simply the wrong shape for this job.
Moving the salt to the end fixes it — conditionally
Once the structure is visible, the fix suggests itself: write id + ':' + salt, so the ID gets multiplied by a power that depends on the salt's length, which differs per experiment. Measured:
Scheme (31-based polynomial)
Exposure of A
P(B|A)
Trailing salt, different lengths (14 vs 15 chars)
9.99%
10.08%
Trailing salt, equal lengths (14 chars)
9.99%
0.00%
Trailing salt, equal lengths, second pair
9.98%
0.00%
Different lengths settle at 10.08%. Equal lengths drop to 0.00% — perfectly exclusive. Not one device in experiment A appears in experiment B.
Same algebra, of course. Equal salt lengths mean equal multipliers on the ID, which puts us back at a constant difference. This time the shift happens to carry the 10% window cleanly outside itself.
That is where I abandoned the trailing-salt fix. Keys like exp_paywall_v2 and exp_onboard_v3 end up the same length all the time. A design that depends on a rule saying "never let two experiment keys have equal length" is a design my future self will violate within a season. Naming conventions for flags are worth having — I wrote up mine in Making Feature Flags Survive Production: Kill Switches and Gradual Rollouts in Rork Max — but the accidents a naming rule should prevent and the accidents a hash function should prevent are better kept separate.
A final mix to clean up the low bits
FNV-1a with a leading salt came in at a healthy 10.32%. Move the salt to the end, though, and it drifts to 12.92%. Taking the bucket with % 100 uses the low bits, and FNV-1a's avalanche in the low bits is shallow after the last multiply, so strings that differ only at the tail retain a trace of correlation.
One round of MurmurHash3's finalizer (fmix32) closes it:
function fmix32(h) { h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35); h ^= h >>> 16; return h >>> 0;}const bucketHash = (s) => fmix32(fnv1a(s));
Re-measured over 500,000 IDs:
Scheme
P(B|A)
Chi-squared
FNV-1a, leading salt
10.63%
91.5
FNV-1a, trailing salt (equal lengths)
12.92%
88.0
FNV-1a + fmix32, leading salt
10.00%
78.3
FNV-1a + fmix32, trailing salt (equal lengths)
10.08%
104.5
With the extra round, either concatenation order lands near 10%. The triple-enrolment rate comes out at exactly 0.100%, and a 34/33/33 three-way split measures 34.00% / 33.04% / 32.96%.
Cost per call is 0.496µs, against 0.510µs without the finalizer. Five lines that remove the need to reason about concatenation order forever — that was not a hard call.
Keeping devices in place during a ramp-up
There is one more operation every experiment eventually reaches: watch it at 10%, then widen to 20%.
If a device already seeing the new experience gets pushed back out, the person perceives a feature disappearing. On a subscription path, that costs you people who were one tap from purchasing.
Three ways to write it:
Approach
Devices at 10%
Still included at 20%
Threshold: h % 10000 < ratio × 10000
49,837
100.00%
Re-salt per stage (exp:v1: → exp:v2:)
100,098
19.85%
Change the modulus (h % 10 < 1 → h % 7 < 2)
100,566
28.64%
Only the threshold form is monotone in the exposure. Bucket numbers stay pinned per device and the boundary is the only thing that moves, so nobody who is inside ever falls out.
Versioned salts turn out to be the dangerous one precisely because they look meaningful. You notice that bumping to v2 reshuffles everyone right after you have bumped to v2. The release-side mechanics of staged rollout are covered separately in A Phased Release Strategy for Rork Apps.
I settled on 10,000 buckets. One-percent steps cover almost everything, but 0.1% granularity earns its keep when walking a kill switch back, where a coarse step makes the retreat clumsy.
The implementation I settled on
This is the assignment layer shared across six apps. The only dependencies are expo-secure-store and @react-native-async-storage/async-storage; the hash is local.
// src/experiments/bucketing.tsconst FNV_OFFSET = 0x811c9dc5;const FNV_PRIME = 0x01000193;export const BUCKET_RESOLUTION = 10_000; // 0.01% granularityfunction fnv1a32(input: string): number { let h = FNV_OFFSET; for (let i = 0; i < input.length; i++) { h ^= input.charCodeAt(i); h = Math.imul(h, FNV_PRIME); // a plain * overflows past 2^53 } return h >>> 0;}function fmix32(h: number): number { h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35); h ^= h >>> 16; return h >>> 0;}/** Bucket 0–9999 from an experiment key and a unit ID. Synchronous, deterministic. */export function bucketOf(experimentKey: string, unitId: string): number { if (!experimentKey || !unitId) { // An empty string would drop every device into the same bucket. Fail loudly. throw new Error(`bucketOf: empty key or unitId (key="${experimentKey}")`); } return fmix32(fnv1a32(`${experimentKey}:${unitId}`)) % BUCKET_RESOLUTION;}export type Variant<T extends string> = { name: T; weight: number };/** * Weighted variant selection. Weights need not sum to 10000; * whatever is left over falls to the first entry, i.e. the control group. */export function pickVariant<T extends string>( experimentKey: string, unitId: string, variants: readonly Variant<T>[],): T { if (variants.length === 0) throw new Error(`pickVariant: no variants for "${experimentKey}"`); const total = variants.reduce((sum, v) => sum + v.weight, 0); if (total > BUCKET_RESOLUTION) { throw new Error(`pickVariant: weights exceed ${BUCKET_RESOLUTION} in "${experimentKey}"`); } const bucket = bucketOf(experimentKey, unitId); let cursor = 0; for (const v of variants) { cursor += v.weight; if (bucket < cursor) return v.name; } return variants[0].name; // outside the allocation = control}/** Ramp-ups. ratioBps runs 0–10000 (10000 = 100%). Raising it never evicts anyone. */export function isInRollout(flagKey: string, unitId: string, ratioBps: number): boolean { const clamped = Math.max(0, Math.min(BUCKET_RESOLUTION, Math.floor(ratioBps))); return bucketOf(flagKey, unitId) < clamped;}
The unit ID side writes to both the Keychain and AsyncStorage, so either one can restore the value:
// src/experiments/unit-id.tsimport * as SecureStore from 'expo-secure-store';import AsyncStorage from '@react-native-async-storage/async-storage';import * as Crypto from 'expo-crypto';const KEY = 'exp.unit_id.v1';let cached: string | null = null;export function getUnitIdSync(): string | null { return cached; // the synchronous path used during launch}export async function loadUnitId(): Promise<string> { if (cached) return cached; // 1) Keychain / Keystore — survives reinstall on iOS try { const secure = await SecureStore.getItemAsync(KEY); if (secure) return (cached = await mirror(secure)); } catch (e) { // Device lock state can make this unreadable. Do not crash here. console.warn('[exp] secure read failed', e); } // 2) AsyncStorage — the fallback when the Keychain is unavailable try { const plain = await AsyncStorage.getItem(KEY); if (plain) return (cached = await mirror(plain)); } catch (e) { console.warn('[exp] async read failed', e); } // 3) Mint a new one const fresh = Crypto.randomUUID(); return (cached = await mirror(fresh));}async function mirror(id: string): Promise<string> { await Promise.allSettled([ SecureStore.setItemAsync(KEY, id), AsyncStorage.setItem(KEY, id), ]); return id;}
Callers await loadUnitId() once at startup and evaluate synchronously from then on. Where the first paint has to be correct, such as a paywall, an unloaded ID returns the control variant. Loading finishes in a dozen or so milliseconds, so in practice this only affects the very first frame of a first launch.
// src/experiments/use-variant.tsimport { useMemo } from 'react';import { getUnitIdSync } from './unit-id';import { pickVariant, type Variant } from './bucketing';export function useVariant<T extends string>( experimentKey: string, variants: readonly Variant<T>[],): T { const unitId = getUnitIdSync(); return useMemo(() => { if (!unitId) return variants[0].name; // control while the ID is still loading return pickVariant(experimentKey, unitId, variants); }, [experimentKey, unitId, variants]);}
On the numbers, SHA-256 held up well on independence (P(B|A) 9.85%). It costs 2.781µs per call against FNV-1a's 0.510µs — 5.4 times more — but evaluating thirty flags at launch still totals only 83.4µs. Speed was never the deciding factor.
The decider was elsewhere. React Native has no synchronous SHA-256. expo-crypto's digestStringAsync returns a promise, which puts an await between launch and the variant you wanted resolved before the first paint. A native module gets you a synchronous version, but whether to add one purely for experiment assignment is a separate decision.
Dimension
FNV-1a + fmix32
SHA-256 (expo-crypto)
Cost per call
0.496 µs
2.781 µs (measured in Node)
Synchronous evaluation
Yes
No (promise)
Extra dependency
None (20 lines of local code)
Native module
Independence across experiments
P(B|A) 10.00%
P(B|A) 9.85%
Nothing here needs cryptographic strength. Nobody is trying to guess their assignment. The requirement was mixing, and only mixing. Reach for the heavier tool on a misread requirement and you give up the one that actually mattered.
Do not over-trust a uniformity test
One more thing surfaced while measuring.
Supposing the unit ID were a sequential counter rather than a UUID, I ran the same uniformity check:
ID shape
Hash
Chi-squared
Min
Max
Sequential (user-1 … user-1000000)
31-based polynomial
0.8
9,976
10,027
Sequential (user-1 … user-1000000)
FNV-1a
32.4
9,845
10,126
Zero-padded sequential
31-based polynomial
0.5
9,983
10,019
Zero-padded sequential
FNV-1a
39.3
9,884
10,220
Chi-squared of 0.8. With 99 degrees of freedom, that sits in the lower rejection region, not the upper one. It is too even. Feed a counter through a linear hash and buckets get filled in orderly rotation, producing a distribution with none of the jitter real randomness has.
Uniformity tests are usually framed as a way to catch skew. For assignment design, "suspiciously smooth" turned out to be just as telling. Seeing that number is what made me stop treating uniformity as the pass condition on its own. Uniformity, independence, and stickiness now get measured together, every time.
The four rules that came out of it
Four lines went into the README of the shared layer:
Build keys as experimentKey:unitId and hash only with fnv1a32 followed by fmix32 — no other hash enters the codebase
Express exposure as a threshold over 10,000 buckets — ramp by raising ratioBps, and never version the salt
Whenever a new experiment lands, check its co-enrolment rate against the existing ones once — a gap over two points from the expectation means a design bug
Send both the variant name and the bucket number with analytics events — exposure accidents should be catchable downstream
The third would not have occurred to me otherwise. Tests around assignment layers gravitate toward "is it deterministic," and the relationship between experiments falls straight through the gap.
If you want to know whether the same trap is sitting in your codebase, take your assignment function and measure the co-enrolment rate for two experiment keys. At 10% and 10%, the conditional probability should land near 10%. That one check reproduces everything here in your own environment.
For what it is worth, I spent most of that day suspecting the ID generator. Only after lining the numbers up did the actual suspect come into view. I hope it saves you the day.
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.