CI Resource Contention & Headless Mode Differences #
CI runners give you a fraction of the CPU, memory, and bandwidth of a developer machine, and headless Chrome schedules rendering and JavaScript differently from headed mode. A layout that settles in 40ms locally may take 400ms on a loaded runner, and any assertion that assumed the faster path fires early.
Mitigate by setting realistic CI resource limits, passing --disable-gpu to stabilize headless rendering, and disabling heavy UI animations during runs so the layout settles predictably.
Unhandled Async State & Network Timing #
Cypress commands auto-retry, but application state often lags the network: payloads arrive out of order, hydration finishes unpredictably, and assertions fire against a half-updated store. The durable fix is to wait on the request itself with cy.intercept() and its alias, never an arbitrary cy.wait(ms).
// Deterministic stubbing removes latency-induced flakiness on CI.
cy.intercept('GET', '/api/data', { fixture: 'stable-payload.json' }).as('getData');
cy.visit('/dashboard');
cy.wait('@getData'); // blocks until the response lands — trade-off: adds a real await, not a guess
cy.get('[data-cy=row]').should('have.length', 5);
Parallel Execution & Test Isolation Leaks #
Running specs in parallel without isolation pollutes shared state — cookies, localStorage, IndexedDB. Since Cypress 12, testIsolation: true clears browser state between each it() automatically; the flake usually means it was disabled or a plugin bypassed it.
DOM Mutation Races & Virtual DOM Batching #
Frameworks batch DOM updates, so Cypress can query a node that re-renders mid-assertion and throw a detached-DOM error. Use stable data-cy selectors, lean on retry-ability, and never query immediately after navigation before the framework settles — the same discipline covered in DOM Mutation & Rendering Races.
Configuration & Code Solutions #
Encode the fixes in config: CI-only retries, isolation, a realistic command timeout, and headless flags.
// cypress.config.ts — CI-hardened defaults
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
retries: { runMode: 2, openMode: 0 }, // reveal flakes on CI, stay strict locally
testIsolation: true, // default since Cypress 12; clears state between tests
defaultCommandTimeout: 8000, // CI headroom — trade-off: keep it modest so real hangs still fail
video: true,
screenshotOnRunFailure: true,
setupNodeEvents(on) {
on('before:browser:launch', (browser, launchOptions) => {
if (browser.name === 'chrome') launchOptions.args.push('--disable-gpu'); // stabilize headless
return launchOptions;
});
},
},
});
Common Pitfalls #
- Hardcoded
cy.wait(ms)instead of waiting on network aliases. - Selecting by visual DOM order rather than semantic
data-cyattributes. - Disabling
testIsolationfor speed, causing state leakage. - Ignoring headless-versus-headed rendering differences on CI.
- Leaving third-party analytics and tracking scripts unstubbed.
FAQ #
Why do my Cypress tests pass locally but fail intermittently in CI? CI allocates less CPU and memory, has different latency, and runs headless — all of which expose async races and contention your machine masks. Wait on real signals, not timeouts.
How do I fix detached DOM errors in Cypress CI runs? They occur when the app re-renders an element mid-query. Use stable selectors, wait for network responses before asserting, and avoid chaining across navigation boundaries.
Should I increase defaultCommandTimeout to fix flaky tests?
No — a bigger timeout only hides the race. Wait explicitly on intercepts, enforce isolation, and confirm state is settled before asserting.
Session Reuse and What It Hides #
Caching a signed-in session removes the slowest step from every spec, and it changes what the suite exercises in ways worth knowing.
A restored session skips the login flow entirely, so any defect in that flow is verified by whichever spec performs it for real — often exactly one. That is a reasonable trade provided the login is covered somewhere deliberately, and a poor one if the caching was introduced without noticing that nothing exercises it any more.
The second effect is on the server. A cached session restores browser state, not server state, so every spec using the same session acts as the same user. Two specs mutating that account’s data race each other regardless of how cleanly the browser was reset — a failure that looks like flakiness and is actually two writers sharing a record.
The fix is to separate identity from convenience: create the account through an API call in a fixture, which is fast and unique per worker, then exchange it for a cached session keyed on that account. The login cost is paid once per account rather than once per spec, and no two specs share server-side data.
// Cache the session, not the account: unique identity per worker.
// Trade-off: one API call per worker at start-up, in exchange for removing a
// class of interference no browser-side reset can fix.
beforeEach(() => {
cy.session(`user-w${Cypress.env('workerIndex')}`, () => {
cy.request('POST', '/api/test/login', { email: userForWorker() });
});
});
Separating the Four Candidate Causes #
“Passes locally, fails in CI” has four plausible explanations, and they are separable with cheap checks rather than by inspection.
Compute headroom. CI runners typically have a fraction of a developer machine’s cores and memory, so every timing-sensitive assertion has less margin. Confirm by running locally in a container constrained to the runner’s limits: if the failure follows, the cause is headroom and the fix is worker count or capacity rather than the test.
Environment differences. Time zone, locale, browser build, fonts and viewport are all inherited unless set explicitly. Confirm by comparing a recorded fingerprint from a passing local run against a failing CI run; any difference is a candidate before any test code is read.
State leakage. CI runs the full suite while local runs are usually a single spec, so a polluting spec earlier in the run only participates in CI. Confirm by running the failing spec alone in CI: green alone and red in the suite is leakage, wherever it happens.
Assets and caching. A developer’s browser has warm caches and pre-loaded fonts; a fresh runner does not, so images arrive late and shift the layout under a click. Confirm from a trace: an element that moved between location and action points here.
Working through these in order takes about twenty minutes and eliminates the common failure of investigating asynchronous logic for a problem that turns out to be a two-core runner.
# Reproduce CI conditions rather than guessing at the delta.
# Trade-off: running in a constrained container is slower than running natively
# and is the only way to compare like with like.
docker run --rm -it --cpus 2 --memory 4g -e TZ=UTC -e CI=true \
-v "$PWD":/app -w /app cypress/included:13.17.0 \
npx cypress run --spec cypress/e2e/checkout.cy.js
Evidence That Survives the Run #
Diagnosing a CI-only failure depends entirely on what the run recorded, and the defaults discard most of it.
Video and screenshots on failure are the baseline, and the setting that matters more is retaining them for a rescued failure — an attempt that failed before a retry passed. With retries enabled and artifacts discarded on a green run, the most interesting failures leave no trace at all, which is why a suite can be simultaneously unstable and undiagnosable.
Two further artefacts repay their cost. A console and network log captured per spec answers whether an unexpected request or an application error preceded the failure, which distinguishes a test problem from a product one. An environment fingerprint — browser build, Node version, core count, zone, image digest — makes the first candidate cause above answerable without re-running anything.
The organising principle is that a CI failure is an experiment that cannot be repeated under identical conditions, so everything needed to explain it must be captured while it happens. Teams that add artefacts only after a difficult debugging session have paid for the lesson twice.
Record the Environment Before You Need It #
The cheapest artefact in this whole investigation is a fingerprint written at the start of every run: Node version, browser build, core count, time zone, locale, image reference. It costs a few hundred bytes and turns “what differed between these runs” from archaeology into a diff.
Reproducing under the runner’s constraints — same image, same core and memory limits, same worker count — is the step that most often resolves these cases, and it is usually faster than any amount of reading the spec.
Recording the runner’s core count alongside the results makes the compute-headroom question answerable without re-running anything.