Recovering a Broken React Deployment Without Guesswork
A page can return 200, paint a convincing shell and still be unusable. When the pubstech.com marketing site showed a spinning Lottie animation that never cleared and an empty #root underneath, the HTTP status was the least interesting fact about it.
The symptoms
The failures arrived in a cluster over the same week:
- The initial loading screen stayed visible. The Lottie animation played on a loop instead of fading once the hero section was ready.
#rootwas either empty or populated too late for the loader transition to fire.- The canary checker — a headless browser that tests tenant pages on a schedule — started reporting transient loading shells as hard failures.
- Memory use climbed after the release.
Those symptoms do not point to one cause. They looked like a broken React build, and the first instinct was to check the Vite output, the bundle hashes, and the deployment image tag. That instinct was wrong, or at least incomplete. There were three separate problems stacked on top of each other, and fixing the wrong one first would have hidden the other two.
Layer one: Cloudflare was reordering the scripts
The index.html shell loads three scripts in order: a Lottie animation for the loading screen, a runtime configuration file, and the React application entry point. The order matters because the loading screen needs to mount before React's hydration removes it, and the config needs to be available before the app reads it.
Cloudflare's Rocket Loader was silently deferring the type="module" scripts. That changed the execution order just enough to break the loading screen transition. The loader mounted, but by the time the hero section's critical assets arrived, the code responsible for hiding the loader had already run and found nothing to hide.
The fix was a Vite plugin that stamps data-cfasync="false" onto every module script in the built HTML:
{
name: "pubstech-cloudflare-rocket-loader-opt-out",
enforce: "post",
transformIndexHtml(html) {
return html.replace(
/<script(?![^>]*\bdata-cfasync=)([^>]*\btype="module"[^>]*)>/g,
'<script data-cfasync="false"$1>',
);
},
}
That is a build-time guarantee that Cloudflare will not reorder the scripts, regardless of what caching configuration changes in the future.
Layer two: the loader had no fallback
The loading screen was supposed to hide itself once React mounted and the hero section's critical assets — the main background image and the floating circles animation — finished loading. If any of those assets failed or arrived slowly, the loader would wait indefinitely. There was no timeout.
The refactored version moved the loader lifecycle into a standalone module with a 2.5-second fallback timer that starts in main.tsx before React even mounts:
export function scheduleInitialLoadingScreenFallback() {
if (typeof window === "undefined") return;
if (fallbackTimer !== null) return;
fallbackTimer = window.setTimeout(() => {
fallbackTimer = null;
hideInitialLoadingScreen();
}, FALLBACK_HIDE_DELAY_MS);
}
If React mounts normally, AppRoot hides the loader via requestAnimationFrame on its first render and cancels the fallback. If something goes wrong — a script fails to load, a critical asset times out, an error boundary catches — the fallback removes the loader anyway. The page might not look perfect, but it will not stay stuck on a spinning animation.
The Playwright regression tests now simulate slow hero assets and delayed module loads to verify that the loader clears in both the happy and degraded paths, on both desktop and mobile viewports.
Layer three: the node was running out of disk
While chasing the frontend symptoms, ha1 — the Kubernetes node running the Supabase database — was at 91% disk utilisation. The local-path provisioner stores PVC data on the node's root filesystem, and Supabase's storage had grown into 70 GiB of the 197 GiB partition. The node was not failing outright, but pods were being evicted under disk pressure, and the database service intermittently had no endpoints.
When the Supabase pod was pending, the tenant-bootstrap API calls from the React app returned 500. The app shell rendered, but the data it needed to populate the page never arrived. That produced the same symptom — a loader that never cleared — for an entirely different reason.
The immediate fix was pinning Sentry's ClickHouse to ha2 to relieve disk pressure on ha1. The longer-term response was a storage expansion plan to move PVC data off the root filesystem onto a dedicated volume, so a growing database cannot fill the disk that the operating system needs to run.
What the canary learned
The browser canary checker had been treating a stuck loading shell as a deterministic failure. After the incident, it gained a new classification: if the page is stuck on a loading or authentication shell and the errors look transient — no 401, no 403, no hostname mapping problem — the canary retries before reporting a failure.
That distinction matters because a transient loading shell caused by a slow pod restart is a different kind of problem from a genuine tenant misconfiguration. The first resolves itself. The second needs human attention.
What I would do differently
I would have added the loader fallback timer from the start. A loading screen that can only be dismissed by application code is a loading screen that will eventually get stuck. The fallback should have existed from the first commit.
I would also keep the infrastructure layer in scope earlier during frontend debugging. The symptoms were identical regardless of whether the cause was script ordering, a missing fallback, or a database pod under disk pressure. Checking the pod status and node conditions before assuming a frontend problem would have saved time.
What changed
The loader is now a standalone module with a fallback timer, a requestAnimationFrame-guarded hide, and a Playwright suite that tests the degraded path. Cloudflare Rocket Loader is opted out at build time. The canary retries transient shells instead of reporting them as hard failures. The storage plan exists on paper and the immediate pressure is relieved.
I now start from the rendered browser and trace the failing dependency until the evidence changes layer. A blank page is a symptom. It tells you something is wrong. It does not tell you which of three things is wrong, and fixing the most visible one first is how you hide the one that matters.