Performance

Build and dev performance

What module setup, pre-bundling and template generation cost your teammates, how to measure build time properly, and the levers that actually move it.

This is the performance layer a tooling author owns outright. Your module's setup runs on every nuxt dev start and every build in every consuming app; your layer's config decides how much Vite has to pre-bundle. A second added here is a second multiplied by every developer, every day.

Know

  • setup runs on every start and every build. Do no synchronous heavy IO, no network calls, and no deep globbing there. Do once-only work in a hook that runs later (modules:done, app:templates), run independent async work with Promise.all, and cache derived data on disk under nuxt.options.buildDir.
  • Vite pre-bundling is the usual dev villain. CommonJS dependencies your runtime imports belong in vite.optimizeDeps.include; huge ESM dependencies you do not want pre-bundled belong in exclude. Getting this wrong produces the "new dependencies optimized… reloading" loop that restarts the page mid-work.
  • Templates are regenerated a lot. Keep getContents cheap, prefer virtual templates (write: false) so nothing touches the disk, and regenerate selectively with updateTemplates({ filter }) from builder:watch instead of letting everything rebuild.
  • Global components and auto-imports are not free. global: true components land in the entry chunk and defeat per-page splitting; hundreds of registered auto-imports slow the scan and the transform. Register what consumers actually use.
  • Measuring: DEBUG=nuxt:* and the debug option for hook timings, nuxt build printing per-phase durations, nuxt analyze for client and server treemaps, node --cpu-prof node_modules/nuxt/bin/nuxt.mjs build for a flame graph of the build itself, and hyperfine to compare with and without your module.
  • The 4.5 landscape: Vite 8 runs on Rolldown, and builder: '@nuxt/rspack-builder' is the alternative pipeline. experimental.buildCache exists and is disabled by default; treat it as a thing to test, not a thing to promise.

How it works

The cheap-setup pattern: do the expensive part once, after modules have run, and cache it.

src/module.ts
import { existsSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { defineNuxtModule, useLogger } from '@nuxt/kit'

export default defineNuxtModule({
  meta: { name: 'nuxt-team-toolkit', configKey: 'toolkit' },
  async setup(options, nuxt) {
    const logger = useLogger('toolkit')
    const startedAt = performance.now()

    // Disk cache in buildDir: the expensive derivation runs once per machine, not once per dev start.
    const cacheFile = join(nuxt.options.buildDir, 'toolkit/registry.json')
    let registry: Record<string, string>
    if (existsSync(cacheFile)) {
      registry = JSON.parse(await readFile(cacheFile, 'utf8'))
    }
    else {
      registry = await buildRegistry(nuxt)   // globbing, parsing, whatever is slow
      await mkdir(dirname(cacheFile), { recursive: true })
      await writeFile(cacheFile, JSON.stringify(registry))
    }

    // Anything that needs *all* modules to have run waits for the hook instead of blocking setup.
    nuxt.hook('modules:done', () => {
      // inspect nuxt.options here, where it is final
    })

    if (options.debug) logger.info(`setup took ${(performance.now() - startedAt).toFixed(0)}ms`)
  },
})

Pre-bundling hygiene, declared by the module so consumers never see the reload loop:

src/module.ts
import { defineNuxtModule } from '@nuxt/kit'

export default defineNuxtModule({
  setup(_options, nuxt) {
    nuxt.options.vite.optimizeDeps ||= {}
    nuxt.options.vite.optimizeDeps.include ||= []
    // A CommonJS dependency our runtime imports: pre-bundle it up front.
    nuxt.options.vite.optimizeDeps.include.push('some-cjs-charting-lib')
  },
})

Regenerating only your own templates when a watched file changes:

src/module.ts
nuxt.hook('builder:watch', async (_event, path) => {
  if (!path.includes('toolkit-routes/')) return
  await updateTemplates({ filter: t => t.filename.startsWith('toolkit/') })
})

Measuring the delta your module costs, which is the number to quote in a README:

hyperfine --warmup 1 --runs 5 'pnpm nuxt build playground' 'pnpm nuxt build playground-without-toolkit'
Gotcha· `await fetch()` in setup

A module that fetches a schema, a token or an icon list during setup adds that latency to every build and every dev start, and breaks builds on machines without network access. Move it behind a script, a cached file under buildDir, or a runtime call.

Verify before the interview:

Whether debug accepts an object (for example debug: { hooks: true }) and which keys it supports. The reference says it can be an object but does not list the keys today.

docs ↗

Exercise

Exercise
  • Add await fetch('https://example.com') to your module's setup, measure a cold build with hyperfine, then replace it with a disk cache under buildDir and measure again.
  • Import a CommonJS dependency from a runtime plugin, start nuxt dev, and trigger the "new dependencies optimized" reload. Fix it with optimizeDeps.include.
  • Run nuxt analyze on the playground before and after adding your module; write the entry-chunk delta into your notes.

Be able to say

Be able to say· How does a module or layer hurt developer experience, and how do you measure it?

"Three ways: a slow setup that runs on every dev start and build, dependencies that Vite discovers late and re-optimises mid-session, and templates that regenerate on every file change. I measure build time with hyperfine with and without the package, hook timings with the debug flag, and the bundle with nuxt analyze. The fixes are to keep setup free of network and heavy synchronous IO, cache derived work under buildDir, declare CommonJS dependencies in optimizeDeps.include, and regenerate only my own templates from builder:watch."