Most single-page apps have a hidden synchronous dependency: the API. The JavaScript arrives, React mounts, and then the page shows a spinner while the real content crosses the network a second time as JSON. If the API is slow, the page is slow; if the API is down, the page is a spinner forever. This site takes the opposite stance — it renders completely from data bundled into the app, then treats the live API as a progressive upgrade. The pattern is small enough to describe in full, and it is worth stealing.
The shape of the pattern#
- A generated fallback module ships the current content inside the bundle itself — a mirror of the backend’s seed data, regenerated by a script whenever content changes, never edited by hand.
- First render uses that bundled data immediately, tagged source: "pending". No spinner, no layout shift; the page is real content from the first paint.
- In the background, the app fetches the live endpoints in parallel with a hard timeout (3.5 seconds here). Success swaps in fresh data; failure keeps the bundled data and flips the tag to source: "fallback".
- Components that genuinely need the live backend — the contact form — read that tag and degrade explicitly, switching to an "offline demo mode" instead of failing on submit.
const INITIAL_DATA = { ...fallbackData, source: 'pending' };
const [data, setData] = useState(INITIAL_DATA);
useEffect(() => {
let cancelled = false;
loadPortfolio().then((result) => {
// result.source is 'live' on success, 'fallback' on timeout/error —
// never 'pending', so offline UI only appears after a real failure.
if (!cancelled) setData(result);
});
return () => { cancelled = true; };
}, []);The three-state tag is the detail that keeps the UX honest. pending means "optimistic render, verdict not in yet" — the UI must not show offline warnings during it, or every visitor sees a flash of doom on a cold cache. Only a fetch that actually failed earns fallback and the degraded mode.
What it buys#
- Latency: content paint no longer waits on an API round-trip, so the critical path is just HTML + the entry chunk. The API can be slow without the site feeling slow.
- Resilience: the backend can be down, redeploying, or unreachable from a hostile network, and the site still renders everything except live-write features.
- Cheap SSG: the same bundled data feeds the build-time prerender, so crawlers get full HTML from exactly the content the client would render — one source of truth, two consumers.
What it costs, and where it stops working#
The bundled data is a cache, and every cache needs an invalidation story. Here the content is small (tens of kilobytes of JSON) and changes rarely, so "regenerate the module on content change, redeploy the static bundle" is acceptable. The pattern degrades as either assumption breaks:
- Large content makes the bundle pay for data most visits never read — at that point, prerender per route and keep fallbacks per page, or accept the spinner for deep pages.
- Fast-changing content makes staleness visible. Users tolerate a portfolio that is one deploy behind; they will not tolerate yesterday’s prices. Anything truly live belongs on the fetch-always path with explicit loading states.
- Personalized or authenticated data cannot be bundled at all. This pattern is for public, shared content — the shell and substance of the site, not the user’s inbox.
Treat the network as an enhancement and the bundle as the baseline, and the failure mode of your site changes from "blank page" to "slightly stale page" — which is a trade every visitor will take.