Home

What vite build does to your files

How Vite turns imports into production files.

·41 min read
01Sep 25How Vite's HMR works under the hoodWhy some saves reload the page and others don't.
02Sep 29What vite build does to your filesHow Vite turns imports into production files.
🔖 TL;DR
  • Vite's default dev server turns your source files into browser-loadable modules while keeping their imports as separate requests. A production build follows those imports ahead of time and chooses which code belongs in each output file.
  • Bundling combines modules; tree shaking removes code the app does not need. Removing an unused export is safe only when doing so also preserves the program's effects.
  • Production output has a loading structure of its own. Its HTML points at built JavaScript and CSS, and its JavaScript can import other chunks. The browser follows those URLs, not your original source paths.
  • Hashed URLs let a host cache built assets for a long time. Chunk boundaries determine how much a returning visitor must download when something changes. The HTML needs a policy that lets visitors discover the new URLs.
  • A dynamic import can move code and its CSS out of the first load. That code remains in the build; delaying its download also requires keeping it out of the page's static imports.

Vite serves your app differently while you develop it and after you build it for visitors. npm run dev starts a development server that reads your source files, turns them into JavaScript the browser can run, and sends updates as you edit. npm run build produces a production build: a dist/ folder of HTML, JavaScript, CSS and images that a web host can serve without running Vite's development server.


The app looks the same, but the browser's Network panel changes. Development mode has requests for /src/main.tsx and /src/App.tsx. The production page instead loads a file such as index-CPbFC2lZ.js. Where did the components go, and what did the build change to make that possible?

React and TypeScript starter page with the React and Vite illustration, Get started heading, counter, Documentation and Connect sections.
The React and TypeScript starter page, running in development mode.

We'll follow a React and TypeScript starter page. It has a “Get started” heading, a counter button, and an illustration made from hero.png with the React and Vite logos above it. Below the counter, the Documentation and Connect sections also use icons from public/icons.svg. These images and styles belong to the same page as the counter; we'll follow their files too.


The counter keeps its number in React state. Its button uses debounce from lodash-es, so it increments after the visitor stops clicking for 100 ms. Development and production start from the same App.tsx. Here are its imports, image elements and button:

tsx
import { useState } from 'react'
import { debounce } from 'lodash-es'
import heroImg from './assets/hero.png'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import './App.css'

// Image elements inside App's returned JSX:
<img src={heroImg} className="base" alt="" />
<img
  src={reactLogo}
  className="framework"
  alt="React logo"
/>
<img src={viteLogo} className="vite" alt="Vite logo" />

// The counter button below the heading:
<button
  type="button"
  className="counter"
  onClick={debounce(
    () => setCount((count) => count + 1),
    100,
  )}
>
  Count is {count}
</button>

main.tsx mounts App into the page and imports the shared stylesheet, index.css; App.tsx imports its own App.css. We'll first open this page through the development server, then serve its production files. Later, we'll split the JavaScript and add a panel that loads after a click.

Development mode: what the browser receives

Start in development mode with npm run dev. This command runs vite, starting a server that works from the source files as you edit them. All requests in this section go to that development server.


Opening its URL requests the project's index.html. The source HTML has a script tag pointing at main.tsx:

html
<body>
  <div id="root"></div>
  <script
    type="module"
    src="/src/main.tsx"
  ></script>
</body>

The browser requests /src/main.tsx because the script tag points there. That module imports App.tsx, so the browser requests it too. Each response can reveal more imports to follow.


The Network panel makes those relationships visible. Here are selected requests in development mode. Initiator identifies the document or script that caused a request; main.tsx:4, for example, points at the import of App.tsx in the JavaScript response:

Network
NameInitiatorSize
localhost0.9 kB
main.tsx(index):182.0 kB
App.tsxmain.tsx:421.1 kB
lodash-es.js?v=7dd2f644App.tsx:21,589 kB
App.cssApp.tsx:63.9 kB
...

These are separate responses even though they belong to one page. The app's modules, its library and even its stylesheet appear as scripts here. Let's open App.tsx first to see what Vite sends for a source module.


A .tsx URL does not mean the browser can execute TypeScript or JSX. When Vite receives the request, it reads the source and returns JavaScript. Here is a shortened excerpt of the response for our component; the extra development arguments to the JSX call are left out:

GET/src/App.tsx
200OKtext/javascript
import { debounce } from "/node_modules/.vite/deps/lodash-es.js?v=7dd2f644";
import "/src/App.css";
// Other imports and component code...
_jsxDEV("button", {
  type: "button",
  className: "counter",
  onClick: debounce(() => setCount((count) => count + 1), 100),
  children: ["Count is ", count]
});

The JSX becomes calls to React's element-creation runtime. Vite also removes TypeScript annotations without checking types. Both transformations produce JavaScript the browser can execute. Type checking is separate: the template's npm run build runs tsc -b before it runs vite build.


The development server also supplies source maps, which connect positions in the generated JavaScript to positions in your source. DevTools uses them to show App.tsx even though the browser runs JavaScript. Vite often embeds the map and original source text in a comment at the end of the response. That debugging information adds bytes to the download without adding code the app executes.


The import paths need work too. Without an import map, a browser cannot resolve a bare package name such as 'lodash-es'. It does not search your project's node_modules. Vite resolves that package on the server and replaces its name with a URL. Relative source imports also become URLs that Vite can serve.


A URL pointing straight at lodash's installed entry would still leave the browser with hundreds of files to load. The package lives in node_modules/lodash-es/, where lodash.js re-exports functions from separate modules:

js
// node_modules/lodash-es/lodash.js
export { default as debounce } from './debounce.js'
export { default as throttle } from './throttle.js'
// Hundreds of other exports...

Selecting debounce does not stop the browser from loading the other modules that this entry re-exports. Those modules also import helpers of their own. Served as separate files, they would produce hundreds of requests before our component could use debounce.


Even on localhost, the browser has to schedule those requests and process their responses. Many downloads can overlap, but a response can reveal an import the browser has not seen yet, starting another download. Following several such links creates a request waterfall: later requests wait for earlier responses to reveal their URLs.


Vite follows these package imports on the development server and combines their code into node_modules/.vite/deps/lodash-es.js. The browser gets the prepared lodash module in one response, avoiding the requests between its original files. This is dependency pre-bundling, and it explains why the rewritten import points into .vite/deps/.


Vite discovers package entries from the app's imports and saves the prepared files in a cache. The installed originals remain alongside it:

node_modules/
lodash-es/installed package
lodash.jspackage entry
debounce.js
throttle.js
isObject.js
...
react/
react-dom/
...
.vite/
deps/generated development files
lodash-es.js
react.js
react-dom_client.js
...

The prepared lodash file still exports both debounce and throttle. If we change App.tsx to use throttle too, the same dependency file already provides it. Its first download still contains library code our counter does not use. A production build can reduce that further by following which exports the app actually uses; we'll see how below.


Pre-bundling also makes CommonJS packages usable in the browser. React's package uses operations such as module.exports; Vite converts it into a module with exports the browser can import.

CSS and image imports in development

The source says import './App.css', but JavaScript's ordinary module import does not tell a browser to apply a stylesheet. The browser expects a JavaScript module at that URL. Returning text/css to this import would fail the module's type check, and CSS text is not JavaScript it could execute.


In development, Vite makes the import work by returning JavaScript that holds the CSS text and installs it in a <style> element. The relevant operations in that response are:

GET/src/App.css
200OKtext/javascript
import {
  updateStyle as
  __vite__updateStyle
} from "/@vite/client";
const __vite__id =
  "/project/src/App.css";
const __vite__css =
  ".counter { /* CSS */ }";
__vite__updateStyle(
  __vite__id,
  __vite__css
);

The path and CSS text above are shortened. When this module runs, the helper creates or updates the stylesheet's <style> element. That is why a JavaScript import can make the page look different, and why Vite can replace the CSS while the page remains open.


The illustration above the counter gives us another kind of import. heroImg refers to hero.png, the base image beneath the React and Vite logos. App.tsx passes it to the image element's src:

tsx
import heroImg from './assets/hero.png'

<img src={heroImg} alt="" />

The browser still expects JavaScript from that import. In development, Vite rewrites it to /src/assets/hero.png?import, which returns a JavaScript module rather than the PNG. That module's export is:

GET/src/assets/hero.png?import
200OKtext/javascript
export default "/src/assets/hero.png"

heroImg receives this URL string. When React creates the <img> element with that src, the browser makes another request, this time for /src/assets/hero.png and the actual image bytes. Importing the URL and downloading the image are two separate operations.

Edits and reloads in development

Our own source files remain separate in the default development workflow. Vite can transform and serve the modules the page needs, cache the results, and invalidate affected work as we edit. It also adds its development client and the React plugin's refresh code to the page. Those let a running page receive edits; they are not needed by a visitor to the finished build.


On a reload, the browser can send back the version identifier from a source response's ETag header. If that version is still current, Vite answers 304 Not Modified, letting the browser reuse the cached body. Serving modules on demand does not mean compiling every file again on every visit.


Vite's plugins participate in both development and production builds. The React plugin, for example, enables JSX transformation in both, but adds component-refresh code only during development. Running a build therefore reuses some of the same transformations without including everything the development server sends.

Production build: from the page to dist

Now switch to a production build of the same app. npm run build checks the types, then runs vite build to write the finished files into dist/. It does not start a page server. Run npm run preview to serve those files locally, or upload them to a web host. The requests in this section are for the finished production files.


To create those files, vite build starts from the project's index.html. It finds the script pointing at main.tsx and gives that entry to Rolldown, Vite's bundler. Rolldown follows its imports into App.tsx, React, lodash and the other files, resolving and transforming them as it goes. For a package import, it reads the installed package sources in node_modules, not the generated development files under .vite/deps/.


The imports determine which source code belongs to the production app. A forgotten component elsewhere in src/ does not become part of it simply because it is in that directory. It must be reachable from an entry, or otherwise included by the build configuration or a plugin.


Our app's output is:

dist/
index.html
assets/
index-CPbFC2lZ.js
index-D64VDMd1.css
hero-CLDdwZDr.png
react-CHdo91hT.svg
vite-BF8QNONU.svg
favicon.svgcopied from public/
icons.svgcopied from public/

There is one JavaScript chunk, a generated file containing code from many source modules. It includes the component, its dependencies and the code that mounts the app. The stylesheet contains the imported CSS, and the image files sit alongside it.


The browser needs a way to find these new files. Vite writes another index.html into dist/, replacing the source script tag with references to the output. When a production server serves that file, its HTML response contains these references:

GET/
200OKtext/html
<head>
  <!-- The page's title and other metadata remain here. -->
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
  <script type="module" crossorigin src="/assets/index-CPbFC2lZ.js"></script>
  <link rel="stylesheet" crossorigin href="/assets/index-D64VDMd1.css">
</head>
<body>
  <div id="root"></div>
</body>

Whether served by the local preview server or the deployed host, this HTML makes the browser follow the new script tag. It never requests main.tsx or App.tsx: their code is inside the chunk that the HTML now loads. The JavaScript still renders React into the empty root element. Producing a production build has not rendered the counter into HTML ahead of time; React creates the page's content in the browser.


The corresponding production requests now include a JavaScript chunk and a stylesheet loaded by the HTML:

Network
NameInitiatorSize
localhost0.7 kB
index-….js(index):869.9 kB
index-….css(index):91.8 kB
hero-….png13.3 kB
...

Vite collects the imported CSS into index-D64VDMd1.css. The stylesheet link in the built HTML tells the browser to download that file and apply its rules. In development, importing App.css ran JavaScript that inserted a <style> element; the production page uses the linked stylesheet instead.


The illustration still needs an image response. React and lodash, however, no longer have their own requests: their code arrives inside index-CPbFC2lZ.js.

The build changes both the JavaScript files and the HTML that loads them. The browser follows the resulting URLs, not the original source imports.

An import can disappear while its function remains

To see how vite build combines source files for production, consider a smaller example alongside our React app:

js
// message.js
export function greeting(name) {
  return `Hello, ${name}`
}

export function farewell(name) {
  return `Goodbye, ${name}`
}
js
// main.js
import { greeting } from './message.js'

const message =
  document.querySelector('#message')
message.textContent = greeting('Ada')

Served as separate modules in development, main.js leads to a request for message.js. The response contains both functions, even though the page uses only greeting.


During the production build, the bundler can see the function definition and the code that calls it. It can place them together in one output file. Here is a readable illustration, rather than literal minified output:

js
function greeting(name) {
  return `Hello, ${name}`
}

const message =
  document.querySelector('#message')
message.textContent = greeting('Ada')

The call now finds the function in the same file, so the import is unnecessary. farewell is absent because nothing uses it and declaring it has no other effect. Bundling combines modules into output files; tree shaking removes code the program does not need. A function can survive even when its original file and import statement do not.

Inside the counter's production chunk

The counter's build does this on a larger scale. main.tsx, App.tsx, React and the lodash code needed by debounce all contribute to one JavaScript file. The default output shortens names and packs the code into long lines. To see its organization, run npx vite build --minify false. This still produces a production bundle, but leaves the code readable.


Open the resulting index-Dst_pJd4.js. Comments beginning with //#region identify where code from a source module begins; //#endregion marks its end. Below are selected regions in their actual order. The ellipses stand for the code inside them and other regions between them:

GET/assets/index-Dst_pJd4.js
200OKtext/javascript
Selected regions
...
//#region node_modules/react/cjs/react.production.js
...
//#endregion
...
//#region node_modules/react-dom/cjs/react-dom-client.production.js
...
//#endregion
...
//#region node_modules/lodash-es/debounce.js
...
//#endregion
...
//#region src/App.tsx
...
//#endregion
//#region src/main.tsx
...
//#endregion

The React and React DOM regions contain the library implementations. The App.tsx region contains the compiled counter component, including its button and click handler. The main.tsx region at the end mounts that component into the page's root element.


In the source, main.tsx imports App, and App.tsx imports React and debounce. The build rewrites those connections to use values available within the combined file. The component can call the library code earlier in the chunk, and the mount at the end can use that component. One download supplies both the code that defines the page and the code that starts it.


The browser no longer requests the development lodash file under .vite/deps/. Production builds work from the package sources and the app's imports, not by copying the development pre-bundle into dist/.


This also changes how the browser discovers code. In development, it receives main.tsx before it can read that module's import of App.tsx, and reads App.tsx before discovering its lodash URL. The production HTML identifies the chunk containing all three, so one request can start loading that code. HTTP/2 can carry many requests over a connection, but it cannot request a dependency whose URL has not yet been discovered.


All of the counter's JavaScript is needed when the page opens, so it can share one file. A production app can also use several chunks. Later, we'll separate the libraries for caching and give the panel its own file so it can load after a click.

Which code can the production build leave out?

Removing farewell from the production JavaScript was straightforward because nobody calls it and its declaration does nothing else. Now add this statement outside the functions in message.js:

js
console.log('message module loaded')

When the imported module is evaluated, that statement runs even if nobody calls either exported function. If main.js keeps only import './message.js', the log still belongs in the program. An unused return value or export does not make every statement in its module unnecessary.


These observable effects matter when the bundler follows a package entry that re-exports hundreds of functions. It needs to know whether an unused branch can disappear entirely, or whether evaluating it might register something, modify a global or perform another operation that must remain.


Lodash-es declares "sideEffects": false in its package.json. That is a promise that the package's modules can be discarded when none of their exports is needed. The build can keep the implementation of debounce and its helpers while skipping the unused parts of the library. The promise helps remove whole modules; it is not required to remove an obviously unused declaration such as farewell.


The development pre-bundle already combines these files into one response. Production goes further: because main.tsx leads to an app that imports only debounce, the build can remove throttle and code needed only by it. The visitor therefore downloads less lodash code.


A wrong promise can remove behavior the application relies on. For example, a package module might import a stylesheet for its effect on the page. If the build discards that module, the stylesheet can disappear along with it. Package authors need to preserve effectful modules rather than applying false indiscriminately.


There is another large reduction that does not depend on which library functions we import: development-only code can be excluded. Under the normal production settings, Vite replaces import.meta.env.DEV with false. A branch like this then has no work to do:

js
if (import.meta.env.DEV) {
  console.log('debug information')
}

Once the condition is known at build time, the branch can be removed. React's package entry similarly selects its production implementation using process.env.NODE_ENV, leaving out its development diagnostics. The React plugin also stops adding refresh registration and update handling, and JSX uses React's production runtime.


The development and production files therefore contain different programs for the same application behavior: one supports editing and diagnostics, while the other keeps the code needed to run the deployed app.

How images become production URLs

Our component imports its hero image from src/assets/hero.png. We could also put that PNG in public/ and write its URL directly. These are two alternative ways to use the same image:

tsx
// With the file at src/assets/hero.png:
import heroImg from './assets/hero.png'

<img src={heroImg} alt="" />
tsx
// With the file at public/hero.png:
<img src="/hero.png" alt="" />

With the import, Vite supplies the value of heroImg and can change it to suit the build output. With the literal /hero.png, we choose the URL; Vite makes the file in public/ available there. The browser ultimately needs an image source in either case, but the build has different work to do.


The directory names can obscure this difference. src/assets is just where this app keeps its images; importing from src/images would work the same way. public/ is a directory Vite recognizes and copies into the build. The dist/assets/ directory we saw earlier is where Vite places generated output, including JavaScript and images. It is not a copy of src/assets/.

An imported PNG can become a file or a data URL

During a normal production app build, Vite checks the size of an imported PNG. By default, a file smaller than 4096 bytes (4 KiB) is embedded in the code that uses it. Larger files, including files exactly 4096 bytes long, are emitted separately. Our hero PNG is above that limit, so Vite writes dist/assets/hero-CLDdwZDr.png and puts its URL in the app's JavaScript.


When the production page loads, the browser receives that URL with the JavaScript chunk. React gives the image element a src of /assets/hero-CLDdwZDr.png, and the browser requests the PNG at that address. The development response at /src/assets/hero.png?import is no longer needed: the production chunk already contains the address it used to supply.


Now suppose we use a 1 KiB PNG instead, keeping the same import and <img src={heroImg}>. Vite can put this smaller image's bytes into the JavaScript as a data URL: a string containing the image data itself. For a PNG, the bytes are encoded as base64 text. React passes that string to src, producing an element like this; the encoded bytes are shortened here:

html
<img
  src="data:image/png;base64,..."
  alt=""
>

The browser decodes the text after the comma to display the PNG. It makes no separate image request because the bytes have already arrived inside the JavaScript. We did not write base64 in the source or change the component. Vite chose this representation during the production build.

A public file keeps the URL you wrote

The public/hero.png alternative takes no part in that size decision. Vite copies it to dist/hero.png, preserving the filename and contents. The production host serves that file at /hero.png, matching the src we wrote. Even a 1 KiB PNG stays a separate file when used this way. The browser requests it when it needs to display the image.


Some files need to be deployed even though the app never refers to them. A search crawler, for example, looks for /robots.txt at the site's root to read crawling rules. It knows that address without loading our React app. Putting the file at public/robots.txt makes Vite copy it to dist/robots.txt, where the host can serve it at the expected URL. An unreferenced file left in src/ would not be included by the normal app build.


The public/ directory therefore supplies files independently of the references Vite follows through the app. Their paths and contents pass through unchanged, so another program can request a known URL without learning a generated filename from the app's output. The starter also chooses this approach for /favicon.svg and /icons.svg. Its HTML and icon elements already contain those URLs; Vite copies the files to match them.


For these PNG examples, the default production behavior is:

PNG sourceBuild outputImage request?
Import below 4 KiBData in app JSNo
Import at least 4 KiBPNG fileYes
public/ URLCopied PNGYes

Making the production JavaScript smaller

After deciding what code belongs in the output, the build can use less text to express it. Minification removes unnecessary whitespace and comments, shortens local variable names, and rewrites expressions while preserving behavior. Vite 8 uses Oxc for this work by default.


For example, the counter's update function contains a local parameter called count:

js
(count) => count + 1

The build can use a shorter parameter name because it can change the declaration and all its uses together:

js
e => e + 1

The displayed text "Count is " cannot be shortened the same way without changing the page. Nor can an ordinary object property be renamed merely because its spelling is long: other code may look it up using that spelling. Minification depends on what the tool can safely change, not on shortening every word.


Minification reduces the counter's JavaScript from roughly 585 kB to 225 kB. Unused code has already been removed in both versions: build.minify: false preserves readable formatting, without undoing tree shaking.


A host can then compress that file for transfer. Gzip makes this minified JavaScript roughly 69 kB before HTTP overhead. The browser decompresses the response back into JavaScript before running it. This is why a Network panel's transferred size can be much smaller than the file on disk. Bundling, code removal, minification and compression change different things, even when all of them contribute to a smaller download.

Debugging the production JavaScript

Combining modules and shortening names also changes error locations. To see what that means for debugging, add a deliberate failure to the counter. Define report above App, then call it from the button's existing debounced handler:

tsx
// Above App in App.tsx:
function report() {
  throw new Error('report')
}

// The counter button inside App:
<button
  type="button"
  className="counter"
  onClick={debounce(() => {
    report()
    setCount((count) => count + 1)
  }, 100)}
>
  Count is {count}
</button>

Build the app and click the counter on the production page. It throws before reaching setCount. The error's first stack frame points into the generated JavaScript (the host is omitted here):

Error: report
at Ee (…/assets/index-CLbjUs7B.js:9:53443)

report has become Ee, and the location is line 9, column 53443 of a file containing the whole app and its libraries. Opening line 9 of App.tsx would not generally find the same operation: the build has moved code and changed its layout. We need a record of which output positions came from which source positions.

What a production source map contains

A source map provides that record. The maps Vite supplies in development describe its development responses; production needs maps for the bundled and minified files instead. Enable their generation in the build configuration:

ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  build: {
    sourcemap: true,
  },
})

After rebuilding, the JavaScript has a neighboring map file:

dist/assets/
index-CLbjUs7B.js
index-CLbjUs7B.js.map
...

The JavaScript ends with a comment telling debugging tools where to find it:

js
//# sourceMappingURL=index-CLbjUs7B.js.map

The map connects the error's location in the built file to its location in the original source:

text
index-CLbjUs7B.js
  line 9, column 53443
          ↓
src/App.tsx
  line 9, column 13

That source position points at Error in our throw new Error('report') statement. DevTools can take us there even though the browser is executing the bundled file.


The map stores these connections in JSON. Here is its shape, with other entries and long strings shortened to ...:

json
{
  "file": "index-CLbjUs7B.js",
  "sources": [
    ...,
    "../../src/App.tsx",
    ...
  ],
  "sourcesContent": [
    ...,
    "import { useState } from 'react'\n...",
    ...
  ],
  "mappings": "kvBAWA,IAAIwB,EAAqB,...",
  ...
}

file identifies the built JavaScript, and sources lists the original files. mappings records how positions in that built file correspond to positions in those sources. Its compact encoding is meant for debugging tools to read; decoding it supplies connections like the one above.


The map also carries the original code in sourcesContent. Once DevTools has found App.tsx and the relevant line, it can display that code directly from the map. The production host does not need to serve a separate src/App.tsx file. The page continues running the bundled JavaScript; the original source is what the debugger shows us.

Choosing where to send the map

Vite's production default is build.sourcemap: false. The browser can run the app without a map; the extra file is for debugging. Enabling it is a separate choice about what debugging information to generate and where to make it available.


With sourcemap: true, you can serve the .map beside the JavaScript so DevTools can retrieve it through the comment. These maps also contain the original source in sourcesContent. Anyone who can download the map can read that source, even though the JavaScript they normally receive is minified.


An error-reporting service can use the map too. It receives a production stack and translates its generated positions back to source locations. To do this privately, generate the maps, upload them to that service, and exclude them from the files sent to the public host. sourcemap: 'hidden' supports this arrangement by generating the maps without the discovery comments. It does not make a deployed .map private: if the host serves the file, its URL can still be requested.


An external map is a separate download used by debugging tools; ordinary page loading does not need it. sourcemap: 'inline' puts the map in a data URL at the end of the JavaScript instead. That makes the debugging data part of every download of the chunk, including visits where nobody opens a debugger.

Caching production files by URL

Remove the deliberate report() failure and return to the default sourcemap: false setting. The counter's entry chunk is again index-CPbFC2lZ.js. The extra characters are a hash that Vite's build uses to distinguish this output from other versions. Change the heading in App.tsx and the next build emits index-Y6lgfvc1.js. Its new index.html points at that new URL.


The browser caches responses by URL. It can keep the old JavaScript response without confusing it with the new one: a request for index-Y6lgfvc1.js cannot be satisfied by the cached response for index-CPbFC2lZ.js. This lets a host give generated assets a long cache lifetime, so repeat visits need not keep asking whether an unchanged asset is still current.


The HTML has a different role. Its name remains index.html, and it contains the URLs that tell a visitor which build to load. If the browser keeps reusing old HTML without checking, it will keep following the old URLs. A typical hosting policy is:

ResponseCache-Control
HTMLno-cache
Assets with versioned filenamespublic, max-age=31536000, immutable

no-cache allows storage but requires a check before reuse. The browser can send the HTML's ETag back to the host, and an unchanged page can receive 304 Not Modified with no body. When the HTML changes, the host sends the new document with its new asset URLs. The asset policy instead allows reuse for a year without revalidation while the cached response is fresh.


Vite writes the files; your host supplies these HTTP headers. Fixed URLs copied from public/, such as /favicon.svg, need their own policy because their names do not change when their contents change.


The hash is also more than a digest of the final JavaScript bytes alone. A chunk can depend on other built files. Vite includes its associated CSS filename when calculating the chunk's hash, so changing only App.css can rename the JavaScript chunk even when its JavaScript text stays identical. If one chunk imports another, changing the imported filename can also change the importing chunk. A source edit can therefore affect several output URLs.

Separating code that can stay cached

There is a cost to putting the production counter app in one JavaScript file. Change its heading and a returning visitor needs the renamed file, including React and lodash again. Those libraries account for most of its size even though the edit changed neither library.


We can ask the build to put package code in a separate chunk. In Vite 8, this configuration groups modules from node_modules into a chunk called vendor:

ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  build: {
    rolldownOptions: {
      output: {
        codeSplitting: {
          groups: [
            { name: 'vendor', test: /node_modules/ },
          ],
        },
      },
    },
  },
})

Now the relevant files are:

dist/assets/
index-DI6I7E1Y.js
vendor-DhPjmUiy.js
index-D64VDMd1.css
...

The app chunk is about 3.8 kB; the vendor chunk is about 221 kB. React and the required lodash code have moved into the vendor file. The app still needs them to run, so its generated JavaScript contains an import from ./vendor-DhPjmUiy.js. The browser loads that module before running the app module. Earlier, combining the code made an import unnecessary; separating it into two files creates an import between those files.


Vite also puts a preload for the vendor chunk in the built HTML, alongside the script that loads the app:

html
<script
  type="module"
  crossorigin
  src="/assets/index-DI6I7E1Y.js"
></script>
<link
  rel="modulepreload"
  crossorigin
  href="/assets/vendor-DhPjmUiy.js"
>

Without the preload link, the browser first downloads the app chunk. Only after reading its import can it request vendor:

Network
Name
0 ms100 ms200 ms
localhost
index-….js
vendor-….js

With the preload in Vite's generated HTML, both URLs are available as the document arrives. The app and vendor downloads can start together:

Network
Name
0 ms100 ms200 ms
localhost
index-….js
vendor-….js

The script tag starts the app request, and the preload link starts the vendor request. modulepreload fetches and prepares the vendor module, but does not run it. When the browser follows the app's import, it can use the module already being loaded instead of starting a second download.


Now change the heading and build again. The new HTML points at a new app chunk, index-XHWAmAo0.js, while the vendor URL remains /assets/vendor-DhPjmUiy.js. A returning browser must fetch the new app file, but it can reuse its cached vendor response under the cache policy above. It needs no network download for that unchanged library file.


The vendor file can still change. If the app starts using lodash's throttle as well as debounce, Vite must include more library code. The vendor file becomes vendor-CnkO63SS.js, and the app's import changes to that new filename. Both JavaScript files then have new URLs. Separating packages from app code helps only while the package chunk itself stays unchanged.


The first visit still needs all of this JavaScript, now in two requests. The benefit appears on later visits after an app edit: the browser can download the changed app without downloading the unchanged libraries with it.

Loading a production chunk after a click

The production page loads React and lodash with the counter. Now add a Show panel button below that counter. Clicking it reveals a small box with a purple border and the text “Loaded later”. That box is the panel: an extra part of this page, rendered by a separate React component.

Before the click: Count is 0 and the Show panel button, with no panel beneath it.
Before clicking Show panel
After the click: a purple-bordered box reading Loaded later appears beneath Show panel.
After clicking Show panel

The counter stays on screen when the panel opens. Visitors who never open the panel do not need its component code, so we can make that code wait for the click.


A static import Panel from './Panel' would still make the browser load the panel's code when it loads App. A condition such as open && <Panel /> controls whether React renders the panel; it does not change how a static import loads its code. To delay the download too, we can use import(), which starts loading a module when that expression runs.


Keep the vendor configuration and add a component with its own stylesheet:

tsx
// src/Panel.tsx
import './Panel.css'

export default function Panel() {
  return <div className="panel">Loaded later</div>
}
css
/* src/Panel.css */
.panel {
  border: 3px solid rebeccapurple;
}

These are the new parts of App.tsx; its existing counter and other page content stay in place:

tsx
import {
  useState, lazy, Suspense,
} from 'react'

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

function App() {
  const [open, setOpen] = useState(false)
  // Existing counter state remains here.

  return (
    <>
      {/* Existing page content remains here. */}
      <button
        type="button"
        onClick={() => setOpen(true)}
      >
        Show panel
      </button>
      <Suspense fallback={null}>
        {open && <Panel />}
      </Suspense>
    </>
  )
}

lazy receives the function () => import('./Panel'). React will call it when it first needs to render <Panel />. Initially open is false, so the function has not been called and the panel's code has not been requested.


Vite still has to build that code before deployment. It can read the path './Panel' in the source without executing the import, find Panel.tsx, and compile the component into a separate JavaScript file. The panel's JavaScript is already on the host when the visitor clicks the button; the click starts loading it.


After vite build, the production files include:

dist/assets/
index-DsSqBzgZ.js
vendor-DhPjmUiy.js
index-D64VDMd1.css
Panel-D0Kilrbg.js
Panel-B1cBTy_Y.css
...

The main chunk contains the counter and the code that handles the button click. Its dynamic import now points at ./Panel-D0Kilrbg.js. The vendor file still supplies the libraries; the panel chunk imports React's JSX runtime from that same file. Loading the panel will therefore use the React code already loaded for the counter.


When a visitor opens the production page, its HTML loads the main chunk, preloads vendor and links the main stylesheet, as before. It contains no link to either panel file. Both files are present in dist/, but neither is needed to show the counter.


Clicking Show panel sets open to true. React now tries to render <Panel /> and calls the loading function given to lazy. The import returns a promise. While it is pending, Suspense displays its fallback, here null, so the panel's place stays empty. After the JavaScript has loaded and run, the promise resolves to an object containing the module's exports. Its default property is the component function defined in Panel.tsx. React can now use that function to render the panel.


This works because the initial page has no static import of Panel. If another part of the initial app statically imports it, the browser needs its code from the start. Adding a dynamic import elsewhere cannot undo that requirement. Here, the panel is kept in the build because it can be used later, while tree shaking would remove code that is not needed at all.

Loading the panel's production CSS

The panel's border comes from import './Panel.css'. During the production build, Vite extracts those rules into Panel-B1cBTy_Y.css. The generated panel JavaScript no longer imports that CSS file. Loading the JavaScript alone would give React the component function, but would not load its border styles.


The browser's import() loads and runs a JavaScript module and returns a promise for its exports. It does not create stylesheet links for CSS that Vite extracted during the build. Vite therefore adds a function to the main JavaScript chunk that loads the panel's CSS before allowing the panel module to run. This function executes in the visitor's browser; the production host only serves the built files.


Our source gives lazy this function:

tsx
() => import('./Panel')

Vite changes it during the build to call its helper, __vitePreload. Here is the generated call with readable names and the build's compact file list expanded:

js
() => __vitePreload(
  () => import('./Panel-D0Kilrbg.js'),
  [
    'assets/Panel-D0Kilrbg.js',
    'assets/vendor-DhPjmUiy.js',
    'assets/Panel-B1cBTy_Y.css',
  ],
)

The first argument is a function that will perform the JavaScript import when called. The second lists files that Vite found while building the panel: its JavaScript, the vendor chunk it imports, and its extracted stylesheet. Passing the import as a function lets the helper decide when to start it.


On the click, React calls the function given to lazy. The helper first adds links for files that have no existing link. Vendor already has a preload link from the initial HTML, so it adds only the two panel links:

html
<link
  rel="modulepreload"
  as="script"
  crossorigin
  href="/assets/Panel-D0Kilrbg.js"
>
<link
  rel="stylesheet"
  crossorigin
  href="/assets/Panel-B1cBTy_Y.css"
>

The stylesheet link starts loading the CSS. The helper waits for its load event before calling the function containing import(), so the panel cannot render before its border styles are ready. Waiting until then to start downloading the JavaScript would add another wait. The modulepreload link starts that download while the CSS is loading, without running the panel module yet.


Here, “preload” means before the deferred import() runs, not before the visitor clicks. Both panel downloads still begin at the click. A dynamic import does not inherently create a preload link; this link is an optimization added by Vite's production build.


Once the CSS is ready, the helper calls import(). The browser can use the panel JavaScript already fetched or being fetched by the preload, then run it. The resulting promise gives React the module's exports, including the Panel function. React renders the box with its purple border.


The waterfall shows the two downloads started by those links. It begins with the first panel request:

Network
Name
0 ms40 ms
Panel-….js
Panel-….css

There is no new request for the main chunk or vendor: the browser is still running the same page, and those modules are already loaded.


Setting build.cssCodeSplit: false would put the panel's CSS in the initial stylesheet instead. Its JavaScript could still wait for the click, but its styles would be downloaded when the page opens. Vite's CSS code splitting guide explains this pairing of delayed JavaScript with its stylesheet.

An open tab can still ask for an old file

Suppose a visitor opens the deployed counter but leaves the panel closed. Their browser has the main and vendor chunks, but has never requested Panel-D0Kilrbg.js or its CSS.


You then change the panel and deploy a new build. The host now serves new HTML and new asset filenames. The open tab does not automatically fetch that HTML or replace the JavaScript it is running. Its loading function still contains the old panel URLs. When the visitor finally clicks Show panel, those are the URLs it requests.


If the deployment deleted the old panel files, the requests fail. Long-lived caching does not help this visitor: the browser never downloaded those files in the first place. Keeping assets from earlier builds available lets the old tab finish loading the version it started with, while new visitors receive the new version.


An app can also offer a reload when a delayed import fails. Vite's generated loading code reports these failures through the browser event vite:preloadError, which application code can listen for. The event does not reload the page or find the replacement file on its own.


Reloading starts again from the HTML. This is where its no-cache policy matters: the browser checks with the host, receives the current document, and follows that build's asset URLs. Until the visitor reloads, the tab continues to use the URLs written into the code it already has.