Skip to content

TopicsIntermediateUpdated Oct 2026

The Hidden Side of Frontend Deployments: Bundles, Chunk Hashes, and Stale Clients

Why open tabs can crash with a ChunkLoadError after a React deployment, why refreshing fixes it, and how to deploy so existing users keep working.

15 min read

What you'll learn

  • What a bundler turns your source files into before they reach the browser
  • Tree shaking, code splitting, lazy loading and Suspense, and how they differ
  • Why a tab opened before a deploy can request files that no longer exist
  • How hashed filenames and cache headers change what the browser sees
  • Deployment and error-handling patterns that keep existing users working

Have you ever deployed a new version of your React application, only to discover that some users are experiencing errors while others are using the application without any problems?

Sometimes, the user sees a blank screen. Sometimes, navigating to a specific page causes the application to crash. And sometimes, everything starts working again as soon as the user refreshes the page.

At first, this might look like a React rendering issue. However, the problem can be much deeper than that.

To understand why this happens, we need to look beyond React components and explore how our applications are built, how JavaScript is delivered to the browser, and what happens to an application that is already running when we deploy a new version.

A browser window still running version 1 of an app, connected by a dashed line to a server that now hosts version 2
The browser tab keeps running the release it loaded. The server has already moved on.

What happens to our code before it reaches the browser?

When we develop a React application, we usually work with many different files. We have components, hooks, utility functions, libraries, styles, and assets. These files depend on one another, forming a dependency graph.

For example, a dashboard component might import a chart component, which imports a charting library. The dashboard may also depend on several hooks and utility functions.

The browser does not need our entire development environment. It needs the code and assets required to run the application.

This is where a bundler comes in.

Tools such as Webpack, Vite, and Turbopack help process our application for production. Depending on the tool and configuration, they resolve dependencies, transform code, optimize assets, and generate files that the browser can download and execute. The Frameworks guide compares the build tools that ship with each React setup.

These generated files are often called bundles or chunks.

A simplified build might produce something like this:

Text
src/
├── App.tsx
├── components/
├── pages/
└── utils/

          ↓ Build process

dist/
├── index.html
└── assets/
    ├── main-a81f.js
    ├── dashboard-b72c.js
    ├── settings-c93d.js
    └── main-d41e.css

From source files to build output

Your source

  • App.tsx
  • components/
  • pages/
  • styles.css

Bundler: Webpack, Vite (Rollup) or Turbopack

  1. 1Resolve the dependency graph
  2. 2Transform TS and JSX
  3. 3Tree shake unused exports
  4. 4Split into chunks
  5. 5Minify and hash file names

Build output (dist/)

  • index.html
  • main-a81f.js
  • dashboard-b72c.js
  • settings-c93d.js
  • main-d41e.css
The browser downloads the generated files at the bottom, not your source tree.

The filenames are illustrative, but the idea is important: the files running in production are build outputs, not necessarily a direct copy of our source files.

Understanding this is the first step toward understanding what can go wrong during deployment.

Key takeaway

Production runs the files the bundler generated, not your src folder. Every deployment produces a new set of those files.

Tree shaking: removing code we do not use

Modern applications often depend on libraries that provide much more functionality than we actually need.

Imagine importing a utility library that contains dozens of functions when our application uses only two of them. Including all that unused code increases the amount of JavaScript the browser may need to download and process.

Tree shaking helps address this problem.

It is a build-time optimization that allows a bundler to eliminate code that can be determined to be unused. It works particularly well with statically analyzable ES module imports and exports.

A tree of code files where unused files fall away and only the one used file is packed into the final bundle
Only the exports the app actually uses make it into the production bundle.

For example:

JavaScript
// utilities.js
export function formatDate(date) {
  return new Date(date).toLocaleDateString()
}

export function calculateTotal(items) {
  return items.reduce((total, item) => total + item.price, 0)
}

export function debugApplication() {
  console.log('Debugging...')
}

If our application imports and uses only formatDate, a production build may eliminate the other unused exports, depending on the bundler and whether it can safely prove they are unnecessary.

Tree shaking keeps only what is imported

utilities.js exports

  • formatDateused
  • calculateTotal
  • debugApplication

App.tsx

import { formatDate }
  from './utilities'

Static ES module imports let the bundler see exactly what is used.

Production bundle

  • formatDateshipped

calculateTotal and debugApplication were removed: less JavaScript to download and parse.

Only exports the bundler can prove are used end up in the production bundle.

The benefit is straightforward: less unnecessary JavaScript can mean smaller downloads and less work for the browser. Bundle Size Optimization goes deeper into the import patterns that help or block it.

However, tree shaking does not solve every bundle-size problem, and it is not a deployment recovery mechanism. It changes what goes into a build, not how existing browser sessions transition to a new build.

That distinction becomes important when we talk about code splitting.

Checkpoint

Which statement about tree shaking is true?

Bundles, chunks, and lazy loading

Imagine an application with a dashboard, reports page, settings page, and administration panel.

If all the code for every page is included in one large JavaScript file, the browser may need to download and process code for features the user does not immediately need.

Code splitting allows us to divide the application into smaller pieces that can be loaded separately.

Instead of shipping every page in one bundle, a build might generate separate chunks for the dashboard, reports, and settings pages.

Lazy loading allows us to load some of those chunks only when they are needed.

main.js loads with the page, while a reports chunk is fetched separately when the user picks an item from the menu
The main bundle loads up front. Page chunks are fetched when the user needs them.

In React, we can use React.lazy() with a dynamic import:

JSX
import { lazy, Suspense } from 'react'

const ReportsPage = lazy(() => import('./ReportsPage'))

function App() {
  return (
    <Suspense fallback={<p>Loading reports...</p>}>
      <ReportsPage />
    </Suspense>
  )
}

Here, import("./ReportsPage") allows the bundler to create a separate chunk in a typical production build. React can then wait for the lazy component's code to load before rendering it.

The Suspense fallback gives the user something to see while that work is pending.

There are three related but different responsibilities here:

  • Code splitting divides code into separate chunks.
  • Lazy loading delays loading some code until it is needed.
  • Suspense allows React to display a fallback while a supported dependency is pending.

One entry bundle, chunks on demand

Loaded at startup

main-a81f.js

React, router, app shell

user opens /dashboard

dashboard-b72c.js

user opens /reports

reports-e41a.js

user opens /settings

settings-c93d.js

Initial downloadDownloaded on demand with import()
Lazy chunks can be requested long after the first page load, as the user navigates.

These techniques help us avoid loading everything upfront, but they introduce an important consideration: the application may need to download additional files long after the initial page has loaded. React Performance Optimization covers when lazy loading is worth it in the first place.

What happens if one of those files is no longer available?

Checkpoint

What makes ReportsPage end up in its own file?

The real problem: a new deployment meets an old browser session

Let's walk through a realistic scenario.

A user opens our application at 10:00 AM. The browser downloads the HTML and JavaScript files for version A.

The application starts successfully, and the user continues working. Perhaps they leave the dashboard open for several hours without refreshing the page.

At 11:00 AM, we deploy version B.

The new version contains updated JavaScript files, including a renamed or newly generated chunk for the reports page.

If the deployment removes the old files immediately, the user's browser may still be running code from version A. When the user opens the reports page, that old code may try to load a chunk belonging to version A.

But the server now has only the assets for version B.

The request fails.

Depending on the application and the error-handling setup, the user might see a chunk-loading error, a broken route, or an error screen.

This is one reason a deployment can appear successful while some existing users experience problems.

Consider the following timeline:

Version skew: an old tab meets a new deployment

  1. 10:00

    Browser tabRelease A

    User opens the app and loads main-A.js

    ServerRelease A

    Serves release A

  2. 10:30

    Browser tabRelease A

    User keeps working. No refresh.

    ServerRelease A

    Release A still live

  3. 11:00

    Browser tabRelease A

    Still running release A code in memory

    ServerRelease B

    Release B deployed. Old A chunks deleted.

  4. 11:15

    Browser tabRelease A

    Opens Reports, so the old code asks for reports-A.js

    GET reports-A.js

    ServerRelease B

    404 Not Found, then ChunkLoadError in the tab

The tab never reloaded, so it still asks for files that belong to release A.

The exact failure depends on how the application is built and deployed. If the old chunk remains available, the request may succeed. If the application does not request another chunk, the deployment may have no immediate effect on that session.

The important point is that the browser can continue running an older version of an application even after the server has been updated.

A deployment does not automatically replace every JavaScript module already loaded into every open browser tab.

Predict the output

A tab opened release A at 10:00 and visited Reports at 10:30. Release B is deployed at 11:00 and every A file is deleted. What happens at 11:15 and 11:20?

JSX
const Reports = lazy(() => import('./Reports')) // reports-A.js
const Settings = lazy(() => import('./Settings')) // settings-A.js

// 10:00  tab loads release A (main-A.js)
// 10:30  user opens Reports, reports-A.js is downloaded
// 11:00  release B is deployed, every A file is deleted
// 11:15  user opens Reports again
// 11:20  user opens Settings for the first time

Why does refreshing the page sometimes fix it?

When the user refreshes, the browser requests the application entry point again.

If the deployment is healthy and the caching configuration allows the browser to discover the new version, it receives the current HTML and asset references.

The browser then loads the files belonging to version B instead of continuing to execute the old application code from version A.

That is why a refresh can appear to fix the problem.

However, refreshing is not a universal solution. If the new deployment is broken, the server is unavailable, or the application has an incompatible API contract, reloading may not help.

We should treat a refresh as a possible recovery mechanism, not as the actual fix for every deployment problem.

Try it below. Open the app, visit Reports, deploy, and then try both pages in the old tab. Then switch on "Keep old assets" or the error handler and run the same steps again.

Try it: deployment simulator

Open the app, visit Reports, deploy a new release, then keep using the old tab. Reports still works because its chunk is already loaded. Settings is a different story.

Browser tab

No tab open yet. Press “Open app”.

Server /assets

live A
  • A
    • main-a81f.js
    • reports-e41a.js
    • settings-c93d.js

Event log (newest first)

  1. Nothing yet. Start with “Open app”.

Key takeaway

A refresh works because it throws away the old release in the tab and starts again from a fresh index.html. It does not repair anything on the server.

How do caching and hashed filenames affect this?

Production builds commonly generate filenames containing a content hash:

Text
main-a81f.js
main-b72c.js

When the content changes, the generated filename usually changes too. This allows the browser and CDN to cache versioned assets for a long time without confusing one version with another.

A common caching strategy is:

  • Hashed JavaScript and CSS assets: cache for a long time, often with Cache-Control: public, max-age=31536000, immutable.
  • HTML documents: use revalidation or a short cache lifetime so clients can discover the latest asset references.

Two kinds of files, two cache policies

HTML entry point

  • index.htmlchanges every release

Cache-Control: no-cache

Revalidated on every visit, so a refresh discovers the new release.

Hashed assets

  • main-a81f.jsnever changes
  • main-d41e.cssnever changes

Cache-Control:public, max-age=31536000,immutable

A new build gets a new name, so a long cache can never serve the wrong version.

Keep previous hashed files available for as long as old HTML or open tabs may still reference them.

The exact policy depends on the application and hosting environment. If your HTML is rendered on a server rather than served as a static file, React Rendering Strategies explains where that HTML comes from, and Update Content Without Redeploying walks through the other cache layers that can serve an old copy.

But hashed filenames introduce a deployment requirement: if an old HTML document or an already-running application still refers to an old asset, that asset may still be needed.

If we publish the new HTML and immediately delete every old JavaScript chunk, existing sessions can break.

In other words, content hashing helps with cache correctness, but it does not guarantee deployment compatibility by itself.

Checkpoint

Which response should get Cache-Control: public, max-age=31536000, immutable?

How can we prevent these problems?

The best approach is to design deployments with existing users in mind.

A shelf holding both v1 and v2 asset boxes, with a v1 browser tab and a v2 browser tab each connected to their own release
Old and new assets live side by side, so tabs on either release can still load their files.

Keep old assets available

Do not delete old JavaScript and CSS files immediately after deploying a new version. Retain previous hashed assets long enough to support users who are still running older versions.

The appropriate retention period depends on how long users keep tabs open, how frequently they return to the application, and the deployment environment.

Deploy assets before referencing them

Upload and verify the new assets before publishing HTML that references them. Where possible, use atomic or release-based deployments so users do not receive a mixture of incomplete releases.

This reduces the risk of the application referencing files that have not yet been published.

For a static site on object storage, the order looks like this:

Terminal
# 1. Upload the new hashed assets next to the old ones
aws s3 sync dist/assets s3://my-app/assets \
  --cache-control "public, max-age=31536000, immutable"

# 2. Publish the HTML last, so it only points at files that already exist
aws s3 cp dist/index.html s3://my-app/index.html \
  --cache-control "no-cache"

Configure caching deliberately

Long-lived caching is useful for content-hashed assets. HTML needs a different strategy because it points users toward a particular release.

A good caching strategy helps the browser discover new releases without unnecessarily downloading every unchanged asset.

Handle chunk-loading errors

Even with a good deployment strategy, network errors and unexpected failures can happen.

An Error Boundary can prevent some rendering errors from taking down the entire React interface. For failed lazy imports, we can provide a recovery path that informs the user or offers a controlled reload when a new release is available.

However, Error Boundaries do not automatically fix failed network requests. A lazy import may remain rejected, and retrying the same cached failure may not work.

A robust implementation should distinguish a likely stale-build problem from ordinary network failures, avoid infinite reload loops, and ensure that a successful reload actually loads the intended release.

Here is one way to do that. lazyWithReload reloads the page once when a chunk fails to load, and remembers the attempt in sessionStorage so a broken release cannot trap the user in a reload loop:

JSX
import { lazy } from 'react'

const RELOAD_KEY = 'chunk-reload-at'

function isChunkLoadError(error) {
  const message = error?.message ?? ''
  return (
    error?.name === 'ChunkLoadError' ||
    /Loading chunk .+ failed|Failed to fetch dynamically imported module|Importing a module script failed/i.test(message)
  )
}

export function lazyWithReload(factory) {
  return lazy(async () => {
    try {
      const module = await factory()
      sessionStorage.removeItem(RELOAD_KEY)
      return module
    } catch (error) {
      const lastReload = Number(sessionStorage.getItem(RELOAD_KEY) ?? 0)
      const reloadedRecently = Date.now() - lastReload < 10_000

      if (isChunkLoadError(error) && !reloadedRecently) {
        sessionStorage.setItem(RELOAD_KEY, String(Date.now()))
        window.location.reload()
        return new Promise(() => {})
      }
      throw error
    }
  })
}

If the reload already happened and the chunk still fails, the error is thrown to the nearest Error Boundary, which can show a calm message instead of a blank screen:

JSX
import { Component, Suspense } from 'react'

class ChunkErrorBoundary extends Component {
  state = { error: null }

  static getDerivedStateFromError(error) {
    return { error }
  }

  render() {
    if (!this.state.error) return this.props.children
    return (
      <div role="alert">
        <p>This page could not be loaded. A newer version may be available.</p>
        <button onClick={() => window.location.reload()}>Reload</button>
      </div>
    )
  }
}

const ReportsPage = lazyWithReload(() => import('./ReportsPage'))

function App() {
  return (
    <ChunkErrorBoundary>
      <Suspense fallback={<p>Loading reports...</p>}>
        <ReportsPage />
      </Suspense>
    </ChunkErrorBoundary>
  )
}

Maintain compatibility between frontend and backend

Not every production failure is caused by JavaScript chunks.

Imagine the new frontend expects an API field that the backend does not provide yet. Reloading the browser will not necessarily solve the issue.

For systems where frontend and backend deployments happen independently, backward-compatible API changes and appropriate release coordination are equally important.

Key takeaway

Keep old assets, publish HTML last, cache HTML and hashed files differently, and give lazy routes a recovery path. Each one covers a gap the others leave open.

Where do React and the bundler fit into the solution?

It is useful to separate the responsibilities of each layer.

React manages component rendering, lazy component loading, Suspense boundaries, and rendering error recovery.

The bundler generates optimized assets and determines how modules are grouped into bundles and chunks.

The browser and CDN handle asset requests and caching.

The deployment infrastructure determines when new files become available, how releases are published, and whether previous assets remain accessible.

Who is responsible for what

React

  • Rendering components
  • lazy() and Suspense fallbacks
  • Error Boundaries

Cannot: Restore a chunk that was deleted from the server

Bundler

  • Grouping modules into chunks
  • Tree shaking
  • Hashed file names

Cannot: Decide how long old files stay online

Browser and CDN

  • Requesting assets
  • Caching responses
  • Keeping old code running in a tab

Cannot: Swap the code in an open tab for a new release

Deployment

  • Publishing a release atomically
  • Retaining previous assets
  • Ordering assets before HTML

Cannot: Fix a frontend that expects an API the backend lacks

A safe release needs every layer to do its part.

No single layer can solve the entire problem on its own.

For example, adding Suspense can improve the loading experience, but it does not ensure that a requested chunk exists on the server. Adding an Error Boundary can contain a rendering failure, but it does not restore a deleted JavaScript file.

Preventing these problems requires the build process, caching strategy, and deployment workflow to work together.

Conclusion

When we build a React application, it is easy to focus on components, hooks, rendering performance, and bundle size. These are important, but they are only part of the story.

A production application is also a collection of generated assets delivered through browsers, servers, and CDNs. Users may keep an older version running while we deploy a newer one, and that difference can expose problems that never appear during local development.

Tree shaking helps remove unused code. Code splitting and lazy loading help control when code is downloaded. Suspense helps manage waiting for supported asynchronous work. But none of these techniques, by themselves, guarantees that a deployment will be safe for every active user.

A reliable frontend needs more than an optimized build. It needs a deployment strategy that accounts for users who are already there.

The key takeaway

The goal is not just to deploy a new version successfully. It is to make sure that users can continue using the application while that new version is being released.

Check your understanding

Test yourself6 questionsQuestion 1 of 6

What does a bundler such as Webpack, Vite or Turbopack produce for the browser?

Recap

What to remember from this article

Sections read

0

Questions

0

none answered yet

Quiz

0

not taken yet

Up next: JavaScript Performance Patterns · 4 min

Continue