Stop Leaking Resources: How to Use AbortSignal in Playwright Tests
Source: Dev.to
The Problem
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 the test times out (slow API, server under load, etc.), Playwright stops the test but the
fetchcall keeps running until it finishes or hits its own timeout (often 30 s+).
Why this matters at scale
- Connection‑pool exhaustion – workers run out of sockets.
- Server overload – your staging environment handles requests nobody is waiting for.
- Misleading logs – orphaned requests appear as errors in server logs.
- Cascading failures – one exhausted service can bring down the whole test suite.
Why afterEach doesn’t help
By the time afterEach runs you no longer have a reference to the in‑flight request; the promise is trapped inside the timed‑out test function. You can’t cancel what you can’t reach.
Solution: Pass a cancellation token (AbortSignal) into every async operation up‑front, then trigger it when the test ends.
AbortController & AbortSignal
AbortController (standard in browsers and Node.js) creates a signal that can be used to cancel async operations:
const controller = new AbortController();
const signal = controller.signal;
// Pass the signal to fetch
await fetch('/api/data', { signal });
// Cancel later
controller.abort(); // fetch rejects with an AbortError
The @playwright-labs/fixture-abort Package
Installation
npm install @playwright-labs/fixture-abort
What the package provides
| Fixture | Description |
|---|---|
abortController | A fresh AbortController instance for each test. |
signal | The associated AbortSignal. |
useAbortController(options?) | Returns the controller; you can supply an onAbort callback. |
useSignalWithTimeout(ms) | Returns a signal that auto‑aborts after ms milliseconds. |
Note: Import
testandexpectfrom the package instead of@playwright/testto get the fixtures automatically.
import { test, expect } from '@playwright-labs/fixture-abort';
Usage Examples
1️⃣ Simple fetch with automatic cancellation
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 request is cancelled instantly.
2️⃣ Polling until a condition is met
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 the next poll
await new Promise(resolve => setTimeout(resolve, 2000));
}
});
The while (!signal.aborted) guard guarantees the loop exits cleanly when the test is aborted.
3️⃣ Parallel requests – one signal, many calls
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);
});
All three requests are cancelled together if the test aborts.
4️⃣ Manual abort 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;
}
}
});
You can abort the whole test from inside the test body.
5️⃣ useAbortController – register a callback 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 // optional – abort the test itself
});
const response = await fetch('/api/long-operation', { signal });
const data = await response.json();
expect(data).toBeDefined();
});
The onAbort hook runs automatically when the signal is triggered.
6️⃣ useSignalWithTimeout – auto‑abort after a fixed duration
import { test, expect } from '@playwright-labs/fixture-abort';
test('should auto‑abort after 10 seconds', async ({ useSignalWithTimeout }) => {
const signal = useSignalWithTimeout(10_000); // 10 s
const response = await fetch('/api/slow-endpoint', { signal });
const data = await response.json();
expect(data).toBeDefined();
});
The signal aborts automatically after the specified timeout, protecting you from runaway requests even if the test itself doesn’t time out.
TL;DR
- Problem: Long‑running async work in Playwright tests can keep running after a test timeout, leaking resources.
- Fix: Pass an
AbortSignalto every async operation and abort it when the test ends. - Tool:
@playwright-labs/fixture-abortgives you ready‑made fixtures (signal,abortController,useAbortController,useSignalWithTimeout). - Result: No orphaned HTTP requests, no connection‑pool exhaustion, and cleaner CI runs.
Happy testing! 🚀
# Abort Fixtures for Playwright
```ts
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);
});
Many modern libraries accept an AbortSignal. You can pass the 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 the
signaloption) - Node.js
fetchimplementation - Many database drivers
- gRPC clients
- …and more.
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
- Before each test – a fresh
AbortControlleris created via Playwright’s fixture system. - The controller and its
signalare exposed asabortControllerandsignalfixtures. - When the test times out, the controller is automatically 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.
Best Practices
- Do not create your own
AbortControllerwhen the fixture already provides one. The fixture‑provided controller is wired into the test lifecycle; a manual controller won’t auto‑abort on timeout. - Always pass the signal to async operations (e.g.,
fetch, DB queries). An unprotected call without a signal remains vulnerable to zombie‑request problems. - Don’t swallow
AbortErrorsilently. When a signal fires, operations reject withAbortError. Let Playwright handle timeout reporting instead of catching and hiding the error.
Installation
npm install @playwright-labs/fixture-abort
Full source code and documentation:
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!