Mocking fetch and axios in Vitest without memory leaks

Large Vitest suites that mock HTTP clients often grow their heap file-over-file until a worker crashes with an out-of-memory error in CI but passes locally. This guide is for frontend and platform engineers running Vitest 1.x or 2.x who mock fetch or axios and have watched heapUsed climb across runs. It diagnoses the three retention vectors that cause the leak, gives a reproducible configuration, and supplies copy-pasteable teardown patterns that keep the heap flat. It assumes the interception choices laid out in the broader HTTP request stubbing techniques guide and uses MSW v2 where a full request lifecycle is needed.

Root Cause Analysis

Memory bloat in Vitest HTTP mocking stems from three deterministic failure vectors: event-loop retention, global-state pollution, and interceptor accumulation across worker threads. When suites scale, these vectors compound, and worker threads retain references long after afterEach hooks fire.

The first vector is stream retention. A mocked Response carries a ReadableStream body. If the code under test creates a response but never consumes it — or an error path short-circuits before .json() is called — the stream stays registered with the event loop and its backing buffer cannot be reclaimed. The second vector is global pollution: vi.mock hoisting installs module-level mocks that persist across suite boundaries, while spies attached dynamically with vi.spyOn create cross-suite bleed that bypasses standard teardown. The third vector is axios interceptor accumulation. Axios retains state through its internal adapter and interceptor registries; every axios.interceptors.request.use(...) that is not ejected stacks up, and under parallel execution these form circular reference chains that survive process recycling.

Vitest’s pool: 'forks' isolation also fails when you mock global prototypes without explicit unstubbing, leaving residual state in the worker heap. Contextualising these vectors within the wider field of advanced mocking and service isolation lets you isolate the trigger before the suite grows large enough to crash.

The diagram below maps each vector to the teardown call that releases it, which is the mental model the rest of this guide builds on.

Three retention vectors and their teardown fix A matrix linking stream retention, global pollution, and interceptor accumulation to the consume, unstub, and restore teardown calls that release them. Stream retention Global pollution Interceptor buildup await response.json() unstubAllGlobals() mock.restore()

Reproducible Setup

Stabilising the runner starts with a strict, deterministic configuration that enforces worker isolation and an explicit teardown contract. This vitest.config.ts baseline removes ambient state 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'],
    teardownTimeout: 10000,
  },
});

Reserve setupFiles exclusively for mock initialisation and spy registration. Inject a baseline heap capture per test so you have a deterministic reference point and an early warning when a single test regresses.

// test/setup.ts
import { afterEach, beforeEach, vi } from 'vitest';

let baselineHeap: number;

beforeEach(() => {
  baselineHeap = process.memoryUsage().heapUsed;
});

afterEach(() => {
  vi.restoreAllMocks();
  vi.unstubAllGlobals();
  const deltaMb = (process.memoryUsage().heapUsed - baselineHeap) / 1024 / 1024;
  if (deltaMb > 50) {
    console.warn(`[LEAK] Heap delta ${deltaMb.toFixed(2)} MB`);
  }
});

Implementation

Step 1: Mock native fetch without prototype retention

vi.stubGlobal is the recommended approach in Node-like Vitest environments because it registers the stub with Vitest’s teardown registry, which vi.unstubAllGlobals() reliably cleans up. Crucially, consume the response body so the stream is released.

import { afterEach, describe, expect, it, vi } from 'vitest';

describe('native fetch isolation', () => {
  afterEach(() => {
    vi.restoreAllMocks();
    vi.unstubAllGlobals();
  });

  it('executes without stream retention', async () => {
    const payload = { status: 'ok' };
    const mockResponse = new Response(JSON.stringify(payload), {
      status: 200,
      headers: { 'Content-Type': 'application/json' },
    });

    vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse));

    const response = await fetch('/api/data');
    const data = await response.json(); // consume the stream

    expect(data).toEqual(payload);
  });
});

Avoid vi.spyOn(globalThis, 'fetch') when fetch is not a real own property of the object — prototype-chain attachment creates persistent references that can bypass vi.restoreAllMocks(). A fresh Response instance per test, fully consumed, is the key to a flat heap.

A subtle trap is the shared Response object reused across tests. A Response body can only be read once; the second read throws TypeError: Body has already been used. Teams work around this by hoisting one Response to module scope and returning it from every call, which both defeats the per-test stream release and reintroduces cross-test coupling. Build the response inside the test, or, when you must reuse a payload, clone it per call so each test reads and releases its own stream:

it('returns a fresh, consumable body each call', async () => {
  const payload = { id: 7, name: 'Bob' };
  vi.stubGlobal(
    'fetch',
    vi.fn(() =>
      Promise.resolve(
        new Response(JSON.stringify(payload), {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
        }),
      ),
    ),
  );

  const first = await (await fetch('/api/users/7')).json();
  const second = await (await fetch('/api/users/7')).json();
  expect(first).toEqual(second); // each call built its own Response
});

Returning the body from a factory (vi.fn(() => Promise.resolve(new Response(...)))) rather than mockResolvedValue(oneResponse) is what guarantees a fresh, independently consumable stream on every call — the single most common source of both the “body already used” error and the slow heap climb.

Step 2: Isolate axios with a per-test adapter

Global axios mocking without a per-test reset guarantees cross-test contamination. Create a fresh MockAdapter in beforeEach and tear it down completely in afterEach.

import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

describe('axios instance isolation', () => {
  let mock: MockAdapter;

  beforeEach(() => { mock = new MockAdapter(axios); });

  afterEach(() => {
    mock.reset();   // clears registered routes
    mock.restore(); // removes the adapter entirely — prevents accumulation
  });

  it('returns mocked user data through real axios logic', async () => {
    mock.onGet('/api/users/1').reply(200, { id: 1, name: 'Alice' });
    const { data } = await axios.get('/api/users/1');
    expect(data).toEqual({ id: 1, name: 'Alice' });
  });
});

Always call mock.restore(), not just mock.reset(). reset() clears handlers but leaves the adapter installed; restore() removes it, which is what stops interceptor and adapter references from accumulating across tests. For interceptor-specific stubbing, see stubbing axios interceptors in Vitest.

Step 3: Prefer MSW for integration-style suites

For anything beyond a narrow unit test, MSW (msw/node) registers cleanly, handles streaming and redirects correctly, and resets through a single server.resetHandlers() hook — so there is no manual stub to forget. This is the same lifecycle described under external service simulation and keeps the leak surface tiny because there are no global prototypes to unstub.

The lifecycle is three hooks, set once for the whole suite. The server is started before all tests, reset between each one to discard per-test overrides, and closed at the end so the interceptor releases its global patch:

import { afterAll, afterEach, beforeAll } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  http.get('/api/users/:id', ({ params }) =>
    HttpResponse.json({ id: Number(params.id), name: 'Alice' }),
  ),
);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Because HttpResponse.json(...) constructs and serializes the body for you, there is no unread stream to leak — the response is fully realized before it reaches the client. Per-test overrides go through server.use(...) and are wiped by resetHandlers(), so a handler registered in one test cannot bleed into the next. The onUnhandledRequest: 'error' flag turns any request you forgot to mock into an immediate, loud failure rather than a real network call that hangs CI. This is the same MSW v2 resolver style used throughout using MSW to mock GraphQL endpoints locally.

Step 4: Eject interceptors registered inside a test

When a test deliberately installs an axios interceptor — to assert that an auth header is attached, say — capture its handle and eject it in teardown. The handle returned by use() is the only way to remove a single interceptor without disturbing others.

import axios from 'axios';
import { afterEach, expect, it } from 'vitest';

let interceptorId: number | undefined;

afterEach(() => {
  if (interceptorId !== undefined) {
    axios.interceptors.request.eject(interceptorId);
    interceptorId = undefined;
  }
});

it('attaches a bearer token', async () => {
  interceptorId = axios.interceptors.request.use((config) => {
    config.headers.set('Authorization', 'Bearer test-token');
    return config;
  });
  // ...exercise a request and assert the header...
});

For a fuller treatment of interceptor stubbing and the order in which request and response interceptors run, see stubbing axios interceptors in Vitest.

Verification

Add a hard heap assertion that turns a leak into a pipeline failure rather than a console warning. Use v8.getHeapStatistics() for numeric comparison — v8.getHeapSnapshot() returns a readable stream, not a sizeable object.

// test/leak-validation.ts
import v8 from 'v8';
import { afterAll, beforeAll } from 'vitest';

let initial: number;
beforeAll(() => { initial = v8.getHeapStatistics().used_heap_size; });
afterAll(() => {
  const deltaMb = (v8.getHeapStatistics().used_heap_size - initial) / 1024 / 1024;
  if (deltaMb > 50) throw new Error(`Heap delta ${deltaMb.toFixed(2)} MB exceeds threshold`);
});

A passing run prints a small, stable delta per file. A leaking run shows the delta climbing monotonically across files — the file where it jumps is the regression source. Run with NODE_OPTIONS="--max-old-space-size=2048" npx vitest run --pool=forks --reporter=verbose so the cap forces earlier GC cycles and surfaces the leak sooner.

For a more granular signal, enable Vitest’s built-in per-file heap reporting with the --logHeapUsage flag. The reporter prints the resident heap after each file, so the regression shows up as a step change in a single column rather than a final crash:

npx vitest run --logHeapUsage --pool=forks
 ✓ users.test.ts        (4 tests)  42 MB heap used
 ✓ orders.test.ts       (6 tests)  44 MB heap used
 ✓ interceptors.test.ts (3 tests) 118 MB heap used   <- regression here

When a file jumps like interceptors.test.ts above, open it and check the two highest-yield culprits first: an axios MockAdapter created without a matching restore(), and an interceptor installed without an eject(). To confirm the diagnosis rather than guess, capture a heap snapshot before and after the suspect test and diff the retained object counts in Chrome DevTools or node --heap-prof:

import v8 from 'v8';
v8.writeHeapSnapshot('before.heapsnapshot');
// run the suspect operation
v8.writeHeapSnapshot('after.heapsnapshot');

Loading both snapshots into DevTools and comparing the “Objects allocated between” view points directly at the retained Response, adapter, or interceptor instances — turning a vague “it grows” into a named class you can trace to its missing teardown call.

Troubleshooting

Spies survive teardown. If vi.restoreAllMocks() does not clean up a fetch spy, you almost certainly used vi.spyOn(globalThis, 'fetch') on a property that lives on the prototype, not the object. Switch to vi.stubGlobal('fetch', ...) and clear it with vi.unstubAllGlobals().

Heap climbs only under parallel runs. This points to axios interceptor accumulation across workers. Confirm every test that calls axios.interceptors.*.use() ejects the handler in teardown, or use a fresh axios.create() instance per test so there is no shared registry to leak into.

mock.reset() does not stop the growth. reset() leaves the adapter installed; only mock.restore() removes it. Use both in afterEach, restore last.

TypeError: Body has already been used. A single Response instance was returned from more than one mocked call, so the second read found a drained stream. Return the response from a factory (vi.fn(() => Promise.resolve(new Response(...)))) so every call builds a fresh, independently consumable body.

The heap is flat locally but the worker still OOMs in CI. CI usually runs more files per worker and a lower memory ceiling. Lower your local cap to match (--max-old-space-size) and increase --max-workers pressure so the leak reproduces, then fix the file the --logHeapUsage step change identifies. Raising the CI memory limit hides the leak; it does not remove it.

MSW reports unhandled requests as real network calls. You started the server without onUnhandledRequest: 'error', so a request you forgot to mock escaped to the network and either hung or hit a real endpoint. Set the flag in server.listen() so every unmocked request fails loudly and immediately.

vi.restoreAllMocks() does not reset vi.fn() mocks. restoreAllMocks only restores spies created with vi.spyOn; standalone vi.fn() mocks are cleared by vi.clearAllMocks() or reset by vi.resetAllMocks(). If a vi.fn() accumulates call records across tests, add the appropriate reset call to teardown.

FAQ

Does this also apply to Jest, not just Vitest?

Yes — the leak vectors are identical because they come from the JavaScript runtime and the HTTP clients, not the test runner. The hooks differ only in import path: Jest uses jest.restoreAllMocks() and jest.unstubAllGlobals is not available, so under Jest you manually restore globals you replaced. The MSW and axios-mock-adapter patterns are unchanged.

Why does consuming the response body matter for memory?

A Response body is a ReadableStream backed by a buffer that stays alive while the stream is unread, because the runtime assumes a consumer may still arrive. Calling .json() or .text() drains and closes the stream, letting V8 reclaim the buffer; returning HttpResponse.json(...) from MSW does this for you.

Should I call global.gc() between tests to force collection?

No — manual garbage collection masks the symptom rather than fixing the retained reference, and it requires the --expose-gc flag that is absent in normal CI. Fix the root cause: consume streams, restore globals, and remove axios adapters. A correctly torn-down suite keeps a flat heap without forcing GC.

Is pool: 'forks' enough to contain leaks on its own?

It contains a leak within a single process so one bad file cannot poison its siblings, but it does not prevent that file from crashing its own worker. Forks are a blast-radius limiter, not a substitute for correct teardown; keep both.