# Playwright Trace Viewer as a Root-Cause Workflow, Not a Video Player

> A Playwright trace is a recorded crime scene, not a screencast. This walks through what each panel proves, a seven-step read order for a CI-only failure, and why the standard on-first-retry setting often hands you a recording of the run that passed.

- Author: [Shashank Rawlani](https://shashank.rawlani.com)

- Published: 2026-09-04T13:30:00.000Z

- Updated: 2026-09-04T11:08:05.966Z

- Category: Automation Tutorials

- Canonical source: https://automationtester.in/blog/automation-tutorials/playwright-trace-viewer-root-cause-workflow

- Tags: playwright, trace-viewer, debugging, ci-cd, flaky-tests, test-automation

**Quick answer:** A Playwright trace is not a video. It is a recording of every action with a full DOM snapshot before, during and after each one, plus the network log, the browser console, the call log and your source. Read it in a fixed order — Errors, then the failing action's Call and Log tabs, then the *Action* snapshot, then Network filtered to that action — and most CI-only failures resolve without ever running the test again. The mode you configure decides whether the trace you get is of the run that failed or of the retry that passed.

The pattern is familiar. A test is green on your machine and red on CI. Someone downloads the artifact, opens the trace, scrubs the film strip at the top, says "it looks like the button just wasn't there", adds a two-second wait, and the build goes green. Three weeks later the same test fails again for the same reason.

That is what happens when a trace is treated as a video. The film strip is the least informative thing in the file. A trace is a recorded crime scene: the DOM at the exact instant the failing action ran is preserved inside it, along with what Playwright was waiting for and every request that was in flight. Used properly it answers *why*, not just *what*.

## What is actually in the file

A trace is a `trace.zip`. Opening it with `npx playwright show-trace` or by dropping it on [trace.playwright.dev](https://trace.playwright.dev) gives you a fixed set of panels, and each one answers a different class of question. Knowing which is which is most of the skill.

- **Actions** — every action in order, with the locator used and how long it took. Selecting one reveals its snapshots, its log, and its source location.

- **Before / Action / After snapshots** — three complete DOM snapshots per action. *Before* is the DOM at the moment the action was called. *Action* is the DOM at the moment of the performed input, and it highlights both the target DOM node and the exact click position. *After* is the DOM once the action completed.

- **Call** — the mechanics of the action: duration, the locator, whether it ran in strict mode, which key was pressed.

- **Log** — what Playwright was doing internally: scrolling the element into view, waiting for it to be visible, enabled and stable, then performing the action.

- **Errors** — the error message for the failed test, with a red line on the timeline marking where it occurred.

- **Console** — browser console output and logs from the test file, with different icons distinguishing the two sources.

- **Network** — every request, sortable by type, status code, method, request, content type, duration and size, with request and response headers and bodies.

- **Source** — your test code, with the line for the selected action highlighted.

- **Metadata** — sits next to Actions; browser, viewport size, test duration.

- **Attachments** — anything the test attached, including visual-comparison diffs where you can slide the expected image over the actual one.

Two interactions turn this from a browser into an instrument. Double-clicking an action in the sidebar selects that action's time range and filters the Console and Network tabs to only what happened during it; dragging a start and end point on the timeline does the same for a span of actions. A "Show all" button restores the unfiltered view. Almost every triage question is really "what else was happening at this instant", and that is the control that answers it.

## Configuring capture, and the trap in the default advice

The recommended CI setting is the one everybody copies:

```
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  retries: 1,
  use: {
    trace: 'on-first-retry',
  },
});
```

It is a reasonable default, and it has two consequences that are not obvious from the name. Each mode differs along two independent axes: which runs it *records*, and which recordings it *keeps*.

ModeRecords a trace onKeeps the trace when

`'off'`never—

`'on'`every runalways

`'retain-on-failure'`every runthat run failed

`'retain-on-first-failure'`first run onlythe first run failed

`'retain-on-failure-and-retries'`every runthat run failed, or it is a retry

`'on-first-retry'`first retry onlyalways

`'on-all-retries'`every retryalways

The first consequence: **`'on-first-retry'` records nothing at all unless retries are configured.** If your config sets `trace: 'on-first-retry'` and `retries: 0`, no trace will ever exist. That combination is a common accident when someone tightens retries to stop hiding flakiness and forgets that the trace setting depended on them.

The second is sharper. Under `'on-first-retry'`, a test that fails on its first run and passes on the retry produces exactly one trace — *of the retry*. You get a complete, high-fidelity recording of a run that worked. The evidence of the failure was never recorded, because the first run was not being traced.

If the failures you care about are the intermittent ones, that is the wrong instrument. `'retain-on-first-failure'` records the first run only and keeps it only if it failed, which is precisely the crime scene you wanted, at the cost of recording overhead on every first run. `'retain-on-failure-and-retries'` keeps a trace for any run that failed *and* for every retry, so you can compare the failing run against the passing retry side by side. That comparison is the fastest way to identify a timing-sensitive dependency.

**Cost is real.** The documentation calls `trace: 'on'` "not recommended as it's performance heavy". Tracing every run captures a screencast and a full DOM snapshot on every action for every test in the suite. Reach for a `retain-on-*` mode instead: it records the same detail but discards it for runs that passed.

For finer control, pass an object instead of a string. `mode` takes the same values; `attachments`, `screenshots`, `snapshots` and `sources` all default to `true`:

```
// Cheaper traces: keep the DOM snapshots (the diagnostic payload) but drop
// the screencast film strip, which is the expensive part and the least useful.
export default defineConfig({
  use: {
    trace: {
      mode: 'retain-on-failure',
      screenshots: false,
      snapshots: true,
      sources: true,
    },
  },
});
```

Keep `sources: true` unless you have a reason not to. It embeds your test source into the zip, which is why a trace from CI shows the correct code in the Source panel even when opened weeks later on a machine that has never had the repository checked out.

## A read order for a CI-only failure

Triage collapses if you start by scrubbing. Work top-down instead, and stop as soon as the answer appears.

- **Errors.** Read the message and note where the red line lands on the timeline. This tells you which action to select — nothing more, but nothing less.

- **The failing action in the sidebar, then Call.** Confirm the locator that actually ran and how long the action took. A locator that ran for the full action timeout and a locator that failed in 30 ms are different bugs: the first waited and never resolved, the second resolved to something wrong immediately.

- **Log.** This is where the distinction is made explicit. "waiting for element to be visible, enabled and stable" that never completes means the element existed but was covered, disabled or animating. No such line means the locator never matched anything.

- **The *Action* snapshot.** Not *Before*, not *After*. Look at the highlighted node and the click position. This is where you discover the click landed on an overlay, a cookie banner, a sticky header, or a second element that matched.

- **Network, filtered to that action.** Double-click the action. If the failure is "the row is missing", the request that should have populated it either returned an error, returned an empty body, or had not returned yet. Open the response body and read it.

- **Console, still filtered.** A framework error thrown during render explains a DOM that stopped updating far better than any locator theory does.

- **Source, last.** Only once you know what the browser did should you go back to what the test asked for.

Roughly speaking, steps 2 and 3 separate "wrong locator" from "right locator, wrong state", and steps 4 through 6 explain how that state came about. Most triage sessions end at step 4 or 5.

## Why the snapshot beats re-running it locally

The instinct after a CI failure is to run the test locally with `--headed` and watch. That reproduces a *different* run on a *different* machine. If the failure is environment-dependent — a slower runner, a narrower viewport, a flag that differs by environment, a race that only opens under parallel load — you are not investigating the failure. You are hoping to generate a new one that resembles it.

The DOM snapshot removes that. It is the actual document from the actual failing run, and it is interactive. Concretely, it settles the questions local reproduction cannot:

- **"The element was there."** The snapshot shows whether it was there and covered, there and zero-height, or genuinely absent.

- **"It's just slow."** The Network tab shows whether the request completed, and what it returned. Slowness and a 500 look identical from the outside.

- **"It works for me."** The Metadata tab gives the viewport the run used. A responsive layout that collapses a nav into a hamburger at CI's viewport is invisible on your wider screen.

Reproduce locally only after the trace has told you what to reproduce.

## Getting traces out of CI intact

A trace you cannot retrieve is worth nothing. Traces are written into the output directory — `test-results` by default, cleaned at the start of each run, with a unique subdirectory per test — and the HTML reporter copies attachments, including trace zips, into a `data` subdirectory of the report folder. Uploading the report folder therefore carries the traces with it. The workflow Playwright scaffolds does exactly that:

```
# .github/workflows/playwright.yml
- name: Run Playwright tests
  run: npx playwright test
- uses: actions/upload-artifact@v4
  if: ${{ !cancelled() }}
  with:
    name: playwright-report
    path: playwright-report/
    retention-days: 30
```

The `if: ${{ !cancelled() }}` condition is the load-bearing part. A step with no condition is skipped when the test step fails, which uploads artifacts only for runs that had nothing interesting in them.

Two settings quietly delete evidence. `preserveOutput: 'never'` discards the output directory for all tests; `'failures-only'` keeps it just for failures. The default is `'always'`. And if you upload the raw `test-results` directory rather than the report, you get the zips without the index that maps them to test names.

To read a downloaded artifact, unzip the report and serve it — opening `index.html` from the filesystem does not work — then click the trace icon beside the failing test:

```
# Serve a downloaded HTML report. Playwright also accepts the .zip directly,
# as long as index.html sits at the top level of the archive.
npx playwright show-report playwright-report.zip

# Or open a single trace, from a path, a directory, or a URL.
npx playwright show-trace test-results/checkout-pays-with-card/trace.zip
npx playwright show-trace https://ci.example.com/artifacts/1842/trace.zip

# Serve the viewer somewhere reachable, e.g. from inside a container.
npx playwright show-trace --host 0.0.0.0 --port 9323 trace.zip
```

If your team already publishes artifacts to object storage, you can skip the download entirely by passing the URL to the hosted viewer as a query parameter — `https://trace.playwright.dev/?trace=<url>` — subject to CORS on the storage bucket. That viewer is statically hosted and loads the trace entirely in your browser without transmitting it anywhere, which is usually what a security review needs to hear before you paste a production-shaped trace into it.

One habit makes every trace easier to read: wrap meaningful phases in `test.step()`. Steps appear as collapsible groups in the Actions sidebar, so a fifty-action trace becomes six named phases you can scan.

```
await test.step('sign in as a returning customer', async () => {
  await page.getByLabel('Email').fill(user.email);
  await page.getByRole('button', { name: 'Continue' }).click();
});
```

If you use Playwright as a library rather than through the test runner, tracing is driven manually on the browser context:

```
const context = await browser.newContext();
await context.tracing.start({ screenshots: true, snapshots: true });
const page = await context.newPage();
await page.goto('https://example.com');
await context.tracing.stop({ path: 'trace.zip' });
```

## Apply this now

Open your `playwright.config.ts` and check one thing: whether `retries` is greater than zero if `trace` is set to `'on-first-retry'`. If retries are zero, you have been running with tracing effectively disabled. Then decide which run you actually need recorded. If you are chasing intermittent CI failures, switch to `'retain-on-failure-and-retries'` for a sprint so that you get both the failing run and the passing retry from the same test.

Then take the most recent CI failure your team explained away with a wait, and read its trace in the order above. The evidence to capture is specific: the locator from the Call tab, the last line of the Log tab, and a description of the *Action* snapshot. If those three do not agree with the fix that was applied, the fix was a guess.

## FAQ

### Why is there no trace for a test that clearly failed?

Most often the mode requires a retry that never happened: `'on-first-retry'` and `'on-all-retries'` only record retries. Other causes are `preserveOutput` set to `'never'`, an upload step that was skipped because it lacked `if: ${{ !cancelled() }}`, or a crash that killed the worker before the zip was finalised.

### The trace I downloaded shows the test passing. Is it corrupt?

No — that is `'on-first-retry'` behaving as documented. It records the first retry and keeps it unconditionally, so when the retry passes you receive a trace of a passing run. Use `'retain-on-first-failure'` or `'retain-on-failure-and-retries'` to keep the failing run.

### Should I record video as well?

Video accepts the same set of modes as trace, but it gives you pixels where the trace gives you a queryable DOM, network log and call log. If you must choose one for CI, choose the trace. Video earns its cost when the bug is visual — an animation, a rendering glitch — rather than structural.

### Is it safe to open a trace on trace.playwright.dev?

The viewer is statically hosted and loads the trace entirely in your browser; it does not transmit the file anywhere. The separate question is what is inside the zip: response bodies, headers and console output are all captured, so a trace from an authenticated run can contain tokens and personal data. Treat the file itself with the same care as a production log.

### How is this different from just reading the HTML report?

The report tells you which tests failed and shows the error and call log. The trace tells you what the browser looked like at the moment of failure. The report is the index; the trace is the evidence.

## References

- [Trace viewer](https://playwright.dev/docs/trace-viewer) — establishes the panel set (Actions, Before/Action/After snapshots, Source, Call, Log, Errors, Console, Network, Metadata, Attachments), the filtering behaviour of double-click and the timeline, and the `show-trace` and `trace.playwright.dev` entry points.

- [TestOptions — `trace`](https://playwright.dev/docs/api/class-testoptions) — the authoritative definition of every mode string and of the object form's `mode`, `attachments`, `screenshots`, `snapshots` and `sources` fields.

- [Test use options — trace modes](https://playwright.dev/docs/test-use-options#trace-modes) — the record-versus-keep comparison table, including which trace survives when a test fails then passes on retry.

- [TestConfig](https://playwright.dev/docs/api/class-testconfig) — `outputDir` defaults and cleanup behaviour, and the `preserveOutput` values that decide whether artifacts survive the run.

- [Reporters](https://playwright.dev/docs/test-reporters) — that HTML report attachments live in the `data` subdirectory, and that `show-report` accepts a `.zip` with `index.html` at its top level.

- [Setting up CI](https://playwright.dev/docs/ci-intro) — the scaffolded GitHub Actions workflow and its artifact-upload step.

- [Command line](https://playwright.dev/docs/test-cli) — `show-trace` options, including `--browser`, `--host` and `--port`.

## Continue reading on AutomationTester.in

- [Design Playwright Fixtures for Parallel Test Isolation](https://automationtester.in/blog/automation-tutorials/playwright-fixtures-parallel-test-isolation)
- [Debug Playwright Strict-Mode Locator Failures](https://automationtester.in/blog/automation-tutorials/debug-playwright-strict-mode-locator-failures)
- [Playwright Auto-Waiting vs Business-State Waiting](https://automationtester.in/blog/automation-tutorials/playwright-auto-waiting-vs-business-state-waiting)
- [Playwright Test Architecture: Start with Risk, Not Pages](https://automationtester.in/blog/automation-tutorials/playwright-test-architecture-risk-not-page-objects)

Source: [Playwright Trace Viewer as a Root-Cause Workflow, Not a Video Player](https://automationtester.in/blog/automation-tutorials/playwright-trace-viewer-root-cause-workflow) by Shashank Rawlani.
