◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 32 days left. If your app calls it, check the migration target●SDK 58 ISSUE — Early reports include Android widgets not refreshing on a schedule and Stripe linking statically●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 32 days left. If your app calls it, check the migration target●SDK 58 ISSUE — Early reports include Android widgets not refreshing on a schedule and Stripe linking statically●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/AI Models
◉ AI Models/2026-10-29Intermediate

Connected, but the Body Stops Halfway. Measuring Header-Only, Total, and Idle Timeouts on the Same Response

You added a timeout to fetch, yet the loading indicator never goes away. I reproduced a response whose body stalls halfway on a local server and measured header-only, total, and idle timeouts under identical conditions. Runnable test code included.

fetchTimeoutAbortControllerNetwork ErrorsIndie Development31Rork610

The loading indicator never goes away. Switching screens does not help, and only restarting the app brings things back. In a weak-signal spot, that state is entirely possible.

The timeout was supposed to be there. Reading the code again, fetch gets an AbortController and the request is cut off after a few seconds. And yet it does not stop.

My suspicion was this: the timeout only guards the time until the response begins, and nobody is watching the clock while the body is being read. So I stood up a test server, ran three kinds of timeout against the same response, and lined up the results.

One caveat first. These numbers come from fetch in Node.js 22. I have not confirmed that an Expo or React Native device behaves the same way, so please treat this as a yardstick to try in your own app.

Building a response that stalls on purpose

The test server has three paths.

PathBehaviorSituation it stands in for
/fast5KB every 50ms, 20 times (done in about 1.0 s)Normal traffic
/slow5KB every 400ms, 12 times (done in about 4.8 s)Slow, but still moving
/stallSends headers and 3 chunks, then neither sends nor closesSignal lost, or a middlebox got stuck

The difference between /slow and /stall is the point. Neither finishes in 3 seconds, but one keeps inching forward and the other has stopped completely. A good timeout should be able to treat those two differently.

Three ways to read

I compared these three:

  • A: time only until the headers arrive (stop the clock once they do)
  • B: one cap on everything (AbortSignal.timeout, headers and body together)
  • C: abort after a period of silence (reset the clock on every chunk)

Here is the test code. Save it as timeout-lab.mjs and run node timeout-lab.mjs. No dependencies are needed.

// timeout-lab.mjs — run three kinds of timeout against a response whose body stalls halfway
import http from "node:http";
 
// --- Test server: reproduces three behaviors ---
const chunk = "x".repeat(5 * 1024); // 5KB
const server = http.createServer((req, res) => {
  res.writeHead(200, { "content-type": "text/plain" });
  if (req.url === "/fast") {            // healthy: 20 chunks, 50ms apart, about 1.0 s in total
    let n = 0;
    const t = setInterval(() => { res.write(chunk); if (++n === 20) { clearInterval(t); res.end(); } }, 50);
  } else if (req.url === "/slow") {     // healthy but slow: 12 chunks, 400ms apart, about 4.8 s in total
    let n = 0;
    const t = setInterval(() => { res.write(chunk); if (++n === 12) { clearInterval(t); res.end(); } }, 400);
  } else if (req.url === "/stall") {    // broken: sends headers and 3 chunks, then neither sends nor closes
    let n = 0;
    const t = setInterval(() => { res.write(chunk); if (++n === 3) clearInterval(t); }, 50);
  }
});
await new Promise((r) => server.listen(0, r));
const BASE = `http://127.0.0.1:${server.address().port}`;
 
// --- Three ways to read ---
// A: time only until the headers arrive (the body is unbounded)
async function readA(url, ms) {
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), ms);
  const res = await fetch(url, { signal: ac.signal });
  clearTimeout(timer); // <- stops the clock as soon as the headers arrive
  return await res.text();
}
// B: one cap on everything (headers + body, ms in total)
async function readB(url, ms) {
  const res = await fetch(url, { signal: AbortSignal.timeout(ms) });
  return await res.text();
}
// C: abort after ms of silence (the clock resets on every chunk)
async function readC(url, idleMs) {
  const ac = new AbortController();
  let timer = setTimeout(() => ac.abort(), idleMs);
  const res = await fetch(url, { signal: ac.signal });
  let bytes = 0;
  for await (const part of res.body) {
    bytes += part.length;
    clearTimeout(timer);
    timer = setTimeout(() => ac.abort(), idleMs);
  }
  clearTimeout(timer);
  return "x".repeat(bytes);
}
 
// --- Measure ---
async function run(label, fn, url, ms, cap) {
  const t0 = performance.now();
  const outcome = await Promise.race([
    fn(`${BASE}${url}`, ms).then((b) => `done ${(b.length / 1024).toFixed(0)}KB`, (e) => `aborted (${e.name})`),
    new Promise((r) => setTimeout(() => r(`gave up waiting (still unfinished after ${cap}ms)`), cap)),
  ]);
  const el = Math.round(performance.now() - t0);
  console.log(`${label.padEnd(34)} ${String(el).padStart(5)}ms  ${outcome}`);
}
 
const CAP = 8000;
console.log("--- Broken (body stalls halfway) ---");
await run("A headers-only 3000ms / stall", readA, "/stall", 3000, CAP);
await run("B total 3000ms / stall", readB, "/stall", 3000, CAP);
await run("C idle 1500ms / stall", readC, "/stall", 1500, CAP);
console.log("--- Healthy but slow (finishes in 4.8 s) ---");
await run("A headers-only 3000ms / slow", readA, "/slow", 3000, CAP);
await run("B total 3000ms / slow", readB, "/slow", 3000, CAP);
await run("C idle 1500ms / slow", readC, "/slow", 1500, CAP);
console.log("--- Healthy and fast (finishes in 1.0 s) ---");
await run("B total 3000ms / fast", readB, "/fast", 3000, CAP);
await run("C idle 1500ms / fast", readC, "/fast", 1500, CAP);
server.closeAllConnections(); server.close();

A is the shape that comes naturally. You set a setTimeout to abort, and once fetch returns you call clearTimeout. The headers arrived, so it feels safe.

What I measured

I ran the same code twice, and the times matched within about 10ms. These are the first run's values.

Approach/stall (stops)/slow (slow but moving)/fast
A header-only 3000msStill unfinished after 8,005msDone in 4,812ms(not measured)
B total 3000msAborted at 3,004msAborted at 3,002ms (though healthy)Done in 1,018ms
C idle 1500msAborted at 1,659msDone in 4,813msDone in 1,017ms

The 8,005ms for A is where my test code gave up waiting after 8 seconds. Left alone, it would have waited indefinitely. The symptom I first suspected reproduced right here.

What the numbers say

A cannot protect you from a stalled response. Once the headers arrive the clock stops, so after the body freezes nobody cuts it off. That matches the never-ending loading indicator exactly.

B catches the stall, but it cuts a merely slow response just as hard. /slow would have arrived in full after 4.8 seconds, and it was aborted at 3. Raising the cap rescues it, but then the wait for /stall grows by the same amount. Telling "slow" from "stopped" with one number is hard, which is how it felt in practice.

C separated the two. /stall was aborted 1.5 seconds after its last chunk, while /slow kept resetting the clock every 400ms and was read to the end.

How I decided to split them

After measuring, I settled on this:

  • Small JSON of a few KB: B, a total cap. The payload is small enough that cutting it before it feels slow is fine, so I pick the simpler idea.
  • Large payloads with a wide range of read times, such as image lists or catalogs: C, idle. For something like a wallpaper app fetching a list of images, this is the one I would choose.
  • Streamed AI responses that arrive piece by piece: C as well. While text keeps arriving, nothing has stalled, so this feels more natural than a total cap that cuts a long answer short. This is a judgment drawn from these results, not something I measured.
  • In every case: show a retry button on the screen after aborting.

One caution with C. The code above reads res.body with for await, which works in Node.js 22, but I cannot promise that res.body is readable the same way in a device's JavaScript environment. Check each environment's fetch before porting. Where it is not readable, I would use B as the base and make the cap somewhat longer.

What to try in your own app

Here is the order I would check things in:

  1. Open the function that makes the request and look for a clearTimeout right after the headers arrive (if there is one, you have shape A).
  2. Switch to airplane mode while the body is loading and see whether the loading indicator disappears.
  3. If it does not, swap the reading part to shape C and try step 2 again.

For choosing retry intervals, see The 429s Kept Coming After Recovery: Measuring Retry Jitter Across 2,000 Clients, and for the broader picture of failure handling including timeouts, Making Rork App API Calls Survive Failure — Timeouts, Exponential Backoff, and Circuit Breakers That Actually Hold. For the etiquette of discarding a cancelled request, Stop Your Rork App's Search Field from Hammering the API: debounce + AbortController + Empty States is a useful companion.

A timeout is less a time limit than a choice of what to watch. Before settling on a number, check whether you are watching only the headers or the body as well. That should mean fewer nights of a loading indicator that never leaves.

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 →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

◉ AI Models2026-10-27
Remembering What the AI Decided: Four Marks in the Cache Key Paid Off on Model-Swap Day
A small on-device cache for AI answers, with the model name and prompt version in the key so old answers vanish on swap day. TypeScript plus simulated results.
◉ AI Models2026-10-27
Sending temperature to the Claude API Now Returns a 400 on the New Version. Finding Every Spot in Your App
Claude API version 2026-10-21 returns a 400 if you send temperature, top_p or top_k. When it hits you, a script to scan your app and SDK setup, and the order to fix things in.
◉ AI Models2026-10-25
Is Your Rork App Still Calling Sonnet 4.5? Finding Out Before November 30
Claude Sonnet 4.5 is scheduled to retire from the API on November 30, 2026. A small script to find leftover model IDs in your app, and the order to fix them in depending on where they live.