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 when fetch is not a real property of the object. Prototype chain attachment creates persistent references that may bypass vi.restoreAllMocks().
  • Force garbage collection readiness by consuming mock response streams explicitly (call .json(), .text(), etc.). Unconsumed ReadableStream bodies retain event loop references.
  • Prefer MSW (msw/node) over manual fetch stubs 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-adapter rather than vi.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, use vi.importActual to retain the real axios.create behavior and only stub the transport method.
  • Always call mock.restore() in teardown, not just mock.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=2048 to cap baseline allocation and force earlier GC cycles.
  • Use v8.getHeapStatistics().used_heap_size (not getHeapSnapshot()) for numeric comparisons.
  • Enforce fail-fast thresholds: heapUsed delta > 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.