DEV Community

Cover image for Stop Leaking Resources: How to Use AbortSignal in Playwright Tests
Vitali Haradkou
Vitali Haradkou

Posted on

Stop Leaking Resources: How to Use AbortSignal in Playwright Tests

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');
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The package extends Playwright's test with fixtures built in:

  • abortController - an AbortController instance, fresh for each test
  • signal - the associated AbortSignal
  • 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');
});
Enter fullscreen mode Exit fullscreen mode

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));
  }
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

How It Works Under the Hood

The implementation is straightforward:

  1. Before each test, a fresh AbortController is created via Playwright's fixture system.
  2. The controller and its signal are made available as abortController and signal fixtures.
  3. When the test times out, the controller is aborted.
  4. Any operation listening to the signal receives an AbortError and stops.
  5. 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
Enter fullscreen mode Exit fullscreen mode

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)