July 28, 2026
What to Log When Playwright Tests Only Fail in CI on Shared or Throttled Runners
A practical checklist for logging Playwright CI failures caused by CPU contention, cold caches, parallel load, and environment drift on shared or throttled runners.
When Playwright tests only fail in CI on shared or throttled runners, the usual instinct is to look at the test code first. Sometimes that is the right move. More often, the failure is a timing problem that only becomes visible when the browser is competing for CPU, starting from a cold cache, or running alongside other jobs in a noisy environment.
That means the most useful debugging data is not just the stack trace. You need enough logging to reconstruct what the browser, the page, the test runner, and the machine were doing at the moment the failure happened. If you collect the wrong signals, you end up with a sea of screenshots and almost no evidence.
This article is a practical checklist for the logs, traces, and environment facts that help explain why Playwright tests fail in CI on shared runners. It is written for SDETs, QA engineers, DevOps engineers, and frontend developers who need to separate true product defects from infrastructure-induced flakiness.
The short version: log the layers that can drift
A CI-only browser failure is usually caused by one or more of these differences:
- The machine is slower or more contended than local dev.
- The browser starts with a cold profile, cold DNS, and cold application caches.
- Tests run in parallel with different ordering or shared backend dependencies.
- The CI environment uses a different browser version, viewport, timezone, locale, or font stack.
- The app under test responds differently when the network is slower, the DOM updates later, or animations take longer.
The practical response is to log enough detail at each layer to answer four questions:
- What changed in the environment?
- What did the page look like when the test began failing?
- What timings shifted, especially around navigation and waits?
- Was the failure reproducible, or only visible under load and timing pressure?
If you cannot tell whether the failure came from the browser, the app, or the runner, you do not yet have enough observability.
Start with runner-level facts, because shared runners hide the root cause
On throttled or shared runners, the machine itself is part of the test environment. Log enough to compare one CI job to another and to local runs.
Log these runner facts for every failing job
- CI provider name and job ID
- Runner type, instance size, or label
- CPU count and memory limit visible to the job
- Container image or VM image version
- Browser version and Playwright version
- Node.js version
- Operating system and kernel version
- Timezone, locale, and system clock skew if available
- Whether the job ran in a container, a VM, or directly on a host
- Whether other test shards or services were scheduled on the same runner
This sounds basic, but it matters because a flaky test may only surface when the browser has fewer CPU cycles, or when the runner is sharing disk and network with something else. For example, a click that succeeds locally might miss the intended target in CI if rendering, hydration, or layout is delayed just enough for the DOM to shift.
A simple way to emit this data at job start is to write a small diagnostic block to the CI log:
node -e "console.log({
node: process.version,
platform: process.platform,
arch: process.arch,
cpus: require('os').cpus().length,
memoryMB: Math.round(require('os').totalmem() / 1024 / 1024)
})"
That will not explain every failure, but it gives you a baseline. If the same test fails only on one runner class, you have already narrowed the search.
Log Playwright versioning and browser build details
Browser automation often breaks on version drift, and CI is where drift hides longest. Log the exact versions used by the test run, not just the dependency ranges in package.json.
Capture these version details
@playwright/testversion- Browser channel or bundled browser version
- Whether the test used Chromium, Firefox, or WebKit
- Any custom launch arguments
- Whether browsers were installed fresh or reused from cache
If you use Playwright’s built-in browsers, record the output of version information at runtime. If your pipeline uses a prebuilt container image, log the image tag and digest. If you use continuous integration environments that cache dependencies aggressively, note the cache key or invalidation rule too.
A common failure mode is browser auto-update or image drift, where local and CI are not actually running the same executable even though the package lockfile is identical. The lockfile protects dependencies, not the browser binary installed on the runner.
Record the test identity and shard context
A failing test without shard context is only half a clue. Shared runners can produce different symptoms depending on shard placement, order, and concurrency.
Log the following for each test failure
- Test file path
- Test title
- Project name, for example Chromium or mobile emulation project
- Shard index and shard total
- Worker index
- Retry count
- Test file order, if your suite depends on it
- Whether the failure happened on first run or after retries
Why this matters: a test can pass on retry simply because it was scheduled later, after the app server finished warming up or a backend cache was populated. That is not a fix. That is evidence that the timing model changed.
If you parallelize aggressively, log the number of workers and the total test files in flight. When failures correlate with higher parallelism, the issue may be resource contention, backend rate limiting, or a hidden dependency between tests.
Keep the Playwright trace, but do not stop there
Playwright traces are one of the best tools for browser test observability, but they answer a specific kind of question, not every one. A trace helps you inspect the DOM, network, console, and action timeline around the failure. It does not automatically tell you why the runner was slow or why the page was late to settle.
Enable trace retention for failures
For most teams, the right default is to capture a trace on first retry or on failure. That gives you enough evidence without flooding storage.
import { defineConfig } from '@playwright/test';
export default defineConfig({ use: { trace: ‘retain-on-failure’, screenshot: ‘only-on-failure’, video: ‘retain-on-failure’ } });
When the test fails, inspect:
- The action timeline, to see whether the click, fill, or navigation took longer than expected
- Console errors and warnings
- Network failures, retries, or stalled requests
- DOM snapshots before and after the action
- Any locator mismatch, strict mode error, or unexpected element overlap
A trace is especially useful for timing-sensitive failures, such as a click intercepted by an overlay, a button disabled for a few hundred milliseconds longer in CI, or an assertion that checks too early.
Trace data is the evidence. The runner logs explain the conditions under which the evidence was produced.
Log navigation and wait timings, not just the final assertion
Many CI-only failures happen before the assertion. The real problem is often that the browser had not yet reached the state the test assumed.
Add timing checkpoints around the risky parts of the flow
Log timestamps for:
- Page navigation start and end
- First meaningful paint of the test page, if you can instrument it
- API call completion for data needed by the UI
waitForLoadStatecompletion, if used- Locator wait completion
- The actual action time for click, fill, or select
- Assertion start and assertion pass or failure time
A compact pattern is to bracket a suspicious step with explicit logs:
typescript
const start = Date.now();
console.log('[checkout] waiting for submit button');
await page.getByRole('button', { name: 'Submit order' }).waitFor();
console.log('[checkout] submit button ready in', Date.now() - start, 'ms');
Use this selectively. If you instrument every line in every test, your logs become noise. Focus on the steps that commonly fail under CPU contention, network delay, or slow rendering.
Capture the browser console and page errors
If a test passes locally but fails in CI, browser console output often contains the clue that local execution hides. This is especially true when the app logs hydration issues, unhandled promise rejections, missing assets, or feature flag mismatches.
Log these browser-side signals
console.error,console.warn, and unexpectedconsole.lognoisepageerrorevents- uncaught promise rejections, if available in your setup
- failed network requests, especially 4xx and 5xx responses
- WebSocket or SSE disconnects if the page relies on live updates
A small Playwright hook can capture page errors during the test run:
test.beforeEach(async ({ page }) => {
page.on('pageerror', error => {
console.log('[pageerror]', error.message);
});
page.on(‘console’, msg => { if (msg.type() === ‘error’ || msg.type() === ‘warning’) { console.log(‘[console]’, msg.type(), msg.text()); } }); });
This is especially useful when the browser UI fails because the app threw an error before it rendered the state the test expected. Without those logs, the failure looks like a locator problem, when it is actually a frontend runtime problem.
Log network behavior, including slow and failed requests
CI-only failures often show up as timing shifts caused by real network variance, backend contention, or cold cache misses. Even if your test environment uses mocked APIs for some paths, you still want visibility into the requests that remain real.
Include these network details
- Request URL and method for key flows
- Response status and timing
- Retries or redirects
- Stalled requests and request aborts
- API response shape, if a missing field can break the UI
- Whether the test used mocked routes, fixtures, or live backend calls
Do not log full sensitive payloads by default. Instead, log the endpoint, status, duration, and a small set of fields that matter to the page state.
A failure caused by a slow API often appears as a UI flake. The browser is not broken, the assertion is just racing a late response. Network logs help you distinguish “element never appeared” from “element appeared after the test had already failed.”
Capture screenshots, but annotate them with the reason they matter
A screenshot is most valuable when it is paired with timing and state. A naked PNG can show a banner, a spinner, or an overlay, but it will not explain whether the page was still loading, the server returned the wrong data, or the browser was too slow to paint.
When a screenshot helps most
- A click target is hidden by a modal, toast, or sticky header
- The layout is different in CI because of viewport, font, or density differences
- A loading skeleton never resolved
- A responsive breakpoint changed the DOM structure
Store screenshots with metadata that includes the test title, attempt number, and timestamp. Otherwise, the artifact is hard to connect to the rest of the log stream.
Log viewport, media, and rendering assumptions
Shared or throttled runners can expose differences in layout and paint timing. Browser tests often assume the page looks the same as on a developer laptop, but CI may render with different fonts, screen density, or headless behavior.
Record the rendering context
- Viewport size
- Device scale factor
colorSchemereducedMotion- Locale and timezone
- Headless or headed mode
- Whether GPU acceleration is disabled
- Font availability if your app depends on text measurement
Small differences here can produce surprisingly large failures. A button label may wrap, an element may shift below the fold, or a locator based on text may match a different node once the DOM reflows.
If you use browser emulation settings in Playwright, keep them visible in the logs rather than assuming they are obvious from code. This helps when a CI configuration change silently alters the test runtime.
Log application state, not only DOM state
A lot of flakiness lives between the network and the DOM. The browser might have loaded the page, but the app may still be waiting on feature flags, cached configuration, auth state, or client-side hydration.
Useful app-state facts to log
- Auth state, for example signed in or signed out
- Feature flag values relevant to the flow
- Build hash or frontend version
- Backend environment name, if the test target can vary
- Seed data identifier or fixture version
- Whether the user profile or workspace was freshly created
If your test can access a debug endpoint or expose a small status object in test mode, log the minimum state that explains the rendered page. The point is not to dump the entire Redux store, it is to identify the mismatch that caused the UI to diverge.
A common failure mode in CI is stale or missing setup data. The test opens the correct page, but the account, workspace, or entitlement it expects is not there. The DOM failure is real, but the root cause is data setup, not browser timing.
Log retries, but treat them as evidence of instability
Retries are useful for collecting more evidence, but they are not a cure. If a test passes on retry, you need to know what changed between attempts.
For each retry, log
- Attempt number
- Whether setup was repeated or reused
- Which fixtures, auth states, or temporary data were recreated
- Whether the page was reloaded from scratch
- The elapsed time since job start
- Whether a worker or shard was reused
A retry can hide the original bug if state leaks across attempts. For that reason, retry logs should make it clear whether the test began from a clean page, a clean browser context, and fresh backend state.
Log resource contention indicators from the runner
This is the part many teams skip, because Playwright itself does not know whether the host is under pressure. But for CI-only flakiness on shared runners, resource contention is often the trigger.
Useful contention signals include
- CPU saturation metrics from the host or container runtime
- Memory pressure or OOM killer events
- Disk I/O latency spikes
- Network retransmits or request queueing
- Long event loop delays in the Node test process
- Browser startup time, which can balloon when the host is busy
If your CI platform exposes runner metrics, correlate them with test timestamps. If it does not, a crude but useful proxy is to log browser startup duration and the time between major test actions. Large unexplained jumps often point to contention.
You can also log simple event loop delay measurements in Node if your team already has a utility for it. The point is not perfect precision, it is showing whether the test process itself was starved.
A practical logging checklist for CI-only Playwright failures
Use this as a triage checklist when a failure only appears on shared or throttled runners.
At job start
- CI provider, job ID, shard index, worker index
- Runner type, CPU, memory, OS, architecture
- Node, Playwright, browser versions
- Headless or headed mode
- Locale, timezone, viewport
- Build hash or frontend version
At test start
- Test file and title
- Retry attempt number
- Test tags or project name
- Authentication or fixture state
- Any feature flags relevant to the flow
Around risky actions
- Navigation start and end
- Wait completion times
- Click, fill, or selection timings
- API call timing for the data that drives the page
- DOM or state checkpoint before assertion
On failure
- Trace
- Screenshot
- Video if useful for motion or layout issues
- Console errors and page errors
- Failed network requests
- Current URL and active frame, if relevant
- Any unexpected overlay, spinner, or modal visible at failure time
On retry or rerun
- Did the failure disappear?
- Was the runtime slower or faster?
- Did the browser start differently?
- Was there a difference in shard placement, network behavior, or test data?
How to tell timing flakiness from real product bugs
The logging goal is not just to make failures reproducible. It is to classify them.
Signs the issue is likely timing or infrastructure related
- The test fails only under CI load, never locally
- The same failure vanishes on retry
- The trace shows the element appears slightly later than the assertion expects
- The browser console is clean, but the action happens before the UI settles
- The failure correlates with slower runner startup or longer network times
Signs the issue may be a product defect
- The browser console shows an actual runtime error
- The API response is missing data consistently
- The same bad DOM state appears across reruns and environments
- The trace shows the app never reached the expected state, even with extra time
- The issue reproduces on a dedicated runner as well as a shared one
The tradeoff is that a shared runner can amplify both classes of problems. A real bug may become more visible under pressure, and a flaky test may look like a bug. Good logs let you sort those out without guessing.
What not to log, because noise hides the evidence
More logging is not always better. If the logs are too broad, the actual clue gets buried.
Avoid:
- Full HTML dumps of every page state unless you have a specific parsing workflow
- Full request and response bodies containing secrets or large payloads
- Repetitive debug prints for every action in a stable test suite
- Logs without timestamps
- Logs that do not identify the shard, worker, or retry attempt
If a log line cannot help you answer a specific question, it is probably noise.
A minimal standard for browser test observability
For teams maintaining a Playwright suite, a workable baseline is:
- Failure-only trace capture
- Failure screenshots with timestamps and test identity
- Runner metadata at job start
- Console and page error capture
- Navigation and key wait timings
- Network timing for critical calls
- Shard and retry context
That set is usually enough to narrow most CI-only browser failures from “mysterious” to “timing problem,” “data problem,” or “environment drift.” It also creates a durable trail for later debugging, which matters when several engineers own the same suite.
Final checklist: if you only add five logs, add these
If you need a prioritised subset, start here:
- Runner CPU, memory, OS, and container or VM identity
- Playwright, browser, and Node version
- Shard, worker, and retry attempt
- Console errors, page errors, and failed network requests
- Timings for the specific navigation, wait, or click that failed
That is usually enough to explain why a test passes on a laptop but fails on a shared runner.
Closing thought
CI-only browser failures are rarely solved by a single screenshot or a blanket retry policy. They are solved by collecting the right evidence from the right layer, then comparing the failing run against a known good run.
If your test infrastructure is noisy, your logs need to be precise. If your suite is parallel, your logs need shard context. If your app is timing-sensitive, your logs need timings, not just outcomes. That is the difference between chasing flakiness and actually understanding it.
For teams building and maintaining browser automation, the goal is not perfect logs everywhere. The goal is enough observability to answer the next failure quickly, before it becomes a habit.