Loading & Error Boundaries
Graceful UI for SPA navigation and async data fetching. No dependencies, no build step.
Overview
The boundaries module provides two layers of protection for your SPA:
- Navigation boundary — A top-of-page progress bar that shows when the SPA router is navigating between pages.
- Data boundary — A
withBoundary()helper that wraps async data fetches in graceful loading and error UI, with built-in Retry.
Both are progressive enhancements: if JavaScript is disabled or the script fails, navigation still works (via full-page reloads) and data fetches silently fail (no broken UI).
Installation
Add boundaries.js to your page, ideally before the app.js loader:
<script type="module" src="/boundaries.js"></script>
The module initializes itself on load and exports the SpaNavBoundary global with two APIs:
SpaNavBoundary.withBoundary(el, asyncFn, opts)— Wrap an async data fetchSpaNavBoundary.startNavProgress()— Manually trigger the progress barSpaNavBoundary.endNavProgress()— Manually end it
Navigation Boundary
The SPA router (spa.js) intercepts same-origin link clicks and fetches pages asynchronously. The boundaries module adds a progress bar that animates from left to right during the fetch.
How it works
- User clicks a link
- Boundaries detects the click (via capturing listener) and shows the progress bar
spa.jsfetches the page- SPA router emits
spa:navigatedevent - Boundaries catches
spa:navigatedand hides the progress bar
Styling
The progress bar is injected at the top of the page with these inline styles:
- Position: fixed, top, height 3px
- Gradient from
--accentto--accent-2 - Opacity 0 by default; fades in when navigating
You can override it with CSS:
.spa-nav-progress {
/* your overrides */
background: #your-color !important;
height: 4px !important;
}
Manual control
If you want to trigger the progress bar without SPA navigation:
SpaNavBoundary.startNavProgress();
// ... do something async
SpaNavBoundary.endNavProgress();
Data Boundary
The withBoundary() helper wraps async functions (typically fetch() calls) in three states:
- Loading — Shows a spinner and "Loading…" message
- Success — Renders the returned content
- Error — Shows an error banner with a Retry button
Basic usage
const el = document.getElementById("data-container");
const fetchData = async () => {
const res = await fetch("/api/data");
if (!res.ok) throw new Error(`status ${res.status}`);
return res.text(); // HTML string to render
};
SpaNavBoundary.withBoundary(el, fetchData, {
loading: true, // show spinner while fetching
error: true // show error banner on failure
});
Return types
The async function can return:
- HTML string —
res.text()or"<div>content</div>" - HTMLElement — A DOM node created with
document.createElement() - Any other value — Will be coerced to string and rendered
// Return HTML string
SpaNavBoundary.withBoundary(el, async () => {
const res = await fetch("/api/html");
return res.text();
});
// Return DOM element
SpaNavBoundary.withBoundary(el, async () => {
const div = document.createElement("div");
div.className = "card";
div.textContent = "Loaded!";
return div;
});
Options
The third argument controls loading/error UI:
{
loading: true, // (default) show a spinner while fetching
error: true // (default) show an error banner with Retry
}
Set either to false to hide that state:
// No loading spinner, just fail silently on error
SpaNavBoundary.withBoundary(el, asyncFn, {
loading: false,
error: false
});
Error handling
When the async function rejects:
- First retry: shows error banner + Retry button
- Second retry: shows error without button (to prevent infinite loops)
The error message is extracted from the caught exception:
// Custom error message
throw new Error("Failed to load user data");
// → displays in the banner as "Failed to load user data"
Retry behavior
The Retry button re-runs the entire boundary:
SpaNavBoundary.withBoundary(el, asyncFn, {
loading: true,
error: true
});
// User clicks Retry → calls withBoundary again
This clears both the error state and the isRetry flag, allowing fresh attempts.
Custom Loading States
By default, withBoundary() shows a centered spinner. To use custom loading UI (e.g., skeleton screens), pre-populate the container before calling the boundary:
// Custom skeleton loading
const el = document.getElementById("content");
el.innerHTML = `
<div class="skeleton skeleton-card"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line skeleton-line-sm"></div>
`;
// Disable the default spinner
SpaNavBoundary.withBoundary(el, asyncFn, {
loading: false, // don't show default spinner
error: true // but still show error UI
});
The boundary will replace your skeleton with real content once the fetch completes.
Accessibility
Boundaries integrate aria-busy and semantic roles for screen readers:
- Loading —
aria-busy="true"on the container - Error —
role="alert"on the banner (via CSS.banner) - Status —
role="status"on the loading message
Console logging
All boundaries emit [boundaries] breadcrumb logs:
[boundaries] initialized (SPA nav boundaries + withBoundary helper)
[boundaries] navigation start
[boundaries] showing loading state
[boundaries] content rendered
[boundaries] navigation end
[boundaries] fetch error: Network error
[boundaries] showing error UI: Network error
Open DevTools → Console to debug loading/error states in real time.
Progressive enhancement
The entire boundaries module is a progressive enhancement:
- Without JavaScript: links still work (full-page navigation), form submissions work
- With JavaScript disabled: no spinners, no error banners, no fancy UX
- If boundaries.js fails to load:
spa.jsstill works, but you lose the progress bar and error UI
This is why withBoundary() doesn't throw on init — the page keeps functioning even if the boundary is absent.
Demo
Visit /boundaries-demo to see loading boundaries and error handling in action.
Performance
- Lazy init — The module only adds listeners when
document.readyStateis "interactive" or later - Minimal overhead — One progress bar DOM element, one listener per navigation, one per async boundary
- No dependencies — Pure vanilla JS; zero third-party code
Browser support
Requires:
fetch()APICustomEvent(forspa:navigatedlistening)document.readyState(for init guarding)
Gracefully degrades on very old browsers (IE9 and earlier).