โ† Writing

Optimizing performance your React App with Vite.js and Typescript

ยท Rizkyy. ยท 13 min read

Optimizing performance your React App with Vite.js and Typescript
On this page

Optimizing React Performance with Vite and TypeScript

Faster builds, instant HMR, and a smoother development loop, plus the part most posts skip: what any of that actually does for the person loading your app.

There are two clocks running on every React project.

The first one is yours. It starts when you save a file and stops when you see the change in the browser. You hit it a few hundred times a day, and when it drifts past a second or two your attention starts leaking out through the gap.

The second clock belongs to whoever opens your app on a mid-range Android phone on a train. It starts on navigation and stops somewhere around Largest Contentful Paint. You hit it once per session. They hit it every time.

Vite is spectacularly good at the first clock. It is neutral on the second. It gives you sharp tools for shipping a smaller, better-ordered bundle, and it will just as happily ship you a 4 MB single chunk if that's what your imports describe. Almost every "Vite made my app fast" post quietly slides between the two, and readers who came for load times leave with dev-server benchmarks.

So this post does both, separately, and says which is which.


Part 1: The clock you hit 300 times a day

Why the dev server starts instantly

Webpack-era tooling built a complete module graph, bundled it, and only then handed you a URL. Cost scaled with the size of your app, which is why CRA cold starts on a large repo ran into the tens of seconds.

Vite doesn't bundle in dev. It starts an HTTP server, serves your index.html, and waits. The browser hits /src/main.tsx, Vite transpiles that one file and returns it as an ES module. The browser reads its imports and requests those. Vite transpiles each on demand. Nothing gets touched until something asks for it, so startup cost is roughly independent of how much code you have.

Two mechanisms make that practical instead of merely clever:

Dependency pre-bundling. A package like lodash-es is several hundred internal modules. Served naively over native ESM, importing one function would fire several hundred requests. Vite pre-bundles everything in node_modules once, converting CJS to ESM along the way, caches the result in node_modules/.vite, and serves it with aggressive cache headers. Your dependencies get resolved once per lockfile change instead of once per page load.

Module-granular HMR. When you save, Vite invalidates that module and walks up to the nearest Fast Refresh boundary, usually the component itself. It sends one small update over a WebSocket. React swaps the component and keeps state. The work is proportional to the module you edited rather than to the app it lives in, which is why HMR time stays flat as the codebase grows.

The two things that actually make a Vite dev server slow

Neither of them is the bundler.

Barrel files. This is the single most common cause of "our Vite dev server got sluggish around 400 components," and it's self-inflicted:

TS
// src/components/index.ts: the trap
export * from "./Button";
export * from "./Modal";
export * from "./DataGrid";
// ...200 more
TS
// Looks like one import. Is 200.
import { Button } from "~/components";

In dev, that import forces Vite to transform every module the barrel re-exports before the browser can render your button. In production it degrades tree-shaking, because now the bundler has to prove each of those 200 modules is side-effect-free before it can drop them. Import from the source path, keep barrels to genuinely small public surfaces, and set "sideEffects": false in package.json if your code truly has none.

Late dependency discovery. You've seen this message:

[vite] new dependencies optimized: heavy-charting-lib
[vite] optimized dependencies changed. reloading

A dependency reachable only through a dynamic import wasn't in the initial scan, so Vite re-runs pre-bundling mid-session and hard-reloads your page, taking your app state with it. Fix it by declaring the dep:

TS
optimizeDeps: {
  include: ["heavy-charting-lib", "some-cjs-package/submodule"],
}

And if you know which files you always touch first, warm them:

TS
server: {
  warmup: {
    clientFiles: ["./src/main.tsx", "./src/routes/**/*.tsx"],
  },
}

Vite 8 changed the engine underneath

Most tutorials still describe Vite as "esbuild in dev, Rollup in prod." That held through Vite 6 and is no longer true.

Vite 8 stable landed on March 12, 2026, shipping Rolldown as the single unified engine for both dev and production, with plugin compatibility maintained. Rolldown is a Rust-based bundler, and it reached 1.0 stable in May 2026 with a semver-locked API. Oxc now handles JavaScript transforms and Lightning CSS handles stylesheet minification, replacing esbuild in both roles.

The build-speed numbers are real, with reported reductions on the order of 46 seconds down to 6 on large projects. The speed is the less interesting half. The dual-bundler design meant dev and prod ran through different transformation pipelines, which produced a small but genuinely miserable category of bug: works in dev, breaks in prod, and nothing in your code is wrong. One engine for both, and that category stops existing.

Things to know before you upgrade:

  • Node 20.19+ or 22.12+, and the package is ESM-only.
  • Install size grows roughly 15 MB, since Rolldown and Lightning CSS ship as native binaries.
  • optimizeDeps.esbuildOptions is superseded by optimizeDeps.rolldownOptions.
  • Most rollupOptions still work through the compatibility layer.
  • For complex setups, swap to the rolldown-vite package on Vite 7 first to isolate bundler-specific breakage, then move to Vite 8.

Update your React plugin while you're in there. If you're carrying @vitejs/plugin-react-swc from a 2023 setup, that advice has expired. Vite's own guidance is that if you're using plugin-react-swc without SWC plugins or custom SWC options, you should switch to @vitejs/plugin-react to use Oxc. Version 6 of that plugin replaces Babel with Oxc for Refresh transforms and installs smaller as a result. Keep plugin-react-swc only if you actually depend on an SWC plugin. One live exception is worth knowing: React Compiler support currently means using the Babel-based @vitejs/plugin-react, since Rolldown doesn't carry the Babel plugin. Check the state of oxc-plugin-react-compiler before you commit either way.


Part 2: Keeping TypeScript off the hot path

The most important TypeScript fact for anyone tuning a Vite build:

Vite does not type-check. Not in dev, not in vite build, not ever.

It strips types and moves on. That is why the loop is fast: you're never waiting on tsc to save a file. It also means a build with fifty type errors succeeds and deploys.

Fine trade, as long as you put the check back somewhere:

JSONC
// package.json
{
  "scripts": {
    "dev": "vite",
    "typecheck": "tsc --noEmit",
    "build": "tsc --noEmit && vite build",
  },
}

Run typecheck in CI and as a pre-push hook. If you want errors surfaced in the browser during development, vite-plugin-checker runs tsc in a worker and overlays failures. It costs you a core and some memory, and whether that's worth it depends on how disciplined your editor habits are.

Make TypeScript itself cheaper

Because each file is transpiled in isolation, the compiler has no cross-file view during the transform. Configure for that explicitly:

JSONC
{
  "compilerOptions": {
    "verbatimModuleSyntax": true, // import/export elision is now predictable
    "isolatedModules": true, // errors on patterns single-file transpilation can't handle
    "erasableSyntaxOnly": true, // rejects enum/namespace, syntax that isn't type-only
    "skipLibCheck": true, // don't re-check every .d.ts in node_modules
    "incremental": true,
  },
}

verbatimModuleSyntax forces import type { User } from "./types" when you mean a type import, which removes an entire class of "why is this module in my bundle" confusion. erasableSyntaxOnly bans enum and namespace, constructs that emit runtime code and therefore can't be cleanly stripped. Use union types and as const objects instead. They're smaller and they tree-shake.

On a large monorepo, project references plus incremental turn a full re-check into a partial one. That's your CI time, and it's usually the number that gets big.

The one caveat that matters at runtime

TypeScript's guarantees end at your program's edge. Anything from fetch, localStorage, or JSON.parse is unvalidated data wearing a type annotation you wrote by hand. Zod, Valibot, or a hand-rolled guard at the boundary is the actual check. Types describe your intent. Nothing about them inspects the world.


Part 3: The clock the user is on

Everything above shortens your feedback loop. None of it makes the app load faster. This part does.

Measure first, and be specific about what you're measuring

Two different questions, two different tools:

SH
npx vite-bundle-visualizer     # what's in my bundle and why
npx lighthouse https://... --view    # what does that cost a real load

The visualizer answers "which dependency is 400 KB and did I mean to ship it." Lighthouse answers "does that 400 KB land before or after first paint," which is frequently the difference between a real problem and a number that looks bad.

On large repos, also turn this off:

TS
build: {
  reportCompressedSize: false,  // gzips every chunk just to print a table
}

Route-level code splitting is the whole game

If you do one thing from this post, do this one. Nothing else in your build config comes close.

TSX
import { lazy, Suspense } from "react";

const Dashboard = lazy(() => import("./routes/Dashboard"));
const Analytics = lazy(() => import("./routes/Analytics")); // pulls in the chart lib
const Settings = lazy(() => import("./routes/Settings"));

<Suspense fallback={<RouteSkeleton />}>
  <Routes>
    <Route path="/" element={<Dashboard />} />
    <Route path="/analytics" element={<Analytics />} />
    <Route path="/settings" element={<Settings />} />
  </Routes>
</Suspense>;

Each import() becomes a chunk Vite fetches on demand. A user who never opens Analytics never downloads your charting library. That's typically hundreds of kilobytes off the initial payload, an order of magnitude more than any amount of manualChunks tuning will win you.

The failure mode to watch: a single eager import can quietly undo it. If Dashboard.tsx imports a formatter from a barrel that also re-exports the chart wrapper, the chart library is back in your entry chunk and the split accomplished nothing. Verify in the visualizer rather than assuming.

Raise your build target

TS
build: {
  target: "baseline-widely-available",  // Vite's sane modern default
}

Every step you lower this, the transformer down-levels more syntax. async/await compiled to generator state machines is meaningfully more bytes than the native form, on code paths you probably use everywhere. Unless analytics show you real users on ancient browsers, don't pay for them.

Chunking: the mistake that looks like optimization

You'll find this pattern in a lot of configs, usually copied from a post that copied it from another post:

TS
// Anti-pattern. Please don't.
function renderChunks(deps) {
  const chunks = {};
  Object.keys(deps).forEach((key) => {
    if (["react", "react-dom", "react-router-dom"].includes(key)) return;
    chunks[key] = [key];
  });
  return chunks;
}

One chunk per npm dependency. It reads like thoroughness and behaves like sabotage: dozens of tiny files where per-request overhead dominates the payload, waterfalls where the browser can't discover a chunk until its parent resolves, and worse caching for small libraries, since a patch bump on a 2 KB helper now invalidates a standalone file for zero benefit.

Group by change cadence and size instead. Big and stable together, everything else left to the bundler:

TS
build: {
  sourcemap: true,
  rollupOptions: {
    output: {
      manualChunks(id) {
        if (!id.includes("node_modules")) return;
        if (/[\\/]react(?:-dom|-router-dom)?[\\/]/.test(id)) return "react-vendor";
        return "vendor";
      },
    },
  },
}

For most apps the correct amount of manual chunking is close to zero. Rolldown's defaults are good, they keep improving, and hand-tuned chunk maps are a recurring source of self-inflicted regressions. Someone writes one in 2024, the dependency tree changes, and nobody re-measures. Reach for manualChunks when the visualizer shows you a specific problem.


Part 4: A production config, and two plugins I removed

TS
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
import tsconfigPaths from "vite-tsconfig-paths";
import { ViteImageOptimizer } from "vite-plugin-image-optimizer";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    react(),
    tsconfigPaths(),
    svgr(),
    ViteImageOptimizer({
      png: { quality: 80 },
      jpeg: { quality: 80 },
      includePublic: true,
    }),
  ],
  optimizeDeps: {
    include: ["heavy-charting-lib"],
  },
  build: {
    target: "baseline-widely-available",
    sourcemap: true,
    reportCompressedSize: false,
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (!id.includes("node_modules")) return;
          if (/[\\/]react(?:-dom|-router-dom)?[\\/]/.test(id))
            return "react-vendor";
          return "vendor";
        },
      },
    },
  },
});

vite-tsconfig-paths makes Vite honor the paths aliases in your tsconfig.json. Skip it and you get imports that type-check cleanly and fail to bundle, which is a confusing twenty minutes.

vite-plugin-svgr lets you import an SVG as a component (import Logo from "./logo.svg?react") rather than a URL, so you can style and animate it inline.

vite-plugin-image-optimizer compresses images at build time. Be precise about the claim: it shrinks the bytes you ship. It does not generate size variants or negotiate formats per device the way Next's <Image> does. That's runtime optimization, and a build plugin can't reach it.

Compression plugins usually don't earn their slot

The common claim is that vite-plugin-compression "minifies your code and speeds up builds." Both halves are wrong.

Minification is already done, by Oxc for JS and Lightning CSS for CSS. The compression plugin doesn't touch it. What it does is emit precompressed .br and .gz copies alongside your assets so an origin server can serve them with Content-Encoding instead of compressing per request. It adds build time, since Brotli at high quality is expensive, in exchange for smaller transfer size and lower origin CPU.

On Vercel, Netlify, Cloudflare, or any CDN already compressing at the edge, that's redundant work for no delivery gain. Add it when you control the origin and it isn't compressing for you. If you do, vite-plugin-compression2 is the maintained fork.

@vitejs/plugin-legacy is dead weight for most teams

It emits a parallel nomodule bundle plus SystemJS polyfills for browsers without native ESM. In 2026 that audience is very close to zero, and Vite's defaults already target widely-available baseline. You're doubling part of your build and shipping the cost to everyone else. Drop it unless your analytics say otherwise.


Part 5: The image component, corrected

A hand-rolled <Image> I've seen variations of many times, with three bugs stacked:

TSX
// The broken version
<img
  fetchpriority="high" // 1
  className="lazyload blur-up"
  srcSet={`${props.src} 2x, ${props.src} 1x`} // 2
  {...props} // 3
/>
  1. fetchpriority="high" says fetch this immediately. lazyload says defer until near the viewport. They're contradictory instructions on the same element. An above-the-fold hero is eager and high priority, anything below is lazy, and it can't be both.
  2. Both density descriptors point at the same file. srcSet exists to offer the browser a choice between different resources, so pointing it at one file is a no-op with extra characters.
  3. Spreading {...props} after setting alt lets an incoming alt silently overwrite yours. Subtle, and an accessibility landmine.

The 2026 version is smaller because the platform absorbed what lazysizes used to do:

TSX
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "~/utils/common";

type ImageProps = ComponentPropsWithoutRef<"img"> & {
  /** Set only for above-the-fold hero images. */
  priority?: boolean;
};

export const Image = ({
  priority = false,
  className,
  alt = "",
  ...props
}: ImageProps) => (
  <img
    {...props}
    alt={alt}
    className={cn(className)}
    loading={priority ? "eager" : "lazy"}
    fetchPriority={priority ? "high" : "auto"}
    decoding="async"
  />
);

Native loading, decoding, and fetchPriority cover the common cases with no JS dependency. If you need responsive sources, supply a real srcSet with distinct files or a CDN that generates them.

And if you find yourself rebuilding format negotiation, per-device sizing, blur placeholders, and layout-shift prevention from scratch, take that as information. The project wanted a framework.


What Vite will not fix

Worth saying plainly, because build tooling gets credit for problems it can't touch.

Your bundle is one term in the load-time equation. It doesn't help if you parse a 3 MB JSON blob on mount, fetch three endpoints in a waterfall because each depends on the last, ship an unmemoized context that re-renders the tree on every keystroke, or block first paint on a font that arrives from a third-party domain. Rolldown builds in six seconds and the app still feels slow, because the slowness was never in the build.

Profile the running app. React DevTools Profiler for render cost, the Network panel for waterfalls, Lighthouse for the composite. Bundle work and render work are different problems that happen to share a vocabulary.


When to use something else

Vite is a build tool, not a framework, and that's the whole design. It hands you a fast dev loop and a good bundler and no opinions about routing, data fetching, or rendering. For a dashboard behind a login, an internal tool, or any SPA where SEO is irrelevant, that's exactly right.

It stops being right when you need server rendering for SEO, server-side data and mutations without hand-rolling an API layer, or edge middleware. Then you want Next.js, TanStack Start, React Router, or Astro, several of which run on Vite underneath, so you're choosing the layer above it rather than replacing it.

The fair version of the trade-off: the flexibility I've been praising is the same property that makes teams who want strong defaults happier in a framework. "More control" and "more decisions to get wrong" describe the same thing.

One thing that isn't a judgment call: Create React App is deprecated. The React team sunset it in February 2025, and it's genuinely broken against React 19's peer requirements. If you're on it, you have a migration on your hands.


The short version

  • Vite makes your loop fast. It doesn't make the app fast. Different clocks, different fixes.
  • Barrel files and late-discovered dependencies are what actually slow a Vite dev server.
  • Vite never type-checks. Put tsc --noEmit in CI or type errors reach production.
  • Route-level React.lazy splitting beats every manualChunks config you will ever write.
  • One chunk per dependency is not optimization.
  • Measure with the visualizer before you tune anything, and again after.
  • Compression and legacy plugins are usually build cost with no delivery benefit.

References