RORK LABJP
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 toolingPAPER — Rork acquired the app builder Paperline and says it will stay acquisitive, largely as a way to bring in engineering talentMAXSWIFT — Rork Max is a separate line that generates native Swift rather than React Native, covering iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessageSCALE — Rork reports roughly 743,000 monthly visits and 85% growth, putting no-code app builders squarely into production-tool territoryEXPOAI — Expo pairs Claude Code, Expo Skills, Expo MCP, Argent, and AI-readable docs to hand agents the framework context that general-purpose models lackAGENT — Expo Agent runs in the browser, letting you generate and modify an app from prompts while working directly against a project or repositorySEED — Rork closed a $15M seed round led by Left Lane Capital, a sign of how much capital is flowing into prompt-to-mobile-app toolingPAPER — Rork acquired the app builder Paperline and says it will stay acquisitive, largely as a way to bring in engineering talentMAXSWIFT — Rork Max is a separate line that generates native Swift rather than React Native, covering iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessageSCALE — Rork reports roughly 743,000 monthly visits and 85% growth, putting no-code app builders squarely into production-tool territoryEXPOAI — Expo pairs Claude Code, Expo Skills, Expo MCP, Argent, and AI-readable docs to hand agents the framework context that general-purpose models lackAGENT — Expo Agent runs in the browser, letting you generate and modify an app from prompts while working directly against a project or repository
Articles/Dev Tools
Dev Tools/2026-08-01Advanced

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.

Rork524React Native216A/B Testing8Feature Flags3Architecture20

Premium Article

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:

  1. Deterministic — the same device sees the same variant on every launch
  2. Independent across experiments — being in one experiment must not change the odds of being in another
  3. Sticky through ramp-ups — going from 10% to 20% must not push anyone back out
  4. 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 unit of assignment is a UUID stored in expo-secure-store. Using identifierForVendor directly means the value changes on reinstall, quietly moving the same person into a different cohort. What survives a reinstall and what does not is something I worked through in Why Reinstalling Users Never See "First Launch" Again — Asymmetric State Persistence in Rork (Expo) Apps.

Four hashes, one million IDs

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 22
import { 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.

HashTime for 1MChi-squaredMin bucketMax bucketVerdict
Character-code sum529.6 ms26,013.07,65012,483Fails
31-based polynomial (javaHash)535.9 ms103.09,78010,262Passes
FNV-1a 32-bit536.0 ms89.39,70510,320Passes
SHA-256, first 4 bytes2,834.4 ms113.09,81310,287Passes

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.

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

Dev Tools2026-07-24
Collapsing Duplicate Requests Into One: A Reference-Counted Single-Flight Layer
When several components fire the same API call at launch, you get a burst of identical requests. Here is a single-flight layer that shares one in-flight promise instead, avoiding the trap of handing out a failed promise forever and the trap of one caller's abort cancelling everyone, with the real network numbers alongside.
Dev Tools2026-07-24
Resolving App Config in Three Layers: Merging Defaults, User, and Remote With Bounded Overrides
A single type-safe layer that merges compiled defaults, user preferences, and remote config. So a broken remote value never takes your app down, each key gets its own override strength, plus schema validation and range clamping, built from a real production incident.
Dev Tools2026-07-14
Designing Seams That Survive AI Regeneration in Rork
Every follow-up prompt to Rork can quietly wipe out logic you wrote by hand. Protecting it with prompts is a patch, not a fix. Here is how to separate generated code from code you own, and draw a boundary that regeneration cannot reach, with working Zustand and service-layer examples.
📚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 →