Playwright architecture, selector reliability, and advanced interaction patterns.

Analyzing Test Failures with the Playwright Trace Viewer

A CI job goes red with expect(locator).toBeVisible() failed. The stack trace tells you which assertion broke but not why — and the failure does not reproduce on your machine. This is the exact situation the Playwright Trace Viewer was built for. A trace recorded on the failing run is a complete recording of what the browser did, so you diagnose the failure that already happened instead of trying to recreate it. This walkthrough takes a red run from artifact to root cause using nothing but the trace. It sits under Trace Viewer & Debugging, part of the wider Debugging & Test Observability guide.

From red CI run to root cause A failed run produces a trace.zip that is opened, scrubbed to the failing action, and read across DOM and network panels to find the cause. Red run trace.zip Open viewer show-trace Scrub to red action DOM snapshot was it there? Network panel 200 or 500? Root cause fix + verify
The diagnosis path is linear: open the trace, jump to the failing action, then read DOM and network to name the cause.

Root cause: the failure is invisible from the log alone

A bare assertion failure conflates many causes into one message. toBeVisible() failed can mean the element never rendered, rendered then was removed, rendered off-screen, was covered by an overlay, or rendered after the locator's timeout because the API that feeds it was slow. The log cannot tell these apart, so any fix you guess at is a coin flip. The trace removes the guessing: it holds the DOM at the instant the assertion ran and the network activity that preceded it, which is exactly the evidence needed to distinguish "the element was never there" from "the data arrived too late." The procedure below converts that evidence into a root cause every time.

The reason this matters more in CI than locally is that the two environments fail for different reasons. On a developer laptop the app is warm, the API is on localhost, and a race that needs 300 ms of latency to appear never appears. On a shared runner the CPU is contended, the database is cold, and the same test hits the exact timing window that exposes the bug. Reproducing that window by rerunning locally is guesswork; reading the recording of the run that already hit it is not.

What a trace archive actually holds

A trace.zip is not a video. It is a structured recording with several parallel streams, all stamped against one clock, and knowing which stream answers which question is most of the skill. The action log lists every Playwright call the test made, with its duration, its parameters and the line of source that issued it. Each action carries up to three DOM snapshots — before the action, at the moment of the action, and after it — and those snapshots are re-rendered live in the viewer rather than being screenshots, so you can hover elements, expand the tree and run the locator picker against a page that no longer exists. Alongside them sit a screenshot filmstrip for visual scrubbing, the network log, the console log, and the test's own attachments.

Two properties of that recording drive the whole workflow. First, selecting an action scopes every other panel to that action's slice of the timeline, which is what turns a thousand-line network log into the four requests that mattered. Second, the snapshot for a failing web-first assertion is the state at the last retry before the deadline, not the state of the page when the run finished — a distinction that explains most "but the element is clearly there" confusion. The Network and Console streams have enough depth to deserve their own treatment, covered in Reading Network and Console Tabs in Traces.

Minimal reproducible example

Here is a test that fails intermittently in CI. It asserts a confirmation banner after submitting an order, but the banner depends on a /api/orders POST that is sometimes slow.

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

test('order submission shows confirmation', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByLabel('Card number').fill('4242424242424242');
  await page.getByRole('button', { name: 'Place order' }).click();
  // This is the assertion that fails in CI when the POST is slow.
  await expect(page.getByText('Order confirmed')).toBeVisible();
});

To make sure a trace exists for the failure, configure capture on retry so CI records evidence automatically. This is the same setting described in Playwright Config & Fixtures.

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

export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  use: {
    // Record a full trace the first time a failing test is retried.
    trace: 'on-first-retry',
  },
});

Choosing when the trace gets written

on-first-retry is the setting most suites should run, but it has a consequence worth understanding before you go hunting for an artifact that does not exist: the first attempt is never recorded. A test that fails once and passes on retry produces a trace of the passing attempt. A test that fails deterministically produces a trace of a failing retry, which is what you want. And a bug that only manifests on a cold cache — the first attempt of the first shard — may be gone by the time recording starts, which is the case for switching that project to retain-on-failure.

Retry states that decide whether a trace exists A state machine showing an untraced first attempt branching on pass or fail, with only the retry path producing a trace.zip artifact. Attempt 1 tracing off Retry 1 tracing on trace.zip written for the retry Green first try no artifact kept fails keeps passes retain-on-failure records every attempt and discards the trace whenever the test passes
With on-first-retry the first attempt is deliberately untraced, so a bug that only appears on a cold first run needs retain-on-failure instead.

Retry counts and timeout budgets interact with this directly — a test whose assertion deadline is shorter than the API's slow tail will fail every attempt and burn three recordings to say the same thing, which is the tuning problem covered in Configuring Retries and Timeouts for Stable CI. Make sure the output directory is published as a job artifact too; on a sharded pipeline each machine writes its own test-results tree, so the wiring described in Running Playwright Tests in GitHub Actions with Sharding has to upload all of them, and the artifact options themselves are catalogued in Capturing Screenshots and Video on Test Failure.

Step-by-step fix

  1. Locate and download the trace artifact. From the failed CI job, download trace.zip (under test-results/<test>/). If your pipeline publishes the HTML report, the failed test row links to its trace directly. If no trace exists, the run was not configured with trace: 'on-first-retry' — fix that first, then rerun.
  2. Open the trace in the viewer. Run npx playwright show-trace path/to/trace.zip, or drag the file onto trace.playwright.dev, which parses it entirely client-side. The viewer opens to the action timeline with the failing run loaded. Nothing leaves your machine either way, so a trace from a staging environment can be inspected without a data-handling argument.
  3. Jump to the failing action. In the Actions list on the left, find the entry highlighted in red — the expect(getByText('Order confirmed')).toBeVisible() call. Click it to pin every panel to the moment that assertion timed out. Note its duration: an assertion that ran for exactly the configured timeout was starved, while one that failed in milliseconds hit a strict-mode or resolution error instead.
  4. Read the DOM snapshot at the failure. Open the Action/After snapshot tab and search the rendered DOM for the banner text. If "Order confirmed" is absent, the element never rendered — a data or logic problem, not a selector problem. If it is present but the locator did not match, the selector is wrong; revisit Reliable Selector Strategies for Playwright. The snapshot is interactive, so hover the element to see the accessible name Playwright would have matched against.
  5. Check the network panel for the triggering request. Open the Network tab and find the POST /api/orders issued by the click. Inspect its status and timing. A pending or 200-but-late response that resolves after the assertion's timeout is the smoking gun: the UI rendered the banner a few hundred milliseconds after the locator gave up.
  6. Apply the fix that matches the evidence. Because the snapshot showed the banner missing and the network showed a slow POST, the cause is a race, not a bug. Replace the implicit wait with an explicit wait on the response so the assertion only runs once the data has arrived — see below.
  7. Verify with a repeated run and a fresh trace. Rerun with npx playwright test --repeat-each=10 --trace on and confirm the test is now stable and the new trace shows the assertion firing after the POST resolves.

The fix that step 6 points to synchronizes the assertion with the network instead of with the clock:

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

test('order submission shows confirmation', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByLabel('Card number').fill('4242424242424242');
  // Wait for the POST to resolve before asserting on the banner it produces.
  const orderResponse = page.waitForResponse('**/api/orders');
  await page.getByRole('button', { name: 'Place order' }).click();
  await orderResponse; // gate the assertion on the real network event
  await expect(page.getByText('Order confirmed')).toBeVisible();
});

Matching the evidence pair to a verdict

Steps 4 and 5 produce two independent readings — whether the element existed in the snapshot, and what the request that feeds it did — and the diagnosis is the intersection of the two, never either one alone. A missing element with a healthy fast response points at the rendering layer. The same missing element with a response that landed after the deadline is a synchronization defect in the test. Reading only the snapshot would collapse both into "element not found", which is how teams end up raising a locator timeout and getting a fix that widens the timeout.

Snapshot state crossed with network outcome A matrix pairing whether the element appears in the DOM snapshot with how the feeding request behaved, giving six distinct verdicts. Trace evidence 200, fast 200, arrives late error or missing Element missing from the snapshot Render or logic bug in the app Race — await the response first Backend or mock contract fault Element present in the snapshot Selector fault or strict mode Overlay or animation covers Stale UI from a cached response Read the snapshot first, then the request that fed it — the verdict is the pair.
Six cells, six different fixes: the snapshot alone or the network log alone would merge several of them into one wrong conclusion.

Only the middle column of the top row is fixed by waiting. The left cell needs an application change, the right cell needs a backend or stub correction, and the entire bottom row needs the locator or the page state reconsidered rather than the timing. Choosing between waiting on traffic and waiting on rendered state is a decision with its own trade-offs, unpacked in Waiting for Network Idle vs Element State.

Troubleshooting variants

The failing action snapshot looks correct

If the DOM snapshot shows the element present and visible, the assertion likely failed on a transient state that the After snapshot already moved past. Use the Before snapshot and the timeline filmstrip to inspect the instant the locator's timeout expired, and check whether an overlay or animation covered the element. Auto-waiting assertions retry, so the relevant snapshot is the last attempt before the timeout, not the final page state.

There is no network entry for the request you expected

The request may have been served by a route handler. The Network tab flags fulfilled requests; if you mocked the endpoint per Network Interception Basics, confirm the mock returned the shape the UI expects. A mock that drifts from the real contract produces a green-looking request and a missing banner.

The trace opens but panels are empty

The trace was captured without snapshots or resources. If you started tracing manually, pass snapshots: true and screenshots: true to context.tracing.start(); otherwise switch to the config-driven trace: 'on-first-retry', which records everything needed.

The trace shows a passing run instead of the failure

This is the on-first-retry behaviour from the state machine above: the recording starts at the retry, and if the retry went green you are looking at a healthy run. Rerun the job with trace: 'retain-on-failure' so every attempt is recorded and only successful ones are discarded, or reproduce locally with --trace on and --repeat-each until an attempt fails. When the failure survives none of that, the cause is usually environmental rather than in the test — worker contention, a shared fixture, or ordering between specs, which the workflow in Detecting and Fixing Flaky Playwright Tests is built to isolate.

Verification

Confirm the root cause is fixed three ways. First, npx playwright test --repeat-each=20 passes with zero flakes. Second, open the new trace and confirm the POST /api/orders resolves before the toBeVisible() action on the timeline. Third, artificially slow the endpoint (throttle in DevTools or add latency to a mock) and confirm the test still passes because it now waits on the response rather than a fixed timeout. A test that survives an injected delay has had its race genuinely removed, not merely papered over.

Compare the two traces side by side while you are there. Open the failing archive and the green one in separate viewer tabs and step through the same action in each: the action durations should collapse, the assertion should now sit after the response rather than spanning it, and the number of retries recorded against the assertion should drop to one. That comparison is also the cheapest way to spot a fix that only moved the problem — an assertion that still burns two seconds before passing is waiting on something, and the trace names it. When the evidence points at the test's own logic rather than its timing, stepping through the same spec interactively with Debugging with Playwright Inspector and UI Mode is faster than another round of trace archaeology.

Frequently Asked Questions

Where does Playwright save the trace for a failed test?

By default under the output directory at test-results/<test-name>/trace.zip. When the HTML reporter is enabled, each failed test in the report links directly to its trace, and in CI you typically publish that directory as a job artifact so the trace is one click from the failed pipeline.

How do I know whether a failure is a race or a real bug from the trace?

Compare the DOM snapshot at the failing action with the network panel. If the element is absent and a feeding request resolved after the assertion's timeout, it is a race — fix it by waiting on the response. If the element is present but the locator did not match, or the request returned an error, it is a selector or backend bug.

Can I analyze a trace without installing Playwright?

Yes. Drag the trace.zip onto trace.playwright.dev, which runs the full viewer in your browser without uploading the file anywhere. This is handy for sharing a failure with a teammate who does not have the project checked out.

Is it worth recording a trace of a passing run?

Often, yes. A green trace captured with --trace on gives you a baseline to diff against: the same spec, the same actions, and the timings the run has when nothing is wrong. Keeping one alongside the red archive turns "this looks slow" into a measured difference, and it is the quickest way to see that an action which takes 80 ms on a healthy run took 4 seconds on the failing one.

Back to overview