Playwright tests that fail after dynamic imports are usually not failing because Playwright is unreliable. They are failing because the app is doing exactly what modern frontend apps are supposed to do: loading code later, rendering routes asynchronously, hydrating in stages, and replacing placeholders after the test already moved on.

That distinction matters. If a test passes on a local machine and flakes in CI, the instinct is often to add a longer wait and move on. That tends to hide the real problem. The better approach is to trace the app’s loading boundary, find out which assumption the test made too early, and decide whether the test should wait differently, assert differently, or interact differently.

In practice, most failures in this category come from a small set of causes:

  • the route component was lazy loaded, but the test clicked before the chunk finished loading
  • the page hydrated in phases, and a locator matched server-rendered markup that was later replaced
  • a dynamic import path was valid in development, but the production build split code differently
  • a network request or chunk load was slow enough in CI to expose a race
  • the test asserted against a visible shell, not the final interactive UI

This article is my debugging guide for those failures, with the focus on Playwright, but the underlying logic applies to other browser automation stacks too. Playwright’s own docs are the right place to start for core APIs and waiting behavior, especially the Playwright introduction.

What usually breaks when an app starts loading code on demand

Route-based lazy loading and code splitting change the shape of the page lifecycle. The app no longer goes from “navigate” to “ready” in one step. It goes through a series of states:

  1. initial HTML arrives
  2. bootstrap script runs
  3. route shell renders
  4. dynamic import requests a chunk
  5. chunk loads and evaluates
  6. component state initializes
  7. hydration or post-hydration effects run
  8. the interactive UI becomes stable

A test can fail at any of those boundaries.

The common symptom is not always a timeout. Sometimes the failure is more subtle:

  • locator.click() times out because the button exists but is covered by a loading layer
  • locator.fill() succeeds, then the field disappears when hydration re-renders the tree
  • expect(locator).toBeVisible() passes on a skeleton, but the real button never appears because the chunk failed
  • page.goto() completes, but the route transition still has pending async work
  • the test is stable on a warm dev server, but CI cold starts expose chunk loading latency

A test that assumes “page load” and “user-ready” mean the same thing is usually making the wrong assertion.

That is the first debugging question I ask: what exact state did the test need, and what state did the app actually reach?

Start by classifying the failure, not by adding a sleep

Before changing the test, I classify the failure into one of four buckets.

1. Missing or delayed chunk

The browser requested a split bundle, but it did not arrive in time, or it never loaded.

Typical signs:

  • 404 or 500 on a JS chunk in the browser console
  • Failed to fetch dynamically imported module
  • route stays on a spinner or blank shell
  • the failure is more common in CI or after a deployment

This is often a build or deployment problem, not a test problem.

2. Hydration mismatch or replacement

The server rendered markup is visible, but once the client hydrates, the DOM changes and previously located elements become stale or disappear.

Typical signs:

  • locator resolves, then goes stale on interaction
  • text changes after initial assertion
  • buttons are disabled or detached during hydration

This is often an app timing problem or an overly eager assertion.

3. Route transition still in flight

The navigation has changed URL, but the route is not fully interactive yet.

Typical signs:

  • page.waitForURL() succeeds, but the UI is still loading
  • button clicks on the destination page intermittently fail
  • assertions pass locally because the route is fast enough

This is usually where Playwright tests fail after dynamic imports, because the test equates URL change with readiness.

4. Bad test synchronization

The app is fine, but the test is interacting with the wrong thing, too early, or via a brittle selector.

Typical signs:

  • strict mode violations due to duplicate shell and final elements
  • selecting placeholder content instead of final content
  • depending on animation timing, focus state, or CSS transitions

This is the test’s fault, but the fix is usually to wait on the correct business condition, not to globally slow down the suite.

Reproduce the failure in a way that tells you something

The fastest way to waste time is to reproduce the issue with too little observability. I want three things from a repro run:

  • Playwright trace or video
  • browser console output
  • network activity around the route and chunks

A minimal debugging loop looks like this:

import { test } from '@playwright/test';
test('debug lazy route', async ({ page }) => {
  page.on('console', msg => console.log('console:', msg.text()));
  page.on('pageerror', err => console.log('pageerror:', err.message));
  page.on('response', res => {
    if (res.url().includes('.js')) console.log(res.status(), res.url());
  });

await page.goto(‘http://localhost:3000/settings’); await page.screenshot({ path: ‘debug-settings.png’ }); });

That snippet is not a final test. It is a diagnostic harness. The point is to answer, quickly, whether the failure is coming from network delivery, client execution, or timing assumptions.

If the console shows a chunk load error, stop blaming locators. If the network shows the chunk returned 200 but the page never stabilized, focus on hydration or route readiness. If the UI rendered but the interaction failed, inspect whether the target element was present, visible, stable, and enabled at the moment of action.

Use traces to separate what happened from what you expected

Playwright’s trace viewer is one of the few tools that makes race conditions easier to reason about because it records action timing, DOM snapshots, and network events. The official docs cover tracing in the broader test runner workflow, and the key value is not “pretty playback”, it is causality.

When I inspect a failing lazy-loaded route test, I look for these questions in order:

  1. Did the navigation finish before the assertion?
  2. Did the chunk request fire?
  3. Did the chunk return successfully?
  4. Did the page render a placeholder before the final component?
  5. Did the locator point at the placeholder or the final UI?
  6. Did the test act before the component became stable?

If the trace shows an overlay, skeleton, or spinner present at the moment of click, the issue is usually not flaky infrastructure. The test is too eager.

Why code splitting creates test failures that only appear in CI

The reason CI exposes these issues is not mysterious. CI changes the timing envelope:

  • machines are often slower or more contended
  • browsers start cold, with no warmed cache
  • network and filesystem conditions differ
  • environment variables can change bundling behavior
  • production-like builds may be used instead of dev mode

If the app uses route-based lazy loading, timing differences become visible. In dev, a chunk might arrive fast enough that a test appears stable. In CI, the same route can sit on a skeleton for a few hundred milliseconds longer, which is enough to invalidate a click on a soon-to-be replaced element.

The practical consequence is that a test written against local developer latency is not portable. That is not a Playwright problem, it is a test design problem.

The first fix is usually to assert on readiness, not presence

This is the main pattern shift I use. Instead of waiting for an element to exist, I wait for the application state that implies the route is actually usable.

For example, if a settings page lazy loads, I would rather wait for a stable heading, a known API response, or a route-specific marker than for a random spinner to disappear.

typescript

await page.goto('http://localhost:3000/settings');
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
await expect(page.getByTestId('settings-loaded')).toHaveText('ready');

The exact selector does not matter as much as the principle. The test should wait on a condition that is tied to the final screen state, not an implementation detail like a CSS class or internal loading flag.

A good readiness signal is:

  • user-visible
  • route-specific
  • hard to fake accidentally
  • stable after hydration

A bad readiness signal is:

  • a loading class
  • a spinner disappearing
  • a node count that can change with styling
  • an internal state marker that the user never sees

When waitForLoadState helps, and when it does not

I see a lot of tests wait for networkidle as a catch-all fix. That is usually too blunt.

networkidle can be useful when you are debugging a page that keeps making requests after navigation, but it is not a universal readiness signal for lazy-loaded routes. Many modern apps continue to make background requests, analytics calls, or deferred data fetches long after the screen is usable.

The tradeoff is simple:

  • page.waitForLoadState('load'), waits for the document load event, which may be too early for lazy routes
  • page.waitForLoadState('networkidle'), can be too strict or misleading in apps with background traffic
  • a targeted UI assertion is usually the right long-term synchronization point

If I use a load-state wait, it is usually only as a debugging aid, not as the final fix.

Debugging missing chunks means checking the build, not just the test

If the browser console reports a failed dynamic import or a missing JS chunk, I check the deployment path first.

Common build and deployment failure modes include:

  • chunk filenames changed between build and deploy
  • CDN cache has stale HTML pointing at deleted assets
  • base path or asset prefix is wrong in production config
  • service worker caches an old shell that references missing bundles
  • reverse proxy rewrites break chunk requests

These are especially painful because the test may fail only after a deployment, only in a certain environment, or only when cache state changes.

A useful debugging step is to inspect the network request directly in the browser context, or to check whether the app is serving the same build manifest that the HTML references. If the HTML points to a chunk that does not exist anymore, the test is just the first thing to notice a deployment integrity bug.

For teams using continuous integration, this is where the concept of continuous integration matters operationally: the test is not just validating UI behavior, it is detecting a mismatch between build artifacts, server config, and deployed asset resolution.

Route transitions need a stable contract

Lazy loaded route testing becomes much simpler when the application exposes a reliable post-navigation contract. I look for one of these:

  • a route-specific heading
  • a page-level test id on the final container
  • a data fetch that can be awaited through a mocked response
  • a URL change plus a final content assertion

If the route is entirely client-side and the page content is assembled after several async steps, I avoid tying the test to the transition animation. Animations make traces pretty and tests brittle.

A practical pattern for route checks

typescript

await page.goto('http://localhost:3000/app');
await page.getByRole('link', { name: 'Billing' }).click();
await expect(page).toHaveURL(/\/billing/);
await expect(page.getByRole('heading', { name: 'Billing' })).toBeVisible();

This seems ordinary, but it encodes an important rule: URL change is necessary, not sufficient. The final heading tells me the route finished rendering.

If the route is lazy loaded, I sometimes add a more specific assertion on a page marker that only exists after the route component is mounted. That is better than waiting for the absence of a spinner, because the spinner may disappear before the real content is ready.

Hydration bugs are often mistaken for flaky tests

Hydration is a classic source of confusion. The server renders markup, the browser displays it, then client-side React or another framework hydrates it and may replace nodes or rebind events. A test can locate the server-rendered node, but by the time it clicks, the node has been replaced.

Signs of hydration-related failure:

  • element appears briefly and then disappears
  • click fails with “element is detached” or similar timing-related errors
  • test passes with a retry but remains unstable
  • interactions work after a manual pause, which is a clue, not a fix

The useful debugging question is whether the element is genuinely interactive at the moment the test acts. If not, the right response is to wait for the final interactive state, not to keep clicking the same node until the scheduler cooperates.

One robust approach is to wait for the control to be enabled and visible, then interact:

typescript

const saveButton = page.getByRole('button', { name: 'Save changes' });
await expect(saveButton).toBeVisible();
await expect(saveButton).toBeEnabled();
await saveButton.click();

If that still flakes, I inspect whether the button is being replaced during hydration or whether another overlay intercepts the click.

Use browser debugging tools to inspect the failure boundary

Sometimes the fastest path is to turn the browser into a microscope.

I commonly use:

  • page.pause() for interactive inspection
  • headed mode for visual confirmation
  • browser console logs around route transitions
  • request logging for chunk files and API calls

typescript

await page.pause();

That line is not for CI. It is for understanding what the page is doing between navigation and interaction.

If the route appears instantly in headed mode but fails in headless CI, the problem is usually not visual, it is timing and state. If the page never leaves a skeleton, the problem is deeper, either a missing chunk, a blocked request, or a failed client-side exception.

What I change in the app when tests keep failing

At some point, the test is not the only artifact that needs attention. If a route is hard to test, it is often hard to reason about in production too.

I look for app changes that make the UI more testable and more stable:

  • expose a definitive loaded state for each route
  • avoid replacing entire interactive regions after initial render
  • keep skeletons distinct from final controls
  • separate shell rendering from data rendering
  • make chunk errors visible in the UI instead of failing silently

That last point matters a lot. If a dynamic import fails, the app should show a meaningful error state. Otherwise the test sees a blank page or spinner forever, and the operational bug gets misclassified as test flakiness.

A good failure mode is obvious to a user and obvious to a test. A bad failure mode is silent uncertainty.

If a route can fail to load a chunk, the app should surface that failure in a way the test can assert on. Silent loading states make debugging miserable.

When to mock, when to intercept, and when not to touch the network

For debugging a lazy route, I sometimes intercept a chunk-adjacent API request, but I am careful not to over-mock the wrong layer.

Guidelines I use:

  • mock API data when the purpose of the test is route behavior, not backend integration
  • do not mock chunk loading itself, because that hides build and deployment problems
  • intercept only the slow or variable dependency that is masking the issue
  • keep one end-to-end path that exercises real asset loading

Playwright’s network interception can help isolate whether a failure is caused by the JS chunk or by the data fetch after the chunk loads. If the chunk loads and the data request fails, the test is not really about lazy loading anymore, it is about the route’s runtime dependency.

A compact triage checklist

When a test fails after a dynamic import, I run this checklist in order:

  1. Did the route URL change as expected?
  2. Did the chunk request appear in the network log?
  3. Did the chunk return a successful response?
  4. Did the final route heading or marker appear?
  5. Did a placeholder or skeleton remain visible when the action occurred?
  6. Did hydration replace the element before interaction?
  7. Is the test waiting for the right business condition?
  8. Is this actually a deployment or asset-path issue?

That sequence keeps me from overfitting to the symptom.

A better mental model for lazy loaded route testing

The goal is not to make Playwright wait longer. The goal is to make the test observe the same readiness boundary that a user cares about.

If the user cannot meaningfully interact with the page until a route chunk loads, the test should not interact until then either. If a route can render a shell before the actual feature is ready, the shell is not the final assertion target. If the app can fail to fetch chunks in CI, the test should help reveal that, not hide it behind arbitrary timeouts.

That is why I prefer direct assertions on visible route state, good logging, and traces over vague waits. It produces failures that are more actionable.

What I would do first on a new flaky test

If a teammate hands me a test that fails after code splitting or route lazy loading, I start here:

  • inspect the trace and console output
  • confirm whether the failure is a chunk load failure, hydration replacement, or timing issue
  • replace generic waits with route-specific assertions
  • make sure the app exposes a stable loaded state
  • check production-like build and asset paths in CI

If the failure disappears after adding a sleep, I do not treat that as success. I treat it as a clue that the test is waiting on the wrong thing.

Final take

Playwright tests fail after dynamic imports for reasons that are usually understandable and fixable. The app is splitting work across time, and the test is trying to act as if all work happened at once. That mismatch is the source of most flakiness.

The durable fix is not a bigger timeout budget. It is better synchronization, clearer route readiness signals, and tighter feedback on chunk delivery and hydration boundaries. If you can make the test wait for the exact state a user needs, the suite becomes calmer and the failures become more honest.

For teams building modern frontend apps, that is the real goal, tests that fail for the right reasons, at the right moment, with enough information to fix the problem quickly.

For reference, the Playwright docs are the place to ground your understanding of locators, auto-waiting, and trace tooling, and the broader testing and CI concepts are worth revisiting whenever you are debugging a failure that only appears in a pipeline.