Next.js 15でISR設定を間違えてメモリ枯渇、6ヶ月運用して気づいた落とし穴

本番運用6ヶ月でメモリ80%まで急上昇。ISR・revalidatePatternの実装ミス、Server Componentsのデータフェッチ設計の失敗を実体験から解説します。

先日プロジェクトで本当に痛い目を見た話

先週の朝、本番環境のメモリ使用率が80%まで跳ね上がって、対応に追われました。原因は Next.js 15 の ISR(Incremental Static Regeneration)設定が完全に間違ってたんですよ。うちのチームは昨年 React 19 へ移行したときに、あれは夢のように動く——みたいな記事を読んでがっつり実装したんですけど、実際に運用してみると「ネットに書いてあることと現実が全然違う」ってことが次々と出てきたんです。

6ヶ月運用してわかったこと、正直にぶちまけます。

ISR・revalidatePattern の落とし穴

最初の失敗:ISR の再生成コスト

初期段階で、うちはほぼすべてのページを ISR で設定してました。正確には、revalidate: 60 でいい感じに再生成されるだろって根拠なく思ってたんです。

// pages/products/[id].tsx - 最初の実装(失敗パターン)
export const revalidate = 60; // 60秒ごとに再生成

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await fetch(
    `https://api.example.com/products/${params.id}`,
    { next: { revalidate: 60 } }
  ).then(r => r.json());

  return (
    <div>
      <h1>{product.name}</h1>
      <p>¥{product.price}</p>
    </div>
  );
}

うちのプロダクトは商品ページが約5000個あるんです。で、ISR が 60 秒ごとに全部再生成しようとするわけですよ。動的ルートも含めて。サーバーのビルドプロセスが完全に回り始めて、メモリはガンガン増える。CPU も吹っ飛ぶ。

こんなことになるとは想像もしてませんでした。ネットの記事では「ISR で自動的にキャッシュが更新されます」みたいな説明ばっかりで、実際の負荷についての言及がほとんどなかったんですよね。

revalidateTag を活用した動的な無効化

3ヶ月目で、チーム内で「そもそも ISR いる?」みたいな話になって、戦略を変えました。人気商品だけを事前生成して、あとは必要な時だけキャッシュを無効化する方針に切り替えたんです。

// app/products/[id]/page.tsx - 改善後
export const dynamic = 'force-static';

export async function generateStaticParams() {
  // 人気商品TOP100だけを先に生成
  const topProducts = await fetch(
    'https://api.example.com/products/top?limit=100'
  ).then(r => r.json());

  return topProducts.map((p: any) => ({
    id: p.id.toString(),
  }));
}

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await fetch(
    `https://api.example.com/products/${params.id}`,
    { next: { tags: ['product', `product-${params.id}`] } }
  ).then(r => r.json());

  return (
    <div>
      <h1>{product.name}</h1>
      <p>¥{product.price}</p>
    </div>
  );
}

重要なのは、API 側で商品情報が更新されたときに、Next.js の revalidateTag を呼び出すことです。実は、キャッシュの無効化をイベント駆動にするだけで、無駄な再生成がなくなるんですよ。

// API routes/middleware
import { revalidateTag } from 'next/cache';

export async function PATCH(req: Request, { params }: { params: { id: string } }) {
  const body = await req.json();
  
  // 商品情報を更新
  await updateProduct(params.id, body);
  
  // 特定の商品キャッシュだけを無効化
  revalidateTag(`product-${params.id}`);
  revalidateTag('products-list'); // 一覧も無効化
  
  return Response.json({ success: true });
}

これだけで、メモリ圧力が一気に下がりました。60秒ごとの無駄な再生成がなくなるからですね。更新頻度が低い商品なら、キャッシュは何日も有効のままで、本当に変更された時だけ新しくなる。こういうシンプルさって、実運用では本当に大事だなって痛感しました。

Server Components でのデータフェッチ設計

並列フェッチ vs 直列フェッチの悲劇

React 19 の Server Components を使いはじめたとき、「複数のデータを並列に取得すればいいんだ」って学んで、こんなコードを書きました。

// app/dashboard/page.tsx - 最初は直列(悪い例)
export default async function Dashboard() {
  // これはやっちゃダメなパターン
  const user = await fetchUser();
  const products = await fetchProducts(user.id);
  const analytics = await fetchAnalytics(user.id);

  return (
    <div>
      <UserCard data={user} />
      <ProductList data={products} />
      <Analytics data={analytics} />
    </div>
  );
}

Lighthouse で見ると、FCP が 3.2秒だったんですよ。あるページ遅い…って思って計測したら、API レスポンスが直列で待機されてました。user → products → analytics って順番に。もうバカみたいですよね。最初のリクエストが完了するまで、2番目のリクエストが始まらないんですから。

React 19 では Promise.all を使って並列化できます:

// app/dashboard/page.tsx - 改善後
export default async function Dashboard() {
  // 3つのリクエストを同時に開始
  const [user, products, analytics] = await Promise.all([
    fetchUser(),
    fetchProducts(), // ユーザーIDなくても取得可能な場合
    fetchAnalytics(),
  ]);

  return (
    <div>
      <UserCard data={user} />
      <ProductList data={products} />
      <Analytics data={analytics} />
    </div>
  );
}

これで FCP が 1.8秒に短縮。わずかな修正で一気に変わります。それ以来、複数のデータが必要なページでは、相互依存がないかぎり自動的に Promise.all を使うようになりました。

キャッシュレイヤーのメモリリーク

4ヶ月目のあたりで、Server Components が少しずつメモリを食っていく現象が出ました。ビルドキャッシュ(next/cache の内部)が無限に蓄積されてたんです。

// 問題:キャッシュキーが無限増殖
export default async function DynamicPage({ params }: { params: { id: string } }) {
  const data = await fetch(
    `https://api.example.com/data/${params.id}`,
    { next: { revalidate: 3600 } }
  ).then(r => r.json());

  return <div>{data.content}</div>;
}

generateStaticParams を実装していなかったので、すべての動的ルート(/page/1, /page/2, … /page/10000)がメモリ内で個別にキャッシュされてました。マジで気づきませんでした。アクセスされたページが全部メモリに残ってるんですよ。

対策としては、以下の3つを組み合わせました:

1. キャッシュキーの明示化

// 動的ページのキャッシュを明確にスコープ
const cacheKey = `page-${params.id}`;
const data = await fetch(
  `https://api.example.com/data/${params.id}`,
  { 
    next: { 
      tags: [cacheKey],
      revalidate: 3600 
    } 
  }
).then(r => r.json());

2. 定期的なキャッシュクリア

// API ルートで古いキャッシュを削除
import { revalidateTag } from 'next/cache';

export async function POST(req: Request) {
  const { pageId } = await req.json();
  revalidateTag(`page-${pageId}`);
  return Response.json({ success: true });
}

3. ISR の revalidate を賢く設定

export const revalidate = process.env.NODE_ENV === 'production' 
  ? 3600 
  : 10; // 開発時は短い

これらを組み合わせることで、メモリ蓄積をほぼ止められました。特にtagsを細かく分けることが効いた感じですね。

フロントエンド側の Client Components との相互作用

useCallback のメモ化漏れ

Server Components と Client Components を組み合わせるとき、意外とハマるのが状態管理です。特に、親の Server Component から子の Client Component にコールバックを渡すパターン。

// app/list/page.tsx - Server Component(やってはいけない例)
export default async function ListPage() {
  const items = await fetchItems();

  return (
    <div>
      <ItemList items={items} onSelect={(id) => {
        // 直接これやるとエラー
        'use server';
        await updateSelection(id);
      }} />
    </div>
  );
}

これだと Client Components 側で「あ、サーバーの関数が入ってるんですか」ってなるんですよ。正しくはServer Actions として分離する必要があります:

// app/actions.ts
'use server';

export async function updateSelection(id: string) {
  // DB更新
  await db.selection.upsert(id);
  revalidateTag('selections');
}

// app/list/page.tsx
import { updateSelection } from './actions';

export default async function ListPage() {
  const items = await fetchItems();

  return (
    <div>
      <ItemList 
        items={items} 
        onSelect={updateSelection}
      />
    </div>
  );
}

// components/ItemList.tsx
'use client';

export function ItemList({ items, onSelect }: any) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>
          <button onClick={() => onSelect(item.id)}>
            {item.name}
          </button>
        </li>
      ))}
    </ul>
  );
}

このパターンだと、Client Component から Server Action を呼び出すという、クリアな流れになります。地味だけど、こういう細かい設計が本番の安定性に直結するんですよね。

パフォーマンス比較:6ヶ月の改善推移

改善前後での FCP の推移はこんな感じです。

xychart-beta
  title 本番運用6ヶ月のパフォーマンス推移
  x-axis [1月, 2月, 3月, 4月, 5月, 6月]
  y-axis "FCP (秒)" 1 --> 4
  line [3.2, 3.1, 2.5, 2.3, 1.9, 1.8]

ISR 最適化と並列フェッチの組み合わせで、FCP が44%改善されました。数字だけ見ると小さく見えるかもしれませんが、体感では全然違います。

本当にしんどかったメモリ管理

うちの Vercel 環境(Pro Plan)では、Function Memory が 3,008MB。ISR の初期設定で1,500MB 近く使ってたんです。改善後は 400MB 程度に収まりました。これ、本当に大事なポイントで、メモリが逼迫してるとコールドスタートが頻発するんですよ。

改善前の内訳はこんな感じでした:

pie title メモリ使用率の変化(改善前)
  "ISR再生成" : 1500
  "キャッシュ蓄積" : 800
  "その他" : 300

改善後:

pie title 改善後のメモリ使用率
  "実データキャッシュ" : 200
  "Server Components" : 150
  "その他" : 50

差は歴然ですよね。ISR の戦略を変えるだけで、これだけメモリが減るんです。

チーム的に学んだこと

改善を進める過程で、チーム内での理解も大きく深まりました:

  • ISR は万能じゃない:動的ルートが多いなら revalidateTag + Server Actions の組み合わせの方が管理しやすい
  • 並列フェッチは必須:React 19 なら Promise.all でほぼ全てのシーンで使える
  • キャッシュキーを明示的に:next/cache の自動キャッシング機構は便利だけど、本番では tag を明確に分けた方が後で苦しまない
  • メモリ監視は早めに:Function Memory が 60% を超えたら、キャッシュ戦略を見直すシグナル

正直、最初はこういうことに気づいてませんでした。ネット記事を読んだだけで「これで大丈夫だ」って思ってたんですけど、実運用は全然違うんですよね。

まとめ

Next.js 15 × React 19 は確かに強力ですが、キャッシュ設計が 9割だと感じています。特に本番運用では以下の 3 点を意識することで、大きなパフォーマンス改善と安定性が手に入ります:

  1. ISR の再生成コストを最小化するrevalidateTag で必要な時だけ無効化
  2. データフェッチは Promise.all で並列化:直列での待機は FCP の大敵
  3. キャッシュキーを明示的に管理:メモリリーク防止と、後からの保守性向上

正直、個人プロジェクトなら気になりませんが、日次 PV が数百万という規模になると、この 3 つができているかどうかで本番の安定性が大きく変わります。もし今 Next.js 15 への移行を考えてるなら、設計段階からこのあたりを意識しておくことをマジで推奨します。後から直すと、結構な工数がかかりますからね。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事