Skip to content

Frontend Vitest suite is ~3x slower than it needs to be: every spec re-imports the full module graph #436

Description

@cteyton

Problem

The frontend Vitest suite takes ~51s in CI and ~55s locally (173 files, 2365 tests). Profiling shows the bottleneck is not insufficient parallelism — the suite is already CPU-saturated (~600s of worker CPU spread over 11 cores ≈ the observed wall clock). The bottleneck is that every test file re-imports the whole module graph from scratch.

Vitest's default isolate: true gives each test file a fresh module registry. Measured cost of that:

  • Importing @packmind/ui costs 1.27s (of which @chakra-ui/react alone is 831ms).
  • ~150 of the 173 spec files import @packmind/ui.
  • That accounts for roughly 190s of the ~250s total import time.

In other words, Chakra + the design kit are being evaluated ~150 times per run instead of once per worker.

Measurements

Tight interleaved A/B, same machine (11 cores, macOS, Node 24.18.0, Vitest 4.1.10, Vite 8.1.5). Nx cache was bypassed throughout (--skip-nx-cache, then direct ./node_modules/.bin/vitest invocations).

Config Wall clock import setup
Baseline (current) 56.9s / 55.0s 252–262s 25s
pool: 'threads' + deps.optimizer.web 57.7s / 51.7s 237s 26s
isolate: false 22.9s / 19.4s 41–60s 2.3s

Two things worth calling out:

  1. isolate: false is a ~2.5–3x win. Sharing the module registry per worker collapses ~150 imports of Chakra down to ~11 (one per worker). setup drops 10x for the same reason.
  2. pool: 'threads' and deps.optimizer are worth nothing. An earlier, looser measurement suggested ~10%, but that did not survive a back-to-back A/B on a quiet machine. Ambient machine load dominates at that magnitude — don't bother with these.

Also ruled out: the vite-plugin-checker in apps/frontend/vite.config.ts is not gated on VITEST (unlike the reactRouter() plugin right above it), but it does not actually spawn tsc under Vitest — its checker only activates via configureServer for the dev server. Not a contributor. Gating it would be cosmetic only.

What it costs

Exactly 15 of 173 files leak state under isolate: false. They cluster into three groups:

Shared ApiService / axios module singleton (7 files)

  • src/services/api/ApiService.test.ts
  • src/domain/accounts/api/gateways/AuthGatewayApi.test.ts
  • src/domain/accounts/api/gateways/OrganizationGatewayApi.test.ts
  • src/domain/accounts/api/gateways/UserGatewayApi.test.ts
  • src/domain/change-proposals/api/gateways/ChangeProposalsGatewayApi.spec.ts
  • src/domain/git/api/gateways/GitProviderGatewayApi.spec.ts
  • src/domain/skills/api/gateways/SkillsGatewayApi.test.ts

Clipboard global (2 files)

  • src/shared/components/inputs/CopiableTextField.spec.tsx
  • src/shared/components/inputs/CopiableTextarea.spec.tsx

Component specs (6 files)

  • src/domain/change-proposals/api/queries/ChangeProposalsQueries.spec.tsx
  • src/domain/deployments/components/redesign/DeploymentsOverviewRedesign.spec.tsx
  • src/domain/git/components/ConnectionDrawer/ManageReposPanel.spec.tsx
  • src/domain/git/components/ManageGitProvider/__tests__/GitProviderAdvancedPanel.spec.tsx
  • src/domain/marketplaces/components/MarketplaceDetailLayout.spec.tsx
  • src/domain/spaces/components/SpacesManagementPage/SpacesManagementPage.test.tsx

Suggested fix

Step 1 — bank the win now, without fixing all 15 files. Split into two Vitest projects in apps/frontend/vite.config.ts: the leaky list stays isolated, everything else shares the registry.

const LEAKY_SPECS = [
  'src/services/api/ApiService.test.ts',
  'src/domain/accounts/api/gateways/*.test.ts',
  // ...the 15 files listed above
];

test: {
  projects: [
    {
      extends: true,
      test: { name: 'shared', isolate: false, exclude: LEAKY_SPECS },
    },
    {
      extends: true,
      test: { name: 'isolated', isolate: true, include: LEAKY_SPECS },
    },
  ],
}

This should take the suite from ~51s to roughly ~20s in CI.

Step 2 — retire the list file by file. The 7 gateway specs share a single root cause (a module-level axios instance in ApiService), so one fix likely clears all of them at once. Each file removed from LEAKY_SPECS moves into the fast project.

Step 3 (optional, smaller) — split the @packmind/ui barrel. packages/ui/src/index.ts re-exports everything including PMCodeMirror, which drags in 6 CodeMirror language grammars (lang-css, lang-json, lang-rust, lang-vue, lang-xml, lang-yaml, legacy-modes). Only 10 frontend components actually use it, but all ~150 spec files pay for it. Moving PMCodeMirror behind a subpath export would trim the non-Chakra portion of the barrel. Note this becomes largely moot once Step 1 lands, since the graph is then evaluated once per worker rather than once per file — worth doing for dev-server and build times, not for tests.

Notes

  • Worth doing before reaching for Nx Cloud: this removes the work rather than distributing it.
  • Benchmark numbers vary by ±10s with ambient machine load. Any future A/B on this suite must be run back-to-back on a quiet machine, or the result is noise.

🤖 Generated with Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions