How to check why jest did not exit after tests
Run npx jest --detectOpenHandles. It prints each handle with the line that opened it: here a setInterval at src/notifier.js:9 and a TCPSERVERWRAP at line 10. Close them in afterAll rather than adding --forceExit, which exits with code 0 and drops work that was still pending.
Why check this
Run this the first time a green run stops returning the prompt, and before anyone adds --forceExit to unblock a pipeline. A CI job that never exits is billed until its timeout.
The handle is rarely test-only. A server the suite never closed is a server the application never closes either, and the interval that holds the process open in CI holds a descriptor open in production. The run is reporting the code, not the runner.
--forceExit removes the symptom and the evidence at once. Step 3 shows a write that was still scheduled and never happened, on a run that exited with code 0.
Prerequisites
- Node 22 and npm. Every output came from Node 22.23.2, Jest 30.5.0 and Vitest 5.0.0 on Windows 11, on 2026-09-12.
- A CommonJS scratch project for the Jest half, in an empty directory outside your repository. See Jest CLI options.
mkdir handledemo && cd handledemo
npm init -y > /dev/null
npm install -D jest@30 why-is-node-running
mkdir src test
- A module that opens two handles and closes neither.
// src/notifier.js
const { createServer } = require('node:http');
function startNotifier(port) {
const seen = [];
const server = createServer((req, res) => {
seen.push(req.url);
res.end('{"queued":true}');
});
const sweep = setInterval(() => seen.splice(0, seen.length), 30000);
server.listen(port, '127.0.0.1');
return { server, sweep, seen };
}
module.exports = { startNotifier };
- A test that uses it, and a second file that schedules work for after the run.
// test/notifier.test.js
const { startNotifier } = require('../src/notifier');
let notifier;
beforeAll(() => {
notifier = startNotifier(9736);
});
test('queues a notification', async () => {
const res = await fetch('http://127.0.0.1:9736/notify/42');
expect(res.status).toBe(200);
expect(notifier.seen).toContain('/notify/42');
});
// test/flush.test.js
const { appendFileSync } = require('node:fs');
const { join } = require('node:path');
const LOG = join(__dirname, '..', 'artifacts.log');
test('schedules the deferred audit flush', () => {
setTimeout(() => appendFileSync(LOG, 'flushed\n'), 3000);
expect(true).toBe(true);
});
- Steps 1, 4 and 5 never return. They were interrupted with
timeout 30, so nothing here is a clean exit. - A second, ESM project for steps 7 and 8:
npm pkg set type=module,vitest@5, the same module withexport function, the test copied to port 9737, and this file.
// test/handles.test.js
import { it, expect, beforeAll, afterAll } from 'vitest';
import whyIsNodeRunning from 'why-is-node-running';
import { startNotifier } from '../src/notifier.js';
let notifier;
beforeAll(() => {
notifier = startNotifier(9738);
});
afterAll(() => {
whyIsNodeRunning();
});
it('queues a notification', async () => {
const res = await fetch('http://127.0.0.1:9738/notify/42');
expect(res.status).toBe(200);
});
Steps
- Step 1.
Run the suite and watch a fully green run refuse to end.
npx jestTest Suites: 2 passed, 2 total Tests: 2 passed, 2 total Snapshots: 0 total Time: 0.43 s, estimated 1 s Ran all test suites. Jest did not exit one second after the test run has completed. 'This usually means that there are asynchronous operations that weren't stopped in your tests. Consider running Jest with `--detectOpenHandles` to troubleshoot this issue.Every assertion passed. The process sat there until it was killed.
- Step 2.
Try the flag everyone reaches for first.
npx jest --forceExitTest Suites: 2 passed, 2 total Tests: 2 passed, 2 total Snapshots: 0 total Time: 0.369 s, estimated 1 s Ran all test suites. Force exiting Jest: Have you considered using `--detectOpenHandles` to detect async operations that kept running after all tests finished?Exit code 0, prompt returned, green pipeline. Nothing names the handle.
- Step 3.
Look for the work that was still scheduled when the process died.
cat artifacts.logcat: artifacts.log: No such file or directoryThe deferred flush was due 3 seconds after the tests and
--forceExitleft about 1. After the plain run in step 1 the same file exists and readsflushed. - Step 4.
Ask Jest which handles are still open.
npx jest --detectOpenHandlesJest has detected the following 3 open handles potentially keeping Jest from exiting: ● Timeout > 9 | const sweep = setInterval(() => seen.splice(0, seen.length), 30000); | ^ at setInterval (src/notifier.js:9:17) at Object.startNotifier (test/notifier.test.js:6:14) ● TCPSERVERWRAP > 10 | server.listen(port, '127.0.0.1'); | ^ at listen (src/notifier.js:10:10) at Object.startNotifier (test/notifier.test.js:6:14) ● Timeout > 7 | setTimeout(() => appendFileSync(LOG, 'flushed\n'), 3000); | ^ at Object.setTimeout (test/flush.test.js:7:3)Three handles, each with the line that opened it.
TCPSERVERWRAPis a listening socket. This run hangs too. - Step 5.
Ask Node instead, with a temporary
afterAllthat any runner accepts.afterAll(() => { console.log('active resources:', process.getActiveResourcesInfo()); });npx jest test/notifier.test.jsconsole.log active resources: [ 'TCPSocketWrap', 'TCPServerWrap', 'TCPSocketWrap', 'Timeout', 'Timeout', 'Immediate' ] at Object.log (test/notifier.test.js:16:11)Six entries for two leaks. The rest belong to Jest's own worker channel, and the list carries types without stacks: it says a socket is open, not which line opened it.
- Step 6.
Reach for
why-is-node-running, the usual recommendation.const whyIsNodeRunning = require('why-is-node-running'); afterAll(() => { whyIsNodeRunning(); });npx jest test/notifier.test.jsMust use import to load ES Module: C:\…\node_modules\why-is-node-running\index.js The file contains ESM syntax (import/export) that could not be executed as CommonJS. Either: - Configure a transform (e.g. babel-jest) that compiles this file to CommonJS - If the file is in "node_modules", allow it to be transformed by adjusting "transformIgnorePatterns" - Use Node v24.9+ where Jest supports require(esm) natively > 15 | const whyIsNodeRunning = require('why-is-node-running');Version 3 of that package is ESM only, and Jest 30 on Node 22 cannot
requireit. The message names Node 24.9 as where that changes. - Step 7.
Put the same leak under Vitest and see a very different answer.
npx vitest run test/notifier.test.jsTest Files 1 passed (1) Tests 1 passed (1) Start at 11:42:32 Duration 234ms (import 36%, tests 35%, transform 24%, worker 5%)Exit code 0 in 234 ms, no warning, no hang. Vitest runs the file in a forked child and terminates it, so the unclosed server never reaches the parent.
- Step 8.
Import
why-is-node-runningthere, where ESM loads, and read the stacks.npx vitest run test/handles.test.js --reporter=verbosestderr | test/handles.test.js There are 14 handle(s) keeping the process running. … # Timeout src\notifier.js:11 - return { server, sweep, seen }; … # TCPSERVERWRAP (unknown stack trace)It names the interval through
src/notifier.jsand reports(unknown stack trace)for the socket Jest identified by line. The default reporter hides all of it, because it swallows worker stderr. - Step 9.
Close both handles and confirm with the flag that found them.
const stop = () => { clearInterval(sweep); return new Promise((resolve) => server.close(resolve)); };npx jest --detectOpenHandlesTest Suites: 2 passed, 2 total Tests: 2 passed, 2 total Snapshots: 0 total Time: 3.643 s Ran all test suites.No handle section, exit code 0.
afterAllawaitsnotifier.stop()and the flush test awaits its own timer, soartifacts.logis written before the process ends.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Jest did not exit one second after | Something still holds the event loop | Rerun with --detectOpenHandles |
| TCPSERVERWRAP | A listening server was never closed | await a server.close() in afterAll |
| Timeout | A setInterval or setTimeout is still scheduled | clearInterval, or .unref() when it may outlive the test |
| HTTPParser or a socket type | A connection or client pool is open | Close the client, not the server |
| Force exiting Jest | The handle is still there and the evidence is gone | Take the flag out and read step 4 |
| Green and silent under Vitest | The child was terminated, so nothing is reported | Check the handle yourself, as in step 8 |
Common mistakes
What to check next
- How to check test isolation: the same server, as state the next test inherits.
- How to detect parallel test interference: what a listening server does to the next worker.
- How to reset mocks between tests: the other job of
afterAll. - How to test with fake time: timers that never hold the loop open.
- How to check which tests are slowest: a suite that ends slowly, not never.
FAQ
What does "Jest did not exit one second after the test run has completed" mean?
The tests finished and the event loop still has work. Jest waits one second, prints that line and keeps waiting. The cause is a handle your code opened: a server, an interval, a connection or a watcher.
How do I find open handles after a test run?
npx jest --detectOpenHandles names each one with the line that opened it. It also runs tests in band, so a suite that hangs only under workers can behave differently.
Is --forceExit ever acceptable?
As a temporary unblock, with the handle recorded as a bug. It kills the process about a second after the tests, so anything still scheduled is dropped, as step 3 measured.
Why does the same code exit cleanly under Vitest?
Vitest runs each file in a worker it terminates itself, so the handle dies with the child. Exit code 0 there is not evidence that the code closes what it opens.
What if the handle has no stack?
why-is-node-running prints (unknown stack trace) for handles created before it loaded, including the socket here. Jest named that one, so run both before calling a handle anonymous.
Verified
Verified by Maks Vernynode 22.23.2jest 30.5.0vitest 5.0.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate13 minpublished updated Maks Verny