PRACTICAL GUIDE / Playwright reporter onError workerInfo
Give Playwright worker crashes enough context to investigate
Capture Playwright global errors with worker identity, avoid false test attribution, and make custom reporter failures visible in parallel CI runs.
In this guide8 sections
- Separate global errors from failed tests
- Use worker identity without confusing process and slot
- Build a reporter that fails visibly when logging breaks
- Test serialization without manufacturing a worker crash
- Triage similar-looking failures with different owners
- Worked example: teardown throws after a result
- Worked example: the worker restarts but the resource slot should not
- Near-miss: a locator assertion failed inside a test
- Near-miss: workerInfo is absent
- Roll it out without creating a second silent failure
- Read the record as a lifecycle observation
- When workerInfo is not the answer
What you will learn
- Separate global errors from failed tests
- Use worker identity without confusing process and slot
- Build a reporter that fails visibly when logging breaks
- Test serialization without manufacturing a worker crash
A worker dies between test steps, and the report contains a stack trace with no test title. In a parallel run, "worker failed" is not enough to find the process log, container, or test-data slot that belonged to it. Guessing the last visible test can send the investigation to the wrong owner.
Recent Playwright versions can pass optional WorkerInfo to a custom reporter's onError callback. That context closes part of the gap. It does not turn a global error into an ordinary test failure, and it does not prove which test caused the process-level problem.
Separate global errors from failed tests
The Reporter API has different callbacks because the runner observes different events. onTestEnd(test, result) runs after a test attempt and receives the TestCase plus its completed TestResult. That is where a reporter can safely read the test title, status, retry number, duration, attachments, and test error.
onError(error, workerInfo?) reports something that went wrong outside test execution. The official example is an unhandled exception in a worker process. Its first argument is a TestError. Its second argument, added in Playwright 1.60, is present when the error is associated with a worker and absent when it is not.
The missing TestCase is not an inconvenience to code around. It is an accuracy boundary. A worker can run setup, hooks, fixtures, multiple tests, and teardown during its lifetime. A process-level exception may occur after a test result was emitted or while no test is active. Recording the most recently started test as "the cause" converts timing proximity into a false fact.
A sound reporter emits two record kinds:
- A worker-associated global error includes the project name, unique worker index, parallel slot, error details, and observation time.
- A runner-level global error includes the error details and explicitly null worker fields.
Ordinary assertion failures remain test-result records from onTestEnd. Keeping those streams distinct makes downstream dashboards less convenient at first and much more trustworthy during an incident.
Reporter call order can help with correlation without changing that rule. onTestBegin announces an attempt, step callbacks describe work while it runs, and onTestEnd closes the attempt. A global error can arrive around those events, but temporal overlap is not causal attribution. If you retain an "active attempts" snapshot for investigation, name the field activeCandidates and allow more than one in parallel execution. Never rename it failedTest unless a completed TestResult supports that claim.
The distinction also protects counts. One worker-level exception may disrupt a test, trigger a restart, and be followed by a failed test result. Counting both records as two worker crashes exaggerates the infrastructure problem. Keep event identity, test outcome, and any inferred relationship as separate fields so a later analysis can join them without erasing their origins.
The error object can expose a message, stack, source location, thrown value, and cause when available. Not every thrown value is an Error, so message is not guaranteed. Serialize only documented properties, handle missing values, and avoid dumping arbitrary configuration into logs.
Use worker identity without confusing process and slot
WorkerInfo is a subset of the context available to tests and worker-scoped fixtures. The properties relevant to correlation are workerIndex, parallelIndex, project, and processed config.
workerIndex identifies a unique worker process in the run. When Playwright discards a worker after failure and starts another, the replacement gets a new worker index. Use this value to join a global error with logs or resources tied to that exact process lifetime.
parallelIndex identifies a worker slot from zero up to the configured worker count minus one. A replacement worker keeps the same parallel index. This makes it suitable for resources that must remain partitioned by concurrent slot even as processes restart, such as a test database schema allocated to slot 2.
Consider a failure sequence with two configured workers:
workerIndex=0 parallelIndex=0 project=chromium started
workerIndex=1 parallelIndex=1 project=chromium started
workerIndex=1 parallelIndex=1 globalError="unhandled rejection"
workerIndex=2 parallelIndex=1 project=chromium startedThese lines are illustrative output, not a transcript from a measured run. They show the documented identity rule. Worker 2 replaced worker 1 in parallel slot 1. A report that groups only by parallelIndex would merge two process lifetimes. A cleanup system that allocates permanent slot resources by workerIndex would leak or collide after restarts.
Project name supplies another necessary dimension. Separate Chromium and WebKit projects can each use parallel slot 0. A practical resource key is therefore project plus parallel index, while a practical process-log key is run plus project plus worker index. Add the CI shard identity outside Playwright when multiple jobs produce their own index spaces.
Worker indexes are scoped to a Playwright invocation, not to your entire CI organization. Shard 1 and shard 2 can both report worker 0. A central log record therefore needs externally supplied run and shard fields before its worker fields become globally useful. Read those values from approved CI environment variables in your integration layer. Do not imply that Playwright's workerIndex is unique across machines.
Do not serialize workerInfo.config wholesale. Processed configuration can be large and may include metadata your organization does not want in an error stream. Select the fields you have approved, such as project name and indexes. If a base URL or environment label is necessary, add a non-secret value deliberately.
Build a reporter that fails visibly when logging breaks
The following reporter writes newline-delimited JSON. The pure formatter makes the record easy to unit test, and the class handles file-write errors without throwing them back into a callback that Playwright will swallow.
import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import type {
FullResult,
Reporter,
TestError,
WorkerInfo,
} from '@playwright/test/reporter';
type WorkerContext = {
workerIndex: number;
parallelIndex: number;
project: { name: string };
};
export type GlobalErrorRecord = {
kind: 'worker-global-error' | 'runner-global-error';
observedAt: string;
message: string;
stack?: string;
location?: { file: string; line: number; column: number };
project: string | null;
workerIndex: number | null;
parallelIndex: number | null;
};
export function toGlobalErrorRecord(
error: Pick<TestError, 'message' | 'value' | 'stack' | 'location'>,
workerInfo?: WorkerContext,
): GlobalErrorRecord {
return {
kind: workerInfo ? 'worker-global-error' : 'runner-global-error',
observedAt: new Date().toISOString(),
message: error.message ?? error.value ?? 'Unknown Playwright global error',
stack: error.stack,
location: error.location,
project: workerInfo?.project.name ?? null,
workerIndex: workerInfo?.workerIndex ?? null,
parallelIndex: workerInfo?.parallelIndex ?? null,
};
}
export default class WorkerErrorReporter implements Reporter {
private readonly outputFile: string;
private outputFailure?: string;
constructor(options: { outputFile?: string } = {}) {
this.outputFile = resolve(
options.outputFile ?? 'playwright-diagnostics/worker-errors.ndjson',
);
try {
mkdirSync(dirname(this.outputFile), { recursive: true });
writeFileSync(this.outputFile, '');
} catch (error) {
this.outputFailure = String(error);
}
}
onError(error: TestError, workerInfo?: WorkerInfo): void {
try {
const record = toGlobalErrorRecord(error, workerInfo);
appendFileSync(this.outputFile, `${JSON.stringify(record)}\n`);
} catch (writeError) {
this.outputFailure = String(writeError);
}
}
onEnd(_result: FullResult): void | { status: 'failed' } {
if (!this.outputFailure)
return;
process.stderr.write(
`Worker error reporter could not write output: ${this.outputFailure}\n`,
);
return { status: 'failed' };
}
printsToStdio(): boolean {
return false;
}
}The observedAt value is a real clock reading taken by the reporter. It is not the time the underlying fault began. Use it for correlation with a reasonable tolerance, not as a duration measurement.
The reporter creates an empty output file even when no global error occurs. That makes CI artifact checks predictable. A zero-byte file means the callback wrote no records, assuming the reporter itself loaded and its onEnd did not flag an output failure.
The formatter intentionally stops at the documented top-level fields. TestError can contain a cause, and blindly serializing a cause chain can produce noisy or unexpectedly deep records. If cause data matters in your incidents, add a bounded formatter that selects message, location, and stack at each level. Test the maximum depth and truncation marker. Do not spread the object and assume every future property is safe to publish.
Error grouping needs the same restraint. Raw messages can contain changing paths, ports, ids, or user data. Keep the original message for investigation, but derive any fingerprint from reviewed patterns rather than deleting every number with one regular expression. Two different failures can collapse into one signature, and one failure can fragment across environments. A grouping key is an aid for triage, not a replacement for the original TestError evidence.
Non-Error throws use value instead of message. The fallback in the sample preserves that text. Treat it as untrusted output: escape it in HTML, apply log-size limits in downstream systems, and never execute or parse it as code. The NDJSON writer handles it as a JSON string.
Returning { status: 'failed' } from onEnd is a documented way for a reporter to affect the run result. This sample uses it only when its required error channel could not be written. Some teams prefer observability failure to warn rather than block. Make that policy explicit in code; do not rely on an exception thrown inside onError.
Configure the custom reporter beside a human-readable reporter rather than replacing one with the other:
import { defineConfig } from '@playwright/test';
export default defineConfig({
reporter: [
['line'],
['./reporters/worker-errors.ts', {
outputFile: 'playwright-diagnostics/worker-errors.ndjson',
}],
['html', { open: 'never' }],
],
});printsToStdio() returns false because this reporter's normal output is a file. The explicit line reporter remains responsible for terminal progress. The HTML reporter remains responsible for test-level inspection. One event sink should not pretend to replace all three jobs.
Test serialization without manufacturing a worker crash
An integration test that deliberately creates an unhandled process exception is brittle and can destabilize the test runner used to verify it. Unit-test the formatter first. This catches null handling, field names, and accidental serialization changes without depending on undocumented crash timing.
import { test, expect } from '@playwright/test';
import { toGlobalErrorRecord } from '../reporters/worker-errors';
test('formats a worker-associated global error', () => {
const record = toGlobalErrorRecord(
{
message: 'fixture teardown rejected',
stack: 'Error: fixture teardown rejected\n at teardown.ts:14:9',
location: { file: 'teardown.ts', line: 14, column: 9 },
value: undefined,
},
{
project: { name: 'chromium' },
workerIndex: 7,
parallelIndex: 2,
},
);
expect(record).toMatchObject({
kind: 'worker-global-error',
message: 'fixture teardown rejected',
project: 'chromium',
workerIndex: 7,
parallelIndex: 2,
});
expect(record.observedAt).toEqual(expect.any(String));
});
test('keeps runner-level errors unattributed', () => {
const record = toGlobalErrorRecord({
message: undefined,
value: 'runner-level global fault',
stack: undefined,
location: undefined,
});
expect(record.kind).toBe('runner-global-error');
expect(record.workerIndex).toBeNull();
expect(record.parallelIndex).toBeNull();
expect(record.project).toBeNull();
});Run a TypeScript check as a separate command because Playwright can execute TypeScript without performing a complete type check. This is particularly important when adopting the optional second argument, since an older installed type definition will expose the version mismatch immediately.
npx playwright --version
npx tsc -p tsconfig.json --noEmit
npx playwright test tests/worker-error-record.spec.tsAfter the unit boundary passes, validate the reporter in a disposable CI branch against a real failure your environment already reproduces. Do not add an unhandled rejection to a permanent end-to-end spec. Confirm that the record appears, that its project and indexes match surrounding runner logs, and that a runner-level event remains unattributed.
Validate merged-report behavior separately if the organization uses blob reports. Playwright calls the Reporter API again while producing merged outputs. A custom reporter included in the merge configuration can therefore observe replayed report events rather than a live worker process. The built-in API documentation calls out merged-report differences, including separate project objects from different shards. Keep the worker-error file reporter in live shard configuration unless you have a defined reason to regenerate its output during merge. Otherwise an incident pipeline may publish duplicate records from execution and report conversion.
Ordinary assertion failures are the negative control. Make one test fail an expectation and inspect the output. Its details should appear through the line and HTML reporters and onTestEnd, while worker-errors.ndjson should not gain a record merely because a test assertion failed. If it does, the custom implementation is mixing event channels.
Triage similar-looking failures with different owners
Worked example: teardown throws after a result
A worker-scoped fixture starts a lease-renewal timer, and that timer later rejects without a handler after a test result has completed. The HTML report shows completed tests, then an unhandled worker error appears. WorkerInfo identifies project webkit, worker process 11, parallel slot 3.
Search the CI log stream for the same run, shard, project, and worker index. Inspect fixture timer and teardown logs plus the lease identifier allocated to project and parallel slot. Do not attach the error to the last completed test as fact. That test may only be the final consumer of a broken worker-scoped fixture.
The durable fix is idempotent, observable teardown at the fixture boundary. Record the resource id before cleanup, distinguish an already-released response from a transport failure, and decide whether failed cleanup should fail the run. The trade-off is more fixture code and possibly a slower shutdown. The benefit is that leaked shared resources stop appearing as mysterious test flakes in the next job.
Worked example: the worker restarts but the resource slot should not
A failed test causes Playwright to discard its worker. The replacement receives a new workerIndex but the same parallelIndex. A database helper names schemas with workerIndex, so retry execution connects to a fresh empty schema while the original schema remains allocated.
The reporter record shows worker 5 failing in parallel slot 1, followed by logs from worker 8 in slot 1. That pattern separates expected process replacement from two simultaneous workers. Rename persistent slot resources with project plus parallelIndex, and include the run or shard identity if separate CI jobs share the same database.
This change has a cleanup cost. A slot resource can outlive more than one worker process, so final cleanup cannot belong only to the first process that created it. Use an outer job or project lifecycle that knows when the slot is no longer needed. Do not blindly change every worker-scoped resource; temporary process-local files should still use the unique worker index.
Near-miss: a locator assertion failed inside a test
The log mentions a worker and a test timeout, which tempts someone to route it through onError. The HTML report contains a completed TestResult with a failed status, retry number, attachment list, and locator call log. That is a test failure, even though it ran in a worker.
Handle it in onTestEnd. There you can safely include test.id, test.titlePath(), result.retry, result.status, and the documented error details. Keeping it out of the global error stream prevents dashboards from inflating worker-crash counts with ordinary product or automation failures.
Near-miss: workerInfo is absent
A global error arrives with workerInfo === undefined. The correct record says runner-level and leaves project and indexes null. Search runner stderr, configuration loading, reporter setup, and orchestration logs. Do not substitute zero, because zero is a real worker or parallel index and would create a false correlation.
This absence is itself useful evidence. It narrows the investigation away from a specific worker process. It does not identify the exact runner subsystem, so preserve the original stack, value, and location rather than converting every absent-worker event into one generic category.
Roll it out without creating a second silent failure
Start with file output and a normal line reporter. Do not begin by sending records directly to an external incident service from onError. Network calls add authentication, latency, rate limits, and a new failure path while the runner is already handling an error.
Upload the NDJSON file with the rest of the report even when tests fail:
- name: Run Playwright
run: npx playwright test
- name: Upload reports and global errors
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v5
with:
name: playwright-diagnostics-${{ github.run_attempt }}
path: |
playwright-report/
playwright-diagnostics/worker-errors.ndjson
retention-days: 14The retention value is an example policy, not a measured optimum. Error stacks and project metadata may be sensitive. Apply the same access and retention review used for traces, videos, and screenshots.
Canary the reporter on one required job and monitor three outcomes: records are written for genuine global errors, ordinary test failures do not create records, and output failures make themselves visible. Test the last case in an isolated environment by pointing outputFile at a location the job cannot write. Do not perform that experiment in the production release workflow.
If a later pipeline publishes records to a log platform, keep the local artifact as a fallback until delivery is proven. Batch upload in onExit or a post-test CI step can be easier to retry than making one network request per error. Remember that reporter hooks run on the test-runner side, and added synchronous file work can delay reporting under an error storm.
Define what happens when the sink is unavailable. Blocking the run protects audit completeness but can stop releases for an observability outage. Warning preserves availability but leaves a blind spot. The sample chooses failure because its file is treated as required evidence. A team that chooses warning should emit a conspicuous terminal message and a CI annotation, then test that degraded path. Silence is not a neutral third option.
Avoid high-cardinality labels in metrics. workerIndex is excellent for one-run correlation and poor as a long-lived dashboard dimension because it changes across processes and runs. Aggregate by approved project, error signature, and CI environment, then keep raw indexes in searchable event fields.
The implementation cost is real: custom code, type compatibility across Playwright upgrades, artifact plumbing, privacy review, and unit tests. Adopt it when global worker errors are frequent or expensive enough to justify that ownership. The optional context is not a reason to build a reporting platform around a suite that has never produced such errors.
Read the record as a lifecycle observation
The most important field in the sample output is kind. A healthy worker-associated record says worker-global-error, has a non-null project, worker index, and parallel index, and preserves the original message or thrown value. A healthy runner-level record says runner-global-error and has null worker fields. Those nulls are information. Replacing them with zero creates a convincing but false link to the real worker in slot zero.
Output health has a separate shape. When the reporter loads and observes no global errors, the sample leaves a zero-byte NDJSON file and the run has no reporter-output warning. When an expected fault occurs, each physical line should parse as one complete JSON value, with message plus any available stack and location. A missing file points to reporter loading, path, or initialization before it points to an unusually quiet run. A zero-byte file accompanied by the sample's stderr warning and failed final status is also broken output, not proof that no worker failed. The misleading case is a valid nonempty file copied from a report-merge or earlier run. Its JSON is well formed, but its provenance does not match the current live invocation.
The observation time needs equally careful reading. It says when the runner-side reporter handled the event. It does not say when the rejected operation began, how long a worker was unhealthy, or which active test scheduled the work. Compare it with ordered runner events and logs, but do not subtract it from an application timestamp and call the difference worker-crash latency.
Two defects often produce the same final text, unhandled rejection, from the same helper module. A test can start asynchronous work and return without awaiting it; the work rejects after the test result is emitted. A worker-scoped fixture can instead own a renewal loop that rejects during or after fixture teardown. Both faults may be observed against the same worker and both may sit near the last completed test.
Event order and resource ownership separate them. If the test result closes before the rejection and the stack points to work launched by a test helper, inspect for a promise that escaped the test's awaited control flow. The fix belongs at the call site: await the operation, return its promise, or give the owner a defined cancellation and drain step. If fixture teardown begins first, the rejected operation uses a worker-scoped resource identifier, and the stack leads through fixture cleanup or its timer callback, the fixture owner must stop and await that background work. Attaching the fault to the last test in either case is still inaccurate. The nearby test is a candidate consumer, not the event owner.
The fixes spend different resources. Awaiting previously detached work can extend every affected test by the operation's real completion time. Draining a worker-scoped loop adds shutdown latency once per worker and requires cancellation state that the framework must maintain. Simply suppressing the rejection keeps jobs fast but converts a known infrastructure failure into missing evidence and possible resource leakage.
For an existing reporting pipeline, land the nullable worker schema and unknown-record handling in consumers before enabling the producer. Dashboards that require a test id or assume every error has a worker index are the first likely breakpoints. Next, deploy the reporter with local file output on one job, prove the zero-error and forced sink-failure controls, and verify artifact provenance after download. Only then add central ingestion or make reporter-output failure release-blocking. This order keeps an old consumer from dropping the new runner-level records without notice.
The reporter maintainer owns serialization, output health, and compatibility with the installed Playwright types. The automation framework owner owns escaped test work and fixture lifecycle. The CI or observability owner owns artifact collection and central delivery. A handoff should contain the original NDJSON line, matching stderr, run and shard identity, project and both indexes, the project-local Playwright version, whether the file came from live execution or report processing, nearby test lifecycle events, and the source location from TestError when present. A screenshot of a dashboard card omits the evidence needed to choose an owner.
This technique does not catch a process that is killed before the runner can deliver an error callback. A hard container termination, runner loss, or abrupt resource kill can leave only CI and operating-system evidence. Correlate missing worker completion with the executor's exit reason and resource telemetry. No reporter callback can serialize an event after the process responsible for reporting it is gone.
When workerInfo is not the answer
Do not use it to label the test that "caused" a global error. The callback does not supply a test. Correlation can suggest a candidate, but the record must preserve that uncertainty.
Do not replace onTestEnd with onError. Assertions, test timeouts, expected failures, retries, and attachments belong to completed test results. Mixing them destroys the distinction that makes the new worker context valuable.
Avoid using workerIndex for data that should survive restarts. Use parallelIndex with project and CI-job identity for stable concurrent slots. Use the unique process index for logs and resources whose lifetime truly matches one worker process.
Do not dump the entire processed config, environment, or fixture state into the record. More context is not automatically safer or more useful. Select fields, redact where necessary, and keep secrets out of reporter output.
Skip a custom reporter when built-in stderr, HTML reports, and CI container logs already identify the problem. Every reporter becomes part of the test infrastructure and must be maintained across upgrades. A small incident volume may not repay that cost.
Finally, never treat a missing NDJSON line as proof that no global error occurred until you have verified reporter loading and output health. The sample deliberately creates an empty file and fails the run when writing breaks. Without those checks, the observability layer can fail silently at the exact moment it is supposed to explain the runner.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
When does a Playwright reporter receive onError?
Playwright calls `onError` for a global error outside normal test execution, such as an unhandled exception in a worker process. Ordinary test results, including assertion failures, belong in `onTestEnd` instead.
Why can workerInfo be undefined in onError?
Some global errors are not associated with a specific worker. The optional value was designed for that distinction, so reporters must record a runner-level error without manufacturing worker indexes.
Can onError tell me which test caused a worker crash?
Not reliably. The callback receives a `TestError` and optional `WorkerInfo`, but no `TestCase` or `TestResult`. Correlate timestamps and surrounding runner events, and label any test association as an inference rather than a fact.
What is the difference between workerIndex and parallelIndex?
A worker restart gets a new unique `workerIndex`, while its `parallelIndex` slot stays the same. Use the process index to identify one worker lifetime and the parallel index for resources meant to survive worker replacement.
Will throwing inside a custom reporter fail the Playwright run?
No dependable policy should rely on that. Playwright documents that errors thrown by custom reporter methods are swallowed, so catch output failures yourself and surface them through stderr or an `onEnd` status override.
RELATED GUIDES
Continue the learning route
GUIDE 01
Create Playwright Custom Reporter Attachments for Evidence
Master Playwright custom reporter attachments with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 02
Playwright Project Dependencies for Observable Setup and Teardown
Use Playwright project dependencies for visible, traceable setup and teardown with explicit prerequisites, artifacts, filtering, and cleanup.
GUIDE 03
Debug Memory Growth in Playwright Worker Processes
Master debug Playwright worker memory leak with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 04
Deterministic Resource Allocation with Playwright Worker Indexes
Allocate databases, accounts, ports, and tenants safely with Playwright parallelIndex, workerIndex, worker fixtures, and restart-aware cleanup.
GUIDE 05
Handle Popups and Multi-Page Journeys in Playwright Without Races
Capture Playwright popups before the click, coordinate several tabs, assert the right readiness signal, and diagnose multi-page test races.