エラーの内容
デプロイ直後、ユーザーから真っ白なページや機能が壊れているという報告が届き始めます。コンソールには以下のエラーが表示されます:
ChunkLoadError: Loading chunk 3 failed.
(error: https://example.com/static/js/3.chunk.js)
チャンク3のこともあれば、チャンク7のこともあります。あるいは vendors~main のような名前付きチャンクの場合もあります。ブラウザがJavaScriptファイルを取得しようとした際に、404エラー、ネットワークエラー、もしくは存在しないファイルへのキャッシュ済みポインタが返ってきた状態です。
根本原因
このエラーを引き起こす原因は3つあり、そのうちの1つがほぼ確実に今回の問題を説明しています:
- 古いチャンクファイル名を参照している古いHTML — webpackのコンテンツハッシュはデプロイごとに変わります。ユーザーのブラウザやCDNエッジが古い
index.htmlを配信し続けている場合、前のビルドのチャンクファイル名をリクエストしてしまいます。そのファイルはすでに存在しません。 - サーバー上にチャンクが存在しない(404) — デプロイが静かに失敗したか、誤ったビルドフォルダがアップロードされた場合です。HTMLは正常に読み込まれましたが、参照先の
.chunk.jsファイルが単純に存在しません。 - 動的インポートの公開パスが間違っている —
React.lazy()やimport()の呼び出しが、本番環境に存在しないパスに解決される場合です。PUBLIC_URLの設定ミス、サブディレクトリの不一致、CDNの設定ミスなど、どれでも発生し得ます。
ステップ1 — チャンクが実際に存在するか確認する
最も単純な確認から始めましょう。エラーメッセージに含まれるURLをブラウザで直接開くか、curl で確認します:
curl -I https://example.com/static/js/3.chunk.js
404 Not Found が返ってくる場合、ファイルがデプロイされていないか上書きされています。ローカルのビルド出力を確認してください:
ls -la build/static/js/ | grep chunk
ローカルにファイルが存在するのにサーバーにない場合は、デプロイパイプラインに欠陥があります。アップロードのステップで何かが漏れています。他のことに手をつける前に、まずこれを修正して完全に再デプロイしてください。
ステップ2 — 古いHTMLを修正する(最も一般的な原因)
多くのチームがはまるシナリオです:新しいビルドをリリースし、チャンクのファイル名が変わります(コンテンツハッシュが更新される)。しかし一部のユーザーはまだ古い index.html を読み込んでいます。その古いHTMLは 3.abc123.chunk.js を参照していますが、新しいビルドは 3.def456.chunk.js を生成しているため、そのファイルはもう存在しません。遅延ロードされるルートへのナビゲーションが毎回エラーになります。
サーバーレベルでindex.htmlのキャッシュを無効化する
Nginxの場合:
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
location /static/ {
# チャンクはコンテンツハッシュ付き — 積極的にキャッシュする
add_header Cache-Control "public, max-age=31536000, immutable";
}
Apacheの場合(.htaccess):
<Files "index.html">
Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache"
Header set Expires 0
</Files>
AWS CloudFrontの場合は、/index.html 専用のキャッシュビヘイビアをTTL = 0で作成してください。
この設定があれば、デプロイのたびに最新の index.html がユーザーに届きます。チャンクの参照が常に同期された状態になり、問題は根本から解決されます。
ステップ3 — 自動リロード付きエラーバウンダリを追加する
適切なキャッシュヘッダーを設定していても、デプロイからCDN伝播までの間に古いチャンクに当たるユーザーは必ず出てきます。エラーバウンダリは失敗を捕捉して一度だけリロードし、ほとんどのユーザーに気づかれることなく問題を修正します。
ChunkLoadErrorは動的インポートからの未処理のPromise拒否として伝播します。遅延ルートをバウンダリでラップすることでインターセプトできます:
// ChunkErrorBoundary.tsx
import React, { Component, ErrorInfo } from 'react';
interface State {
hasError: boolean;
reloadAttempted: boolean;
}
export class ChunkErrorBoundary extends Component<React.PropsWithChildren, State> {
state: State = { hasError: false, reloadAttempted: false };
static getDerivedStateFromError(error: Error): Partial<State> {
if (error.name === 'ChunkLoadError') {
return { hasError: true };
}
return {};
}
componentDidCatch(error: Error, info: ErrorInfo) {
if (error.name === 'ChunkLoadError' && !this.state.reloadAttempted) {
this.setState({ reloadAttempted: true });
// 最新のチャンクを取得するために一度だけ自動リロード
window.location.reload();
}
}
render() {
if (this.state.hasError) {
return (
<div>
<p>このセクションの読み込みに失敗しました。</p>
<button onClick={() => window.location.reload()}>再読み込み</button>
</div>
);
}
return this.props.children;
}
}
遅延ルートをラップします:
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
function App() {
return (
<ChunkErrorBoundary>
<Suspense fallback={<Spinner />}>
<Dashboard />
</Suspense>
</ChunkErrorBoundary>
);
}
ステップ4 — PUBLIC_URLとCDNのベースパスを確認する
/myapp/ のようなサブディレクトリにデプロイしている場合や、CDNからアセットを配信している場合はどうでしょうか?webpackはビルド時に正しい公開パスを知る必要があります。そうでなければ、生成されるチャンクのURLは存在しないパスを指してしまいます。
Create React Appの場合は、.env.production に設定します:
PUBLIC_URL=https://cdn.example.com/myapp
カスタムのwebpack設定の場合:
// webpack.config.js
module.exports = {
output: {
publicPath: process.env.PUBLIC_URL || '/',
// webpack 5のautoモードはほとんどの設定でうまく機能します:
// publicPath: 'auto',
},
};
Viteの場合:
// vite.config.ts
export default defineConfig({
base: '/myapp/', // デプロイパスと完全に一致させる必要があります
});
この設定を1文字でも間違えると、バンドル内のすべてのチャンクURLが誤ったドメインまたはパスを指してしまいます。
ステップ5 — インポートレベルでチャンク読み込み失敗を処理する
重要な遅延インポートには、リトライラッパーを追加しましょう。不安定な4G回線を使うモバイルユーザーにとって助かります。ファイルがサーバーに正しく存在していても、一時的なネットワークの乱れでチャンクの取得が失敗することがあるからです。
function retryImport(importFn: () => Promise<any>, retries = 3): Promise<any> {
return new Promise((resolve, reject) => {
importFn()
.then(resolve)
.catch((error) => {
if (retries <= 0) {
reject(error);
return;
}
setTimeout(() => {
retryImport(importFn, retries - 1).then(resolve, reject);
}, 1500);
});
});
}
// 使用例
const Dashboard = React.lazy(() => retryImport(() => import('./pages/Dashboard')));
1.5秒間隔で3回リトライすれば、ユーザーをイライラさせることなく、短い接続の乱れを乗り越えるのに通常は十分です。
修正の確認
- 新しいビルドをデプロイします。DevToolsのNetworkタブを開きます。
- 強制リロード(
Ctrl+Shift+R/Cmd+Shift+R)を行い、index.htmlがレスポンスヘッダーにCache-Control: no-cacheを含んだ状態で200を返すことを確認します。 - 遅延ロードされるルートに移動し、チャンクのリクエストが
200を返すこと、およびファイル名がbuild/static/js/フォルダ内のものと一致することを確認します。 - 古いチャンクのシナリオを再現します:新しいビルドをデプロイし、古いチャンクのURLを手動でリクエストします。404が返るはずです。ページをリロードすると、最新の
index.htmlが正しい新しいチャンクをきれいに読み込むはずです。 - コンソールを確認します。
ChunkLoadErrorが表示されていなければ完了です。
予防策
- 最初の本番デプロイの前に
index.htmlへno-cacheを設定する — 最初のインシデントが起きてからではなく、事前に。5分で設定でき、オンコール対応の何時間もの苦労を省けます。 - デプロイのたびにCDNキャッシュをパージする。 パイプラインに追加しましょう:
aws cloudfront create-invalidation --distribution-id XXXXX --paths "/index.html"。 - チャンクに名前をつける とデバッグが速くなります:
import(/* webpackChunkName: "dashboard" */ './pages/Dashboard')— これにより、エラーが7.chunk.jsではなくdashboard.chunk.jsと表示されます。 - デプロイ後の検証を自動化する。 デプロイ後、CIがいくつかのチャンクURLにアクセスして200が返ることを確認してから、デプロイを成功とマークするようにしましょう。

