Mocking fetch and axios in Vitest without memory leaks
Root Cause Analysis: Vitest Memory Leaks in HTTP Mocking
Memory bloat in Vitest HTTP mocking stems from three deterministic failure vectors: event loop retention, global state pollution, and unhandled promise rejections during mock lifecycle execution. When test suites scale, these vectors compound, causing worker threads to retain references long after afterEach hooks fire.
The primary retention trigger is the mismatch between vi.mock hoisting behavior and dynamic spy attachment. Hoisted mocks persist across suite boundaries, while dynamically attached spies create cross-suite bleed that bypasses standard teardown. Concurrently, unclosed Response streams and dangling AbortSignal references prevent the V8 garbage collector from reclaiming heap allocations. In parallel execution environments, Axios interceptor accumulation across worker threads creates circular reference chains that survive process recycling. Vitest pool: 'forks' isolation also fails when mocking global prototypes without explicit unstubbing, leaving residual state in the worker heap.
Contextualizing these leak vectors within broader HTTP Request Stubbing Techniques methodologies allows teams to isolate retention triggers before scaling test suites.
Reproducible Test Environment Configuration
Stabilizing the test runner requires a strict, deterministic configuration that enforces worker isolation and explicit teardown contracts. The following vitest.config.ts baseline eliminates ambient state pollution and establishes measurable memory boundaries.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
isolate: true,
pool: 'forks',
poolOptions: {
forks: { singleFork: false },
},
setupFiles: ['./test/setup.ts'],
globalSetup: ['./test/global-setup.ts'],
teardownTimeout: 10000,
},
});
Reserve setupFiles exclusively for mock initialization and spy registration. globalSetup handles infrastructure bootstrapping only. Inject a baseline memory capture at the start of each suite to establish a deterministic reference point:
// test/setup.ts
import { beforeEach, afterEach, vi } from 'vitest';
let baselineHeap: number;
beforeEach(() => {
baselineHeap = process.memoryUsage().heapUsed;
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
const delta = (process.memoryUsage().heapUsed - baselineHeap) / 1024 / 1024;
if (delta > 50) {
console.warn(`[LEAK DETECTED] Heap delta: ${delta.toFixed(2)} MB`);
}
});
Aligning this configuration with enterprise-grade Advanced Mocking & Service Isolation Patterns ensures consistent CI/CD execution and deterministic worker recycling across distributed pipelines.
Exact Mitigation Pattern: Native fetch Mocking
Leak-proof fetch stubbing requires bypassing prototype chain retention and forcing explicit stream consumption. vi.stubGlobal is the recommended approach for Node-like Vitest environments because it registers the stub with Vitest’s teardown registry, which vi.unstubAllGlobals() can reliably clean up.
import { describe, it, expect, afterEach, vi } from 'vitest';
describe('Native fetch isolation', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('executes without stream retention', async () => {
const mockPayload = { status: 'ok' };
const mockResponse = new Response(JSON.stringify(mockPayload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse));
const controller = new AbortController();
const response = await fetch('/api/data', { signal: controller.signal });
const data = await response.json();
expect(data).toEqual(mockPayload);
expect(controller.signal.aborted).toBe(false);
});
});
Execution Notes:
- Avoid
vi.spyOn(globalThis, 'fetch')in Node-like environments whenfetchis not a real property of the object. Prototype chain attachment creates persistent references that may bypassvi.restoreAllMocks(). - Force garbage collection readiness by consuming mock response streams explicitly (call
.json(),.text(), etc.). UnconsumedReadableStreambodies retain event loop references. - Prefer MSW (
msw/node) over manualfetchstubs for integration tests — MSW registers cleanly and handles edge cases like streaming and redirects correctly.
Exact Mitigation Pattern: axios Instance Isolation
Axios retains state through its internal adapter and interceptor registries. Global mocking without factory resets guarantees cross-test contamination. Implement strict instance isolation to prevent bleed.
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
describe('Axios instance isolation', () => {
let mock: MockAdapter;
beforeEach(() => {
// Create a fresh mock adapter bound to a new instance each test
mock = new MockAdapter(axios);
});
afterEach(() => {
// Reset all registered routes and restore the original adapter
mock.reset();
mock.restore();
});
it('returns mocked user data', async () => {
mock.onGet('/api/users/1').reply(200, { id: 1, name: 'Alice' });
const response = await axios.get('/api/users/1');
expect(response.data).toEqual({ id: 1, name: 'Alice' });
});
it('prevents interceptor accumulation across tests', async () => {
// Each test starts with a clean mock adapter — no handler leakage from prior tests
mock.onGet('/api/status').reply(200, { healthy: true });
const response = await axios.get('/api/status');
expect(response.data.healthy).toBe(true);
});
});
Execution Notes:
- Use
axios-mock-adapterrather thanvi.mock('axios', ...)for integration-style tests. The adapter intercepts at the Axios adapter layer, which means real Axios logic (retries, interceptors, transformers) still runs. - If mocking Axios with
vi.mock, usevi.importActualto retain the realaxios.createbehavior and only stub the transport method. - Always call
mock.restore()in teardown, not justmock.reset().reset()clears handlers but leaves the mock adapter in place;restore()removes the adapter entirely.
Pipeline Stability & Validation Protocol
Deterministic execution requires automated leak detection and hard CI thresholds. Memory bloat must be treated as a pipeline failure, not a warning.
CI Runner Configuration:
NODE_OPTIONS="--max-old-space-size=2048" npx vitest run --pool=forks --reporter=verbose
Heap Monitoring Hook:
The Node.js v8 module provides heap statistics, but note that v8.getHeapSnapshot() returns a readable stream — not an object with size methods. For size comparisons, use v8.getHeapStatistics():
// test/leak-validation.ts
import v8 from 'v8';
import { beforeAll, afterAll } from 'vitest';
let initialHeapUsed: number;
beforeAll(() => {
initialHeapUsed = v8.getHeapStatistics().used_heap_size;
});
afterAll(() => {
const finalHeapUsed = v8.getHeapStatistics().used_heap_size;
const deltaMB = (finalHeapUsed - initialHeapUsed) / 1024 / 1024;
if (deltaMB > 50) {
throw new Error(`Pipeline abort: Heap delta ${deltaMB.toFixed(2)} MB exceeds 50 MB threshold`);
}
console.log(`[Memory Validation] Delta: ${deltaMB.toFixed(2)} MB — PASSED`);
});
Execution Protocol:
- Configure
--max-old-space-size=2048to cap baseline allocation and force earlier GC cycles. - Use
v8.getHeapStatistics().used_heap_size(notgetHeapSnapshot()) for numeric comparisons. - Enforce fail-fast thresholds:
heapUseddelta > 50 MB triggers immediate pipeline abort. Log memory deltas per test file to pinpoint regression sources. - Execute parallel suites with
pool: 'forks'to contain leaks within isolated processes. Integrate teardown verification scripts to run before artifact upload, ensuring sustained pipeline health.