The browser platform: DOM, events, rendering
What the page is actually made of, and when it is ready. Client versus server rendering, hydration, shadow DOM. Nearly every "flaky UI test" is a misunderstanding at this level rather than a problem with the test framework.
Nearly every ticket labeled "flaky UI test" is not actually about the test framework at all. It is a misunderstanding of what the page is made of and when it is actually ready to be interacted with. A click that fires before an element has finished rendering, an assertion that runs before hydration attaches event listeners, a selector that matches a shadow DOM boundary the framework cannot pierce by default. This post covers the DOM, client versus server rendering, hydration, and shadow DOM at the level a tester needs: enough to tell a real bug from a timing problem before rewriting a single test.
The DOM is not the HTML you sent
The Document Object Model is the browser's live, in-memory representation of the page, built from the HTML but not identical to it. JavaScript can add, remove, or change elements after the initial HTML loads, and the DOM reflects those changes immediately while "view source" still shows the original file. A test that inspects "the page" is actually inspecting the DOM at one instant, and that instant matters enormously.
This is why a test that passes with a short wait and fails with none is not flaky in the traditional sense. It is timing-dependent on when the DOM finishes changing, which is a specific, debuggable condition rather than a mystery.
Client-side, server-side, and why it changes what "loaded" means
Server-side rendering (SSR) sends a fully formed HTML page from the server, so content is visible before any JavaScript runs. Client-side rendering (CSR) sends a near-empty HTML shell and builds the page with JavaScript after it arrives in the browser. A test written against a CSR app that waits for "page load" the way it would for an SSR app will often see a blank page, because the DOM at that moment has nothing in it yet.
Hydration is where a lot of flakiness actually lives
Hydration is the process where a framework like React or Vue takes server-rendered HTML and attaches JavaScript event listeners to it, making static markup interactive. Between the HTML arriving and hydration finishing, elements are visible on the page but not yet clickable in the way the app expects.
// Wait for an app-specific ready signal instead of guessing at a timeout
await page.waitForFunction(() => window.__APP_HYDRATED__ === true)
await page.click('[data-testid="submit-order"]')A team building a checkout flow saw an intermittent failure where a "Place order" click registered as a page click but never triggered the checkout handler. The button was visible in the DOM the instant the test looked for it, but hydration had not finished attaching its click listener yet. The fix was exposing a window.__APP_HYDRATED__ flag the app set once hydration completed, and waiting for that flag instead of just waiting for the button to exist in the DOM.
Shadow DOM hides elements from a naive selector
Shadow DOM lets a component encapsulate its own internal markup and styles, keeping them separate from the rest of the page. This is common in design systems and web components. A standard CSS selector or document.querySelector cannot see inside a shadow root by default, which is why a selector that clearly matches an element visually sometimes returns nothing.
Modern frameworks like Playwright pierce shadow boundaries automatically in most cases, but a raw DOM query in a custom helper function often does not. When a selector fails against an element you can plainly see on screen, checking whether it sits inside a shadow root is worth doing before assuming the selector syntax is wrong.
Four checks catch most rendering-related test failures before they get filed as framework bugs:
- Confirm whether the page is CSR or SSR, since that changes what "loaded" should mean for a wait condition.
- Wait for an app-specific ready signal rather than a generic network-idle or DOM-content-loaded event.
- Check whether a missing element sits inside a shadow root before assuming the selector is broken.
- Reproduce the failure with DevTools open and throttled network, since hydration timing bugs often only appear under real-world latency.
FAQ
Questions people ask
Why does my test pass locally but fail in CI with the exact same rendering code?
CI runners are usually slower and more resource-constrained than a local machine, which stretches out hydration and render timing enough to expose a race condition that a fast local machine never hits.
Is waiting for `networkidle` a reliable way to know a page is ready?
No. It only confirms requests have finished, not that the DOM has finished updating from their responses. Wait for a specific element or app-exposed ready state instead.
How can I tell if an element I can see is inside a shadow root?
Right-click the element and inspect it in DevTools. A shadow root shows up as a distinct #shadow-root node in the element tree above the element itself.
Does server-side rendering eliminate hydration-related flakiness entirely?
No. SSR removes the blank-shell problem, but hydration still has to attach listeners after the HTML arrives, so a click can still land before the app is interactive.