「読み込み中」の表示が、いつまでも消えません。画面を切り替えても、アプリを再起動するまで元に戻りません。電波の弱い場所では、そんな状態が起こりえます。
タイムアウトは付けてあるはずでした。コードを読み返しても、fetch に AbortController を渡して、数秒で中断する書き方になっています。それでも止まらないのです。
疑ったのは、タイムアウトが守っているのが「応答が始まるまで」だけで、本文を読んでいる間は誰も時計を見ていないのではないか、という点でした。手元に検証用のサーバーを立てて、同じ応答に3種類のタイムアウトを当て、結果を並べてみました。
なお、これは Node.js 22 の fetch で測った結果です。Expo や React Native の実機で同じ挙動になるかは、ここでは確かめておりません。ご自身のアプリで試すときの物差しとして、お使いください。
本文が止まる応答を、わざと作る
検証用サーバーは3つの道を用意しました。
| パス | 動き | 想定する場面 |
|---|---|---|
| /fast | 5KB を 50ms ごとに20回(約1.0秒で完了) | 通常の通信 |
| /slow | 5KB を 400ms ごとに12回(約4.8秒で完了) | 遅いが、止まってはいない通信 |
| /stall | ヘッダと3回分だけ送り、その後は何も送らず閉じもしない | 電波が途切れた、途中の中継が詰まった |
大事なのは /slow と /stall の違いです。どちらも「3秒では終わらない」のですが、前者は少しずつでも進んでいて、後者は完全に止まっています。良いタイムアウトは、この2つを別物として扱えるはずです。
3つの読み方
比べたのは次の3方式です。
- A:ヘッダが届くまでだけ時間を計ります(届いたら時計を止めます)
- B:全体に上限を付けます(
AbortSignal.timeoutでヘッダも本文も込みです) - C:無通信が一定時間続いたら中断します(チャンクが届くたびに時計を戻します)
検証コードは次のとおりです。timeout-lab.mjs という名前で保存し、node timeout-lab.mjs で動きます。追加の依存はありません。
// timeout-lab.mjs — 本文が途中で止まる応答に、3 種類のタイムアウトを当てて挙動を比べる
import http from "node:http";
// --- 検証用サーバー:3 つの挙動を再現する -------------------------------
const chunk = "x".repeat(5 * 1024); // 5KB
const server = http.createServer((req, res) => {
res.writeHead(200, { "content-type": "text/plain" });
if (req.url === "/fast") { // 健全:50ms ごとに 20 回 ≒ 1.0 秒で完了
let n = 0;
const t = setInterval(() => { res.write(chunk); if (++n === 20) { clearInterval(t); res.end(); } }, 50);
} else if (req.url === "/slow") { // 健全だが遅い:400ms ごとに 12 回 ≒ 4.8 秒で完了
let n = 0;
const t = setInterval(() => { res.write(chunk); if (++n === 12) { clearInterval(t); res.end(); } }, 400);
} else if (req.url === "/stall") { // 異常:ヘッダと 3 回分だけ送り、あとは何も送らず閉じもしない
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}`;
// --- 3 つの読み取り方 ---------------------------------------------------
// A: ヘッダが届くまでだけ時間を計る(本文は無期限)
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); // ← ヘッダが届いた時点で止めてしまう
return await res.text();
}
// B: 全体に上限を付ける(ヘッダも本文も込みで ms)
async function readB(url, ms) {
const res = await fetch(url, { signal: AbortSignal.timeout(ms) });
return await res.text();
}
// C: 無通信が ms 続いたら中断(チャンクが届くたびに時計を戻す)
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);
}
// --- 計測 ---------------------------------------------------------------
async function run(label, fn, url, ms, cap) {
const t0 = performance.now();
const outcome = await Promise.race([
fn(`${BASE}${url}`, ms).then((b) => `完了 ${(b.length / 1024).toFixed(0)}KB`, (e) => `中断 (${e.name})`),
new Promise((r) => setTimeout(() => r(`待機打ち切り(${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("--- 異常(本文が途中で止まる)---");
await run("A ヘッダまで 3000ms / stall", readA, "/stall", 3000, CAP);
await run("B 全体 3000ms / stall", readB, "/stall", 3000, CAP);
await run("C 無通信 1500ms / stall", readC, "/stall", 1500, CAP);
console.log("--- 健全だが遅い(4.8 秒で完了)---");
await run("A ヘッダまで 3000ms / slow", readA, "/slow", 3000, CAP);
await run("B 全体 3000ms / slow", readB, "/slow", 3000, CAP);
await run("C 無通信 1500ms / slow", readC, "/slow", 1500, CAP);
console.log("--- 健全で速い(1.0 秒で完了)---");
await run("B 全体 3000ms / fast", readB, "/fast", 3000, CAP);
await run("C 無通信 1500ms / fast", readC, "/fast", 1500, CAP);
server.closeAllConnections(); server.close();A は、書きやすさから自然に生まれる形です。setTimeout で中断を仕掛け、fetch が返ってきたところで clearTimeout します。ヘッダが届いたのだから安心、という気持ちで書いてしまうのだと思います。
測った結果
同じコードを2回流して、時間が10ms前後の誤差で揃うことを確かめました。下は1回目の値です。
| 方式 | /stall(止まる) | /slow(遅いが進む) | /fast(速い) |
|---|---|---|---|
| A ヘッダまで 3000ms | 8,005ms 経っても終わらない | 4,812ms で完了 | (未測定) |
| B 全体 3000ms | 3,004ms で中断 | 3,002ms で中断(健全なのに) | 1,018ms で完了 |
| C 無通信 1500ms | 1,659ms で中断 | 4,813ms で完了 | 1,017ms で完了 |
A の「8,005ms」は、検証コード側で8秒待って打ち切った値です。放っておけば、それ以上待ち続けます。私が最初に疑った症状は、ここで再現できました。
読み取れたこと
A は、止まる応答を守れません。 ヘッダさえ届けば時計が止まるので、本文が止まった後は、誰も中断してくれません。読み込み中の表示が消えない症状と、形がぴったり重なります。
B は、止まる応答には効きますが、遅いだけの応答も同じように切ります。 /slow は4.8秒かけて最後まで届くはずなのに、3秒で中断されました。上限を伸ばせば救えますが、今度は /stall を待つ時間も同じだけ伸びます。1つの数字で「遅い」と「止まった」を分けるのは、難しいというのが実感です。
C は、両方を分けられました。 /stall は最後のチャンクから1.5秒後に中断され、/slow は400msごとにチャンクが届くので時計が戻り続け、最後まで読めています。
私が選んだ使い分け
測ってみて、私は次のように分けることにしました。
- 数KBの小さな JSON は、B(全体の上限)にします。遅いと感じる前に切ってよい大きさなので、考え方が単純なほうを選びます
- 画像やカタログのように、サイズが大きく読み時間に幅があるものは、C(無通信)にします。壁紙アプリのように画像の一覧を取る処理なら、こちらを選びます
- AI の応答を少しずつ受け取る(ストリーミング)処理も、C にします。文字が届いている間は止まっていないので、全体の上限で長い回答を途中で切るより自然です。これは測った結果ではなく、今回の結果から考えた判断です
- どの場合も、中断したあとの画面に「やり直す」ボタンを出します
Cを使うときの注意が1つあります。上のコードは res.body を for await で読む形なので、Node.js 22 では動きますが、実機の JavaScript 環境で res.body が同じように読めるとは限りません。そこは各環境の fetch の仕様を確認してから載せ替えてください。読めない環境では、B を基本にして、上限を少し長めにする判断になると思います。
次に試していただくこと
ご自身のアプリで、次の順に確かめていただければと思います。
- 通信を呼ぶ関数を開き、
clearTimeoutがヘッダ到着の直後にないかを探します(あれば A の形です) - 機内モードを本文の読み込み中に切り替えて、読み込み中の表示が消えるかを見ます
- 消えなければ、読み取り部分を C の形に差し替え、2 をもう一度試します
リトライの間隔を決める話は 復旧させたのに 429 が止まらない — リトライのジッター設計を2,000端末で測り直しました に、タイムアウトを含む障害対応の全体像は Rork アプリの API 通信を障害に強くする — Circuit Breaker・指数バックオフ・タイムアウトを実装で固める にまとめております。中断した通信を捨てる側の作法は Rork の検索フィールドが API を連打する問題を debounce と AbortController で止める が参考になります。
タイムアウトは「時間の上限」ではなく、「どこを見張るか」の選択でした。 数字を決める前に、ヘッダだけを見ているのか、本文まで見ているのかを確かめておくと、読み込み中が消えない夜を減らせるのではないかと感じています。