Playwright architecture, selector reliability, and advanced interaction patterns.

Reading Network and Console Tabs in Traces

A Playwright failure almost never reports the thing that actually broke. The runner prints Timed out 5000ms waiting for expect(locator).toBeVisible() and points at an assertion, but the assertion is only the place where the damage surfaced. The real event — a 500 from the orders endpoint, a request the browser cancelled for CORS, an uncaught TypeError that killed the render — happened a second earlier and left no mark on the DOM. The Trace Viewer's Network and Console tabs are where those events are recorded, and this page is about reading them in the right order so the diagnosis takes two minutes instead of an afternoon of console.log.

Where the Network and Console tabs sit in the Trace Viewer The Trace Viewer layout: a timeline across the top, an action list on the left, a DOM snapshot pane, and a row of detail tabs with Network and Console highlighted. Timeline · drag to select a time window Actions page.goto orders getByRole click expect timed out selected action filters the tabs DOM snapshot at the selected action Network Console Errors Source Network: URL, method, status, size, duration Console: log, warning, uncaught page error
Selecting an action in the left-hand list scopes both the Network and Console tabs to that action's slice of the timeline.

Root cause: the assertion is a symptom, not the fault

Playwright's web-first assertions retry until a deadline, so a broken backend response and a broken selector produce byte-identical error text — a timeout on the locator. The information that distinguishes them lives one layer down: the HTTP exchange the page made, and the JavaScript that ran when the response came back. A trace records both. The Network tab holds every request the traced context issued, with its method, status, resource type, transfer size and duration; the Console tab holds every console.* message plus every uncaught exception and unhandled promise rejection the page emitted. Because both streams are timestamped against the same clock as the action list, the viewer can answer "what did the browser know at the moment this assertion started failing?" — which is the only question worth asking. This page sits under Trace Viewer & Debugging, inside Debugging & Test Observability.

Prerequisite: capture a trace that contains the failure

The tabs are only as good as what was recorded. Tracing is off by default, and the common CI setting records the retry rather than the first attempt, so the run you want is the one that failed twice.

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

export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  use: {
    // 'on-first-retry' keeps artifacts small: attempt 1 runs untraced,
    // the retry is recorded in full. Use 'retain-on-failure' when a bug
    // does not reproduce on retry, and 'on' only for local investigation.
    trace: 'on-first-retry',
  },
});

Open the resulting archive with npx playwright show-trace test-results/orders-list-renders-retry1/trace.zip, or drag it onto trace.playwright.dev, which runs entirely in the browser and uploads nothing. Artifact wiring for CI is covered in Capturing Screenshots and Video on Test Failure.

Minimal reproducible example

This test fails on the assertion, but nothing in the assertion is wrong. The click triggers a fetch, the fetch returns a 500, the render throws, and the row never appears.

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

test('order list renders after refresh', async ({ page }) => {
  await page.goto('/orders');

  // The click fires GET /api/orders in the background. Nothing here awaits it,
  // so a failed response is invisible to the test until an assertion starves.
  await page.getByRole('button', { name: 'Refresh' }).click();

  // This is the line that fails, but it is not the line that is broken.
  // Reported as: Timed out 5000ms waiting for expect(locator).toBeVisible()
  await expect(page.getByRole('row', { name: /ORD-/ }).first()).toBeVisible();
});

Step-by-step fix

  1. Select the failed action, not the top of the list. Open the trace and click the red entry at the bottom of the Actions list. The timeline highlights that action's window and both detail tabs immediately narrow to it. Reading the tabs with no action selected shows the whole run and buries the three lines that matter under two hundred irrelevant ones.
  2. Read the Console tab before the Network tab. An uncaught exception explains a dead UI faster than any HTTP status, because it tells you the page stopped rendering. Look for entries tagged as page errors — TypeError: Cannot read properties of undefined (reading 'map') is the classic signature of a component that received an error payload where it expected an array. Click the message to jump to its source location.
  3. Switch to Network and scan the status column. Sort or filter by status and look for three things in order: any 4xx or 5xx, any request whose status is empty or shows as failed or cancelled, and any request to a real host you believed was stubbed. The last one is how you discover that a page.route() glob never matched, a topic covered in Intercepting and Modifying Network Requests.
  4. Open the failing request and compare its body to the UI's expectation. Selecting a row reveals the request headers, the request payload and the response headers and body. A 200 with {"orders": null} fails a UI just as hard as a 500, and only the body pane shows it. Check the Authorization and Cookie request headers here too — a missing session cookie usually means the storage state expired, which Reusing Login State with storageState addresses.
  5. Widen the time window when the cause preceded the action. Drag across the timeline to select a range spanning the previous two or three actions, or click the earlier action instead. Preflight OPTIONS requests, token refreshes and hydration fetches frequently fire before the click that appears to fail, and scoping too tightly hides them.
  6. Correlate the two tabs on the same timestamp. A cancelled request in Network plus a CORS message in Console is one event, not two: the browser blocked the response and reported it only to the console. A 500 in Network plus a TypeError twenty milliseconds later is a cause and its consequence. Matching timestamps turns a list of symptoms into a chain.
  7. Convert the finding into a permanent assertion. Once the trace names the fault, add a check that fails on the fault directly rather than on a downstream timeout — a waitForResponse status assertion, or a pageerror listener that empties into an expect. The next regression then reports itself in one line instead of requiring another trace.
Correlating network, console and action lanes on one clock Three timeline lanes show a click, a GET request returning 500, a console TypeError, and the assertion timing out five seconds later. Test action Network log Console log click Refresh expect timed out GET /api/orders 500 response TypeError: rows is undefined no rows render 0 ms 180 ms 540 ms 5.5 s
The failure originates at 540 ms in the network lane; the assertion only reports it five seconds later, which is why reading the tabs in timestamp order matters.

Troubleshooting variants

The Network tab is empty even though the app clearly made requests

Three causes account for nearly all of these. First, a selection problem: an action is selected whose window predates the traffic, so widen the timeline range or clear the selection to see the whole run. Second, a service worker served the responses from its own cache, so the requests never crossed the network layer the trace observes — set serviceWorkers: 'block' in use while debugging, which forces every fetch back onto the wire. Third, the traffic belongs to a different context: tracing is started per BrowserContext, so a popup or a second context created inside the test records into its own trace or none at all. If you opened a context manually, call context.tracing.start({ screenshots: true, snapshots: true, sources: true }) on that object too.

Console shows a CORS error but Network reports the request as successful

This pair is expected behaviour, not a contradiction. The server answered — the trace records the real status — and the browser then refused to hand the body to the page because the Access-Control-Allow-Origin header did not match the page origin. The console message reads like Access to fetch at 'https://api.example.com/orders' from origin 'http://localhost:3000' has been blocked by CORS policy. Fix it at the source by serving the API through the same origin as the app under test, or stub the endpoint entirely with route.fulfill() so no cross-origin request is made; Mocking API Responses with Playwright covers the stubbing pattern.

The response body pane is blank for the request I need

Bodies are recorded opportunistically. A 204, a 304 and a preflight OPTIONS genuinely have no body. Responses that were streamed, aborted mid-flight, or turned into a download are not buffered, and very large payloads are skipped to keep the archive from ballooning. When the body is the evidence you need, capture it explicitly in the test with a waitForResponse and attach it via testInfo.attach(), or record the run to a HAR file, which stores full bodies — see Recording and Replaying HAR Files.

Triage tree from a timed-out assertion to a root cause A decision tree branching from an assertion timeout into console page errors, failing HTTP statuses, and missing requests, each with its remedy. Assertion timed out Console: page error Network: 4xx or 5xx Network: no request Fix the app error or unhandled rejection Re-check storageState and route() stubs Check the URL glob and service workers Re-run with tracing on to confirm the fix
Which tab holds the answer depends on the shape of the evidence, and each branch ends in a different class of remedy.

Verification

Turn the trace reading into a regression guard, then prove the guard catches the original fault. The test below asserts on the HTTP response and on the console stream directly, so the same breakage now fails in under a second with a message that names the cause.

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

test('orders load without page errors', async ({ page }) => {
  const pageErrors: string[] = [];
  const consoleErrors: string[] = [];

  // 'pageerror' fires for uncaught exceptions and unhandled promise rejections.
  page.on('pageerror', (error) => pageErrors.push(error.message));
  // 'console' fires for every console.* call; keep only the error level.
  page.on('console', (msg) => {
    if (msg.type() === 'error') consoleErrors.push(msg.text());
  });

  // Register the waiter BEFORE the navigation so the response cannot be missed.
  const responsePromise = page.waitForResponse(
    (r) => r.url().includes('/api/orders') && r.request().method() === 'GET',
  );

  await page.goto('/orders');
  const response = await responsePromise;

  // The second argument becomes the failure message, so a 500 prints its body.
  expect(response.status(), await response.text()).toBe(200);

  await expect(page.getByRole('row', { name: /ORD-/ }).first()).toBeVisible();
  expect(pageErrors).toEqual([]);
  expect(consoleErrors).toEqual([]);
});

Confirm the guard three ways. Re-run against the broken build and check that the failure message is now the response body rather than a locator timeout. Re-run against the fixed build with --trace on and verify the Network tab shows a single 200 for /api/orders and an empty Console tab. Finally, run the spec with --repeat-each=10 to be sure the response waiter is not itself racy — the intermittent-failure workflow is described in Detecting and Fixing Flaky Playwright Tests, and the interactive equivalent of this loop lives in Debugging with Playwright Inspector and UI Mode. For the wider anatomy of a trace archive, including snapshots and the source tab, see Analyzing Test Failures with the Playwright Trace Viewer.

One habit is worth adopting across a suite: when a test depends on data arriving over HTTP, assert on the response rather than waiting for a rendered consequence of it. Locator assertions are the right tool for UI state, but they make network faults indistinguishable from selector faults, and every such ambiguity costs a trace download. Choosing between the two is discussed in Waiting for Network Idle vs Element State.

Frequently Asked Questions

Does the Network tab include requests made with the request fixture?

Yes, when the APIRequestContext belongs to a traced context. Calls made through the built-in request fixture or through page.request are recorded alongside browser traffic and appear in the same list, which is what lets you check an API precondition and a UI symptom in one place. A standalone context created with request.newContext() records only if you started tracing on that context yourself.

Why do console messages vanish when I click a different action?

The Console tab is filtered to the selected action's time window by design, so switching to an earlier or shorter action hides anything outside it. Clear the selection or drag a wider range across the timeline to see the full log. Messages logged before the first traced action — during initial page load, for example — sit at the very start of the timeline and are easy to scroll past.

Can I get the network log out of a trace as a HAR file?

Not directly from the archive. A trace stores network entries in its own format optimised for the viewer, and it omits some bodies. When you need a portable, complete capture, record one deliberately with the recordHar context option or the --save-har flag on npx playwright open, which produces a standard HAR you can replay or hand to a backend team.

Does enabling tracing slow tests down enough to matter?

Recording adds measurable overhead — snapshots and screenshots dominate it — which is why on-first-retry is the sensible default for pipelines: the fast path stays fast and only failing tests pay. On a shared runner the archives also consume disk and upload bandwidth, so keep trace: 'on' for local sessions and retention policies short on your artifact store, as discussed in CI/CD Integration.

Back to overview