npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

vitest-leak-detector

v1.1.1

Published

Vitest plugin to detect async resource leaks between tests using async_hooks

Readme

vitest-leak-detector

TypeScript badge Vitest badge Node.js badge npm

License: MIT CI

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.

Requirements

  • Node.js ≥ 24
  • Vitest ≥ 4.0.0

Runtime compatibility

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.

Installation

pnpm add -D vitest-leak-detector

Setup

Add 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 all afterEach hooks in every sequence.hooks mode — so cleanup performed by other setup files (Testing Library cleanup(), MSW resetHandlers(), …) always completes before the snapshot, regardless of the order of setupFiles. On 1.1.0 and earlier the report ran in a competing afterEach, so vitest-leak-detector/setup had to be listed first in setupFiles — still a fine default.

How it works

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.

What is tracked

| 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 |

Configuration

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 configureLeakDetector from a Vitest globalSetup file will not work — global setup runs in a separate process before workers start. Call it from a file listed in setupFiles instead.

Example output

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

Common fixes

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 calls

Fs 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())

License

MIT