A zero-dependency Vitest plugin that detects async resource leaks between tests using Node's async_hooks. Identifies which tests leave behind uncleaned timers, open sockets, or pending HTTP/fetch() requests.
- Node.js ≥ 24
- Vitest ≥ 4.0.0
This package is Node.js only. It relies on node:async_hooks — specifically the init and destroy lifecycle callbacks — to track async resource creation and cleanup at the event loop level. This API is deeply tied to Node.js's libuv-based runtime and V8's async context tracking.
Deno ships its own equivalent natively: sanitizeOps and sanitizeResources are built into Deno.test() and enabled by default, with --trace-leaks for detailed stack traces. No plugin needed.
Bun runs on JavaScriptCore (not V8) and does not expose the async resource lifecycle hooks this package depends on.
pnpm add -D vitest-leak-detectorAdd the setup file and reporter to your vitest.config.ts:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
setupFiles: ['vitest-leak-detector/setup'],
reporters: ['default', 'vitest-leak-detector/reporter'],
},
})Note: The leak report runs via
onTestFinished, which fires after allafterEachhooks in everysequence.hooksmode — so cleanup performed by other setup files (Testing Librarycleanup(), MSWresetHandlers(), …) always completes before the snapshot, regardless of the order ofsetupFiles. On 1.1.0 and earlier the report ran in a competingafterEach, sovitest-leak-detector/setuphad to be listed first insetupFiles— still a fine default.
The setup file runs in Vitest worker threads. It enables an async_hooks hook that tracks async resource lifecycles, but only between beforeEach and the end of each test (onTestFinished, after all afterEach hooks) — preventing Vitest's own internals from registering as false positives.
Stack traces are captured at resource creation time (init), not at detection time, so you get useful call sites pointing to your test code.
At the end of each test, any resources that were created but not destroyed are written to a temporary NDJSON file, namespaced with a per-run ID that the reporter shares with the workers through the environment. After the run completes, the reporter reads only the files belonging to the current run, prints a grouped summary, and deletes them — concurrent Vitest runs on the same machine never touch each other's files. Files left behind by interrupted runs are garbage-collected at run start once they are older than 24 hours.
fetch() requests go through Node's bundled undici, which bypasses the async_hooks network types entirely. The detector tracks them separately via undici's diagnostics_channel events (undici:request:create / undici:request:trailers / undici:request:error) — still zero-dependency — and reports requests that are still in flight when a test ends as the synthetic FETCH type. Completed, failed, and aborted (AbortSignal) requests are not reported.
Concurrent tests (it.concurrent, describe.concurrent) are supported: each test body runs inside its own AsyncLocalStorage context, so resources created by interleaved test bodies are attributed to the right test. Resources created in user beforeEach/afterEach hooks fall outside that context and fall back to the most recently started test — exact for sequential tests, best-effort when hooks of concurrent tests interleave.
Because Node emits async_hooks destroy events asynchronously, a resource cleaned up during teardown (e.g. clearTimeout inside a React effect cleanup) may still look active at the exact moment the test ends. To avoid such false positives, the detector waits up to ~30ms after each test for queued destroy events to drain before reporting — this latency only applies when leak candidates exist. Additionally, handles are re-checked for liveness at report time: any handle that reports hasRef() === false (an unref()'d timer, or a handle that was already closed) or that has been garbage-collected is filtered out, since it no longer keeps the event loop alive. FILEHANDLE resources expose no hasRef() and never emit destroy on close(), so they are re-checked through their file descriptor instead: a closed handle's fd turns negative and is filtered out.
| Handle type | Default | Notes |
|---|---|---|
Timeout / Interval |
✅ | setTimeout / setInterval not cleared |
TCPWRAP, TLSWRAP |
✅ | Open sockets |
HTTPCLIENTREQUEST, HTTPPARSER |
✅ | Pending HTTP |
UDPSENDWRAP, UDPWRAP |
✅ | UDP sockets |
GETADDRINFOREQWRAP |
✅ | DNS lookups |
FETCH |
✅ | In-flight fetch() requests (undici), tracked via diagnostics_channel |
FSEVENTWRAP, STATWATCHER |
✅ | fs.watch() / fs.watchFile() not closed |
FILEHANDLE |
✅ | fsPromises.open() without close() — no stack trace available (created at an async boundary), identified by test name only |
PROMISE |
⚙️ opt-in | Noisy by default |
ROOT, TickObject, TIMERWRAP, Immediate |
❌ | Vitest internals — always ignored |
configureLeakDetector must be called before the first test runs — i.e. at the top of the same setup file, before any import side-effects that might trigger async resources. Options are read at beforeEach/report time, so calling this at module scope in the setup file is always safe.
// vitest-setup.ts ← referenced in setupFiles
import { configureLeakDetector } from 'vitest-leak-detector/setup'
// Call before any other setup so options are in effect from the first test.
configureLeakDetector({
trackPromises: false, // default: false
trackTimers: true, // default: true
trackNetwork: true, // default: true
trackFs: true, // default: true — fs watchers and file handles
stackDepth: 6, // default: 6 frames
warnInline: true, // default: true — console.warn per leaked resource
ignoreTypes: [], // additional resource types to skip
})Note: Calling
configureLeakDetectorfrom a VitestglobalSetupfile will not work — global setup runs in a separate process before workers start. Call it from a file listed insetupFilesinstead.
Async Leak Report
────────────────────────────────────────────────────────────
/project/src/components/Timer.test.ts
✖ updates display after delay (2 leaks)
type: Timeout
at setTimeout (src/components/Timer.ts:12:5)
at Object.<anonymous> (src/components/Timer.test.ts:18:3)
1 async leak detected
Timer leaks
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())Network leaks
let controller: AbortController
beforeEach(() => { controller = new AbortController() })
afterEach(() => controller.abort())
// pass controller.signal to fetch callsFs watcher / file handle leaks
let watcher: fs.FSWatcher
beforeEach(() => { watcher = fs.watch(configPath, onChange) })
afterEach(() => watcher.close())
// same idea for fs.watchFile → fs.unwatchFile(path)
// and fsPromises.open → await handle.close()MSW cleanup
afterEach(() => server.resetHandlers())
afterAll(() => server.close())MIT