Performance

The author's performance checklist

What a layer or module costs every consuming app, how to measure that cost, and how to keep it in CI so it cannot grow unnoticed.

Everything before this page was about an app. This page is about the thing you are actually being hired to build: a shared package whose cost is paid by every app that installs it, including the apps that use almost none of it. That asymmetry is the whole argument for budgets.

Know

  • Prefer auto-imported composables over plugins. A composable is tree-shaken when unused; a plugin runs on every SSR request and every page load whether or not the page needs it. Provide $foo from a plugin only when the app genuinely needs app-wide setup.
  • Split client and server deliberately. .client.ts plugins, import.meta.client guards, addComponent({ mode: 'client' }), addServerImports for server-only utilities. Each one keeps code out of a bundle where it cannot be used.
  • Ship ESM and declare sideEffects: false only when it is true, so bundlers can drop unused exports. Avoid CommonJS dependencies; when unavoidable, add them to vite.optimizeDeps.include so consumers do not hit the reload loop.
  • Keep runtimeConfig.public tiny. It is serialised into every SSR response of every consuming app. Large or derived data belongs in a build-time template that is tree-shaken.
  • No global: true components and no unnecessary global CSS. Both land in the entry chunk. A design-system layer registers components normally, with a prefix, and lets per-page splitting do its job.
  • Setup does no network and no heavy synchronous IO, caches derived work under buildDir, and does once-only work in hooks.
  • Offer a debug option that logs your setup timings through useLogger, so a consumer investigating a slow build can see your contribution rather than guess.
  • Budget in CI: the client bundle delta of a fixture with and without the package (size-limit or a nuxt analyze JSON diff) and the build-time delta (hyperfine). Fail the build when either regresses beyond a threshold you chose deliberately.

How it works

Two fixtures and one script give you both numbers:

test/fixtures/
├─ empty/        # a bare Nuxt app
└─ basic/        # the same app plus `extends: ['../../..']` (or the module installed)
package.json
{
  "size-limit": [
    {
      "name": "client bundle (fixture with the layer)",
      "path": "test/fixtures/basic/.output/public/_nuxt/*.js",
      "limit": "220 kB"
    }
  ],
  "scripts": {
    "budget": "nuxt build test/fixtures/basic && size-limit"
  }
}
.github/workflows/budget.yml
name: budget
on: [pull_request]
jobs:
  size:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: pnpm }
      - run: pnpm install
      - run: pnpm budget
      - name: Build time with and without the layer
        run: |
          npx hyperfine --warmup 1 --runs 3 \
            'pnpm nuxt build test/fixtures/empty' \
            'pnpm nuxt build test/fixtures/basic'

A debug option that makes your cost visible instead of mysterious:

src/module.ts
export default defineNuxtModule<ModuleOptions>({
  meta: { name: 'nuxt-team-toolkit', configKey: 'toolkit' },
  defaults: { debug: false },
  setup(options, nuxt) {
    const logger = useLogger('toolkit')
    if (!options.debug) return setupToolkit(options, nuxt)

    const started = performance.now()
    const result = setupToolkit(options, nuxt)
    nuxt.hook('build:done', () => logger.info(`toolkit setup: ${(performance.now() - started).toFixed(0)}ms`))
    return result
  },
})
Gotcha· The cost of the feature nobody switched on

An opt-in feature that still imports its library at module scope, or registers its plugin unconditionally, costs every consumer. Register plugins, components and CSS inside the branch that the option enables, and verify with nuxt analyze on a fixture that leaves the option off.

Verify before the interview:

The current name and default of the CSS inlining feature (features.inlineStyles) before quoting it as a lever; it did not appear in the config reference I checked.

docs ↗

Exercise

Exercise
  • Create the empty and basic fixtures, build both, and record the entry-chunk delta and the build-time delta. Put both numbers in your layer's README.
  • Add size-limit with a threshold five per cent above today's number and watch a deliberate regression fail CI.
  • Register one component as global: true, re-measure, and then remove it. Note the delta; it is a good interview anecdote.

Be able to say

Be able to say· How do you keep a shared layer or module from slowing down every app that uses it?

"By treating its cost as a number rather than an intention. I measure what an empty fixture app costs with and without the package: client bundle delta and build-time delta. Then I keep both in CI as budgets so a regression fails a pull request. The design rules follow from that: tree-shakeable composables instead of plugins, client and server code split properly, no global components or global CSS unless a consumer asks for them, a tiny public runtime config, opt-in features that register nothing when they are off, and a debug option that prints my setup timings so a consumer can see my share of their build."