Stop Leaking Resources: How to Use AbortSignal in Playwright Tests

Published: (February 16, 2026 at 06:19 AM EST)
6 min read
Source: Dev.to

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 fetch call 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

FixtureDescription
abortControllerA fresh AbortController instance for each test.
signalThe 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 test and expect from the package instead of @playwright/test to 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 AbortSignal to every async operation and abort it when the test ends.
  • Tool: @playwright-labs/fixture-abort gives 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 signal option)
  • Node.js fetch implementation
  • 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

  1. Before each test – a fresh AbortController is created via Playwright’s fixture system.
  2. The controller and its signal are exposed as abortController and signal fixtures.
  3. When the test times out, the controller is automatically 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.

Best Practices

  • Do not create your own AbortController when 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 AbortError silently. When a signal fires, operations reject with AbortError. 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!

0 views
Back to Blog

Related posts

Read more »