If you write Playwright tests that make HTTP requests, call APIs, or perform any long-running async work, you probably have a resource leak problem you do not know about.
In this tutorial, I will walk you through the problem, explain why standard cleanup patterns fall short, and show you how to integrate AbortController / AbortSignal into your Playwright tests using the @playwright-labs/fixture-abort package.
The Problem: Zombie Requests After Test Timeouts
Consider a straightforward Playwright test that calls an API:
test('should fetch user profile', async () => {
const response = await fetch('https://api.example.com/users/123');
const user = await response.json();
expect(user.name).toBe('Alice');
});
If this test times out - maybe the API is slow, maybe the server is under load - Playwright stops the test. But the fetch call does not get cancelled. The request continues running until it either completes or hits its own timeout (often 30 seconds or more by default).
In isolation, this is harmless. At scale - hundreds of tests, multiple workers, CI running every push - it creates real problems:
- Connection pool exhaustion: Your test infrastructure runs out of available connections.
- Server overload: Your staging environment handles requests nobody is waiting for.
- Misleading logs: Errors from orphaned requests appear in server logs, confusing debugging efforts.
- Cascading failures: Resource exhaustion in one service affects others in your staging environment.
Why afterEach Does Not Solve This
Your first instinct might be to clean up in an afterEach hook. But there is a fundamental issue: by the time afterEach runs, you have no reference to the in-flight requests. The fetch promise is trapped inside the timed-out test function. You cannot cancel what you cannot reach.
What you need is a cancellation token that you pass into every async operation upfront - something that can be triggered externally when the test ends.
This is exactly what AbortController and AbortSignal were designed for.
Enter AbortSignal
AbortController is a web standard (also available in Node.js) that provides a mechanism for cancelling async operations:
const controller = new AbortController();
const signal = controller.signal;
// Pass signal to fetch
fetch('/api/data', { signal });
// Later, cancel the request
controller.abort(); // The fetch rejects with AbortError
The @playwright-labs/fixture-abort package wires this pattern directly into Playwright's test lifecycle.
Setting Up fixture-abort
Install the package:
npm install @playwright-labs/fixture-abort
The package extends Playwright's test with fixtures built in:
-
abortController- anAbortControllerinstance, fresh for each test -
signal- the associatedAbortSignal -
useAbortController(options?)- returns the controller with optional abort callback -
useSignalWithTimeout(ms)- returns a signal that auto-aborts after the given duration
Import test and expect from the package instead of @playwright/test, and the fixtures are ready to use.
Basic Usage: Cancellable Fetch
Here is the simplest pattern - passing the signal to a fetch call:
import { test, expect } from '@playwright-labs/fixture-abort';
test('should fetch user profile', async ({ signal }) => {
const response = await fetch('https://api.example.com/users/123', {
signal
});
const user = await response.json();
expect(user.name).toBe('Alice');
});
If the test times out, signal fires, and the fetch call is immediately cancelled. No orphaned request. No wasted server resources.
Pattern: Polling with Cooperative Cancellation
Many tests need to poll an endpoint until a condition is met. Without abort signals, a timeout leaves the polling loop running in the background. With signal, you get clean cooperative cancellation:
import { test, expect } from '@playwright-labs/fixture-abort';
test('should wait for order processing', async ({ signal }) => {
const orderId = await createOrder();
while (!signal.aborted) {
const response = await fetch(`/api/orders/${orderId}`, {
signal
});
const order = await response.json();
if (order.status === 'completed') {
expect(order.total).toBeGreaterThan(0);
return;
}
// Wait 2 seconds before next poll
await new Promise(resolve => setTimeout(resolve, 2000));
}
});
The while (!signal.aborted) check means the loop exits cleanly when the signal fires. The fetch call inside the loop is also protected by the same signal. Double coverage.
Pattern: Multiple Parallel Requests
When a test fires multiple requests in parallel, all of them need to be cancellable:
import { test, expect } from '@playwright-labs/fixture-abort';
test('should fetch dashboard data', async ({ signal }) => {
const [users, orders, metrics] = await Promise.all([
fetch('/api/users', { signal }),
fetch('/api/orders', { signal }),
fetch('/api/metrics', { signal })
]);
expect(users.ok).toBe(true);
expect(orders.ok).toBe(true);
expect(metrics.ok).toBe(true);
});
One signal, three requests, all cancelled together if the test times out.
Pattern: Manual Abort for Early Exit
You are not limited to timeout-driven cancellation. You can abort manually based on test logic:
import { test, expect } from '@playwright-labs/fixture-abort';
test('should stop on first error', async ({ signal, abortController }) => {
const items = await getItemsToProcess();
for (const item of items) {
if (signal.aborted) break;
const response = await fetch(`/api/process/${item.id}`, {
method: 'POST',
signal
});
if (!response.ok) {
abortController.abort(); // Cancel remaining work
break;
}
}
});
Pattern: Abort Controller with Callback
Use useAbortController to register a callback that fires on abort:
import { test, expect } from '@playwright-labs/fixture-abort';
test('should handle abort with cleanup', async ({ useAbortController, signal }) => {
const controller = useAbortController({
onAbort: () => console.log('Operation cancelled, cleaning up'),
abortTest: true
});
const response = await fetch('/api/long-operation', { signal });
const data = await response.json();
expect(data).toBeDefined();
});
Pattern: Signal with Timeout
Use useSignalWithTimeout to get a signal that auto-aborts after a specific duration:
import { test, expect } from '@playwright-labs/fixture-abort';
test('should complete within 5 seconds', async ({ useSignalWithTimeout }) => {
const timeoutSignal = useSignalWithTimeout(5000);
const response = await fetch('/api/slow-endpoint', {
signal: timeoutSignal
});
expect(response.ok).toBe(true);
});
Pattern: Passing Signal to Third-Party Libraries
Many modern libraries accept an AbortSignal. You can pass signal to anything that supports it:
import { test, expect } from '@playwright-labs/fixture-abort';
test('should query database', async ({ signal }) => {
// Many DB clients accept abort signals
const result = await db.query('SELECT * FROM users', {
signal
});
expect(result.rows.length).toBeGreaterThan(0);
});
This works with Axios (via signal option), the Node.js fetch implementation, many database drivers, gRPC clients, and more.
Custom Expect Matchers
The package also provides custom expect matchers for testing abort states:
import { test, expect } from '@playwright-labs/fixture-abort';
test('should verify abort state', async ({ signal, abortController }) => {
expect(signal).toBeActive();
abortController.abort('test reason');
expect(signal).toBeAborted();
expect(signal).toBeAbortedWithReason('test reason');
expect(abortController).toHaveAbortedSignal();
});
test('should verify timeout signal aborts', async ({ useSignalWithTimeout }) => {
const timeoutSignal = useSignalWithTimeout(100);
await expect(timeoutSignal).toAbortWithin(150);
});
How It Works Under the Hood
The implementation is straightforward:
- Before each test, a fresh
AbortControlleris created via Playwright's fixture system. - The controller and its signal are made available as
abortControllerandsignalfixtures. - When the test times out, the controller is aborted.
- Any operation listening to the signal receives an
AbortErrorand stops. - After each test, the controller is cleaned up.
This means every test gets its own isolated cancellation scope. One test timing out does not affect any other test.
Common Mistakes to Avoid
Do not create your own AbortController when the fixture provides one. The whole point is that the fixture's controller is wired into the test lifecycle. A manually created controller will not auto-abort on timeout.
Do not forget to pass the signal. An unprotected fetch() call without signal is still vulnerable to the zombie request problem. Make it a habit: every async operation gets the signal.
Do not swallow AbortError silently. When a signal fires, operations reject with AbortError. This is expected behavior during timeouts. Let Playwright handle the timeout reporting rather than catching and hiding the error.
Getting Started
npm install @playwright-labs/fixture-abort
Full source code and documentation: github.com/vitalics/playwright-labs
The package is part of the @playwright-labs monorepo. Import test and expect from @playwright-labs/fixture-abort instead of @playwright/test, and the abort fixtures are ready to use in every test.
Give it a try. Your staging servers will thank you.
Top comments (0)