Playwright architecture, selector reliability, and advanced interaction patterns.

Trace Viewer & Debugging

The Playwright Trace Viewer is the closest thing to a flight recorder for an automated browser. With tracing enabled, every test run is captured into a single trace.zip containing a frame-by-frame timeline of actions, before-and-after DOM snapshots, the complete network log, console output, and a source map back to the exact line of test code. Open it with npx playwright show-trace and you get a time-travel debugger: scrub to any action and see precisely what the page looked like, what the network was doing, and which locator the step resolved. This guide, part of Debugging & Test Observability, covers capturing traces, reading every panel, and turning a recorded failure into a fix.

Anatomy of a Playwright trace A trace.zip opened in the viewer exposes an action timeline plus synchronized network, console, and DOM snapshot panels. Run with --trace on trace.zip artifact show-trace viewer Action timeline goto() click() expect() failed scrub to any step DOM snapshot Network panel Console panel
One trace.zip drives the whole viewer: pick an action on the timeline and the DOM, network, and console panels jump to that instant.

What a trace records

A trace is not a log file — it is a structured recording with three synchronized layers. The action layer lists every Playwright call in order (goto(), click(), fill(), expect()), with timing and pass/fail status, and links each back to the source line that issued it. The snapshot layer stores a serialized DOM before and after each action, so you can hover a step and see the rendered page exactly as it was, including styles and the highlighted target element. The resource layer captures the full network waterfall, console messages, and any test attachments. Because all three are timestamped against one clock, selecting an action moves every panel to that moment — this is what makes "time travel" literal rather than a metaphor.

The practical consequence: a failed assertion is no longer a stack trace and a guess. You scrub to the failing expect(), read the DOM snapshot to see whether the element existed, and check the network panel to see whether the request that should have populated it actually returned. Most failures resolve in under a minute this way.

It is worth being precise about what a trace is not. It is not a video recording, though it contains a screenshot filmstrip; it is not a browser profile, so it will not tell you which JavaScript function burned 400ms of main thread; and it does not capture server-side state. What it does capture is the complete client-visible history of one test attempt, which is the surface area where almost all end-to-end failures actually live.

What is inside a trace.zip

Unzipping a trace is instructive even if you never do it again. The archive holds a newline-delimited JSON event stream describing every action, assertion, and network event; a set of .dat resource bodies for the responses the page received; the JPEG frames of the filmstrip; and the serialized DOM snapshots keyed by action id. The viewer is a static single-page app that reads those files and reconstructs the session, which is why it works offline and why no server ever sees your data.

Two consequences follow from that layout. First, trace size scales with the number and weight of network responses — a page pulling multi-megabyte JSON or uncompressed images produces a much heavier trace than a lean one, so a suite recording on in CI can generate gigabytes fast. Second, because resource bodies are stored verbatim, a trace of an authenticated session may contain tokens, personal data, or response payloads you would not want in a public artifact store. Treat traces as sensitive by default and keep the retention rules described later in this guide tight.

Prerequisites

Tracing is built into @playwright/test, so nothing extra needs installing — but three things need to be in place before a trace is useful. You need a project config you can edit, since the trace mode is a use option that belongs in defineConfig(); the details of that file are covered in Playwright Config & Fixtures. You need retries configured if you plan to use the retry-based trace modes, because those modes never fire in a suite with retries: 0. And you need a writable output directory that survives the run — in CI that means uploading test-results/ as a job artifact rather than letting the runner discard it.

One habit pays off immediately: keep the trace mode different locally and in CI. Local runs benefit from richer recording because you are watching them; CI runs benefit from recording only what a human will actually open. The config example further down encodes exactly that split.

Turning capture on

Tracing is off by default because recording carries overhead. You enable it in three ways depending on context.

For a one-off local run, pass the flag:

import { test, expect } from '@playwright/test';

// Run this spec with: npx playwright test --trace on
test('checkout completes', async ({ page }) => {
  await page.goto('/cart');
  await page.getByRole('button', { name: 'Checkout' }).click();
  await expect(page.getByText('Order confirmed')).toBeVisible();
});

For a whole project, set the mode in config. The most economical value is on-first-retry: green runs stay fast, and the moment a test is retried Playwright records a full trace of the retry so you have evidence without paying the cost on every run. This setting lives in Playwright Config & Fixtures and is the backbone of Flaky Test Management.

import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    // 'on' records always, 'retain-on-failure' keeps only failed traces,
    // 'on-first-retry' records the first retry — the best cost/evidence trade.
    trace: 'on-first-retry',
  },
});

For surgical control inside a test, drive the tracing API on the context directly. This is useful when you only want to trace one critical block of a long test.

import { test } from '@playwright/test';

test('trace only the risky block', async ({ context, page }) => {
  await context.tracing.start({ snapshots: true, screenshots: true });
  await page.goto('/reports');
  await page.getByRole('button', { name: 'Generate' }).click();
  // Write the trace to disk; open later with show-trace.
  await context.tracing.stop({ path: 'trace.zip' });
});

Whichever mode you use, the output is a trace.zip written under the configured output directory (by default test-results/<test>/trace.zip).

Opening and reading the trace

Open any trace with the bundled viewer:

// Not test code — run in your shell:
// npx playwright show-trace test-results/checkout/trace.zip
import { test } from '@playwright/test';
test.skip('placeholder so the block compiles', async () => {});

You can also drag a trace.zip onto trace.playwright.dev — the viewer is a static web app, so traces are never uploaded anywhere. Inside the viewer:

Snapshots are interactive — you can open browser DevTools against a stored DOM snapshot and inspect elements as if the page were live, which makes selector debugging far easier than reading Reliable Selector Strategies for Playwright advice in the abstract.

A repeatable triage workflow

Reading a trace well is less about the viewer's features than about the order in which you consult them. The sequence below turns an unfamiliar red run into a named root cause without guessing, and it works identically for a local failure and a CI-only one.

Trace triage sequence from red CI run to root cause A sequence diagram showing a CI job uploading a trace, an engineer downloading and opening it, and the viewer returning the failing action and its network evidence. CI job Artifact store Engineer Trace Viewer upload trace.zip fetch on red check open show-trace pin failing action read snapshot + network named root cause
The triage loop is fixed: the CI job publishes the trace, the engineer pins the failing action, then the snapshot and network panels supply the cause.

Start at the failing action, not at the top. The viewer highlights it, and it is the only step whose surroundings matter. Read the error text attached to it — a timeout waiting for an element reads differently from a strict mode violation, and the two send you to different panels. Next, open the Before snapshot for that action and ask a single question: did the element exist at that moment? If it did, you have a state problem (disabled, covered, off-screen). If it did not, jump to the network panel and look for the request that was supposed to create it.

Only after those two checks does the rest of the timeline become interesting. Walk backwards to the last action that clearly succeeded and compare its After snapshot with the failing step's Before snapshot; whatever changed between them is where the run diverged from expectation. This backwards walk is the technique expanded step by step in Analyzing Test Failures with the Playwright Trace Viewer.

The trace modes, compared

Choosing the right trace mode is the difference between a fast suite with evidence when you need it and a slow suite that records gigabytes nobody reads. There are five values for trace:

Trace mode comparison matrix A matrix comparing the five Playwright trace modes by runtime cost, disk cost, and the evidence each one produces. trace mode runtime cost disk cost evidence kept off none none nothing on high, always very large every attempt retain-on-failure high, always failures only each failure on-first-retry none on green small first flaky retry on-all-retries none on green medium every retry Outlined row is the recommended CI default
Runtime cost, storage cost, and evidence value pull against each other; on-first-retry is the row that wins on all three for a CI suite.

The decision hinges on how you run. Locally, on while you debug a specific spec gives you a trace even when the test passes, which is invaluable when "passing" is itself suspicious. In CI, on-first-retry paired with retries: 2 is almost always correct: it costs nothing on the common path and produces a full trace exactly when a flake appears. If your team quarantines unstable specs rather than retrying them, as described in Quarantining Flaky Tests Without Blocking CI, promote the quarantined project to on so every quarantined run leaves evidence behind.

import { defineConfig } from '@playwright/test';

export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  use: {
    // Cheap on green, full evidence on the first flake.
    trace: process.env.CI ? 'on-first-retry' : 'off',
  },
});

The network panel in depth

The network panel is where trace analysis pays off most, because a large share of test failures are really data-timing failures wearing a selector costume. The panel shows every request the page made during the recording as a waterfall, with method, URL, status, size, and duration. Selecting an action on the timeline filters the waterfall to the requests in flight at that moment, so you can see exactly what the page was waiting on when an assertion fired.

Three readings are routine. First, status codes: a 500 or 403 on a request that should populate the UI explains a missing element immediately. Second, timing: a request that resolves after the failing assertion's timeout is a race — the data was on its way but the test gave up, which is the exact scenario weighed in Waiting for Network Idle vs Element State. Third, the request that never happened: if an expected call is absent from the waterfall, the action that should have triggered it did not fire, which points back to the click or submit one step earlier.

This panel is also how you confirm interception worked. When you mock or rewrite a request per Network Interception Basics, the trace flags the served request, so you can verify your handler ran and returned the shape you expected instead of trusting that it did. A mock that silently fails to match its glob shows up here as a real network call where you expected a fulfilled one — the same check that keeps a recorded fixture honest when you work through Recording and Replaying HAR Files.

The console panel is the network panel's twin, and the two are usually read together: an uncaught TypeError at the same timestamp as a 500 tells a complete story that neither panel tells alone. Reading Network and Console Tabs in Traces walks through correlating the two panels on a real failure, including how to filter the waterfall and what the console's page-versus-test message sources actually mean.

import { test, expect } from '@playwright/test';

test('trace shows the mocked request was served', async ({ page }) => {
  // Mock the endpoint so the network panel flags it as fulfilled, not live.
  await page.route('**/api/profile', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ name: 'Ada', plan: 'pro' }),
    });
  });
  await page.goto('/account');
  // If this fails, the trace network panel tells you whether the mock fired.
  await expect(page.getByText('Ada')).toBeVisible();
});

DOM snapshots and selector debugging

A trace's DOM snapshots are live, inspectable copies of the page at each phase of an action, not screenshots. That distinction is what makes them powerful for selector work. When an assertion fails because a locator matched zero or many elements, you open the snapshot, launch DevTools against it, and run the selector by hand to see what it actually resolved to. There is no faster way to fix a strict mode violation than to query the snapshot and watch three elements light up, which is why Resolving Strict Mode Violations starts from the trace.

The viewer outlines the target element of the selected action in the snapshot, so you can confirm at a glance that getByRole('button', { name: 'Save' }) landed on the button you meant and not a hidden duplicate in a collapsed menu. When it landed wrong, the snapshot shows you the real DOM structure to write a better locator against — turning the abstract advice in Reliable Selector Strategies for Playwright into a concrete fix for the page in front of you.

Snapshots come in Before, Action, and After variants. Before shows the page as the action began; Action captures the input point (for clicks, where the pointer landed); After shows the result. Comparing Before and After across the failing step often reveals the problem directly — a modal that opened over your target, a list that re-ordered, a spinner that never resolved.

Before, Action and After snapshots of one click Three snapshot panels along a single action timeline showing the target visible, the pointer landing on it, and a modal covering it afterwards. Before target element visible Action pointer lands here After modal on top target now covered t = start t = input t = end
One click produces three snapshots along the same clock; comparing Before with After is usually enough to name what changed underneath the locator.

Attaching context to a trace

Traces become more useful when they carry the test's own context. Anything you attach to a test — a screenshot, a downloaded file, a JSON payload — appears in the trace and the HTML report, so you can annotate a recording with the data that explains it. This is the bridge between traces and the broader artifact story in Reporters & Test Artifacts, and it is how a custom reporter can surface trace links alongside your own metadata.

import { test, expect } from '@playwright/test';

test('attach the response body for later inspection', async ({ page }, testInfo) => {
  const res = await page.request.get('/api/health');
  // Attach the raw body so it shows up in the trace and HTML report.
  await testInfo.attach('health.json', {
    body: await res.text(),
    contentType: 'application/json',
  });
  expect(res.ok()).toBeTruthy();
});

Attachments turn a trace from a recording of clicks into a record of decisions: the exact payload a test asserted against, the file it downloaded, the screenshot of a state worth keeping. When a failure needs more than the built-in snapshots to explain, an attachment is how you put that evidence in the same place everyone already looks. Visual diffs behave the same way — a failed comparison from Visual Regression Testing attaches its expected, actual, and diff images, and they ride along inside the trace of the attempt that produced them.

Failure modes and gotchas

Traces have a small set of recurring problems, and each has a specific cause worth recognizing before you start doubting the tool.

An empty or nearly empty trace almost always means manual tracing started without the options that populate the panels. context.tracing.start() defaults to recording actions only; without snapshots: true and screenshots: true you get an action list with no filmstrip and no DOM to inspect. The trace: 'on' config value sets all of them for you, which is why config-driven tracing rarely hits this.

No trace at all after a failure with on-first-retry set means the test never retried. Either retries is zero for the project, or the failure was a hard error that aborts the run rather than a test failure. Check the resolved config for the project that actually ran, since project-level use blocks override the top-level one.

A truncated trace on a hung test is expected: tracing writes its archive when the context closes, so a process killed by an external watchdog leaves nothing usable. Bound the run with timeout and expect.timeout so Playwright fails the test itself and gets the chance to flush, a discipline covered in Configuring Retries and Timeouts for Stable CI.

Missing source lines in the Source tab happen when the trace is opened on a machine that does not have the test files, or when the paths recorded in CI do not exist locally. The action list and snapshots still work; only the source view degrades. Opening the trace from a checkout at the same commit restores it.

A trace that is enormous points at heavy responses being stored verbatim. Trimming what the page loads — blocking images and fonts for scraping-style runs, or narrowing the traced block with the manual API — shrinks the archive far more than any trace option will.

Multiple contexts, multiple traces. Each BrowserContext records its own trace, so a test that opens a second context for a second user, as in Browser Contexts & Isolation, produces two archives. Open both when a cross-user interaction is the thing under suspicion.

CI/CD considerations

In CI, traces are written under the output directory and should be uploaded as job artifacts so a failed pipeline carries its own evidence. The mechanics of publishing belong to CI/CD Integration, but the principle is simple: a red check should be one click from the trace that explains it. Upload the whole test-results/ tree rather than globbing for trace.zip, so screenshots, videos, and attachments arrive together and the HTML report can link them.

Sharding changes the bookkeeping. When a suite runs across several shards, each shard produces its own output directory, and an artifact named identically in every job will collide or overwrite. Name the artifact after the shard index, then merge the blob reports into one HTML report so every trace is reachable from a single page — the pattern used in Running Playwright Tests in GitHub Actions with Sharding.

Retention is the trade-off to manage. Traces from on-first-retry are small because they only exist for flakes, while on in CI would produce a trace per test and quickly exhaust artifact storage. Match retention to the trace mode: a few days for verbose local-style runs, longer for the rare captures that are worth keeping. Because resource bodies are stored verbatim, also match retention to sensitivity — a trace of an authenticated flow belongs behind the same access controls as the environment it recorded.

Runtime overhead matters less than people expect once the mode is right, but it is not zero, and a job that already spends a minute downloading browsers has cheaper wins available in Caching Playwright Browsers in CI before you start trimming trace options.

Sharing and storing traces

A trace is most valuable when the right person can open it without friction. Because trace.zip is a single self-contained file, sharing is simple: send the file, or send a link to the published HTML report that embeds it. The recipient needs nothing installed — trace.playwright.dev opens it client-side, and the report links to the same viewer. For sensitive applications where the DOM or network panel might contain secrets, prefer the offline npx playwright show-trace and avoid uploading the file anywhere.

A convention worth adopting on a team: when you file a bug from a failing test, attach the trace rather than a screenshot. A screenshot shows one frame and invites debate; a trace shows the whole attempt and usually ends the debate in the first reply. Pair it with the screenshot and video capture described in Capturing Screenshots and Video on Test Failure when the audience is non-technical and a video communicates faster than a timeline.

When a trace is not the right tool

Traces are post-mortem evidence, which makes them the wrong tool for two jobs. The first is authoring: while you are still writing a flow and do not yet know the selector or the right wait, the record-then-read loop is too slow. For that, step through the live browser with the Inspector or UI mode. The second is a hang: a test that never finishes never writes a complete trace, so a stuck run needs a timeout to fail it first, after which the trace up to the hang becomes available. Set bounded timeout and expect.timeout values in Playwright Config & Fixtures so a hang turns into a diagnosable failure rather than an infinite wait.

For everything else — a failure you cannot reproduce, an intermittent flake, a CI-only break, a selector that resolves wrong — the trace is the fastest path from red to root cause, which is why it anchors the entire Debugging & Test Observability workflow.

Where to go next

This guide branches into three focused walkthroughs.

Analyzing Test Failures with the Playwright Trace Viewer is the end-to-end procedure for taking a red CI run, opening its trace, and pinning the exact action that broke.

Reading Network and Console Tabs in Traces goes panel by panel through the request waterfall and the console log, showing how to correlate a failed status code with the error message the page logged at the same instant.

Debugging with Playwright Inspector and UI Mode covers the interactive tooling — PWDEBUG=1, --debug, --ui, page.pause(), watch mode, and the locator picker — for stepping through a test live while you write it.

Frequently Asked Questions

Does enabling traces slow down my tests?

Recording adds overhead, which is why on is rarely used in CI. The recommended trace: 'on-first-retry' records nothing on a passing first attempt and only captures a full trace when a test is retried, so the cost lands exactly when you need the evidence and never on green runs.

Is my trace data uploaded anywhere when I use the online viewer?

No. The viewer at trace.playwright.dev is a fully client-side application; the trace.zip you drop in is parsed in your browser and never sent to a server. For sensitive data, npx playwright show-trace runs the same viewer entirely offline.

Why is the Network tab empty in my trace?

Network capture requires snapshots and resources to be recorded, which the standard trace modes enable. If you start tracing manually with context.tracing.start(), pass snapshots: true and screenshots: true; without them the timeline and resource panels will be sparse or empty.

Why was no trace written even though a test failed?

The retry-based modes only fire when a retry actually happens, so on-first-retry produces nothing in a project configured with retries: 0. Confirm which project ran and whether its use block overrides the top-level trace setting, and switch to retain-on-failure if you need a trace for every failure without retrying.

Can one test produce more than one trace?

Yes. Tracing is scoped to a browser context, so a test that creates additional contexts records a separate archive for each one. Open every archive when the interaction between two sessions — two signed-in users, or a main window and a popup — is what you are investigating.

Back to overview