◉RORK LABEN
●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SONNET 4.5 — Claude API での提供終了は 11/30、残り32日。API 連携を足したアプリは移行先の確認を●SDK 58 ISSUE — Android ウィジェットが定期更新されない、Stripe が静的リンクになるなど初期報告が出ています●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…●GPT-6.1 SOL — Rork のモデルメニューに追加(09-29)。Low/Medium/High を選べ、Pro・Max に含まれます●SONNET 5.5 — UI づくりが得意で、Sonnet 5 より出力が速いモデルが選べます(09-28)●SONNET 4.5 — Claude API での提供終了は 11/30、残り32日。API 連携を足したアプリは移行先の確認を●SDK 58 ISSUE — Android ウィジェットが定期更新されない、Stripe が静的リンクになるなど初期報告が出ています●OBSERVE — EAS Observe がネイティブクラッシュも記録します(SDK 57.0.21 以降・10/07)●NEW — 起動広告が、背面から戻った回にしか出ていませんでした。「前面に戻った」合図が、…
記事一覧/AIモデル
◉ AIモデル/2026-10-29中級

接続はできているのに、本文が途中で止まる。ヘッダまで・全体・無通信の3つのタイムアウトを同じ応答で測りました

fetch にタイムアウトを付けたのに、読み込み中の表示が消えない。本文が途中で止まる応答を手元のサーバーで再現し、ヘッダまで・全体・無通信の3方式を同じ条件で測りました。実行できる検証コード付きです。

fetchタイムアウト3AbortController通信エラー個人開発233Rork610

「読み込み中」の表示が、いつまでも消えません。画面を切り替えても、アプリを再起動するまで元に戻りません。電波の弱い場所では、そんな状態が起こりえます。

タイムアウトは付けてあるはずでした。コードを読み返しても、fetch に AbortController を渡して、数秒で中断する書き方になっています。それでも止まらないのです。

疑ったのは、タイムアウトが守っているのが「応答が始まるまで」だけで、本文を読んでいる間は誰も時計を見ていないのではないか、という点でした。手元に検証用のサーバーを立てて、同じ応答に3種類のタイムアウトを当て、結果を並べてみました。

なお、これは Node.js 22 の fetch で測った結果です。Expo や React Native の実機で同じ挙動になるかは、ここでは確かめておりません。ご自身のアプリで試すときの物差しとして、お使いください。

本文が止まる応答を、わざと作る

検証用サーバーは3つの道を用意しました。

パス動き想定する場面
/fast5KB を 50ms ごとに20回(約1.0秒で完了)通常の通信
/slow5KB を 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 ヘッダまで 3000ms8,005ms 経っても終わらない4,812ms で完了(未測定)
B 全体 3000ms3,004ms で中断3,002ms で中断(健全なのに)1,018ms で完了
C 無通信 1500ms1,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 を基本にして、上限を少し長めにする判断になると思います。

次に試していただくこと

ご自身のアプリで、次の順に確かめていただければと思います。

  1. 通信を呼ぶ関数を開き、clearTimeout がヘッダ到着の直後にないかを探します(あれば A の形です)
  2. 機内モードを本文の読み込み中に切り替えて、読み込み中の表示が消えるかを見ます
  3. 消えなければ、読み取り部分を C の形に差し替え、2 をもう一度試します

リトライの間隔を決める話は 復旧させたのに 429 が止まらない — リトライのジッター設計を2,000端末で測り直しました に、タイムアウトを含む障害対応の全体像は Rork アプリの API 通信を障害に強くする — Circuit Breaker・指数バックオフ・タイムアウトを実装で固める にまとめております。中断した通信を捨てる側の作法は Rork の検索フィールドが API を連打する問題を debounce と AbortController で止める が参考になります。

タイムアウトは「時間の上限」ではなく、「どこを見張るか」の選択でした。 数字を決める前に、ヘッダだけを見ているのか、本文まで見ているのかを確かめておくと、読み込み中が消えない夜を減らせるのではないかと感じています。

シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

◉ AI モデル2026-10-27
AI に判定させた答えを端末に覚えさせる。キーに入れた4つの印が、モデル差し替えの日に効きました
画像の分類などを AI に頼むアプリでは、同じ画像に何度も呼び出しが走ります。端末にキャッシュ層を置き、キーへモデル名と指示文の版を入れて、差し替えの日に古い答えを残さない TypeScript の実装と、模擬カタログでの実測をまとめます。
◉ AI モデル2026-10-23
Rork の AI が直した場所の隣が壊れたとき、Undo・Revert・メッセージ編集のどれで戻るかを先に決めます
Rork で AI の修正が裏目に出たとき、Undo・Revert・メッセージ編集のどれで戻るかを三つの問いで選ぶ手順です。戻るのは無料で、戻れないものは何か、戻ったあとの頼み方まで書き残しました。
◉ AI モデル2026-10-05
Sonnet 5.5・GPT-6.1 Sol・Opus 5.5 のどれに頼むかを、修正の種類で先に決めておきます。クレジットの行き先を1週間記録して引いた割り当て
Rork のモデルメニューが Sonnet 5.5・GPT-6.1 Sol・Opus 5.5 の三つになったあと、修正を三種類に分けてモデルと effort を割り当てました。変更履歴に書かれた違いの読み方、クレジットの行き先を記録する小さな台帳、そこから引いた線引きを書き残します。