RORK LABJP
R8 — From Expo SDK 58, R8 is enabled by default for Android release builds. Measuring the same code before and after shows both what shrank and what brokeXCODE26.6 — The Xcode 27 image for EAS Build is still coming soon, and latest is still Xcode 26.6. Which bugs you hit depends on whether your local Xcode is already on 2711/01 — Extension requests for the Google Play target API level close on November 1, forty days away. New and updated apps need API 36 or higher, existing apps API 35 or higherFETCH — expo/fetch is reported to hang without settling on iOS, while Android resolves a truncated body as a plain 200. Nothing throws, so the timeout has to be yoursNEW — A build that stops at exit code 0: the flag that silenced the logs, and the check that let an empty file throughSCENE — expo@57.0.23 added an opt-in for launching under Xcode 27 while staying on SDK 57. Enable ios.enableSceneSupport through expo-build-propertiesR8 — From Expo SDK 58, R8 is enabled by default for Android release builds. Measuring the same code before and after shows both what shrank and what brokeXCODE26.6 — The Xcode 27 image for EAS Build is still coming soon, and latest is still Xcode 26.6. Which bugs you hit depends on whether your local Xcode is already on 2711/01 — Extension requests for the Google Play target API level close on November 1, forty days away. New and updated apps need API 36 or higher, existing apps API 35 or higherFETCH — expo/fetch is reported to hang without settling on iOS, while Android resolves a truncated body as a plain 200. Nothing throws, so the timeout has to be yoursNEW — A build that stops at exit code 0: the flag that silenced the logs, and the check that let an empty file throughSCENE — expo@57.0.23 added an opt-in for launching under Xcode 27 while staying on SDK 57. Enable ios.enableSceneSupport through expo-build-properties
Articles/Dev Tools
Dev Tools/2026-08-08Advanced

The 429s Kept Coming After Recovery: Measuring Retry Jitter Across 2,000 Clients

My backend came back but the 429s did not stop. I rebuilt the scenario in a 2,000-client discrete simulator and measured what plain exponential backoff, delay caps, 10% jitter, and honoring Retry-After cost in seconds — then reran everything with server capacity that degrades under load.

retrybackoffExpo210React Native238reliability

Premium Article

One morning I took down the backend behind my indie developer apps for about four minutes. It was supposed to be a one-line config change; the deploy failed and left the service dead.

I rolled it back and watched the logs, relieved. The relief did not last. The service was up, capacity was back, and the 429s kept coming.

Opening the app on my own phone gave me the same thing. Errors, for several minutes, against a server that was demonstrably healthy.

The cause was in my own retry code. Worse, the part doing the most damage was the part I had written by the book.

I could not reason my way out of it, so I built a small simulator that replays 2,000 clients reconnecting, and measured what each implementation actually costs in seconds.

What was actually happening

When an outage hits, every client with the app open fails at roughly the same instant. Because their failures are aligned, their retries are aligned too.

The moment the server recovers, that aligned population arrives together. Capacity is exceeded, so the server returns 429. The clients that got 429 come back — still aligned.

The server had recovered. My own clients were keeping it busy.

That much is the standard thundering herd story. What I did not have was the quantitative half: which mitigation buys how much, and where the surprises are.

Building the test rig

Experimenting on a live service was not an option, so I wrote a discrete-time simulator. One tick is 100ms, and the loop only models three things: clients sending, the server accepting up to capacity, and rejected clients rescheduling.

The parameters mirror my setup. 2,000 clients, recovery at the 30 second mark, 120 req/sec of post-recovery capacity. Randomness comes from a seeded mulberry32, and every figure below is the mean of five seeds.

// herd.mjs — run with: node herd.mjs
const N = 2000;               // clients that failed together
const RECOVER_MS = 30000;     // when the server comes back
const CAPACITY_PER_SEC = 120; // post-recovery capacity
const TICK = 100;             // one tick = 100ms
const CAP_TICK = CAPACITY_PER_SEC * TICK / 1000;
const BASE = 1000;            // base delay, 1s
 
// Seeded PRNG so results are reproducible
function mulberry32(a) {
  return function () {
    a |= 0; a = a + 0x6D2B79F5 | 0;
    let t = Math.imul(a ^ a >>> 15, 1 | a);
    t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}
 
function run(nextDelay, seed) {
  const rand = mulberry32(seed);
  const next = new Float64Array(N);   // next send time per client
  const att  = new Int32Array(N);     // attempt count
  const prev = new Float64Array(N);   // previous delay (for decorrelated jitter)
  const done = new Uint8Array(N);
  for (let i = 0; i < N; i++) { next[i] = rand() * 2000; prev[i] = BASE; }
 
  let sentAfterRecovery = 0, ok = 0, peak = 0, t99 = -1;
 
  for (let t = 0; t < 1200000; t += TICK) {
    const arrivals = [];
    for (let i = 0; i < N; i++) if (!done[i] && next[i] <= t) arrivals.push(i);
 
    const up = t >= RECOVER_MS;
    if (up) {
      sentAfterRecovery += arrivals.length;
      const qps = arrivals.length * 1000 / TICK;
      if (qps > peak) peak = qps;
    }
 
    // Everything fails before recovery; afterwards, anything over capacity gets a 429
    let budget = up ? CAP_TICK : 0;
    for (const i of arrivals) {
      if (budget >= 1) { budget--; done[i] = 1; ok++; }
      else {
        att[i]++;
        const d = nextDelay(att[i], prev[i], rand);
        prev[i] = d;
        next[i] = t + d;
      }
    }
    if (t99 < 0 && ok >= N * 0.99) t99 = t;
    if (ok === N) break;
  }
  return { sentAfterRecovery, ok, peak: Math.round(peak), t99 };
}

I track three numbers: total requests reaching the server after recovery, peak post-recovery QPS, and the moment 99% of clients have gotten through.

Mean latency would be misleading here. What people experience during an outage is when the slowest slice comes back.

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 numbers showing exponential backoff without jitter finishing 7x slower than a naive fixed 5s interval
Why the implementation that honors Retry-After exactly was the slowest of all, and how to rewrite it as a floor
A drop-in fetch wrapper combining decorrelated jitter with a per-install offset, with smoke test output
A rerun with server capacity that degrades under load: strategies that hold peak QPS down barely move (full jitter +0.4%) while fixed 5s degrades by 82.7%
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

Dev Tools2026-09-10
I Added a Dim Screen Button, and iPhones Stayed Dark After the App Was Closed
In expo-brightness, setBrightnessAsync applies only to the current activity on Android, but changes the device brightness itself on iOS. Here is why the cleanup burden falls on one platform only, and a hook that restores brightness through AppState.
Dev Tools2026-09-04
EAS secret visibility does not keep a value out of your app — deciding prefix and visibility separately
The EXPO_PUBLIC_ prefix decides what ships inside your app; EAS visibility decides who can read it. Why stacking them blanks a value on OTA updates, and how to check your build.
Dev Tools2026-09-01
Adding image paste with expo-paste-input, and moving the disappearing file:// URIs out of cache
How to let users paste images and GIFs into a chat input with expo-paste-input. The URIs that onPaste hands you are temporary files that can vanish before the user hits send. Here is the relocation code and the order I verify it on real devices.
📚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