GEMINI LABEN
NANOLITE — Nano Banana 2 Liteが登場しました。Googleで最も速く、最もコスト効率の高いGemini Imageモデルで、軽量な画像生成を安く回したい用途に向いていますOMNIFLASH — Gemini Omni Flashがpublic previewになりました。ネイティブにマルチモーダルなモデルで、企業や開発者が独自の動的な動画ワークフローを構築できますAGENTS — Managed Agentsが拡張されました。background: trueでサーバー側の非同期実行とポーリング、リモートMCPサーバー連携、対話をまたぐ認証情報のリフレッシュに対応しますMEMORY — Memory BankのIngestEvents APIが一般提供になりました。イベントの取り込みとメモリ生成を分離し、コンテンツを継続的にストリームできますTHROUGHPUT — Provisioned Throughputで、同一モデル・同一リージョンに対して最大7件の保留オーダーを提出できるようになりましたDEPRECATE — 画像生成モデルは8月17日に、Gemini Enterprise Agent PlatformのGrok 4.1系は8月20日に停止される予定ですNANOLITE — Nano Banana 2 Liteが登場しました。Googleで最も速く、最もコスト効率の高いGemini Imageモデルで、軽量な画像生成を安く回したい用途に向いていますOMNIFLASH — Gemini Omni Flashがpublic previewになりました。ネイティブにマルチモーダルなモデルで、企業や開発者が独自の動的な動画ワークフローを構築できますAGENTS — Managed Agentsが拡張されました。background: trueでサーバー側の非同期実行とポーリング、リモートMCPサーバー連携、対話をまたぐ認証情報のリフレッシュに対応しますMEMORY — Memory BankのIngestEvents APIが一般提供になりました。イベントの取り込みとメモリ生成を分離し、コンテンツを継続的にストリームできますTHROUGHPUT — Provisioned Throughputで、同一モデル・同一リージョンに対して最大7件の保留オーダーを提出できるようになりましたDEPRECATE — 画像生成モデルは8月17日に、Gemini Enterprise Agent PlatformのGrok 4.1系は8月20日に停止される予定です
記事一覧/API / SDK
API / SDK/2026-05-20中級

Gemini API のストリーミングが本番だけまとめて返ってくる — Cloud Run・Vercel・Cloudflare のバッファリング対処

ローカル開発では一文字ずつ流れるのに本番デプロイ後だけ応答が一括で返ってくる — Cloud Run / Vercel / Cloudflare Workers / Nginx で発生するストリーミングバッファリング問題を、原因と対処パターン別に整理しました。

gemini-api279streaming18troubleshooting57cloud-run6vercelcloudflare4

「ローカルでは一文字ずつ流れていた Gemini の応答が、本番にデプロイした途端、数十秒待たされて全文がドンと返ってくる」— Dolice Labs の 6 サイトをまたいで自動投稿パイプラインを組んでいる中で、私自身が一番何度もハマったのがこの問題でした。Gemini API 側は正しく streamGenerateContent を返しているのに、間に挟まるプラットフォーム(Cloud Run / Vercel / Cloudflare Workers / Nginx)が応答を バッファ してしまい、ユーザーには「フリーズしているように見える数十秒」だけが残ります。

整理しておきたいのは、本番だけストリーミングが効かなくなる代表的な 5 つの原因と、プラットフォーム別の対処手順です。検証コマンドや Response ヘッダの組み合わせまで含めて、自分が直近で 1 ヶ月かけて潰した知見をそのまま残しました。

まず「本当にバッファされているか」を切り分ける

トラブルの 7 割は、Gemini API ではなく経路上のどこかで chunk が貯められていることが原因です。最初に切り分けるべきは「サーバー側はちゃんと小分けで吐いているのか」「クライアント側はちゃんと小分けで受け取れているのか」の 2 点です。

最も信用できる検証は curl --no-buffer です。ターミナルから本番エンドポイントを直接叩いて、文字が段階的に流れてくるかを観察します。

curl --no-buffer -N \
  -H "Content-Type: application/json" \
  -X POST "https://your-app.example.com/api/chat" \
  -d '{"prompt": "Gemini API のストリーミング検証用。長めに答えて"}' \
  2>&1 | tee /tmp/stream.log

--no-buffer は curl 自身の標準出力バッファを切り、-N で transfer-encoding chunked を待たずに受け取ります。これで「数十秒沈黙 → ドカッと出る」なら経路でバッファされており、「ポロポロ出てくる」ならサーバーは正常で、ブラウザ側の問題(fetch ReadableStream の扱いや SSE クライアント)に絞れます。

ブラウザの DevTools で確認するなら、Network パネルの該当リクエストを開いて EventStream タブまたは TimingContent Download フェーズを見ます。Content Download が長く伸びていれば正常にストリーミングできており、Waiting (TTFB) だけが伸びている場合はサーバー側で全文生成を待ってから返している状態です。

原因 1: レスポンスを await response.text() で受けてしまっている

実装ミスとして一番多いのがこれです。Gemini SDK の model.generateContentStream() を呼んでも、その結果を JSON.stringify したり一旦バッファに詰めてから返すと、サーバー側でストリーミングは死にます。Next.js の Route Handler や Hono などのフレームワークでは、Response.bodyReadableStream をそのまま渡す形に揃えます。

// app/api/chat/route.ts (Next.js App Router)
import { GoogleGenerativeAI } from "@google/generative-ai";
 
export const runtime = "nodejs"; // ★ Edge にしない(後述)
export const dynamic = "force-dynamic";
 
export async function POST(req: Request) {
  const { prompt } = await req.json();
  const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
  const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });
 
  // SDK のストリームを Web Streams にブリッジ
  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();
      try {
        const result = await model.generateContentStream(prompt);
        for await (const chunk of result.stream) {
          const text = chunk.text();
          if (text) controller.enqueue(encoder.encode(text));
        }
      } catch (err) {
        controller.error(err);
      } finally {
        controller.close();
      }
    },
  });
 
  return new Response(stream, {
    headers: {
      "Content-Type": "text/plain; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      "X-Accel-Buffering": "no", // Nginx 経路のバッファ無効化(後述)
      "Transfer-Encoding": "chunked",
    },
  });
}

ここで効くのは Cache-Control: no-transformX-Accel-Buffering: no の 2 つです。後者は Nginx 系のリバースプロキシ(Vercel の旧 Node Runtime、Cloud Run 前段の Cloud Load Balancer 等)に「このレスポンスはバッファするな」と明示するヒントになります。

原因 2: Cloud Run の HTTP/2 とコネクションタイムアウト

Cloud Run のデフォルトはレスポンス転送に問題はないのですが、罠が 2 つあります。

1 つ目は request timeout です。Cloud Run のサービス設定で「リクエストタイムアウト」を 60 秒のまま運用していると、Gemini 2.5 Pro の長い応答(90 秒程度かかるケースがあります)が途中で 504 になります。gcloud run services update YOUR_SERVICE --timeout=300 で 300 秒程度まで延ばすのが定石です。

2 つ目は HTTP/2 エンドポイントへの切り替え忘れです。デフォルトの HTTP/1.1 でも chunked transfer は動きますが、長時間ストリーミングする場合は HTTP/2 のほうが安定します。gcloud run services update YOUR_SERVICE --use-http2 を一度叩いておくと、Cloud Load Balancer の中間バッファ挙動が変わって応答が滑らかになります。

実際に私が個人開発の壁紙アプリで Gemini を使った類似カテゴリ提案 API を Cloud Run に載せた際、HTTP/1.1 のまま放置していたら 1 秒分の chunk が 4〜5 秒まとめて飛んでくる挙動になっていました。--use-http2 をオンにし、サーバー側で 100 ms 単位の flush を明示的に行うようにしたら、ようやくローカルと同等のスムーズさが戻りました。

原因 3: Vercel の Edge Runtime と Node Runtime の違い

Vercel に Next.js App Router をデプロイしている場合、Route Handler の runtime 指定 によってストリーミング挙動が大きく変わります。

設定ストリーミング最大実行時間備考
runtime = "edge"安定25 秒(Hobby)/ 300 秒(Pro)Web Streams ネイティブ。fetch ベース実装と相性が良い
runtime = "nodejs"標準で OK だが要 force-dynamic10 秒(Hobby)/ 60〜900 秒(Pro 設定次第)export const dynamic = "force-dynamic" を必ず指定

落とし穴は Edge Runtime の subrequest 制限 50 回 と、Node Runtime の force-dynamic 指定忘れによる Static 化です。後者の場合、ビルド時にレスポンスが事前生成されてしまい「常に同じ内容が一括で返ってくる」謎の挙動になります。私はこれを gemini-api-edge-runtime-subrequest-limit-troubleshooting で詳しく書いていますが、5,000 万 DL 規模を想定するなら最初から Edge + KV キャッシュにしておくと、後で書き直す痛みが減ります。

Pro プランで Node Runtime を使う場合は、vercel.json で関数ごとに最大実行時間を伸ばします。

{
  "functions": {
    "app/api/chat/route.ts": {
      "maxDuration": 300
    }
  }
}

原因 4: Cloudflare Workers / Pages Functions のレスポンス整形

私自身が Dolice Labs 4 サイト(Claude Lab / Gemini Lab / Antigravity Lab / Rork Lab)の本体を Cloudflare Workers に載せている関係で、ここは特によく踏みます。Cloudflare Workers は ReadableStream をネイティブで扱えますが、間に cache-worker.js のようなキャッシュレイヤーを挟むと、応答ボディを await response.text() してから返す古い実装が紛れ込んでいるケース が定期的に発生します。

正しい Workers 側の中継パターンはこうです:

// _workers/cache-worker.js(簡略)
export default {
  async fetch(request, env, ctx) {
    // ストリーミング系の API はキャッシュをバイパス
    const url = new URL(request.url);
    if (url.pathname.startsWith("/api/chat")) {
      const upstream = await fetch(request);
      // ★ body をそのまま転送(クローンや text() 化は厳禁)
      return new Response(upstream.body, {
        status: upstream.status,
        headers: {
          ...Object.fromEntries(upstream.headers),
          "Cache-Control": "no-cache, no-transform",
        },
      });
    }
    // 他のパスは通常キャッシュ処理
    return cacheHandler(request, env, ctx);
  },
};

upstream.body をそのまま new Response(..., { ... }) の第一引数に渡すのがポイントです。response.clone()Promise.all で待ったり、await response.text() を経由すると、その時点で全文受信を待つので「Workers レイヤーでバッファされた」結果になります。

加えて、Cloudflare の smart placementregional services を有効にしていると、Workers 自体が東京で動いているのにオリジン側が米国にあって、その間で chunk がまとまる挙動も見ました。workers.dev 直接よりもカスタムドメイン経由のほうが安定するので、本番では Pages Functions / Workers Routes での運用を勧めます。

原因 5: クライアント側 fetch + ReadableStream の処理ミス

サーバーが完璧でも、ブラウザ側で「全文受信してから一気に描画する」コードを書いていると、ユーザー体験はバッファされたのと同じです。fetch の ReadableStream は async iterator で 1 chunk ずつ取り出します。

// クライアント側
async function streamChat(prompt: string, onToken: (t: string) => void) {
  const res = await fetch("/api/chat", {
    method: "POST",
    body: JSON.stringify({ prompt }),
    headers: { "Content-Type": "application/json" },
  });
  if (!res.body) throw new Error("no body");
 
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    onToken(decoder.decode(value, { stream: true }));
  }
}

ここで decoder.decode(value, { stream: true }){ stream: true } を忘れない ようにします。マルチバイト文字(日本語)を chunk の境界で受け取ると、これがないと文字化けや「不完全な文字を捨てる」挙動になり、結果として「数秒ごとに 50 文字単位で表示される」見え方になります。

React で扱うなら、useState を chunk ごとに呼ぶより useRef + flushSync で 60 fps に合わせて更新するか、react-markdown のような重い変換は最後にまとめて流すと体感が良くなります。

検証用チェックリスト

push する前に手元で必ず通している項目です。

  1. curl --no-buffer -N で文字がポロポロ出るか確認した
  2. Response ヘッダに Cache-Control: no-cache, no-transformX-Accel-Buffering: no が含まれている
  3. Cloud Run の場合、--timeout=300--use-http2 を設定済み
  4. Vercel Node Runtime の場合、export const dynamic = "force-dynamic" を明示している
  5. Cloudflare Workers の中継で response.body を直接渡している
  6. クライアントの TextDecoder.decode(value, { stream: true }) を指定している

このチェックリストを 6 サイトのデプロイ前に通すようになってから、本番だけ謎にストリーミングが死ぬ事故はほぼゼロになりました。

全体を振り返って

ストリーミング応答が本番だけ効かない問題は、Gemini API 側ではなく 経路上のプラットフォーム(Cloud Run / Vercel / Cloudflare / Nginx)と、それを正しく扱えていない実装 に原因があるケースが大半です。最初に curl --no-buffer で経路の挙動を切り分け、サーバー実装(特に await response.text() をしていないか)と Response ヘッダ、そしてプラットフォーム固有設定を順に確認していけば、ほとんどの症状は説明がつきます。

次に手を動かす一歩としては、いま動いている本番エンドポイントに対して上の curl --no-buffer を 1 回だけ叩いてみてください。「沈黙 → ドカッ」が再現する場合、この記事のどれかの章に必ず該当しています。

お読みいただきありがとうございました。同じく個人で複数サービスを並行運用している方の役に立てば嬉しいです。

シェア

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

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

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

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

関連記事

API / SDK2026-05-23
Gemini API のストリーミングが本番で「途中で切れる」とき、私が試している原因切り分けの順序
Gemini API のストリーミング応答が本番で途中で切れる症状を、ネットワーク・SDK・サーバー側のどこから疑うかの順序を、個人開発で実際に踏んだ事例ごとに整理しました。
API / SDK2026-04-26
Gemini API のレスポンスで日本語が文字化けする時、最初に見るべき4つの場所
Gemini API で日本語レスポンスが文字化けする原因は、API側ではなくクライアント側の4つのレイヤーに潜んでいます。HTTPデコード、ストリーミングのチャンク境界、出力先の encoding、サロゲートペアの順にチェックする実践ガイドです。
API / SDK2026-03-29
Geminiストリーミング応答が途切れる時の対処法 — 原因特定から再接続まで
Gemini API のストリーミング応答が途中で切れてしまう問題を解決。ネットワークタイムアウト、チャンク処理エラー、トークン上限超過、安全フィルター中断など、原因別の対処方法と再接続ロジックを実装例付きで解説。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →