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.
| Path | Behavior | Situation it stands in for |
|---|---|---|
| /fast | 5KB every 50ms, 20 times (done in about 1.0 s) | Normal traffic |
| /slow | 5KB every 400ms, 12 times (done in about 4.8 s) | Slow, but still moving |
| /stall | Sends headers and 3 chunks, then neither sends nor closes | Signal 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 3000ms | Still unfinished after 8,005ms | Done in 4,812ms | (not measured) |
| B total 3000ms | Aborted at 3,004ms | Aborted at 3,002ms (though healthy) | Done in 1,018ms |
| C idle 1500ms | Aborted at 1,659ms | Done in 4,813ms | Done 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:
- Open the function that makes the request and look for a
clearTimeoutright after the headers arrive (if there is one, you have shape A). - Switch to airplane mode while the body is loading and see whether the loading indicator disappears.
- 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.