How to Fix: Impossible for end to end test to detect loading state from DOM elements

6 min read

Your end-to-end test is not broken; it is racing against how React Server Components, streaming, and the Next.js App Router render loading UI. In this bug, the loading state appears too briefly—or outside the DOM region your test expects—so automation cannot reliably observe it even though users may momentarily see it.

Understanding the Root Cause

This issue usually happens when a test expects a loading indicator to exist in the DOM during navigation or async rendering, but Next.js resolves the server work before the test can observe that intermediate state.

With the App Router, loading UI is often controlled by loading.tsx, Suspense boundaries, and server-rendered async components. That creates a few important behaviors:

  • The loading state may be streamed and replaced very quickly.
  • The fallback may render in a different boundary than the one your selector is targeting.
  • If data resolves fast enough, the fallback may never become stably observable in the DOM.
  • Some frameworks optimize updates so the transition is visible to humans but difficult for test runners polling the DOM at intervals.

In practice, the test fails because it assumes this sequence:

  1. User action starts navigation or async fetch.
  2. A loading element appears in the DOM.
  3. The test captures that element.
  4. The final content replaces it.

But the actual runtime sequence can be closer to this:

  1. User action starts navigation.
  2. Server work resolves almost immediately.
  3. Fallback either never mounts long enough or is replaced before the test assertion runs.
  4. The test reports that no loading state was found.

The root problem is not just timing. It is that DOM visibility is not guaranteed as a stable contract unless you deliberately design the loading state to be testable.

Step-by-Step Solution

The most reliable fix is to make the loading state an explicit, testable UI contract. That means giving it a stable selector, ensuring it is rendered inside the right boundary, and making the async work slow enough in the reproduction or test environment to be observable.

1. Render loading UI through a clear Suspense boundary

If the page depends on async server data, wrap the async part with Suspense and provide a fallback with a stable attribute.

import { Suspense } from 'react';

function LoadingFallback() {
  return <div data-testid="loading-state">Loading...</div>;
}

export default function Page() {
  return (
    <Suspense fallback={<LoadingFallback />}>
      <AsyncContent />
    </Suspense>
  );
}

This gives your end-to-end test a predictable DOM target such as data-testid="loading-state".

2. Make the async work intentionally observable in the reproduction

If the server component resolves too quickly, insert a delay so the fallback stays mounted long enough for the test runner to detect it.

async function AsyncContent() {
  await new Promise((resolve) => setTimeout(resolve, 1500));

  return <div data-testid="loaded-state">Loaded content</div>;
}

This is especially useful when verifying framework behavior in a sandbox or issue reproduction.

3. Prefer boundary-level loading.tsx when testing route transitions

If the issue occurs during navigation between routes, use a route segment loading.tsx file so Next.js can show fallback UI during that transition.

export default function Loading() {
  return <div data-testid="route-loading">Loading route...</div>;
}

Then ensure the destination route actually suspends long enough to trigger that loading UI.

4. Assert both appearance and disappearance in the E2E test

Your test should verify the complete transition instead of only checking final content.

await page.getByRole('link', { name: 'Open page' }).click();

await expect(page.getByTestId('loading-state')).toBeVisible();
await expect(page.getByTestId('loaded-state')).toBeVisible();
await expect(page.getByTestId('loading-state')).not.toBeVisible();

If your runner supports it, use built-in retrying assertions instead of manual sleeps. This makes the test resilient to small variations in render timing.

5. Avoid selectors tied to transient text only

Text like Loading… may change, be localized, or appear in multiple places. Prefer semantic and stable selectors.

<div data-testid="loading-state" aria-busy="true">
  Loading...
</div>

Adding aria-busy can also make the UI more accessible while giving tests another reliable signal.

6. If needed, move loading state to a client-managed transition

When the test must observe a state change from user interaction in the browser, a client component can sometimes provide a more deterministic loading indicator than a purely server-driven transition.

'use client';

import { useState } from 'react';

export default function SearchButton() {
  const [loading, setLoading] = useState(false);

  async function handleClick() {
    setLoading(true);
    await new Promise((resolve) => setTimeout(resolve, 1500));
    setLoading(false);
  }

  return (
    <>
      <button onClick={handleClick}>Run action</button>
      {loading ? <div data-testid="loading-state">Loading...</div> : null}
    </>
  );
}

This is not always the best architectural choice, but it is useful when the goal is a browser-observable loading state rather than a server-streamed fallback.

7. Verify the issue with the official rendering model in mind

If you are reproducing this from the linked sandbox, inspect whether the loading element is:

  • Rendered inside the active Suspense boundary
  • Visible long enough for polling-based assertions
  • Replaced by streamed content before the test step executes
  • Part of a route-level fallback versus component-level fallback

If any of those are misaligned, the DOM may never expose the loading element in a reliable way.

Common Edge Cases

  • Instant cache hits: If data is cached, the fallback may not render at all. Disable caching or force slower data in the test scenario.
  • Wrong boundary selection: A parent fallback may render while the test is querying a child container that never shows the loading element.
  • Hydration confusion: A client-side assertion may run after server output has already been replaced, making the intermediate state easy to miss.
  • Multiple loading indicators: Generic text selectors can match the wrong element when several boundaries suspend simultaneously.
  • Animations or CSS visibility: The node may exist in the DOM but be hidden, transparent, or covered, causing visibility assertions to fail.
  • Prefetching behavior: Route prefetching can reduce or eliminate visible loading states during navigation.

FAQ

Why can a user see the loading state but my E2E test cannot?

Because the loading UI may exist only for a very short interval during streaming render. Humans perceive that transition visually, but the test runner may poll the DOM too slowly to catch it.

Should I use loading.tsx or Suspense fallback for this fix?

Use loading.tsx for route-segment navigation states and Suspense fallback for component-level async boundaries. The correct choice depends on where the loading state is supposed to appear.

Is adding an artificial delay a bad practice?

In production, yes if it harms UX. But in a reproduction, regression test, or framework issue demo, a controlled delay is often the best way to prove whether the loading fallback is actually rendered and testable.

The practical fix is to stop treating loading UI as an incidental visual side effect and start treating it as a testable interface contract. Once the fallback has a stable boundary, a reliable selector, and enough observable lifetime, end-to-end tests can detect it consistently.

Leave a Reply

Your email address will not be published. Required fields are marked *