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. Furthermore, Vitest --pool=forks isolation 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. Identifying the exact lifecycle boundary where references detach is mandatory for deterministic execution.
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 must handle 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 } from 'vitest';
const baselineHeap = process.memoryUsage().heapUsed;
beforeEach(() => {
console.log(`[Baseline Heap] ${(baselineHeap / 1024 / 1024).toFixed(2)} MB`);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
const currentHeap = process.memoryUsage().heapUsed;
const delta = (currentHeap - 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 only deterministic approach for Node-like Vitest environments.
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(window, 'fetch')in Node-like environments. Prototype chain attachment creates persistent references that bypassvi.restoreAllMocks(). - Force garbage collection readiness by consuming mock response streams explicitly. Unconsumed
ReadableStreambodies retain event loop references. - Validate mock cleanup with
expect(vi.getMockSystemTime()).toBeNull()post-suite to confirm timer and spy isolation boundaries are intact.
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 { describe, it, expect, afterEach, vi } from 'vitest';
vi.mock('axios', () => {
const createAxiosInstance = () => axios.create({ baseURL: '/api' });
return { default: vi.fn().mockImplementation(() => createAxiosInstance()) };
});
describe('Axios instance isolation', () => {
afterEach(() => {
axios.interceptors.request.clear();
axios.interceptors.response.clear();
vi.restoreAllMocks();
});
it('prevents interceptor accumulation', async () => {
const instance = axios.create({ baseURL: '/api' });
const mockResponse = { data: { id: 1 } };
instance.get.mockResolvedValue(mockResponse);
const result = await instance.get('/users/1');
expect(result).toEqual(mockResponse);
});
});
Execution Notes:
- Never mock
axiosglobally without an instance factory reset. Shared default instances accumulate request/response interceptors across parallel workers. - Clear interceptors explicitly before
vi.restoreAllMocks()to avoid circular reference leaks in the adapter layer. - If using
axios-mock-adapter, enforcemock.reset()in teardown hooks. Relying solely onvi.restoreAllMocks()leaves adapter state intact.
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" vitest run --pool=forks --reporter=verbose
Heap Snapshot Comparison Script: Integrate this validation step into a pre-commit or CI gate to enforce strict memory boundaries:
// test/leak-validation.ts
import v8 from 'v8';
import { beforeAll, afterAll } from 'vitest';
let initialSnapshot: v8.HeapSnapshot | null = null;
beforeAll(() => {
initialSnapshot = v8.getHeapSnapshot();
});
afterAll(() => {
const finalSnapshot = v8.getHeapSnapshot();
const initialSize = initialSnapshot!.getTotalHeapSize();
const finalSize = finalSnapshot.getTotalHeapSize();
const deltaMB = (finalSize - initialSize) / 1024 / 1024;
if (deltaMB > 50) {
throw new Error(`Pipeline abort: Heap delta ${deltaMB.toFixed(2)}MB exceeds 50MB threshold`);
}
console.log(`[Memory Validation] Delta: ${deltaMB.toFixed(2)}MB - PASSED`);
});
Execution Protocol:
- Configure
--max-old-space-size=2048to cap baseline allocation and force early GC cycles. - Implement heap snapshot comparison pre/post suite execution via
v8.getHeapSnapshot()to isolate persistent allocations. - Enforce fail-fast thresholds:
heapUseddelta > 50MB triggers immediate pipeline abort. Log memory deltas per test file to pinpoint regression sources. - Execute parallel suites with
--pool=forksto contain leaks within isolated processes. Integrate teardown verification scripts to run before artifact upload, ensuring sustained pipeline health and deterministic CI execution.