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:

  1. Navigation boundary — A top-of-page progress bar that shows when the SPA router is navigating between pages.
  2. 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:

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

  1. User clicks a link
  2. Boundaries detects the click (via capturing listener) and shows the progress bar
  3. spa.js fetches the page
  4. SPA router emits spa:navigated event
  5. Boundaries catches spa:navigated and hides the progress bar

Styling

The progress bar is injected at the top of the page with these inline styles:

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:

  1. Loading — Shows a spinner and "Loading…" message
  2. Success — Renders the returned content
  3. 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:

// 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:

  1. First retry: shows error banner + Retry button
  2. 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:

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:

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

Browser support

Requires:

Gracefully degrades on very old browsers (IE9 and earlier).