I woke up with a huge optimization
100+ experiments. 17 iterations. Zero manual config changes. My Vitest suite went from 310 seconds to 7, all while I was sleeping.
Ik werd wakker met een enorme optimalisatie
100+ experimenten. 17 iteraties. Nul handmatige config-wijzigingen. Mijn Vitest suite ging van 310 seconden naar 7, terwijl ik sliep.
Convivo had a fast test suite. In-memory adapters, zero external dependencies. 270 tests in 300 milliseconds. Clean, fast, elegant.
Then LovosMedia happened. A real photo sharing platform. Real React components. Real API routes. Real browser-simulated DOM tests. 2,748 tests across 160 files. And the suite took 5 minutes and 10 seconds.
Five minutes doesn’t sound catastrophic. But it’s slow enough that you stop running tests before committing. Slow enough that CI becomes the gatekeeper instead of your local terminal. Slow enough that you start trusting “it probably works” over “I verified it works.”
I could have spent a weekend tweaking Vitest config. Instead, I gave the problem to an AI agent and went to sleep.
The setup
I use Claude Code as my development tool. It has a feature where you can spawn autonomous agents - background processes that run in isolated git worktrees, making changes, running commands, and iterating without supervision.
I set up three parallel agents on the same codebase:
| Agent | Branch | Mission |
|---|---|---|
| Speed | research/test-speed | Make the test suite faster |
| Coverage | research/test-coverage | Increase test coverage |
| E2E | research/e2e-optimization | Optimize Playwright E2E tests |
Each agent got a simple instruction: “You are an autonomous test optimization agent running in a LOOP. Run until the human interrupts you.”
Then I closed my laptop and went to bed.
What happened while I slept
The speed agent ran 300+ loops. Each loop was a cycle: form a hypothesis, change the config, run all 2,748 tests, record the result, revert if worse, try the next thing. It ran 100+ distinct experiments across 17 iterations of optimization.
Here’s what it tried, in chronological order:
Round 1: the obvious stuff
The suite was using jsdom as the test environment - a full JavaScript implementation of the browser DOM. The agent’s first move was to switch tests that don’t need a DOM (API routes, services, adapters) to run in Node’s native environment. No fake browser overhead for code that never touches document.
Then it disabled CSS processing. Our tests don’t assert on styles, so parsing CSS files was pure waste.
Result: 310s → 310s. Almost no improvement. The bottleneck was elsewhere.
Round 2: parallelization
The default Vitest config was using threads - JavaScript worker threads sharing a process. The agent tried forks instead - separate OS processes with real isolation.
Result: 310s → 61s. Five times faster, just by changing the pool strategy.
It then swept through fork counts: 3, 4, 5, 6, 8. Found the sweet spot at 5 forks on my 12-core machine. Went too high and hit CPU contention.
Round 3: happy-dom
This was the breakthrough. The agent replaced jsdom with happy-dom - a lighter DOM implementation that skips features tests don’t need (full CSS cascade, complex layout calculations).
Result: 61s → 22s. Three times faster from one dependency swap.
Both implement the same DOM API. The tests can’t tell the difference. But happy-dom initializes in a fraction of the time, and when you’re spinning up a DOM environment for each of 160 test files, that fraction adds up.
Round 4: VM pools
This is where it got interesting. The agent discovered vmForks - a Vitest pool strategy that runs tests inside lightweight V8 VM contexts instead of full processes. Multiple VMs share the same module cache, so React, testing-library, and your entire node_modules only get loaded once per worker instead of once per test file.
Result: 22s → 7s. The shared module cache meant React, testing-library, and the entire node_modules only loaded once per worker instead of once per test file.
Round 5: the long tail
The agent kept going. It tried vmThreads (even lighter than vmForks), asymmetric thread allocation (more threads for the project with more files), workspace splitting (separate Vitest projects for Node tests vs DOM tests), userEvent.setup({ delay: null }) to skip simulated keystroke delays, esbuild JSX transforms, and a custom minimal jest-dom shim with only the 8 matchers the tests actually use instead of the full 30+.
Some worked. Some didn’t. It tried and reverted isolate: false (broke mock isolation), sequence.concurrent (815 tests failed from shared state), Vitest 4.x (regressed vmThreads performance 8x), and about 60 other things that were within noise or actively worse.
Every experiment was recorded in a results file with exact timings, run counts, and a clear verdict: BETTER, WORSE, or WITHIN VARIANCE.
The final config
// vitest.workspace.ts
defineWorkspace([
{
test: {
name: 'node',
environment: 'node',
setupFiles: ['./test/setup-node.ts'], // lightweight: no React, no DOM
pool: 'vmThreads',
poolOptions: { vmThreads: { maxThreads: 12, minThreads: 1 } },
},
},
{
esbuild: { jsx: 'automatic', jsxImportSource: 'react' },
test: {
name: 'dom',
environment: 'happy-dom',
setupFiles: ['./test/setup.ts'],
pool: 'vmThreads',
poolOptions: { vmThreads: { maxThreads: 4, minThreads: 1 } },
},
},
]);
Before: 309.64 seconds. After: ~7 seconds. A 44x speedup.
All 2,748 tests pass. Zero behavior changes. The suite runs faster than my terminal can render the output.
The optimization stack
In order of impact:
- happy-dom instead of jsdom - 3x faster DOM simulation
- vmThreads pool - V8 VM isolation shares module cache across test files
- Workspace split - Node tests skip React, jest-dom, and i18n setup entirely
- Minimal jest-dom shim - 8 custom matchers instead of importing 30+ unused ones
- Asymmetric threads - 12 for the Node project (94 files), 4 for DOM (66 files)
- userEvent delay: null - skip artificial keystroke timing in simulated typing
- esbuild JSX - faster transforms than the React Babel plugin
- css: false - skip all CSS processing
What the agent got wrong
It wasn’t all wins. Some lessons from the failed experiments:
isolate: false breaks everything. It sounds tempting - skip the isolation overhead. But vi.mock() calls leak between files. One test mocking fetch poisons the next test that expects real behavior. 16 tests failed immediately.
sequence.concurrent is a trap. Running tests within a file in parallel sounds fast. But tests share vi.mock state at the file level. 815 tests failed. The agent reverted in one iteration.
Vitest 4.x regressed vmThreads. The agent tried upgrading from 3.2.4 to 4.1.0. Environment setup went from 4 seconds to 39 seconds - a 10x regression. The entire 4.x line has this issue. It’s an open bug.
More threads ≠ faster. Going above CPU core count causes contention. The agent found the exact tipping point: 16 total vmThreads on 12 cores works because VMs are lightweight. 22 total starts thrashing.
The meta-lesson
I didn’t write any of this configuration. I didn’t research happy-dom vs jsdom performance characteristics. I didn’t know vmThreads existed before reading the agent’s results file.
What I did was:
- Give a capable tool a clear problem
- Give it the ability to run experiments (bash, git, edit)
- Give it isolation (git worktrees, so it can’t break main)
- Go to sleep
The agent ran more experiments in one night than I would have run in a month. Not because I couldn’t - because I wouldn’t. Each experiment takes 3-5 minutes of waiting, careful measurement, and tedious config editing. It’s the kind of work that humans abandon after the third attempt because it’s boring. The AI doesn’t get bored. It ran 100+ experiments and was still going when it hit the context window limit.
The parallel agents
While the speed agent was optimizing Vitest config, two other agents were working simultaneously:
The coverage agent ran 14 iterations and added 474 new tests. Statement coverage went from 82% to 88%. It found untested error paths, catch blocks, component edge cases, and API route validation gaps, then wrote targeted tests to cover them.
The E2E agent ran 282+ iterations across all 21 Playwright spec files. It classified 129 networkidle waits into removable vs necessary, cached auth sessions to eliminate 35+ redundant HTTP calls, and split read-only specs from write specs. Read-only E2E runs went from 2.3 minutes to 1.0 minute with production builds.
Three agents. Three worktrees. 396+ total experiments. Zero conflicts.
Should you do this?
If you have a test suite that takes more than 30 seconds, yes. The approach is:
- Create a worktree - isolation is non-negotiable. You don’t want experiments on your working branch.
- Write a clear mission - “make the tests faster” is specific enough. The agent knows what success looks like: same tests passing, lower wall clock time.
- Give it bash access - the agent needs to run
vitest,git commit, andgit checkout. Without bash, it’s just writing plans it can’t validate. - Block git push - let it commit freely in the worktree, but never push to remote. You review and merge manually.
- Let it run - overnight, over lunch, during meetings. It doesn’t need you.
The results are in a git branch. You can diff every change, read every commit message, and cherry-pick what you want. If the whole thing is garbage, git branch -D and you’ve lost nothing.
The numbers
Before: 5 minutes and 10 seconds. After: 7 seconds. 44x faster.
2,748 tests. 160 files. Same results, same assertions, zero behavior changes. Verified across dozens of runs in the 6.5-8.5 second range depending on system load.
From “I’ll run the tests after lunch” to “I’ll run the tests between keystrokes.”
That’s the difference between a test suite you use and a test suite you ignore.
Convivo had een snelle test suite. In-memory adapters, nul externe dependencies. 270 tests in 300 milliseconden. Clean, snel, elegant.
Toen kwam LovosMedia. Een echt fotodeelplatform. Echte React-componenten. Echte API-routes. Echte browser-gesimuleerde DOM-tests. 2.748 tests verspreid over 160 bestanden. En de suite duurde 5 minuten en 10 seconden.
Vijf minuten klinkt niet rampzalig. Maar het is traag genoeg dat je stopt met tests draaien voor je commit. Traag genoeg dat CI de poortwachter wordt in plaats van je lokale terminal. Traag genoeg dat je “het werkt waarschijnlijk” gaat vertrouwen in plaats van “ik heb het geverifieerd.”
Ik had een weekend kunnen besteden aan het tweaken van de Vitest-config. In plaats daarvan gaf ik het probleem aan een AI-agent en ging slapen.
De opzet
Ik gebruik Claude Code als mijn ontwikkeltool. Het heeft een functie waarmee je autonome agents kunt starten - achtergrondprocessen die draaien in geisoleerde git worktrees, wijzigingen maken, commando’s uitvoeren en itereren zonder toezicht.
Ik zette drie parallelle agents op dezelfde codebase:
| Agent | Branch | Missie |
|---|---|---|
| Speed | research/test-speed | Maak de test suite sneller |
| Coverage | research/test-coverage | Verhoog de test coverage |
| E2E | research/e2e-optimization | Optimaliseer Playwright E2E-tests |
Elke agent kreeg een simpele instructie: “Je bent een autonome test-optimalisatie-agent die in een LOOP draait. Blijf draaien tot de mens je onderbreekt.”
Toen klapte ik mijn laptop dicht en ging naar bed.
Wat er gebeurde terwijl ik sliep
De speed-agent draaide meer dan 300 loops. Elke loop was een cyclus: hypothese vormen, config aanpassen, alle 2.748 tests draaien, resultaat vastleggen, terugdraaien als het slechter was, het volgende proberen. Hij draaide meer dan 100 verschillende experimenten over 17 iteraties van optimalisatie.
Dit is wat hij probeerde, in chronologische volgorde:
Ronde 1: de voor de hand liggende dingen
De suite gebruikte jsdom als testomgeving - een volledige JavaScript-implementatie van de browser-DOM. De eerste zet van de agent was om tests die geen DOM nodig hebben (API-routes, services, adapters) in Node’s native omgeving te draaien. Geen nep-browser-overhead voor code die nooit document aanraakt.
Daarna schakelde hij CSS-verwerking uit. Onze tests testen geen stijlen, dus het parsen van CSS-bestanden was pure verspilling.
Resultaat: 310s naar 310s. Vrijwel geen verbetering. De bottleneck zat ergens anders.
Ronde 2: Parallelisatie
De standaard Vitest-config gebruikte threads - JavaScript worker threads die een proces delen. De agent probeerde forks - aparte OS-processen met echte isolatie.
Resultaat: 310s naar 61s. Vijf keer sneller, alleen door de pool-strategie te veranderen.
Vervolgens probeerde hij verschillende aantallen forks: 3, 4, 5, 6, 8. De sweet spot lag op 5 forks op mijn 12-core machine. Te hoog en je krijgt CPU-contention.
Ronde 3: happy-dom
Dit was de doorbraak. De agent verving jsdom door happy-dom - een lichtere DOM-implementatie die features overslaat die tests niet nodig hebben (volledige CSS-cascade, complexe layout-berekeningen).
Resultaat: 61s naar 22s. Drie keer sneller door een dependency te wisselen.
Beide implementeren dezelfde DOM API. De tests merken het verschil niet. Maar happy-dom initialiseert in een fractie van de tijd, en als je voor elk van 160 testbestanden een DOM-omgeving opstart, telt die fractie op.
Ronde 4: VM pools
Hier werd het interessant. De agent ontdekte vmForks - een Vitest pool-strategie die tests draait in lichtgewicht V8 VM-contexten in plaats van volledige processen. Meerdere VM’s delen dezelfde module cache, waardoor React, testing-library en je hele node_modules maar een keer per worker geladen worden in plaats van een keer per testbestand.
Resultaat: 22s naar 7s. De gedeelde module cache zorgde ervoor dat React, testing-library en de hele node_modules maar een keer per worker geladen werden in plaats van een keer per testbestand.
Ronde 5: de lange staart
De agent ging door. Hij probeerde vmThreads (nog lichter dan vmForks), asymmetrische thread-toewijzing (meer threads voor het project met meer bestanden), workspace-splitsing (aparte Vitest-projecten voor Node-tests vs DOM-tests), userEvent.setup({ delay: null }) om gesimuleerde toetsaanslagvertragingen over te slaan, esbuild JSX-transforms, en een custom minimale jest-dom shim met alleen de 8 matchers die de tests daadwerkelijk gebruiken in plaats van de volledige 30+.
Sommige werkten. Sommige niet. Hij probeerde en draaide terug: isolate: false (brak mock-isolatie), sequence.concurrent (815 tests faalden door gedeelde state), Vitest 4.x (vmThreads-prestaties 8x slechter), en zo’n 60 andere dingen die binnen de ruis vielen of actief slechter waren.
Elk experiment werd vastgelegd in een resultatenbestand met exacte timings, aantal runs en een duidelijk oordeel: BETTER, WORSE of WITHIN VARIANCE.
De uiteindelijke config
// vitest.workspace.ts
defineWorkspace([
{
test: {
name: 'node',
environment: 'node',
setupFiles: ['./test/setup-node.ts'], // lightweight: no React, no DOM
pool: 'vmThreads',
poolOptions: { vmThreads: { maxThreads: 12, minThreads: 1 } },
},
},
{
esbuild: { jsx: 'automatic', jsxImportSource: 'react' },
test: {
name: 'dom',
environment: 'happy-dom',
setupFiles: ['./test/setup.ts'],
pool: 'vmThreads',
poolOptions: { vmThreads: { maxThreads: 4, minThreads: 1 } },
},
},
]);
Daarvoor: 309,64 seconden. Daarna: ~7 seconden. Een 44x speedup.
Alle 2.748 tests slagen. Nul gedragswijzigingen. De suite draait sneller dan mijn terminal de output kan renderen.
De optimalisatie-stack
Op volgorde van impact:
- happy-dom in plaats van jsdom - 3x snellere DOM-simulatie
- vmThreads pool - V8 VM-isolatie deelt module cache over testbestanden
- Workspace split - Node-tests slaan React, jest-dom en i18n-setup volledig over
- Minimale jest-dom shim - 8 custom matchers in plaats van 30+ ongebruikte importeren
- Asymmetrische threads - 12 voor het Node-project (94 bestanden), 4 voor DOM (66 bestanden)
- userEvent delay: null - sla kunstmatige toetsaanslagtiming over bij gesimuleerd typen
- esbuild JSX - snellere transforms dan de React Babel-plugin
- css: false - sla alle CSS-verwerking over
Wat de agent fout deed
Het was niet allemaal winst. Een paar lessen uit de mislukte experimenten:
isolate: false breekt alles. Het klinkt verleidelijk - sla de isolatie-overhead over. Maar vi.mock()-aanroepen lekken tussen bestanden. Eén test die fetch mockt vergiftigt de volgende test die echt gedrag verwacht. 16 tests faalden onmiddellijk.
sequence.concurrent is een valkuil. Tests binnen een bestand parallel draaien klinkt snel. Maar tests delen vi.mock-state op bestandsniveau. 815 tests faalden. De agent draaide het in een iteratie terug.
Vitest 4.x had een regressie op vmThreads. De agent probeerde te upgraden van 3.2.4 naar 4.1.0. Environment setup ging van 4 seconden naar 39 seconden - een 10x regressie. De hele 4.x-lijn heeft dit probleem. Het is een open bug.
Meer threads is niet sneller. Boven het aantal CPU-cores krijg je contention. De agent vond het exacte kantelpunt: 16 vmThreads totaal op 12 cores werkt omdat VM’s lichtgewicht zijn. 22 totaal begint te thrashten.
De meta-les
Ik heb niets van deze configuratie geschreven. Ik heb niet onderzocht hoe happy-dom vs jsdom presteren. Ik wist niet dat vmThreads bestond voordat ik het resultatenbestand van de agent las.
Wat ik wel deed:
- Een capabele tool een duidelijk probleem geven
- Het de mogelijkheid geven om experimenten uit te voeren (bash, git, edit)
- Het isolatie geven (git worktrees, zodat het main niet kan breken)
- Gaan slapen
De agent draaide in een nacht meer experimenten dan ik in een maand zou doen. Niet omdat ik het niet kon - maar omdat ik het niet zou doen. Elk experiment kost 3-5 minuten wachten, zorgvuldig meten en saai config-werk. Het is het soort werk dat mensen na de derde poging opgeven omdat het vervelend is. De AI verveelt zich niet. Hij draaide meer dan 100 experimenten en was nog steeds bezig toen hij de context window limiet bereikte.
De parallelle agents
Terwijl de speed-agent de Vitest-config optimaliseerde, werkten twee andere agents tegelijkertijd:
De coverage-agent draaide 14 iteraties en voegde 474 nieuwe tests toe. Statement coverage ging van 82% naar 88%. Hij vond ongeteste foutpaden, catch blocks, component edge cases en API-route validatiegaten, en schreef gerichte tests om ze te dekken.
De E2E-agent draaide 282+ iteraties over alle 21 Playwright spec-bestanden. Hij classificeerde 129 networkidle-waits als verwijderbaar of noodzakelijk, cachete auth-sessies om 35+ overbodige HTTP-calls te elimineren, en scheidde read-only specs van write specs. Read-only E2E-runs gingen van 2,3 minuten naar 1,0 minuut met production builds.
Drie agents. Drie worktrees. 396+ experimenten totaal. Nul conflicten.
Moet jij dit ook doen?
Als je een test suite hebt die langer dan 30 seconden duurt: ja. De aanpak is:
- Maak een worktree - isolatie is niet onderhandelbaar. Je wilt geen experimenten op je werkende branch.
- Schrijf een duidelijke missie - “maak de tests sneller” is specifiek genoeg. De agent weet hoe succes eruitziet: dezelfde tests slagen, lagere doorlooptijd.
- Geef het bash-toegang - de agent moet
vitest,git commitengit checkoutkunnen draaien. Zonder bash schrijft het alleen plannen die het niet kan valideren. - Blokkeer git push - laat het vrij committen in de worktree, maar nooit pushen naar remote. Jij reviewt en merget handmatig.
- Laat het draaien - ‘s nachts, tijdens de lunch, tijdens vergaderingen. Het heeft je niet nodig.
De resultaten staan in een git branch. Je kunt elke wijziging diffen, elk commit-bericht lezen en cherry-picken wat je wilt. Als het hele ding rommel is, git branch -D en je hebt niets verloren.
De cijfers
Daarvoor: 5 minuten en 10 seconden. Daarna: 7 seconden. 44x sneller.
2.748 tests. 160 bestanden. Dezelfde resultaten, dezelfde assertions, nul gedragswijzigingen. Geverifieerd over tientallen runs in het bereik van 6,5-8,5 seconden afhankelijk van systeembelasting.
Van “ik draai de tests na de lunch” naar “ik draai de tests tussen twee toetsaanslagen.”
Dat is het verschil tussen een test suite die je gebruikt en een test suite die je negeert.