Fix: Jest Async Test Timeout — Exceeded 5000ms or Test Never Resolves
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix Jest async test timeouts — missing await, unresolved Promises, done callback misuse, global timeout configuration, fake timers, and async setup/teardown issues.
The Problem
A Jest test times out instead of failing or passing:
● UserService › fetchUser › returns user data
Timeout - Async callback was not invoked within the 5000ms timeout specified by jest.setTimeout.
Timeout - Async callback was not invoked within timeout specified by jasmine.DEFAULT_TIMEOUT_INTERVAL.Or a test hangs indefinitely — Jest process never exits:
jest --testPathPattern=user.test.ts
# Tests pass but Jest process hangs — never returns to shell promptOr a Promise-based test always passes even when the assertion inside is false:
test('should reject with error', () => {
fetchUser(-1).catch(err => {
expect(err.message).toBe('User not found');
// This assertion never runs — test passes vacuously
});
// Promise returned to .catch but test doesn't wait for it
});Why This Happens
Jest’s async handling depends on the test function correctly signaling completion. The test runner starts a timer when the test begins and expects one of three signals before the timer expires: the function returns (synchronous), the returned Promise resolves or rejects (async/await or returned Promise), or the done callback is invoked (callback pattern). If none of these signals arrive, Jest reports a timeout.
The default timeout is 5000 milliseconds. This is generous for unit tests but can be tight for integration tests that hit real databases, spin up containers, or make network requests. On CI runners with constrained CPU and memory (especially GitHub Actions free-tier runners sharing a VM with other workflows), a test that completes in 2 seconds locally may take 8 seconds in CI. Docker containers with CPU limits (--cpus=0.5) amplify this effect — every timer, every async operation, every disk I/O takes longer than on bare metal.
The other class of failures is structural: a missing await causes the test function to return before the async work finishes, so Jest marks it as passed without checking assertions. Mixing done callbacks with async/await is another trap — if the test function is async and also declares a done parameter, Jest waits for done to be called and for the returned Promise to settle, which often leads to double-resolution bugs. Open handles (database connections, servers, timers) prevent Jest from exiting even after all tests pass, because Node’s event loop has pending work.
Fix 1: Add Missing await
The most common cause — async test functions without proper awaiting:
// WRONG — no async, Promise returned but not awaited
test('fetches user', () => {
fetchUser(1).then(user => {
expect(user.name).toBe('Alice'); // Assertion runs after test ends
});
// Test returns immediately — always "passes" (no assertions run)
});
// WRONG — async but missing await in the chain
test('fetches user', async () => {
const result = fetchUser(1); // Missing await — result is a Promise, not user
expect(result.name).toBe('Alice'); // Error: Cannot read .name of Promise
});
// CORRECT — await the Promise
test('fetches user', async () => {
const user = await fetchUser(1); // Waits for the Promise to resolve
expect(user.name).toBe('Alice'); // Runs after user is available
});
// CORRECT — return the Promise (works without async/await)
test('fetches user', () => {
return fetchUser(1).then(user => {
expect(user.name).toBe('Alice');
});
// Returning the Promise tells Jest to wait for it
});Common missing await locations:
// WRONG — beforeEach not awaited
beforeEach(() => {
db.connect(); // Missing await — tests run before DB connects
});
// CORRECT
beforeEach(async () => {
await db.connect();
});
// WRONG — assertion inside .then not returned
test('updates user', async () => {
await updateUser(1, { name: 'Bob' });
fetchUser(1).then(user => {
expect(user.name).toBe('Bob'); // Not returned — may not run
});
});
// CORRECT
test('updates user', async () => {
await updateUser(1, { name: 'Bob' });
const user = await fetchUser(1);
expect(user.name).toBe('Bob');
});Fix 2: Don’t Mix done Callback with async/await
When using the done callback pattern, call it in ALL code paths. But never combine done with async:
// WRONG — done never called if fetchUser throws
test('fetches user', (done) => {
fetchUser(1).then(user => {
expect(user.name).toBe('Alice');
done();
});
// If fetchUser rejects, done is never called → timeout
});
// CORRECT — call done in error cases too
test('fetches user', (done) => {
fetchUser(1)
.then(user => {
expect(user.name).toBe('Alice');
done();
})
.catch(error => {
done(error); // Call done with error to fail the test cleanly
});
});
// WRONG — async function AND done parameter
test('fetches user', async (done) => {
// Jest sees both: it waits for done() AND for the async function to return
// This almost always leads to confusing double-resolution
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
done(); // Jest may warn about calling done in an async test
});
// BETTER — use async/await instead of done for clarity
test('fetches user', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
});done with callbacks (not Promises):
// For callback-based APIs — done is appropriate
test('reads file', (done) => {
fs.readFile('./data.json', 'utf8', (err, data) => {
if (err) return done(err); // Fail test with error
const parsed = JSON.parse(data);
expect(parsed.version).toBe('1.0');
done(); // Signal completion
});
});Fix 3: Test Rejected Promises
Testing that a function rejects requires careful handling:
// WRONG — assertion in .catch not returned
test('rejects invalid user', () => {
fetchUser(-1).catch(err => {
expect(err.message).toBe('Not found');
// Not returned — test passes even if assertion fails
});
});
// CORRECT — use expect(...).rejects
test('rejects invalid user', async () => {
await expect(fetchUser(-1)).rejects.toThrow('Not found');
// Or: await expect(fetchUser(-1)).rejects.toMatchObject({ message: 'Not found' });
});
// CORRECT — alternatively, handle manually
test('rejects invalid user', async () => {
expect.assertions(1); // Ensures at least 1 assertion runs
try {
await fetchUser(-1);
fail('Expected fetchUser to throw'); // Shouldn't reach here
} catch (err) {
expect(err.message).toBe('Not found');
}
});expect.assertions(n) — guard against missing assertions:
test('async operations all run', async () => {
expect.assertions(3); // Test fails if exactly 3 assertions don't run
const user = await fetchUser(1);
expect(user).toBeDefined(); // Assertion 1
expect(user.name).toBe('Alice'); // Assertion 2
expect(user.email).toContain('@'); // Assertion 3
// If any assertion is skipped (e.g., due to early return), test fails
});Fix 4: Increase Timeout for Slow Tests
When tests legitimately take longer than 5 seconds:
// Per-test timeout (milliseconds)
test('slow integration test', async () => {
const result = await slowDatabaseOperation();
expect(result).toBeDefined();
}, 30000); // 30 second timeout for this test
// Per-describe timeout (applies to all tests in the block)
describe('integration tests', () => {
beforeAll(async () => {
await startDatabase();
}, 30000); // 30s for beforeAll
test('complex query', async () => {
const data = await runComplexQuery();
expect(data.length).toBeGreaterThan(0);
}, 15000); // 15s for this test
});
// Global timeout — applies to all tests in the file
// jest.config.js
module.exports = {
testTimeout: 15000, // 15 seconds globally
};
// Or per-file using jest.setTimeout
// At the top of the test file:
jest.setTimeout(30000); // Applies to all tests in this fileFix 5: GitHub Actions vs Local Timing
Tests that pass locally but time out in GitHub Actions are usually hitting CI resource constraints. Free-tier GitHub Actions runners have 2 vCPUs and 7GB RAM, shared with other workflows in the same job:
# .github/workflows/test.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test -- --testTimeout=30000
# Override timeout for CI — CPU is slower than localDocker resource limits affecting tests:
# docker-compose.test.yml
services:
test:
build: .
command: npm test
deploy:
resources:
limits:
cpus: '1.0' # Default in many CI systems
memory: '2G'
# With limited CPU, every setTimeout, setInterval,
# and async I/O takes longer
environment:
- JEST_TIMEOUT=30000 # Pass timeout via env// jest.config.js — use environment variable for CI timeout
module.exports = {
testTimeout: process.env.JEST_TIMEOUT
? parseInt(process.env.JEST_TIMEOUT)
: 5000,
};Parallel test execution in constrained environments:
// jest.config.js — reduce parallelism in CI
module.exports = {
// Default: uses all available CPU cores
// In CI, reduce to avoid resource contention
maxWorkers: process.env.CI ? 2 : '50%',
// Or run serially in CI:
// maxWorkers: process.env.CI ? 1 : '50%',
};Fix 6: Fix Open Handles
Jest warns about open handles that prevent clean exit:
# Jest output:
# 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.
jest --detectOpenHandles
# Output shows which handles are open (database connections, timers, servers)Common open handles and how to fix them:
// Database connection not closed
describe('UserService', () => {
let connection;
beforeAll(async () => {
connection = await createDatabaseConnection();
});
afterAll(async () => {
await connection.close(); // MUST close after tests
});
test('finds user', async () => {
const user = await connection.query('SELECT * FROM users WHERE id = 1');
expect(user).toBeDefined();
});
});
// HTTP server not closed
let server;
beforeAll(done => {
server = app.listen(3001, done);
});
afterAll(done => {
server.close(done); // Close server after tests
});
// setInterval or setTimeout not cleared
let intervalId;
beforeAll(() => {
intervalId = setInterval(pollService, 1000);
});
afterAll(() => {
clearInterval(intervalId); // Stop the interval
});Force Jest to exit after tests complete:
# Force exit after tests (workaround — fix the actual handle instead)
jest --forceExit
# Or in jest.config.js:
module.exports = {
forceExit: true, // Not recommended — hides the real issue
};Fix 7: Fix async beforeAll and afterAll
Setup and teardown must properly resolve for tests to run:
// WRONG — async beforeAll not awaited correctly
beforeAll(() => {
setupDatabase(); // Returns Promise but not returned
// Jest doesn't wait — tests run before DB is set up
});
// CORRECT — return the Promise
beforeAll(() => {
return setupDatabase(); // Jest waits for this Promise
});
// CORRECT — async/await
beforeAll(async () => {
await setupDatabase();
await seedTestData();
await startMockServer();
});
// CORRECT — done callback
beforeAll(done => {
setupDatabase()
.then(() => done())
.catch(done);
});
// afterAll — must clean up or remaining tests may timeout
afterAll(async () => {
await db.clearTestData();
await db.disconnect();
mockServer.stop();
});Fix 8: Fake Timers Interaction with Async Code
Combining jest.useFakeTimers() with async tests requires special handling. Fake timers replace setTimeout, setInterval, and Date with controlled versions. If your async code uses timers internally (e.g., retry logic, debouncing, polling), Promises that depend on those timers will never resolve unless you manually advance the fake clock:
// WRONG — fake timers prevent Promise resolution in some setups
jest.useFakeTimers();
test('debounced function calls', async () => {
const fn = jest.fn();
const debouncedFn = debounce(fn, 500);
debouncedFn();
jest.advanceTimersByTime(500);
// If your async code uses setTimeout internally,
// Promises may not resolve with fake timers
await waitFor(() => expect(fn).toHaveBeenCalled());
// Can hang if waitFor uses real time intervals
});
// CORRECT — use runAllTimers or specific advancement
jest.useFakeTimers();
test('debounced function calls', () => {
const fn = jest.fn();
const debouncedFn = debounce(fn, 500);
debouncedFn();
jest.runAllTimers(); // Executes all pending timers immediately
expect(fn).toHaveBeenCalledTimes(1);
});
// For tests mixing real Promises and fake timers (Jest 29+):
test('async with fake timers', async () => {
jest.useFakeTimers();
const result = someAsyncOperation(); // Returns Promise
// runAllTimersAsync runs timers AND flushes the microtask queue
await jest.runAllTimersAsync();
expect(await result).toBeDefined();
jest.useRealTimers(); // Restore real timers after the test
});Selective fake timers — only fake specific timer APIs:
// Jest 27+ — only fake setTimeout, leave setInterval and Date real
jest.useFakeTimers({
doNotFake: ['setInterval', 'Date'],
});
// This avoids breaking async code that relies on setInterval
// while still controlling setTimeout-based logic@testing-library/react waitFor with fake timers:
// waitFor uses setInterval internally — fake timers break it
// Fix: configure fake timers to advance automatically
jest.useFakeTimers({ advanceTimers: true });
// OR use jest.advanceTimersByTimeAsync in the testStill Not Working?
jest --runInBand — run tests serially (one at a time) to isolate which test is timing out:
jest --runInBand --testPathPattern=problem.test.tsCircular Promises — a Promise that depends on itself or creates a cycle never resolves. Add console.log statements at key points to trace where execution stops.
Module mocking hiding async errors — if a mocked module’s async function resolves immediately but the real implementation has async delays, the test may work in unit tests but fail in integration. Verify that mocks accurately represent real async timing.
--testTimeout=60000 flag — use a very high timeout temporarily to confirm the test can pass (just slowly), vs never passing at all:
jest --testTimeout=60000 problem.test.tsAbortController timeout in fetch-based tests — if your code uses AbortController with a timeout (e.g., AbortSignal.timeout(5000)), and the test also has a 5-second timeout, a race condition occurs. The abort fires at roughly the same time Jest’s timer expires. Set the test timeout higher than any internal abort timeouts.
Node.js --expose-gc and Jest worker memory — in long test suites, Jest workers accumulate memory. If a worker runs out of heap, it dies silently and its tests appear to time out. Run with --logHeapUsage to check:
jest --logHeapUsage --testPathPattern=problem.test.ts
# Shows heap size after each test suiteFor related testing issues, see Fix: Jest Fake Timers Not Working, Fix: Jest Coverage Not Collected, Fix: Jest Mock Not Working, and Fix: Jest ESM Error.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Jest Setup File Not Working — setupFilesAfterFramework Not Running or Globals Not Applied
How to fix Jest setup file issues — setupFilesAfterFramework vs setupFiles, global mocks not applying, @testing-library/jest-dom matchers, module mocking in setup, and TypeScript setup files.
Fix: Jest Coverage Not Collected — Files Missing from Coverage Report
How to fix Jest coverage not collecting all files — collectCoverageFrom config, coverage thresholds, Istanbul ignore comments, ts-jest setup, and Babel transform issues.
Fix: Jest Fake Timers Not Working — setTimeout and setInterval Not Advancing
How to fix Jest fake timers not working — useFakeTimers setup, runAllTimers vs advanceTimersByTime, async timers, React testing with act(), and common timer test mistakes.
Fix: Jest Module Mock Not Working — jest.mock() Has No Effect
How to fix Jest module mocks not working — hoisting behavior, ES module mocks, factory functions, mockReturnValue vs implementation, and clearing mocks between tests.