Back to Blog
On this page5 sections

// deep dive

The Playwright trace viewer: what to look for when a test fails

qa.codesqa.codes · 20 February 2026 · 9 min read
Intermediate
playwrightdebuggingtooling

The Playwright trace viewer is the single feature most arguments for Playwright should lead with. Most teams use 10% of it. Here are the five patterns I look for in every trace, and the workflow we use when a CI run goes red at 4pm on a Friday.

Capture: trace on first retry only

Before you can use the trace viewer, you need traces being captured. The naive config captures a trace on every run — that's expensive and fills your artifact storage fast. The right config is 'on-first-retry':

// playwright.config.ts
export default defineConfig({
  use: {
    trace: 'on-first-retry',
  },
});

This means: if a test passes on the first attempt, no trace. If it fails and retries, capture a trace on the retry. The trace is the failure evidence — you only need it when something went wrong.

For debugging locally, 'on' gives you a trace on every run. For CI artifact budgets, 'on-first-retry' is the right production setting.

Once a trace is captured, it lands in test-results/<test-name>/trace.zip. Run the viewer with:

npx playwright show-trace test-results/my-test/trace.zip

Or upload to trace.playwright.dev — a hosted viewer that accepts a local file or a URL to a trace artifact. This is how you share a failed CI trace with a teammate without them needing to have Playwright installed.

The five tabs and what they actually tell you

Open any trace and you see five tabs. Most developers click through Actions and stop. Here's what each tab is actually for:

Actions — a timeline of every Playwright command: locator clicks, fills, assertions, navigations. Clicking an action shows the DOM snapshot at that moment. This is the starting point: what was the last action before the failure?

Source — the test code with the failing line highlighted. Useful context, but less diagnostic than the DOM snapshot in Actions. Jump here after you've identified the failing action to see surrounding test logic.

Network — every HTTP request made during the test: URL, method, status code, response time. This is where you find silent failures: a 404 that the test never asserted on, a 500 that the UI swallowed, a slow API call that caused a timeout. Sorted by time, making it easy to see what was in-flight when the test failed.

Console — browser console output for the duration of the test. JavaScript errors, unhandled promise rejections, deprecation warnings. Often the most direct signal: "Cannot read properties of null" in the console at the exact timestamp of a click failure tells you the component was rendering with missing data.

Snapshots — before/after DOM snapshots for each action, rendered as a visual screenshot. The before snapshot shows what Playwright saw when it attempted the action; the after shows the result. Critical for diagnosing click failures: the before snapshot shows whether the target element was actually visible and in the right position.

Reading the timeline: where the delay actually happened

The Actions timeline shows wall-clock time for each action. A well-functioning test has consistent action durations — assertions resolve in milliseconds, navigations in hundreds of milliseconds. When something goes wrong, the timeline gets irregular.

Three delay patterns to recognise:

Long wait before an action — Playwright spent most of that action's time in the actionability polling loop. The element existed in the DOM but wasn't meeting one of the five checks (visible, stable, receives events, enabled, attached). Check the before snapshot: is the element obscured by another element? Is there a loading overlay?

Long wait on an assertionexpect(locator).toBeVisible() took 8 seconds. This usually means the element appeared eventually but was delayed by a network call. Check the Network tab for a slow API response that triggered the render.

Instant failure on a navigationpage.goto('/dashboard') resolved immediately but the test failed right after. The page loaded but to an unexpected state (redirect to login, error page). Check the Snapshots tab for what the page actually showed.

The five failure patterns

1. Phantom click — a locator.click() action that completed without error, but the expected result didn't happen. Check the before snapshot: was the target element covered by another element (a sticky header, an open dropdown, a modal backdrop)? Playwright clicks the topmost element at the coordinates — if something's covering the target, the cover gets the click, not the target. Fix: scroll the element into view, close the covering element first, or use { force: true } only as a last resort.

2. Silent network failure — the test fails on an assertion that should have passed, but there's no obvious DOM reason in the snapshots. Open the Network tab. Look for 4xx or 5xx responses around the time of the failure. An API returning 403 that the UI swallowed (rendered an empty state rather than an error) leaves no visible trace in the DOM. The Network tab is the only place it shows up.

3. Accidental retry loop — an action takes 20–25 seconds and the test times out. Check the Actions tab: was Playwright retrying an assertion? This happens when an assertion never resolves — expect(locator).toHaveText('Ready') when the text never changed from 'Loading'. Look at the snapshots through the retry period. If the DOM is static and the assertion keeps failing, the app is stuck, not slow.

4. Stale snapshot — the test does something, then immediately asserts, but the assertion reads old DOM state. Common in React apps with batched state updates. The Snapshots before/after for the action shows the UI before the React re-render had committed. Fix: wait for a specific visible change before asserting, rather than asserting immediately after the action.

5. Timing-based flake — the test passes 19 times and fails once, always at the same action. Open the Network tab in the failing trace and the passing trace side by side. The failing trace has a slower response on a specific API call — 600ms vs the usual 80ms. The test was asserting before the data loaded. Fix: add an explicit expect(locator).toBeVisible() on the element that signals data readiness before the assertion that depends on it.

The Friday CI workflow

When a CI run goes red and you need to triage fast: the week we cut our flaky rate from 18% to 2%, the trace viewer was the tool that made the difference — we could share exact failure context without anyone having to reproduce locally.

The workflow:

  1. Download trace.zip from the CI artifacts panel
  2. Upload to trace.playwright.dev (no install needed, runs in-browser)
  3. Share the URL with anyone who needs to see it — they open it in their browser
  4. Start in Actions, identify the failing action
  5. Open Network, look for unexpected response codes around that timestamp
  6. Open Console, look for JavaScript errors at the same timestamp
  7. Look at the Snapshots before/after the failing action

In most cases, steps 4–7 give you the root cause in under five minutes. The failures that take longer are timing-based flakes — those require comparing two traces (a passing run and a failing run) and finding the divergence in the Network tab timings.

Configure trace capture today if you haven't. The next time a test fails in CI at 4pm on a Friday, you'll have everything you need to understand it without reproducing locally.


// related

Comparisons·15 April 2026 · 9 min read

Playwright vs Cypress in 2026: an honest comparison

After shipping production suites in both, here's the honest breakdown — where Playwright pulls ahead, where Cypress still wins, and the single factor that should actually decide it.

playwrightcypresscomparison
Deep dives·10 February 2026 · 10 min read

How Playwright's auto-waiting actually works

Cypress retries commands; Playwright auto-waits on actionability. Same problem, different solution. Here's what Playwright is actually doing when you call .click().

playwrightinternalsflaky-tests