Graceful Test Cancellation: Why Your Playwright Tests Need AbortSignal

posted Originally published at blog-vitaliharadkous-projects.vercel.app 5 min read

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.

1 Comment

0 votes
0

More Posts

5 Things This Playwright SQL Fixture Does So You Don't Have To

vitalicset - Apr 13

Merancang Backend Bisnis ISP: API Pelanggan, Paket Internet, Invoice, dan Tiket Support

Masbadar - Mar 13

Angular-Aware E2E Testing: Query Components by @Input and Signals in Playwright

vitalicset - Apr 2

Beyond the Crisis: Why Engineering Your Personal Health Baseline Matters

Huifer - Jan 24

How We Eliminated Container Boilerplate from Playwright Integration Tests

vitalicset - Mar 26
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!