Fix ChunkLoadError: Loading chunk failed trong React Production Build

intermediate⚛️ React2026-05-22| React 16+, webpack 4/5, Create React App, Vite với code-splitting, mọi môi trường production (Nginx, Apache, CDN, S3+CloudFront)

Error Message

ChunkLoadError: Loading chunk 3 failed. (error: https://example.com/static/js/3.chunk.js)
#webpack#code-splitting#react-lazy#production#dynamic-import

Lỗi gì đang xảy ra

Bạn vừa deploy xong. Người dùng bắt đầu báo cáo trang trắng hoặc tính năng bị hỏng. Console hiển thị:

ChunkLoadError: Loading chunk 3 failed.
(error: https://example.com/static/js/3.chunk.js)

Đôi khi là chunk 3, đôi khi chunk 7, đôi khi là một chunk có tên như vendors~main. Trình duyệt đã cố tải một file JavaScript nhưng nhận về 404, lỗi mạng, hoặc con trỏ cache trỏ đến file không còn tồn tại.

Nguyên nhân gốc rễ

Có ba nguyên nhân gây ra lỗi này, và gần như chắc chắn một trong số đó là thủ phạm trong trường hợp của bạn:

  • HTML cũ vẫn tham chiếu đến tên chunk của bản build trước — webpack thay đổi content-hash sau mỗi lần deploy. Nếu trình duyệt của người dùng (hoặc CDN edge của bạn) vẫn đang phục vụ index.html cũ, nó sẽ yêu cầu tên chunk từ bản build trước — những file đó giờ đã không còn nữa.
  • Chunk bị thiếu trên server (404) — quá trình deploy thất bại âm thầm, hoặc thư mục build sai đã được tải lên. HTML load bình thường, nhưng file .chunk.js mà nó tham chiếu đơn giản là không tồn tại.
  • Public path sai cho dynamic import — một lệnh gọi React.lazy() hoặc import() phân giải đến một đường dẫn không tồn tại trên môi trường production. PUBLIC_URL sai, không khớp subdirectory, cấu hình CDN sai — bất kỳ điều nào trong số này đều có thể gây ra vấn đề.

Bước 1 — Xác minh chunk có thực sự tồn tại không

Bắt đầu với kiểm tra đơn giản nhất. Mở URL từ thông báo lỗi trực tiếp trong trình duyệt, hoặc dùng curl:

curl -I https://example.com/static/js/3.chunk.js

404 Not Found nghĩa là file chưa bao giờ được deploy hoặc đã bị ghi đè. Kiểm tra output build cục bộ của bạn:

ls -la build/static/js/ | grep chunk

File tồn tại ở máy cục bộ nhưng không có trên server? Pipeline deploy của bạn đang có vấn đề — bước upload đã bỏ sót thứ gì đó. Hãy sửa và redeploy hoàn toàn trước khi làm bất cứ điều gì khác.

Bước 2 — Khắc phục HTML cũ (nguyên nhân phổ biến nhất)

Đây là tình huống khiến hầu hết các team vấp ngã: bạn ship bản build mới, tên chunk thay đổi (content-hash được làm mới), nhưng một số người dùng vẫn đang load index.html cũ. HTML cũ đó tham chiếu đến 3.abc123.chunk.js — file không còn tồn tại vì bản build mới đã tạo ra 3.def456.chunk.js. Mọi điều hướng đến lazy route đều bị lỗi.

Buộc no-cache cho index.html ở cấp server

Với 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/ {
  # Các chunk đã được content-hash — cache tích cực
  add_header Cache-Control "public, max-age=31536000, immutable";
}

Với 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>

Với AWS CloudFront, tạo một cache behavior riêng cho /index.html với TTL = 0.

Với cấu hình này, mỗi lần deploy sẽ đẩy index.html mới đến người dùng. Các tham chiếu chunk luôn đồng bộ. Vấn đề được giải quyết tận gốc.

Bước 3 — Thêm Error Boundary với tự động reload

Dù đã có cache header đúng, một số người dùng vẫn có thể gặp phải khoảng thời gian chunk cũ giữa lúc deploy và CDN propagation. Một error boundary bắt lỗi và reload một lần — âm thầm sửa cho hầu hết mọi người.

ChunkLoadError nổi lên dưới dạng uncaught promise rejection từ dynamic import. Bọc lazy route trong một boundary cho phép bạn chặn chúng:

// 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 });
      // Tự động reload một lần để lấy chunk mới
      window.location.reload();
    }
  }

  render() {
    if (this.state.hasError) {
      return (
        <div>
          <p>Không thể tải phần này.</p>
          <button onClick={() => window.location.reload()}>Tải lại</button>
        </div>
      );
    }
    return this.props.children;
  }
}

Bọc các lazy route của bạn:

const Dashboard = React.lazy(() => import('./pages/Dashboard'));

function App() {
  return (
    <ChunkErrorBoundary>
      <Suspense fallback={<Spinner />}>
        <Dashboard />
      </Suspense>
    </ChunkErrorBoundary>
  );
}

Bước 4 — Kiểm tra PUBLIC_URL và CDN Base Path

Deploy vào một subdirectory như /myapp/, hoặc phục vụ asset từ CDN? webpack cần biết public path chính xác tại thời điểm build, nếu không các chunk URL mà nó tạo ra sẽ trỏ đến không đâu.

Trong Create React App, thiết lập trong .env.production:

PUBLIC_URL=https://cdn.example.com/myapp

Trong custom webpack config:

// webpack.config.js
module.exports = {
  output: {
    publicPath: process.env.PUBLIC_URL || '/',
    // webpack 5 chế độ auto hoạt động tốt cho hầu hết các thiết lập:
    // publicPath: 'auto',
  },
};

Với Vite:

// vite.config.ts
export default defineConfig({
  base: '/myapp/', // phải khớp chính xác với đường dẫn deployment
});

Sai dù chỉ một ký tự và mọi chunk URL trong bundle sẽ trỏ đến domain hoặc path sai.

Bước 5 — Xử lý lỗi chunk load ở cấp import

Với các lazy import quan trọng, hãy thêm một wrapper retry. Người dùng di động trên kết nối 4G không ổn định sẽ cảm ơn bạn — sự gián đoạn mạng tạm thời khiến chunk fetch thất bại ngay cả khi file vẫn đang ở đó trên server.

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);
      });
  });
}

// Cách dùng
const Dashboard = React.lazy(() => retryImport(() => import('./pages/Dashboard')));

Ba lần retry với khoảng cách 1,5 giây thường đủ để vượt qua sự cố kết nối ngắn mà không gây khó chịu cho người dùng.

Xác minh bản sửa lỗi

  • Deploy bản build mới. Mở DevTools → tab Network.
  • Hard-reload (Ctrl+Shift+R / Cmd+Shift+R). Xác nhận index.html trả về 200 với Cache-Control: no-cache trong response header.
  • Điều hướng đến một lazy-loaded route. Kiểm tra xem chunk request có trả về 200 và tên file có khớp với những gì trong thư mục build/static/js/ của bạn không.
  • Mô phỏng tình huống chunk cũ: deploy bản build mới, sau đó thủ công yêu cầu một chunk URL cũ — nó phải trả về 404. Reload trang — index.html mới sẽ load đúng các chunk mới một cách trơn tru.
  • Kiểm tra console. Không có ChunkLoadError. Xong.

Phòng ngừa

  • Thiết lập no-cache cho index.html trước lần deploy production đầu tiên — không phải sau sự cố đầu tiên. Chỉ mất năm phút nhưng tiết kiệm hàng giờ đau đầu khi trực on-call.
  • Purge CDN cache sau mỗi lần deploy. Thêm vào pipeline của bạn: aws cloudfront create-invalidation --distribution-id XXXXX --paths "/index.html".
  • Đặt tên cho các chunk để debug nhanh hơn: import(/* webpackChunkName: "dashboard" */ './pages/Dashboard') — để lỗi hiển thị dashboard.chunk.js thay vì 7.chunk.js.
  • Tự động hóa việc xác minh sau deploy. Sau khi deploy, hãy để CI ping một vài chunk URL và xác nhận chúng trả về 200 trước khi đánh dấu deployment thành công.

Related Error Notes