●PRICE — Rork Max spans $200 to $1,800 per month, with the upper tiers aimed at heavier builders and teams●FREE — The free tier lands at roughly five prompts per week, enough to try it but not to build on continuously●SHIP — App Store publishing is automated through builds, certificates, and submission, so you can ship an iOS app without a Mac or Xcode●SIM — A browser-streamed simulator lets you watch your app run in a real Apple environment from your own browser●NATIVE — It reaches HealthKit, ARKit and LiDAR, NFC, Dynamic Island, and Metal 3D — territory React Native cannot touch●FUNDING — Rork raised a $15M seed led by Left Lane Capital, announced April 9, 2026, and acquired app builder Paperline●PRICE — Rork Max spans $200 to $1,800 per month, with the upper tiers aimed at heavier builders and teams●FREE — The free tier lands at roughly five prompts per week, enough to try it but not to build on continuously●SHIP — App Store publishing is automated through builds, certificates, and submission, so you can ship an iOS app without a Mac or Xcode●SIM — A browser-streamed simulator lets you watch your app run in a real Apple environment from your own browser●NATIVE — It reaches HealthKit, ARKit and LiDAR, NFC, Dynamic Island, and Metal 3D — territory React Native cannot touch●FUNDING — Rork raised a $15M seed led by Left Lane Capital, announced April 9, 2026, and acquired app builder Paperline
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: how to build the key that decides the folding, the trap of handing out a failed promise forever, the trap of one caller's abort cancelling everyone, and the test that keeps it all from regressing, with the real network numbers alongside.
Late one night I attached a network inspector to a freshly released wallpaper app and opened it. Rows with the same URL stacked up one after another. /me/entitlements, four times inside roughly 200ms. Same payload, same JSON coming back.
Something tightened in my chest. Four times the wasted traffic would have been bad enough, but right after three concurrent 401s hit, a handful of users were being logged out for no clear reason. Three requests noticed the expired token at the same moment, each fired its own refresh, and the one that finished last invalidated the token the earlier one had already issued.
The cause was not a single screen. Home, the favorites badge, the paywall gate — separate components were each running the same fetch independently, "because I need it." None of them knew the one next door was already reaching for the same thing.
What I want to walk through is the design that folds these simultaneous, identical requests into a single in-flight one. It comes from the code I actually run across six apps on one shared networking base, added the morning after that night. The mechanism is not complicated. But written naively, it has two traps you will always hit, and it only becomes usable once you clear both.
Why the same request fires several times
In a React Native app, each component's useEffect runs the moment the screen mounts. When several components that need the same data appear at once, each issues its own fetch.
The fetch code Rork generates usually lives entirely inside a component. It looks like this.
Call this hook from three components and fetch runs three times. Each hook sees only its own local state; there is nowhere shared that answers "is the same fetch already in flight?"
My first thought was to add a cache. But on the very first launch the cache is empty. Everyone decides "there is nothing cached" at nearly the same instant and all rush to fetch. The cache fills only after everyone has already sent, too late to help. What needed protecting was not "storing the result" but "sharing the single in-flight request."
The core of single-flight: share the in-flight promise
There is one thing to do. If a fetch for a given key is already in flight, do not issue a new fetch — return the in-flight promise as is. This is single-flight, or request coalescing.
The minimal form is just this.
const inflight = new Map<string, Promise<unknown>>();function coalesce<T>(key: string, run: () => Promise<T>): Promise<T> { const existing = inflight.get(key); if (existing) return existing as Promise<T>; const promise = run(); inflight.set(key, promise as Promise<unknown>); return promise;}
The key is a string that uniquely names what you are fetching. Combining method and path, like GET /me/entitlements, reads clearly. Called four times with the same key, run() actually runs only the first time.
✦
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
✦A single-flight layer that collapses duplicate GETs at startup by sharing one in-flight promise
✦The trap where a rejected promise keeps getting handed to every future caller, and why you must delete on settle
✦A reference-counted AbortController so one caller's unmount does not cancel the shared request for everyone
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.
Designing the key: what counts as "the same fetch"
The key is what decides how wide the folding reaches. Make it too broad and unrelated fetches get merged; too narrow and the folding never fires at all. The key turned out to sit closer to real incidents than the coalescer itself.
Sort query parameters before folding
The same fetch can arrive with its query in a different order depending on the caller. Treat ?limit=20&cursor=abc and ?cursor=abc&limit=20 as different keys and you get two identical requests side by side. Keep one function that builds keys and route every fetch through it.
It is one sort() call. But back when keys were written inline at each call site, some screens folded and others did not, and it took a while to accept that the cause was a string rather than the logic.
Put the authenticated subject in the key
Paths that mean "the current user," like /me/entitlements, are the riskiest. Key on the path alone and, right after an account switch, the previous user's in-flight entry is handed out as is. For a value that branches the UI — subscription entitlement, say — a screen that should be free renders as subscribed for a moment.
That is why buildKey above puts subject (a user ID, or a hash of the token) first. Alongside it, drop every in-flight entry at sign-out.
// The entry shape with a controller is built in the next sectionexport function resetInflight(reason: string) { for (const [key, entry] of inflight) { entry.controller.abort(); // waiters receive an AbortError inflight.delete(key); } if (__DEV__) console.log(`[coalesce] reset: ${reason}`);}
Conversely, mix in anything that changes on every call and the folding never happens once. These are the three I actually mixed in:
A cache buster built from Date.now()
A trace ID minted per request
A JSON.stringify of an options object that contained the AbortSignal
The third is the hardest to spot: request counts do not drop at all, yet the implementation looks correct. Keep keys short and human-readable so you can print them straight into a log.
With the key settled, the rest is just putting things into the Map and taking them out. The question is when to delete. That is where the first trap lives.
Trap one: handing out a failed promise forever
The code above never deletes anything. Whether the fetch succeeds or fails, its promise stays in inflight.
If it stays after success, the next caller receives the old result. That is effectively a cache with no expiry — an unintended freeze of freshness.
The failure case is nastier. Once the network drops and the promise rejects, that rejected promise sits in the Map. From then on, everyone calling with the same key gets this "already-failed" promise. Even after signal recovers, they keep seeing the same error until the app restarts. I personally burned half a day reproducing this behavior.
Intuitively you want to "cache the result." But single-flight's job is only to fold the in-flight moment together. Storing results belongs to a different layer, a TTL cache, kept separate. Drawing that line was the crux.
The fix is to delete the entry the instant it settles, whether it resolved or rejected.
const inflight = new Map<string, Promise<unknown>>();function coalesce<T>(key: string, run: () => Promise<T>): Promise<T> { const existing = inflight.get(key); if (existing) return existing as Promise<T>; const promise = run().finally(() => { // Always delete, on success or failure alike. // Forget this and a settled-failed promise keeps being handed out. inflight.delete(key); }); inflight.set(key, promise as Promise<unknown>); return promise;}
Placing it in finally matters. Put it only in then and it never fires on reject. That "does not clear on reject" was precisely the shape of the incident.
Trap two: one caller's abort cancels everyone
In React, the convention is to abort an in-flight request when a component unmounts — use an AbortController and call abort() in the cleanup.
Combined with single-flight, the naive version bites. If you pass each caller's own AbortController into the one shared request, the moment the first component to unmount calls abort(), the fetch for everyone still waiting is cancelled too.
Flip tabs quickly and the remaining screen fills with "request aborted." That is a counter-intuitive way to break: aborting is each component's own business, yet the shared single request gets dragged along by it.
The solution is reference counting. Create exactly one AbortController for the shared request and count how many callers are waiting. Actually abort only when nobody is waiting anymore.
type Entry<T> = { promise: Promise<T>; controller: AbortController; refs: number;};const inflight = new Map<string, Entry<unknown>>();export function coalesce<T>( key: string, run: (signal: AbortSignal) => Promise<T>,): { promise: Promise<T>; release: () => void } { let entry = inflight.get(key) as Entry<T> | undefined; if (!entry) { const controller = new AbortController(); const promise = run(controller.signal).finally(() => { inflight.delete(key); // trap-one fix: always delete on settle }); entry = { promise, controller, refs: 0 }; inflight.set(key, entry as Entry<unknown>); } entry.refs += 1; const release = () => { entry!.refs -= 1; // Abort only when no one is waiting AND we are still the current entry if (entry!.refs <= 0 && inflight.get(key) === entry) { entry!.controller.abort(); inflight.delete(key); } }; return { promise: entry.promise, release };}
The inflight.get(key) === entry check is there so that an old entry's release does not mistakenly delete a newer entry created later. I dropped that one line once and lost time chasing a rare mix-up. It is unglamorous but non-negotiable.
Wiring it into React: useCoalescedQuery
With the coalescer in place, the hook just calls it. Fetch on mount, release on unmount, and swallow the abort that comes from your own release.
import { useEffect, useRef, useState } from "react";import { coalesce } from "./request-coalescer";type State<T> = | { status: "loading" } | { status: "success"; data: T } | { status: "error"; error: unknown };export function useCoalescedQuery<T>( key: string | null, fetcher: (signal: AbortSignal) => Promise<T>,): State<T> { const [state, setState] = useState<State<T>>({ status: "loading" }); const fetcherRef = useRef(fetcher); fetcherRef.current = fetcher; // always the latest per render useEffect(() => { if (key === null) return; let alive = true; setState({ status: "loading" }); const { promise, release } = coalesce(key, (signal) => fetcherRef.current(signal), ); promise.then( (data) => { if (alive) setState({ status: "success", data: data as T }); }, (error) => { // Ignore aborts that came from our own release if (alive && (error as { name?: string }).name !== "AbortError") { setState({ status: "error", error }); } }, ); return () => { alive = false; release(); }; }, [key]); return state;}
The fetcher takes the signal and passes it straight to fetch.
async function fetchEntitlements(signal: AbortSignal) { const res = await fetch(`${API}/me/entitlements`, { headers: { Authorization: `Bearer ${await getToken()}` }, signal, }); if (!res.ok) throw new Error(`entitlements ${res.status}`); return (await res.json()) as Entitlements;}
Now, as long as you pass the same key, no matter how many components call it, the actual network request is folded to one.
What you must not coalesce: writes
Coalescing works only for reads — where calling repeatedly yields the same result. Get this wrong and you lose data silently.
I once thought I could prevent a double-tapped submit button with single-flight, and wrote this.
// Do not do thiscoalesce("POST /posts", (signal) => createPost(draft, signal));
The second tap receives the first tap's in-flight promise. It looks like duplicate creation was prevented. But the different draft the second tap was holding never got sent — it vanished. One of the user's actions was quietly thrown away.
Where you kill duplication depends on the layer, and each layer protects something different. I keep these three separate from the start.
Layer
Protects against
Lifetime
Wrong fit for
Single-flight
Concurrent duplicate reads
Until the one request settles
Writes of any kind
TTL cache
Repeated refetches over a short window
The TTL you set
Values that must be current
Idempotency key
Duplicate writes from retries and double taps
The server's retention window
Saving request count
Preventing duplicate writes belongs to idempotency keys, not to coalescing. That separation becomes clear when you set it beside the companion piece on hardening network calls, Hardening API Calls in a Rork App: Token Refresh, Retries, and Idempotency. Single-flight removes duplicate reads; idempotency keys neutralize duplicate writes. They protect fundamentally different things.
Calming the token-refresh thundering herd
The mysterious logouts at the start were exactly where this read-side folding helps. Several requests notice the expired token at once and each fires a refresh. A refresh looks like a write, but in the sense of "obtain one new access token from the same refresh token," folding the concurrent runs into one and sharing the result is the correct move.
Coalesce the refresh itself under a fixed key.
function refreshAccessToken() { const { promise } = coalesce("auth:refresh", async (signal) => { const res = await fetch(`${API}/auth/refresh`, { method: "POST", body: JSON.stringify({ refresh: getRefreshToken() }), signal, }); if (!res.ok) throw new Error(`refresh ${res.status}`); return (await res.json()) as { access: string }; }); // Never call release here: we do not want a waiting caller to abort the // refresh. The finally on settle still cleans the entry up. return promise;}
By not calling release, a waiting caller's whim can no longer cancel the refresh. Even with three overlapping 401s, exactly one refresh goes out. Everyone shares that single result and retries with the new token. Logouts from token mix-ups stopped happening after this.
Verify it with a count, not with your eyes
Stare at the network inspector, decide "it looks like fewer requests," and the next screen you add quietly undoes it. I prefer to land the instrument that counts the fold rate before migrating anything.
Counting calls and actual runs inside the coalescer is enough.
Add stats.calls += 1 at the top of coalesce and stats.runs += 1 right before run() actually fires. Print the value five seconds after launch and the folding becomes visible at a glance. On my side the equivalent figure was 0 before, and settled around 0.4 to 0.5 right after launch once it was in. If it stays near 0, the likely cause is a key that changes every call, not the implementation.
To stop regressions, one test is enough. Call three times with the same key and assert the fetch runs once.
it("concurrent calls with the same key fold into one", async () => { let runs = 0; const run = () => new Promise<number>((resolve) => { runs += 1; setTimeout(() => resolve(runs), 10); }); const a = coalesce("GET /x", run); const b = coalesce("GET /x", run); const c = coalesce("GET /x", run); await Promise.all([a.promise, b.promise, c.promise]); expect(runs).toBe(1); // after settle, a fresh request runs (regression test for trap one) const d = coalesce("GET /x", run); await d.promise; expect(runs).toBe(2);});
The last three lines are the ones that earn their keep. The day someone — usually me, six months later — moves the delete from finally into then, this is what fails. Having burned half a day reproducing that behavior once, those three lines are worth more to me than they look.
I observed before and after on the one app with the most users out of the six. The averages are over 20 cold starts (launching from a fully terminated state).
Situation
Before
After
/me/entitlements at launch
4 on average
1
/auth/refresh on concurrent 401
up to 3
1
Total requests at startup
11 on average
6 on average
Unexpected logout reports (per week)
a few
0
More than the drop in request count, the last row mattered most to me. The hard-to-trace logouts were gone. A fault that had been happening out of the user's sight closed with a single move — folding requests together. I was quietly relieved.
Where to start
First, attach a network inspector right after launch and look for the same URL repeated. If it is, that is your first target. Entitlements, user profile, remote config — reads referenced from several screens are usually the candidates.
The coalescer fits in one file. You do not need to rewrite all existing fetch code at once. Swap only the reads that are firing in duplicate onto coalesce, and as long as you keep the two points — deleting in finally and the reference count — it quietly starts to help.
I am still working on my own networking base. I hope this is of some use to anyone facing the same problem. Thank you for reading.
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.