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

mkdir handledemo && cd handledemo
npm init -y > /dev/null
npm install -D jest@30 why-is-node-running
mkdir src test
// 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 };
// 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);
});
// 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

  1. Step 1.

    Run the suite and watch a fully green run refuse to end.

    npx jest
    
    Test 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.

  2. Step 2.

    Try the flag everyone reaches for first.

    npx jest --forceExit
    
    Test 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.

  3. Step 3.

    Look for the work that was still scheduled when the process died.

    cat artifacts.log
    
    cat: artifacts.log: No such file or directory

    The deferred flush was due 3 seconds after the tests and --forceExit left about 1. After the plain run in step 1 the same file exists and reads flushed.

  4. Step 4.

    Ask Jest which handles are still open.

    npx jest --detectOpenHandles
    
    Jest 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. TCPSERVERWRAP is a listening socket. This run hangs too.

  5. Step 5.

    Ask Node instead, with a temporary afterAll that any runner accepts.

    afterAll(() => {
      console.log('active resources:', process.getActiveResourcesInfo());
    });
    
    npx jest test/notifier.test.js
    
      console.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.

  6. 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.js
    
        Must 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 require it. The message names Node 24.9 as where that changes.

  7. Step 7.

    Put the same leak under Vitest and see a very different answer.

    npx vitest run test/notifier.test.js
    
     Test 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.

  8. Step 8.

    Import why-is-node-running there, where ESM loads, and read the stacks.

    npx vitest run test/handles.test.js --reporter=verbose
    
    stderr | 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.js and reports (unknown stack trace) for the socket Jest identified by line. The default reporter hides all of it, because it swallows worker stderr.

  9. 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 --detectOpenHandles
    
    Test 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. afterAll awaits notifier.stop() and the flush test awaits its own timer, so artifacts.log is 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

Sign: --forceExit is added and the pipeline goes green, so the ticket is closed.Cause: Jest waits about one second and then kills the process. A write scheduled for three seconds after the tests never ran, and artifacts.log did not exist afterwards, on a run that exited with code 0. Reporter output, coverage writes and cleanup hooks are lost the same way.
Sign: A suite that exits cleanly under Vitest is read as having no leaked handles.Cause: The same module that hung Jest indefinitely finished under Vitest in 234 ms with exit code 0 and no warning, in both the forks and threads pools. Vitest terminates the worker that holds the handle, so a clean exit there says nothing about whether your code closes what it opens.
Sign: why-is-node-running is installed and the suite stops running at all.Cause: Version 3 is ESM only. Jest 30 on Node 22 answers with Must use import to load ES Module and fails the suite before any test. It works from an ESM project under Vitest, and the error text names Node 24.9 as where require of ESM starts working in Jest.
Sign: process.getActiveResourcesInfo() is read as a list of leaks.Cause: It listed six resources for two leaked handles here, because the runner's own worker channel and timers are in it. It also gives types and no stacks. Use it to confirm that something is open, then use --detectOpenHandles or why-is-node-running to find where.

What to check next

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.

intermediate13 minpublished updated Maks Verny