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.
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.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.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: 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.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.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.The cheap-setup pattern: do the expensive part once, after modules have run, and cache it.
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:
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:
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'
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.
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.
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.nuxt dev, and trigger the "new dependencies optimized" reload. Fix it with optimizeDeps.include.nuxt analyze on the playground before and after adding your module; write the entry-chunk delta into your notes."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."
How to talk about performance
The five layers, the instrument for each, and how to structure an answer so an interviewer hears judgement rather than a list of tricks.
Server-side performance
Waterfalls, payload size, the caching layers Nitro gives you, and the rule that keeps caching from becoming a security incident.