There is a class of bugs that only shows up at scale. Not in your code. Not in your infrastructure. In the gap between them -- where your test suite meets your staging environment.
I am talking about resource leaks from timed-out tests.
The Scenario You Have Definitely Seen
A developer pushes a change. CI kicks off 300 Playwright tests across 8 workers. Fifteen of those tests hit a slow endpoint -- maybe the service is under load, maybe there is a network hiccup. They time out after 30 seconds each.
Playwright marks them as failed and moves on. Good. That is what it should do.
But here is what does not happen: the HTTP requests those tests initiated do not get cancelled. They are still in flight. The server is still processing them. The TCP connections are still open.
Fifteen tests, each making two or three API calls. That is 30 to 45 orphaned requests still hammering your staging environment. They will finish eventually -- or time out on their own after another 30 seconds. In the meantime, they consume server threads, database connections, and memory.
Now imagine this happens every CI run. Multiple times a day. Across multiple teams sharing the same staging environment.
This is not hypothetical. This is Tuesday.
Why Playwright Does Not Handle This
This is not a Playwright bug. It is a fundamental constraint of how JavaScript async operations work.
When Playwright times out a test, it stops waiting for the test function to resolve. But fetch, axios, WebSocket connections, and other async operations do not know the test has ended. They were started, they are running, and nothing has told them to stop.
JavaScript has no built-in mechanism to kill arbitrary async operations from outside. You cannot reach into a running fetch and terminate it. You cannot walk the promise chain and cancel everything.
What you can do is provide a cancellation token upfront -- a signal that says "when I tell you to stop, stop." This is what AbortController and AbortSignal are for.
AbortSignal: A Quick Refresher
AbortController is part of the web platform and is also available in Node.js. It provides a simple cancellation mechanism:
const controller = new AbortController();
// Start an operation with the signal
fetch('/api/data', { signal: controller.signal });
// Cancel it from outside
controller.abort();
// The fetch promise rejects with an AbortError
The key insight is that you decide at the start of the operation that it should be cancellable. You pass the signal in. Later, when you need to cancel, you call abort() on the controller.
This is the missing piece for Playwright tests.
Wiring AbortSignal Into the Test Lifecycle
The @playwright-labs/fixture-abort package provides this wiring. It extends Playwright's test with fixtures:
abortController -- a fresh AbortController for the current test
signal -- the associated AbortSignal
useAbortController(options?) -- returns the controller with optional abort callback
useSignalWithTimeout(ms) -- returns a signal that auto-aborts after a duration
The controller is automatically aborted when the test times out. Every operation that received the signal cancels immediately.
import { test, expect } from '@playwright-labs/fixture-abort';
test('should fetch data with cancellation support', async ({ signal }) => {
const response = await fetch('https://api.example.com/data', {
signal
});
const data = await response.json();
expect(data).toBeDefined();
});
If this test times out, signal fires. The fetch call is cancelled. The server stops processing. The connection is freed. No cleanup code required.
Where This Pattern Really Shines
Polling Loops
Tests that poll an endpoint are the most common source of zombie requests. Without an abort signal, a polling loop can run indefinitely after a timeout:
import { test, expect } from '@playwright-labs/fixture-abort';
test('should handle long polling', async ({ signal }) => {
while (!signal.aborted) {
const response = await fetch('/api/poll', {
signal
});
if (response.status === 200) {
const data = await response.json();
expect(data.ready).toBe(true);
return;
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
});
Two layers of protection here. The while (!signal.aborted) check prevents the loop from continuing after cancellation. The signal on the fetch call cancels any in-flight request. If the test times out mid-request, the fetch is cancelled. If it times out between requests, the loop condition catches it.
Parallel Request Batches
When tests fire multiple requests simultaneously, all of them need to be cancellable:
import { test, expect } from '@playwright-labs/fixture-abort';
test('should aggregate data from multiple services', async ({ signal }) => {
const [users, products, analytics] = await Promise.all([
fetch('/api/users', { signal }),
fetch('/api/products', { signal }),
fetch('/api/analytics', { signal })
]);
// Process results...
});
One signal, shared across all requests. When it fires, everything stops.
Retry Logic
Tests with retry mechanisms are particularly dangerous without abort signals. A retry loop can keep hammering a failing endpoint long after the test has timed out:
import { test, expect } from '@playwright-labs/fixture-abort';
test('should retry on failure', async ({ signal }) => {
let lastError: Error | null = null;
for (let attempt = 0; attempt < 5 && !signal.aborted; attempt++) {
try {
const response = await fetch('/api/flaky-endpoint', {
signal
});
if (response.ok) {
const data = await response.json();
expect(data).toBeDefined();
return;
}
} catch (err) {
lastError = err as Error;
if (signal.aborted) break;
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)));
}
}
});
The signal check in the loop condition, the signal on the fetch call, and the signal check in the catch block create a robust cancellation pattern that exits quickly no matter where in the retry cycle the timeout occurs.
Timeout Signals
Need a signal that auto-aborts after a specific duration, independent of the test timeout? Use useSignalWithTimeout:
import { test, expect } from '@playwright-labs/fixture-abort';
test('should complete API call within 5 seconds', async ({ useSignalWithTimeout }) => {
const timeoutSignal = useSignalWithTimeout(5000);
const response = await fetch('https://api.example.com/slow', {
signal: timeoutSignal
});
const data = await response.json();
expect(data).toBeDefined();
});
Custom Expect Matchers
The package includes matchers for asserting abort states:
import { test, expect } from '@playwright-labs/fixture-abort';
test('should verify signal states', async ({ signal, abortController }) => {
expect(signal).toBeActive();
expect(abortController).toHaveActiveSignal();
abortController.abort('done');
expect(signal).toBeAborted();
expect(signal).toBeAbortedWithReason('done');
expect(abortController).toHaveAbortedSignal();
});
The Bigger Picture: Tests as Good Citizens
There is a broader principle here. Your tests are not running in isolation. They share infrastructure with other tests, other teams, and often with real users (if staging is a shared environment).
A test that leaks resources is like a function that leaks memory. It might not matter once. It matters a lot when it happens thousands of times a day.
AbortSignal integration is not just about preventing flaky tests. It is about making your test suite a responsible consumer of shared resources. It is about ensuring that when a test ends -- whether it passes, fails, or times out -- it cleans up after itself completely.
The Cost of Not Doing This
Without abort signal integration, you are relying on:
- Server-side timeouts to eventually clean up orphaned requests (adding load until they fire).
- Connection pool limits to cap the damage (causing legitimate requests to queue).
- Manual investigation when staging environments degrade (burning engineering time).
None of these are solutions. They are damage control.
Getting Started
npm install @playwright-labs/fixture-abort
The package provides raw Playwright fixtures. Import test and expect from @playwright-labs/fixture-abort instead of @playwright/test, use signal in your async operations, and your tests clean up after themselves automatically.
Full source code and documentation: github.com/vitalics/playwright-labs
The change is small. The impact on your infrastructure reliability is not.