End-to-End Worker Tests with Playwright

An end-to-end test is the only layer that can prove the whole chain — bundler output, worker construction, message protocol, and the DOM update at the end — actually works together in a shipped build. Playwright exposes workers as first-class objects, which makes those assertions specific rather than a screenshot and a hope.

This is the integration layer of Testing Workers in CI, part of Debugging, Profiling & Production Optimization. Keep this layer small: a handful of tests that each prove something no cheaper layer can.


Asserting on a Real Worker

// e2e/worker.spec.ts
import { expect, test } from '@playwright/test';

test('the dashboard computes its summary on a worker', async ({ page }) => {
  // Start listening BEFORE the navigation that creates the worker.
  const workerPromise = page.waitForEvent('worker');
  await page.goto('/dashboard');

  const worker = await workerPromise;
  expect(worker.url()).toContain('aggregate');            // the right script

  // This callback runs INSIDE worker scope, not in the page.
  const scope = await worker.evaluate(() => self.constructor.name);
  expect(scope).toBe('DedicatedWorkerGlobalScope');

  await page.getByRole('button', { name: 'Recalculate' }).click();
  await expect(page.getByTestId('summary')).toHaveText(/12,480 rows/);
});

Three assertions, three different claims: a worker exists and was loaded from the expected script; code can execute in its scope, which no mock can fake; and the user-visible result appears. The last one alone would pass even if the computation quietly fell back to the main thread — which is exactly the regression this layer exists to catch.

What each end-to-end assertion actually proves Four links in a chain with the assertion that covers each. The bundler emitted a worker chunk and the URL resolves: proved by the worker event firing and its URL matching. The worker script evaluated without throwing: proved by evaluating an expression inside worker scope. The message protocol works end to end: proved by the user-visible result appearing after an interaction. The main thread stayed responsive: proved by sampling an animation-frame counter during the operation. A note records that only the third link is covered by a naive screenshot test, which is why a fallback to main-thread computation can pass unnoticed. One test, four independent claims the bundle emitted a worker and the URL resolves page.waitForEvent('worker') + worker.url() the script evaluated without throwing worker.evaluate(() => self.constructor.name) the protocol works end to end click, then expect the rendered result the main thread stayed responsive an animation-frame counter kept ticking What a screenshot-only test proves Only the third box. A build that silently computes on the main thread renders the same pixels and passes — which is how worker regressions survive an otherwise thorough suite. Each claim costs one line. There is no reason to assert only the cheapest one.
The pumpkin box is the reason this layer exists at all: user-visible correctness does not imply the architecture is intact.

Executing Inside Worker Scope

worker.evaluate() runs a function in the worker’s global scope, which opens up assertions that are otherwise unreachable:

// Is the environment what the code expects?
expect(await worker.evaluate(() => typeof SharedArrayBuffer)).toBe('function');
expect(await worker.evaluate(() => self.crossOriginIsolated)).toBe(true);

// Did the worker load the module it should have, and is its state initialised?
const state = await worker.evaluate(() => ({
  hasWasm: typeof WebAssembly.Module === 'function',
  cacheSize: (self as unknown as { __cache?: Map<string, unknown> }).__cache?.size ?? -1,
}));
expect(state.hasWasm).toBe(true);

Two cautions. The function is serialised and evaluated in the worker, so it cannot close over test-file variables — pass arguments explicitly as the second parameter. And exposing internals purely for tests (self.__cache) is a real coupling; prefer asserting through the worker’s own API where one exists, and reserve scope evaluation for environment facts the API cannot report.


Faults You Can Only Inject Here

The integration layer is the only place where the loading of the worker can be manipulated, which makes it the natural home for failure-path tests.

test('shows a fallback when the worker script fails to load', async ({ page }) => {
  await page.route('**/aggregate*.js', (route) => route.abort('failed'));
  await page.goto('/dashboard');

  // The Worker object fires `error`; the app should degrade, not hang.
  await expect(page.getByTestId('fallback-note')).toBeVisible();
  await expect(page.getByTestId('summary')).toHaveText(/12,480 rows/);   // computed inline
});

test('reports a worker crash to the error surface', async ({ page }) => {
  await page.goto('/dashboard');
  const worker = await page.waitForEvent('worker');
  await worker.evaluate(() => { throw new Error('synthetic crash'); }).catch(() => {});
  await expect(page.getByRole('alert')).toContainText('background task failed');
});

Both tests assert on behaviour that is invisible in a happy-path suite and that users hit regularly — a CDN hiccup, an out-of-memory kill, an ad blocker that blocks a chunk. The recovery patterns being exercised are those in Error Handling & Crash Recovery; this is how you verify they are wired.

Worker console output is also available, and worth wiring into the test’s diagnostics:

page.on('worker', (w) => w.on('console', (msg) => console.log(`[worker] ${msg.text()}`)));

Without it, a failing test shows only what the page logged, and the worker’s own diagnostics — usually the interesting half — are invisible in CI output.


Proving the Main Thread Stayed Free

The claim a worker exists to support — “the interface stays responsive while this runs” — is testable here, and only here. Rather than timing the operation, measure whether the main thread kept doing its job during it:

test('the grid stays interactive while the aggregation runs', async ({ page }) => {
  await page.goto('/dashboard');

  await page.evaluate(() => {
    const w = window as never as { frames: number };
    w.frames = 0;
    const tick = () => { w.frames++; requestAnimationFrame(tick); };
    requestAnimationFrame(tick);
  });

  await page.getByRole('button', { name: 'Recalculate' }).click();
  await expect(page.getByTestId('summary')).toBeVisible();

  const frames = await page.evaluate(() => (window as never as { frames: number }).frames);
  expect(frames).toBeGreaterThan(20);          // a blocked thread produces almost none
});

The threshold is intentionally far from the boundary. An unblocked thread at 60 fps produces hundreds of frames over a second; a thread blocked by a long synchronous computation produces one or two. Anything in between does not occur in practice, so the assertion separates the two states cleanly without encoding machine speed.

The same technique catches regressions no unit test can: a refactor that accidentally awaits the worker result inside a synchronous layout pass, or a fallback path that silently computes inline when the worker fails to load. Both keep the output correct and both destroy the property the architecture was built for.


Service Workers Will Serve You Yesterday’s Bundle

Any application that registers a service worker needs one extra precaution in this layer, because a service worker installed by an earlier test can serve a cached bundle to a later one — including a bundle from a previous build. The symptom is a test that fails against code that is demonstrably correct, and passes after a manual browser restart.

test.beforeEach(async ({ context }) => {
  await context.clearCookies();
  // Unregister anything a previous test installed, then clear its caches.
  await context.addInitScript(() => {
    navigator.serviceWorker?.getRegistrations?.().then((rs) => rs.forEach((r) => void r.unregister()));
    caches?.keys?.().then((keys) => keys.forEach((k) => void caches.delete(k)));
  });
});

If service-worker behaviour is itself under test — offline support, cached computation as in Service Workers for Computation — do the opposite deliberately: give those tests their own project with a fresh context per test and assert on the registration state explicitly, rather than letting it persist implicitly across an entire suite.


Configuration That Keeps CI Green

// playwright.config.ts
export default defineConfig({
  webServer: {
    command: 'npm run preview',          // the PRODUCTION build, not the dev server
    url: 'http://localhost:4173',
    reuseExistingServer: !process.env.CI,
  },
  use: { baseURL: 'http://localhost:4173', trace: 'on-first-retry' },
  retries: process.env.CI ? 1 : 0,
  workers: process.env.CI ? 2 : undefined,   // Playwright workers ≠ Web Workers
});

Four choices matter for worker tests specifically.

Test the production build. Worker resolution differs between the dev server and the built output — inlining, hashing, base paths — and this layer’s main job is to catch that difference.

Serve isolation headers if needed. The preview server must send the same COOP and COEP headers as production, or every SharedArrayBuffer test fails in a way that has nothing to do with the code.

Limit parallelism. Each Playwright worker runs a browser, and each browser may spawn several Web Workers. On a two-core CI container, eight parallel browsers make timing-sensitive tests unreliable regardless of how well they are written.

Keep trace: 'on-first-retry'. A worker failure in CI is difficult to reason about from a message alone; the trace includes console output, network requests for the worker script, and the DOM at failure.

Why the same test behaves differently on a CI container A comparison of a developer laptop and a CI container running the same suite. The laptop has eight cores, runs four Playwright browsers in parallel, each spawning up to four Web Workers, for sixteen threads against eight cores — comfortable. The CI container has two cores; with the same configuration it runs four browsers and sixteen threads against two cores, so every operation is several times slower and any timing-based assertion fails. With Playwright workers limited to two and the application's pool size injected as one, the container runs four threads against two cores and behaves predictably. Same suite, different machines laptop 8 cores 4 browsers × 4 workers 16 threads / 8 cores comfortable — always green CI, same config 2 cores 4 browsers × 4 workers 16 threads / 2 cores 8× oversubscribed — flaky CI, tuned 2 cores 2 browsers × 1 worker 4 threads / 2 cores predictable — green again Injecting the application's pool size in tests matters as much as limiting Playwright's parallelism.
Both numbers are configuration, and both default to values chosen for a developer machine.

Failures only an end-to-end test can catch Four failure classes unique to this layer. A worker chunk that the production build never emitted, which no unit test exercises. A worker URL that resolves in the dev server and 404s in the built output. A silent fallback to main-thread computation that produces identical pixels and identical results. And a service worker serving a stale bundle, which makes correct code fail in a way no other layer reproduces. What only this layer sees the worker chunk was never emitted the unit layer imports the module directly and never notices the URL resolves in dev and 404s in the build a computed specifier, invisible until the production build a silent fallback to main-thread computation same output, same pixels — only thread count reveals it a service worker serving a stale bundle correct code failing for reasons no other layer reproduces All four produce correct-looking output, which is why assertions on the worker itself matter.
Six tests is enough to cover this list — and the list is the reason the layer exists at all.

Gotchas

Listening for the worker after navigating. page.waitForEvent('worker') only sees workers created after the listener is attached. Create the promise first, then navigate — the ordering in the first example is deliberate.

worker.evaluate closures capture nothing. The function is serialised, so referencing a test-scope variable throws at evaluation time with a confusing message. Pass values as arguments.

Headless and headed engines can differ. Timer resolution and background throttling policies are not always identical between the two, so a test that passes headed can fail headless. Debug headed if that helps, but always confirm in the mode CI uses.

Service workers are a different event. page.waitForEvent('worker') covers dedicated workers; service workers appear on the browser context (context.serviceWorkers()), and a page controlled by a stale service worker can serve an old bundle to the entire suite. Clear storage between tests when service workers are involved.

Blocking the worker script does not throw in application code. It fires an error event on the Worker; code that only handles message sees silence, and the test hangs rather than failing usefully. That is worth asserting deliberately.

A worker created before page.goto resolves is easy to miss. Workers started during module evaluation can be constructed before the navigation promise settles, so a listener attached after goto never fires and the test times out with a misleading message. Attach the listener before navigating, and prefer page.waitForEvent over polling page.workers().

Auto-waiting does not apply to worker state. Playwright retries locator assertions, not worker.evaluate results. Poll with expect.poll when asserting on a value that becomes true asynchronously inside worker scope.


Traces are large when workers are involved. A trace captures console output and network activity for every worker as well as the page, so a suite with trace: 'on' produces artefacts several times bigger than usual. on-first-retry keeps the diagnostic value without the storage cost.


Performance Note

A suite of six worker-focused end-to-end tests against a production build took 11 s on a 2023 laptop and 31 s on a two-core CI container with parallelism limited to two — roughly 5 s per test in CI. That is the budget this layer costs, which is why it holds six tests rather than sixty: everything cheaper belongs in the browser-mode unit layer, where the same assertions run in about 200 ms each.

Frequently Asked Questions

How do I assert that work actually happened on a worker and not on the main thread?
Two complementary checks. Await page.waitForEvent('worker') to prove a thread was created, then evaluate inside it — worker.evaluate(() => self.constructor.name) runs in worker scope and fails if there is no worker. For the stronger claim that the main thread was not blocked, sample responsiveness during the operation: post a requestAnimationFrame counter before starting and assert it kept ticking.
Can Playwright intercept the request for a worker script?
Yes — page.route() matches worker script requests like any other, so you can fail one to test the error path, or serve a modified script to inject a fault. Be aware that a blocked worker script produces an error event on the Worker object rather than a network error in your application code, so the assertion belongs on that handler.

See also