12.2 · difficulty 2/4 · 14 min read
Vite DevTools in Practice
The official Vite 8 debugging framework `@vitejs/devtools` — enabling modes, visualization capabilities, how it relates to vite-plugin-inspect, and how to extend it with your own Dock panel.
1. What is Vite DevTools, really?
@vitejs/devtools isn't yet another "floating widget injected into your page." It's an extensible debugging framework the Vite team built for the v8 era — a shell plus a set of integrations.
Internalizing that distinction prevents confusion with vite-plugin-inspect:
| Dimension | vite-plugin-inspect | @vitejs/devtools |
|---|---|---|
| Scope | Single-purpose plugin | Framework + integration collection |
| Phases | dev only | dev and build |
| Extensibility | Not extensible | Any Vite plugin can host a Dock panel |
| Core power | Per-file transform chain + diff | Module graph / plugin insights / chunk views / dup pkgs / compare |
| Dependencies | Standalone | Vite 8+, deep Rolldown integration |
One-liner: inspect tells you "what did this file become?"; DevTools tells you "what does the whole dev/build ecosystem look like, and where is it expensive?" They're complementary, not competing.
2. How to enable it
DevTools offers three modes. Pick by need.
2.1 Install
pnpm add -D @vitejs/devtoolsRequires Vite 8.0+. On older versions even the install can succeed but it won't work — devtools and build.rolldownOptions.devtools are both Vite 8 additions.
2.2 Standalone mode: analyze after a build
The lightest setup — a single line in defineConfig:
import { defineConfig } from "vite"
export default defineConfig({
devtools: {
enabled: true,
},
})After pnpm build, the terminal prints a DevTools URL (a local analysis server). Open it in a browser for the full build report. Best for CI: surface an artifact link after each build without bothering anyone during dev.
2.3 Embedded mode: a Dock during dev
When you want a live view in dev, go embedded:
import { DevTools } from "@vitejs/devtools"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [DevTools()],
})Run pnpm dev and a Dock surfaces in the bottom corner of your app (it looks like the macOS Dock). Every available DevTools integration appears as an icon. This is the default recommendation for day-to-day development.
If you want the DevTools app to be browsable as a static site after production builds (rather than only via the temporary server), enable withApp:
DevTools({
build: {
withApp: true,
// outDir: "custom-dir",
},
})2.4 Rolldown integration: where the build analysis lives
The killer feature is the Rolldown integration. Flip a switch under build.rolldownOptions:
import { DevTools } from "@vitejs/devtools"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [DevTools()],
build: {
rolldownOptions: {
devtools: {},
},
},
})Now every pnpm build leaves a full build snapshot inside DevTools. Session Compare diffs across those snapshots.
3. The Rolldown integration's five panels
This is the part of DevTools actually worth studying. Each capability maps to one panel.
3.1 Module analysis
Visualizes the full module graph: nodes are files, edges are imports. Click any node to see the transform chain — which plugins processed it in what order, and what each one emitted.
The sequence diagram below shows the actual hook firing order when Vite/Rolldown loads a module; the DevTools transform panel surfaces this same sequence:
Click a hook on the left to inspect details
In practice this panel answers two questions over and over:
- "Why did this file end up in the bundle?" Walk importers backward; you'll reach the entry within a few hops.
- "Why isn't my transform hook running?" If your plugin name doesn't appear in the chain at all, your
applyconfig orenforceorder is probably wrong.
3.2 Plugin insights
Aggregated by plugin: how many times each was called, cumulative cost, which modules it touched. This is the first stop for "dev server feels slow."
DevTools groups the data into three columns — pre / default / post — by inspecting each plugin's enforce field. This diagram builds the intuition:
pre
enforce: "pre"
normal
no enforce
post
enforce: "post"
When you read the Plugin insights panel, watch for two anti-patterns:
- A
enforce: "pre"plugin dominates total time — it gates every file at the very front and is doing unnecessary work. Addidfiltering, or move to on-demandload. - A
enforce: "post"plugin's transform count is much higher than expected — usually post plugins are re-walking upstream sourcemaps. Narrow the trigger.
3.3 Chunk & asset analysis (four views of the same output)
The same artifact, viewed four ways:
- List — a table sorted by size / gzip size
- Graph — node graph of chunk-to-chunk dependencies
- Treemap — square tree, at-a-glance "which chunk swallowed the bulk?"
- Flamegraph — attribution view; great for "which npm package contributed how many bytes?"
Recipe: Treemap to find the largest chunk, Flamegraph to find contributors, List to check gzipped sizes. That order keeps you from panicking at a 1MB uncompressed file that turns out to be 80KB gzipped.
3.4 Package detection
Automatically flags any npm package that ended up in the bundle multiple times — either version mismatches (lodash@4.17.21 and lodash@4.17.20 both present) or path mismatches (node_modules/foo/node_modules/lodash vs node_modules/lodash).
In monorepos this panel pays for itself: a misconfigured pnpm hoisting policy can silently cause duplicate bundling.
3.5 Session compare
Diff "the last main-branch build" against "the current PR build": which chunks grew, which dependencies are new, what got pulled in unexpectedly.
In CI, save the baseline session as an artifact, then run compare after the PR build. You catch size regressions during review instead of in production.
4. When you still reach for vite-plugin-inspect
Two scenarios DevTools doesn't cover but inspect handles well:
- "What did each plugin do to this one file?" — inspect's diff view is the fastest way. DevTools' Module analysis gives you the timeline, but for raw before/after text comparison on a single file, inspect wins.
- "I want a URL I can paste in chat to jump straight to a transform chain" —
/__inspect?module=...is trivial to share.
You can run both side by side — no conflict:
import { DevTools } from "@vitejs/devtools"
import { defineConfig } from "vite"
import Inspect from "vite-plugin-inspect"
export default defineConfig({
plugins: [DevTools(), Inspect()],
})5. Adding your own Dock panel to a Vite plugin
The most interesting part of DevTools: any Vite plugin can drop an icon into the Dock. The mechanism is a new devtools field on the plugin object, whose setup(ctx) is called when DevTools is active.
/// <reference types="@vitejs/devtools-kit" />
import { fileURLToPath } from "node:url"
import { defineRpcFunction } from "@vitejs/devtools-kit"
import type { Plugin } from "vite"
export default function myPlugin(): Plugin {
return {
name: "my-analyzer",
devtools: {
setup(ctx) {
// 1. Host the panel's static assets (your own SPA bundled with Vite/Vue/React)
const clientPath = fileURLToPath(new URL("../dist/client", import.meta.url))
ctx.views.hostStatic("/__my-analyzer/", clientPath)
// 2. Register a Dock entry
ctx.docks.register({
id: "my-analyzer",
title: "My Analyzer",
icon: "ph:puzzle-piece-duotone",
type: "iframe",
url: "/__my-analyzer/",
})
// 3. Expose an RPC the frontend panel can call type-safely
ctx.rpc.register(
defineRpcFunction({
name: "my-analyzer:get-modules",
type: "query",
setup: () => ({
handler: async () => {
// ctx.viteServer is available in dev — read the module graph
return Array.from(ctx.viteServer?.moduleGraph.idToModuleMap.keys() ?? [])
},
}),
})
)
},
},
}
}Key fields on ctx:
ctx.docks— Dock registrationctx.views— static asset hostingctx.rpc— bidirectional RPC registrationctx.viteConfig—ResolvedConfigctx.viteServer—ViteDevServerin dev mode (undefinedin build mode)ctx.mode—"dev" | "build"ctx.cwd/ctx.workspaceRoot
Branch on ctx.mode when writing custom panels: in build mode you don't get viteServer, so hot-path data has to come from the build snapshot instead.
6. Workflow: a funnel for "slow page" → root cause
Treat DevTools like a funnel, coarse to fine:
- Embedded Dock → Plugin insights: start with "top 5 plugins by cumulative time." If 90% of time goes to one plugin, you've already localized the problem.
- Same panel → Module analysis: see which modules that plugin actually processed. Common anti-pattern: a plugin meant for
.svgis firing on every.tsfile. - Hand off to
vite-plugin-inspect: diff the slowest single file to decide whether it's sourcemap regen, AST re-parsing, or heavy string concat. - If the build is large rather than slow → switch to Rolldown integration's Chunk treemap; find the largest chunk.
- Drilling into a big chunk → Flamegraph for attribution by npm package.
- Suspect duplicate bundling → Package detection pinpoints it in one glance.
- Before opening a PR → Session compare against main to catch size regressions.
Walk this funnel and the vast majority of Vite performance problems land at a specific line number or chunk.
Self-check
- What is
@vitejs/devtoolsbest at vs.vite-plugin-inspect? Can they coexist? - What's the difference between Standalone and Embedded mode? When is each more appropriate?
- Inside the Rolldown integration's chunk views, what question do Treemap and Flamegraph each answer?
- What are the minimum three steps a plugin must take to add its own Dock panel?
- In which mode is
ctx.viteServerundefined? What does that mean for how your RPC handler must behave?
// Add DevTools Dock integration to this minimal transform-timer plugin:
// - Dock id: "transform-timer"
// - Panel title: "Transform Timer"
// - Expose an RPC `transform-timer:list` that returns the top 10 modules by cumulative time
//
// You don't need to write the frontend UI — just fill in the Node-side setup().
/// <reference types="@vitejs/devtools-kit" />
import { defineRpcFunction } from "@vitejs/devtools-kit"
import type { Plugin } from "vite"
interface ModuleTiming {
id: string
totalMs: number
count: number
}
export function transformTimerPlugin(): Plugin {
const timings = new Map<string, ModuleTiming>()
return {
name: "transform-timer",
transform(_code, id) {
const start = performance.now()
// In a real plugin you'd wrap the downstream transform — omitted for brevity
const elapsed = performance.now() - start
const prev = timings.get(id) ?? { id, totalMs: 0, count: 0 }
timings.set(id, { id, totalMs: prev.totalMs + elapsed, count: prev.count + 1 })
},
devtools: {
setup(ctx) {
// TODO: 1. Register the Dock entry
// TODO: 2. Register an RPC `transform-timer:list` returning the top-10 timings
},
},
}
}