DEV Community

Dauren Abasov
Dauren Abasov

Posted on

I built a render debugger for Vue — now it runs on Rust/WASM

Vue tells you that a component re-rendered. It doesn't tell you why.

I kept hitting the same problem: a component re-renders, renderTriggered fires, but the payload says target: Proxy — useless. Which ref changed? Which prop got a new reference? Was it Pinia? The router? A computed three levels up?

So I built vue-why-render — a zero-dependency dev tool that names the exact reactive source behind every re-render.

Quick start

import { scan } from 'vue-why-render'

const app = createApp(App)
scan(app, { panel: true })
app.mount('#app')
Enter fullscreen mode Exit fullscreen mode

That's it. Every component that re-renders now gets an overlay label:

ProductCard ×3  1.2 ms
prop badge: {text} → {text} — new reference, same value
💡 Object is rebuilt in parent — hoist to computed
Enter fullscreen mode Exit fullscreen mode

You get the component name, the render count, the duration, the trigger — and an actionable hint when the render is avoidable.

The problem with Vue's built-in hooks

Vue 3 exposes renderTracked and renderTriggered on every component. In theory, they tell you what's going on. In practice:

  • target is a raw Proxy — you can't tell which ref it came from
  • There's no diffing — you don't see old vs. new value
  • Pinia stores show up as generic reactive objects
  • Route changes show the entire $route object, not which param changed
  • There's no way to see the dependency graph across components

vue-why-render hooks into these same APIs, but resolves the raw proxies back to named sources. When Pinia triggers a render, you see store cart.items: Array(1) → Array(0) · action clear(). When a route param changes, you see route.params.id: "1" → "2" — not $route: {...}.

What's in the panel

The floating panel has 5 tabs:

Tab What it shows
Top Components sorted by render count
Slow Components sorted by render duration
Tree Component hierarchy with Σ render sums
Events Chronological log of every render
Deps Reactivity graph (new in 0.8)

Reactivity graph

The Deps tab is the feature I'm most excited about. It builds a live dependency map: component ↔ reactive source.

Two views:

  • Components → which refs, props, stores, and computeds does this component read?
  • Sources → which components depend on this ref?

Every edge has a read count and a trigger count. If UserAvatar reads user.name 47 times but it only triggered 2 re-renders, you know the other 45 reads were harmless. If CartBadge gets triggered by cart.items on every keystroke — that's your problem.

It's like X-ray for Vue's reactivity system.

Why Rust/WASM

A real Vue app can have hundreds or thousands of components. Building the dependency tree, deduplicating render causes, and diffing reports across time — on every frame — adds up.

I rewrote the heavy algorithms in Rust and compiled them to WebAssembly:

  • Tree construction
  • Cause deduplication
  • Report diffing

The WASM module is lazy-loaded as a separate chunk — it doesn't bloat your app bundle. If WASM isn't available (SSR, jsdom, strict CSP), a JS fallback kicks in automatically. No configuration needed.

VS Code extension

Browser devtools are great, but I kept switching between Chrome and VS Code. So I built a WebSocket bridge.

The VS Code extension connects to the scanner running in the browser and shows:

  • Component tree in the sidebar — sorted by renders, with flame icons for hot components
  • Deps tree — same dependency graph, but in the editor
  • CodeLens on .vue files — render count and average duration, right on the component definition
  • Status bar — live render counter
  • Commands — pause, resume, reset from the command palette

No browser tab switching. The data is right where you write code.

Testing renders

The scariest thing about render optimization is regression. You fix a wasted render today, someone adds a prop next week, and it's back.

vue-why-render ships test utilities:

import { expectRenders } from 'vue-why-render/test'

it('ProductCard does not re-render on unrelated state change', async () => {
  // ... mount component, trigger action
  expectRenders(ProductCard).toBe(0, 'adding to cart')
})
Enter fullscreen mode Exit fullscreen mode

This runs in Vitest / Jest. If ProductCard re-renders when it shouldn't, the test fails — with the exact trigger in the error message. The fix stays fixed.

Render budgets

For runtime monitoring, you can set budgets:

scan(app, {
  maxRenders: 10,    // per 5-second window
  maxDuration: 16,   // ms per update
})
Enter fullscreen mode Exit fullscreen mode

Violations show up in the console, the panel, and the reports. Useful for catching regressions in staging before they hit production.

Reports

Export render data as JSON or Markdown:

import { report } from 'vue-why-render'

const data = report('json')   // structured data
const md = report('markdown') // ready for GitHub issues
Enter fullscreen mode Exit fullscreen mode

You can diff two reports to catch regressions between builds.

Nuxt support

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['vue-why-render/nuxt']
})
Enter fullscreen mode Exit fullscreen mode

Listed on nuxt.com/modules.

Try it

Zero runtime dependencies. MIT license. Vue 3.3+.

If you find it useful — a ⭐ on GitHub genuinely helps with discoverability. And if you hit a bug or want a feature, issues are open.

Top comments (0)