Warming Vite Transforms with the warmup Option

Vite transforms modules on demand, so the very first request for a route pays the transform cost for that route’s whole import chain while you wait. server.warmup transforms a list of files at server startup instead, moving that cost off the critical first navigation. This guide picks the right files to warm without warming everything. It builds on optimizing the Vite dev server and HMR; the parent overview is Vite Configuration Ecosystem.

The problem exists because Vite’s dev server is deliberately lazy. Unlike a bundler that produces a single artifact ahead of time, the dev server serves native ES modules and only compiles a file the moment the browser asks for it over the module graph. That laziness is the whole point of Vite’s fast cold start: booting the server does almost no per-module work, so vite is ready to accept connections in milliseconds regardless of how large the app is. The cost is deferred, not removed. The browser requests the HTML entry, discovers a <script type="module">, requests that, and each imported specifier in turn triggers a fresh resolve-plus-transform on the server. For a typical route that fans out into JSX compilation, TypeScript type-stripping, PostCSS, and any custom plugin transforms across dozens of files, the wait is the sum of that fan-out on the critical path.

Without a fix, the symptom is a dev server that reports “ready in 300 ms” and then makes you stare at a blank tab for two or three seconds on the first load after every restart. It feels like a slow server; it is actually a cold module graph being compiled synchronously in request order. server.warmup closes the gap by doing that compilation during the window between “server listening” and “developer’s browser actually requests a route” — time that is otherwise spent idle. It does not make the total work smaller; it reschedules it into slack the process already had. That reframing matters for reasoning about the trade-offs later: warming is a scheduling optimization, not a caching or bundling one.

warmup transforms hot files at startup instead of on first request Without warmup the first request for a route transforms its whole import chain while the user waits; server.warmup transforms those entry files during server startup so the first navigation finds them already transformed. Move the transform cost to startup no warmup startup first request transforms chain warmup startup transforms hot files first request fast the work happens while you were waiting anyway
Figure: warmup spends idle startup time doing the transforms the first request would otherwise block on.

How it works under the hood

When the dev server starts and a server.warmup list is present, Vite resolves each entry against the project root, expands any globs, and calls the same internal transformRequest path that an incoming HTTP request would trigger — but it does so eagerly, without a browser on the other end. Each transform runs the full plugin pipeline: resolveId to pin the specifier to a file, load to read it, and the ordered chain of transform hooks (esbuild for TS/JSX, @vitejs/plugin-react for Fast Refresh boilerplate, PostCSS for styles, and any user plugins). The result is stored in the module graph’s transform cache keyed by the resolved module URL, exactly where an on-demand request would have stored it.

The important detail is what warmup does and does not follow. Warming a file transforms that file, and because the transform pipeline needs to resolve its imports to rewrite them into browser-loadable URLs, the direct static imports of a warmed file also get resolved and, in practice, warmed as their own transforms are requested during traversal. So you do not have to enumerate every leaf module — listing an entry pulls in the reachable static graph beneath it. What it will not chase are dynamic import() boundaries and route-split chunks, since those are exactly the edges Vite defers on purpose. That is why the useful warmup list is short: you name the roots of the eagerly-loaded first-navigation subgraph, and the traversal handles the rest.

Warming runs concurrently with the server accepting connections, and the transforms themselves are Promise-based, so a browser that connects mid-warmup does not deadlock. If the first request arrives for a module that is still being warmed, Vite deduplicates against the in-flight transform rather than starting a second one — the request simply awaits the same promise. This is why warmup never makes the first navigation slower than the no-warmup baseline: worst case, the request lands before warming finished and pays the same cost it always would have; best case, the transform is already resolved in the cache and the request returns immediately.

Prerequisites & reproducible setup

# Vite 5.4.x, Node 20+
npm create vite@latest warmup-demo -- --template react-ts
cd warmup-demo && npm install
# A few heavy entry routes make the first-request delay measurable.

server.warmup exists in Vite 4.3+ and takes clientFiles (browser graph) and ssrFiles (SSR graph). Warming is only useful for files on the likely first path — warming everything just moves the whole build to startup.

The two-graph split is not cosmetic. Vite maintains separate module graphs for the browser and SSR because the same source file is transformed differently for each target: the client build rewrites imports to browser URLs and injects HMR and Fast Refresh runtime code, while the SSR transform emits code shaped for ssrLoadModule and skips the browser-only client runtime. A warmed clientFiles entry therefore does nothing for the server render, and vice versa. A pure SPA only ever needs clientFiles; an app that server-renders its first response needs ssrFiles for the render path and usually clientFiles as well for the hydration bundle the browser fetches immediately afterward. Getting this wrong is the most common reason warmup “does nothing” — the wrong graph was warmed for the request that was actually slow.

Reproducing a measurable delay is worth the trouble before you tune anything, because on a tiny scaffold the transform cost is under the noise floor and warmup will look like it changed nothing. Add a route that imports a genuinely heavy chain — a charting library, a rich-text editor, or a page that pulls in a large shared design-system barrel — so the first-request stall is a number you can watch move rather than a feeling.

warmup splits into clientFiles and ssrFiles The server.warmup option accepts clientFiles for the browser module graph and ssrFiles for the server module graph, so an app doing SSR can warm both graphs independently at startup. clientFilesbrowser graph ssrFilesserver graph two graphs, warmed separately
Figure: warmup distinguishes the browser and SSR graphs so each can be warmed independently.

Diagnosis workflow

  1. Measure the first-request delay. With DevTools open, restart the dev server and time the first navigation — a long delay before the first byte is the transform chain. Open the Network panel, disable cache, and reload; the initial document and its top-level module request will sit in “Waiting (TTFB)” for the duration of the server-side transform fan-out. The tell that this is transform latency and not network or bundling is that the wait appears only on the first load after a server restart and collapses to near-zero on the second reload, because the module graph is now warm from the previous request. If the delay persists across reloads, you are looking at a different problem — dependency pre-bundling or a slow plugin — not something warmup will fix.
  2. Identify the hot entry files. The app entry, the default route, and any large shared component are the highest-value warmup targets. To find them precisely rather than by guessing, run the dev server with DEBUG=vite:transform vite and reload once cold; the log prints each transformed module with its wall-clock duration, so the files with the largest individual times and the modules that appear earliest in the first navigation are your candidates. Prioritise by time on the critical path, not by file size on disk — a 20 KB component with a heavy PostCSS or SVGR transform can cost more than a 200 KB module that is trivial JavaScript.
  3. Avoid warming rarely-hit routes. Warming a route nobody loads first wastes startup time for no first-request benefit. Every file in the warmup list is transform work the process performs on every server start whether or not it is ever requested, so a bloated list quietly re-taxes each restart during the day. Look at your actual traffic: if developers almost always land on / or a login screen, warm that path and leave the admin dashboard and settings screens cold. The goal is to warm exactly the modules whose transforms would otherwise land on the critical path of the load your team actually performs first.
Warm the likely-first path only The highest-value warmup targets are files on the likely first path such as the app entry and default route, whereas warming rarely-hit routes spends startup time without improving the first request. likely-first pathentry + default routewarm these rarely-hit routesno first-request benefitleave cold
Figure: warmup pays off only for files on the first path — everything else just delays startup.

Annotated solution config

// vite.config.ts — Vite 5.4.x, warm the first-navigation path.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: {
    warmup: {
      // Transform these at startup so the first request is already warm.
      // Globs are supported; keep the list to the likely-first path.
      clientFiles: [
        './src/main.tsx',
        './src/App.tsx',
        './src/routes/Home.tsx',
        './src/components/Layout.tsx',
      ],
    },
  },
});
A short clientFiles list of the first-path modules The warmup config lists the entry, the app shell, the default route and the shared layout in clientFiles, keeping the list to the handful of modules the first navigation needs rather than the whole graph. a handful of files, not the whole graph main.tsxentry App.tsxshell Home.tsxdefault route Layout.tsxshared
Figure: the list is the first navigation's dependency chain — deliberately small.

The paths are resolved relative to the Vite root, so leading ./ refers to the project root, not to vite.config.ts’s directory unless they coincide. Globs are matched with the same picomatch semantics Vite uses elsewhere, which means ./src/routes/*.tsx warms every top-level route file and ./src/routes/**/*.tsx recurses. Prefer explicit paths over broad globs here: a glob is convenient but it silently grows as the directory does, and the whole discipline of warmup is keeping the list to the modules that actually sit on the first-navigation path. If you do use a glob, review it whenever the routing layout changes.

Order in the list does not matter for correctness — warming is a set of transforms, not a sequence — but it has a mild practical effect. Vite kicks the transforms off in listing order, so putting the single most expensive entry first lets it start compiling earliest and finish soonest relative to when the browser connects. The gain is marginal on a short list and not worth obsessing over; the far bigger lever is simply keeping the list short.

For a server-rendered app the same file often needs warming in both graphs. The config below warms the SSR entry and its render path in ssrFiles, and the client hydration entry in clientFiles, so neither the first server render nor the immediately following hydration fetch pays a cold transform:

// vite.config.ts — Vite 5.4.x, SSR app: warm both graphs.
import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    warmup: {
      // Browser graph: the hydration entry the client fetches first.
      clientFiles: ['./src/entry-client.tsx'],
      // SSR graph: the render entry and the shared shell it renders.
      ssrFiles: [
        './src/entry-server.tsx',
        './src/App.tsx',
        './src/routes/Home.tsx',
      ],
    },
  },
});

Verification

# Vite 5.4.x — restart and time the first navigation with and without warmup.
npx vite --port 5199
# In DevTools, hard-reload and read Time to First Byte for the initial doc.
# With warmup, the first request should not block on transforming the chain.

With warmup configured, the dev server logs transform activity at startup and the first navigation returns without the transform stall. Remove the warmup block and the first request visibly waits again — the difference is the startup transforms you added.

Measure honestly. Restart is the only fair reset, because a warm module graph from a previous load hides the effect entirely; a plain reload without a restart tells you nothing about warmup. The disciplined comparison is: kill the server, start it, note the first-navigation TTFB, kill it again, comment out the warmup block, start again, and note the same number. Do this two or three times each way, because the first-ever start of a session also pays esbuild’s own cold-start and dependency pre-bundling, which is not what you are measuring. The number that should move is the initial document’s TTFB and the waiting time on the top-level module request; total transferred bytes and the number of requests should not change at all, since warmup alters when work happens, not what is shipped. If TTFB does not move but the second reload was always fast, you had no transform bottleneck to begin with and warmup is the wrong tool.

You can also confirm warming happened at all by watching the transform debug log. Running DEBUG=vite:transform vite prints a burst of transform lines during startup that name exactly the files in your warmup list before any browser has connected — that burst is proof the warming ran. If those lines only appear after you load the page in the browser, warmup is misconfigured (wrong paths, wrong root, or an unmatched glob) and is silently doing nothing.

First navigation no longer blocks on transforms With warmup the startup log shows transforms happening early and the first navigation returns quickly, whereas removing warmup makes the first request stall on transforming the import chain again. warmup: fast first navtransforms done at startup no warmup: first nav stallstransforms on request TTFB on the initial doc is the number that moves
Figure: the measurable change is the initial document's time to first byte.

Gotchas & edge cases

Four warmup edge cases Warming too many files just moves the whole build to startup; warmup does not pre-bundle dependencies so optimizeDeps is still needed; SSR apps must warm ssrFiles separately; and a stale glob that matches deleted files logs harmless warnings. warm too many files→ moves whole build to startup not a dep pre-bundle→ still need optimizeDeps SSR graph cold→ warm ssrFiles too stale glob→ harmless warnings, prune it
Figure: warmup is not a dependency pre-bundle — optimizeDeps still handles that separately.
  • Over-warming. A giant clientFiles list transforms the whole app at startup, trading first-request latency for a slow boot; keep it to the first path. The symptom is a server that used to report “ready in 300 ms” now taking several seconds before it is usable, because you have effectively rebuilt the eager-bundling behaviour Vite exists to avoid. The root cause is treating warmup as “make everything fast” rather than “make the first request fast.” The fix is to cut the list back to the modules on the load your team actually performs first; confirm by watching startup time return to its baseline while the first-navigation TTFB stays low. If both cannot be true at once, your first navigation genuinely is that heavy and the answer is route-splitting, not more warming.
  • Not a substitute for pre-bundling. warmup transforms your source; dependency pre-bundling is still optimizeDeps’ job. The symptom of confusing the two is a first load that stays slow even with a well-chosen warmup list, because the stall is in esbuild’s dependency optimization pass on node_modules, not in your source transforms. Warmup does not touch that pass. Confirm which one you are hitting by checking whether the dev server logs “optimized dependencies changed, reloading” or a Pre-bundling dependencies line on the slow load — if so, the fix lives in dependency pre-bundling, not here. The two are complementary: pre-bundling warms the node_modules side of the first load, warmup warms your source side.
  • SSR needs ssrFiles. An SSR app’s first render uses the server graph; warm ssrFiles too or the SSR path stays cold. The symptom is a clientFiles-only config that measurably speeds up hydration but leaves the initial server-rendered HTML just as slow to produce, because ssrLoadModule is transforming the render path cold on the first request. The fix is to list the server entry and the components it renders under ssrFiles, as in the SSR config above; confirm by timing the server’s own response generation, not just the browser’s perceived load. Remember the same file transformed for the two targets produces two cache entries, so warming it in one graph does nothing for the other.
  • Stale globs. A glob matching deleted files logs a warning but does no harm; prune the list when routes move. The symptom is harmless-but-noisy startup warnings about files that cannot be resolved. The root cause is a warmup entry that outlived the file it named — a renamed route, a deleted component, a moved directory. It does not break the server or slow anything down, but it is a signal that the list has drifted out of sync with the routing layout, which usually means it is also missing whatever replaced the deleted file. Treat the warning as a prompt to re-derive the list from the current first-navigation path rather than as noise to ignore.

Performance considerations

The honest expectation is modest and specific: warmup removes the transform portion of the first-navigation delay after a restart, and nothing else. It does not speed up subsequent navigations (those hit a graph that is already warm from use), it does not shrink the production build, and it does not reduce total CPU — the transforms still run, just earlier. On a small app the win is imperceptible; on a large app with a heavy shared shell and a slow transform stack it can turn a two-to-three-second first load into a near-instant one. The break-even question to ask is whether the transform time you would move to startup is time a developer currently spends blocked. If your team restarts the dev server many times a day (which is the reality with config changes and flaky HMR), the aggregate saving is real; if they start it once each morning, the saving is one slow load per person per day.

There is a genuine cost on the other side of the ledger. Every warmed file is transformed on every server start regardless of whether it is ever requested, so an oversized list makes each restart slower for everyone, all day. The right mental model is a budget: you are allowed to spend a little startup time to buy first-request speed, and the exchange only pays off for modules that sit on the load path you actually take first. Warming a module that is on the first path is pure profit; warming one that is not is pure loss. That asymmetry is why the entire technique reduces to disciplined list curation.

When not to use this

Reach for warmup only when you have confirmed the first-navigation stall is source-transform time and that it recurs often enough to matter. Skip it when the second reload is already fast and the first is only slow once per session — the payoff is too small to justify maintaining the list. Skip it when the slow load is dependency pre-bundling; that is a different subsystem and warmup will not touch it. Skip it, too, when the real fix is architectural: if your first navigation is slow because it eagerly imports the entire app through a barrel file, the durable answer is to break that import graph up so the first route loads only what it needs, which also improves the production bundle. Warmup is a fine complement to good code-splitting but a poor substitute for it — reaching for warmup to paper over an over-broad first-load graph just moves the same excessive work to startup instead of removing it.