Accessibility of Deferred Hydration

Deferring activation moves work out of the critical path, and it also creates something that does not exist in a fully hydrated page: a window in which a control is visible, focusable, and not yet doing what it appears to do. For a sighted visitor with a fast connection that window is a few hundred milliseconds and nobody notices. For a keyboard user on a slow connection, tabbing immediately into a region that has not activated, it is the entire experience. This guide covers what breaks, why the standard fixes are cheap, and how to test for the failures that only appear when activation is genuinely late — the conditions covered from a measurement angle in measuring hydration and interactivity metrics.

The Inert Window Is Longer for Some Visitors Than Others A timeline from paint to activation. A sighted pointer user typically reads for a second or more before clicking, by which time activation has completed. A keyboard or screen reader user often begins tabbing within a few hundred milliseconds and reaches a below-the-fold control before a viewport trigger has fired at all. The diagram marks the two arrival points against the same activation timeline. Same page, two arrival times PAINT 0.4 s ABOVE-FOLD ISLAND READY 1.1 s BELOW-FOLD: ON SCROLL pointer user reads, orients, moves the mouse — 1.5 s before the first click clicks — everything is ready the inert window closed before this visitor reached it keyboard user first Tab at 0.5 s — into a region that has not activated tabs through eleven controls in the next two seconds, several still inert a below-the-fold island reached by Tab never fires its viewport trigger at all — this is the bug to fix first

Concept Definition & Scope

Three distinct problems live under this heading, and they have different fixes.

The inert window. Between paint and activation, a control exists and does not yet have its enhanced behaviour. This is only a problem if the server-rendered version does nothing — which is a progressive-enhancement failure, not an accessibility-specific one, but it lands hardest on people who reach controls quickly.

Trigger blindness. A viewport trigger fires on scroll. Keyboard focus can move into a region without scrolling it into view in the way an observer counts, and assistive technology can read content without moving focus at all. An island whose only trigger is intersection can therefore be reached and used before it exists.

Silent arrival. Streamed content appears in the document after the initial read. A sighted visitor sees it appear; a screen reader user is told nothing unless a live region announces it.

The remedies are small and covered below. What is out of scope here is general accessible-component design — labels, roles, contrast — which applies equally to fully hydrated pages and is not changed by deferral. The related territory of layout stability is treated in preventing layout shift with skeleton placeholders.

Technical Mechanics

Two changes fix most of it: add focus to the trigger set, and give late content a live region that already exists.

// 1. Focus is a first-class activation trigger, not an afterthought.
//    focusin bubbles, so one listener on the island container is enough.
function armTriggers(el, activate) {
  const io = new IntersectionObserver(([entry]) => {
    if (!entry.isIntersecting) return;
    io.disconnect();
    activate(el);
  }, { rootMargin: '200px' });
  io.observe(el);

  // A keyboard user can reach this region without ever scrolling it into
  // view the way the observer counts. Without this line the island stays
  // inert for exactly the visitors least able to work around it.
  el.addEventListener('focusin', () => { io.disconnect(); activate(el); }, { once: true });
}

// 2. Preserve focus across a mount that touches the DOM.
async function mountPreservingFocus(el, mod, props) {
  const active = document.activeElement;
  const path = active && el.contains(active) ? active.dataset.focusKey : null;
  const selection = active && 'selectionStart' in active ? active.selectionStart : null;

  mod.mount(el, props);

  if (!path) return;
  // Re-resolve by a stable key rather than holding the node: the mount may
  // have replaced it, and restoring focus to a detached node does nothing.
  const restored = el.querySelector(`[data-focus-key="${path}"]`);
  if (!restored) return;
  restored.focus({ preventScroll: true });      // never yank the viewport
  if (selection != null && 'setSelectionRange' in restored) {
    restored.setSelectionRange(selection, selection);
  }
}

The preventScroll option matters more than it looks. Restoring focus without it scrolls the element into view, which for a visitor who has scrolled elsewhere during activation is an unexplained jump — a worse outcome than the focus loss you were fixing.

Comparison: Failure and Remedy

Failure Who it affects Symptom Remedy
Control disabled until activation Everyone; keyboard users most Control skipped in tab order, announced unavailable Leave it enabled with a server-side behaviour
Viewport-only trigger Keyboard and screen reader users Control reached by Tab does nothing Add focusin to the trigger set
Focus lost on mount Keyboard users Focus jumps to the document body mid-task Record and restore by stable key
Unannounced streamed content Screen reader users New content is simply never mentioned Polite live region present in initial HTML
Unlabelled skeleton Screen reader users A run of empty groups announced role="status" with a label, or hide it
Focus restore that scrolls Keyboard users Viewport jumps back unexpectedly focus({ preventScroll: true })

Every remedy in that table is a few lines and none of them costs performance. That is the practical argument for treating this as a checklist rather than a project: the entire set can be applied to an existing islands implementation in an afternoon.

An Honest Inert State Versus a Dishonest One The same subscribe control shown two ways during the inert window. The dishonest version is a disabled button with a spinner: removed from the tab order, announced as unavailable, and useless if the script never arrives. The honest version is a working form that posts to a server endpoint, is fully focusable and announced normally, and is upgraded in place when the island activates. The same control during the inert window DISHONEST a disabled button with a spinner removed from the tab order announced as unavailable does nothing if the chunk fails the visitor cannot tell waiting from broken a slow connection becomes a dead interface HONEST a real form posting to a real endpoint focusable and in the tab order announced as a normal button works if the chunk never arrives upgraded in place on activation a slow connection becomes a slower page, not a broken one

Step-by-Step Integration Pattern

  1. Audit every island for its server-rendered behaviour. For each one, disable JavaScript and try to use it. Anything that does nothing is a hole in the baseline, and it is also the case that will fail for a visitor whose chunk request times out.

  2. Add focusin to every deferred trigger. One line per island, or one line in a shared loader. This alone resolves the largest keyboard-specific failure.

  3. Give every streamed region a live region. Put an empty <div role="status" aria-live="polite"> in the initial HTML near the region, and write a short message into it when the content lands: “12 reviews loaded”. Do not put the content itself in the live region; announce its arrival.

  4. Handle focus explicitly in mount functions. Record, mount, restore by key, and never scroll. Make it part of the mount contract so every island gets it.

  5. Label or hide placeholders. A skeleton with aria-hidden="true" plus a role="status" sibling saying “loading reviews” is the combination that reads well.

  6. Keep the focus ring visible through activation. A mount that re-applies classes can remove a focus-visible style, leaving a keyboard user with no indication of where they are. Test by tabbing to a control and forcing activation.

Measurement & Validation

Automated tooling catches some of this and misses the parts that matter most, because the failures are temporal. Two manual tests take five minutes and find nearly everything.

The throttled tab-through. Set the connection to slow 3G and CPU throttling to 4×, reload, and immediately begin tabbing. Every control you reach must either work or clearly indicate a pending state. Note any control that swallows a keypress silently — that is the inert-window failure in its purest form.

The screen reader arrival test. With a screen reader running, load a page with a streamed region and wait without interacting. If nothing is announced when the region fills, the live region is missing or was created too late.

For automation, run an accessibility scan twice: once after the page settles, which is what most tooling does, and once at a fixed early moment before activation completes. The second run is the one that finds disabled controls and unlabelled placeholders, because it inspects the state real visitors actually encounter. Wire both into the same job that tracks the performance budget described in islands performance optimization, so the two never drift apart.

The Cost of Getting This Right

It is worth being explicit about what these remedies cost, because the usual objection is that accessibility work competes with the performance work that motivated deferral in the first place.

Adding focusin to the trigger set costs one line and, in the worst case, activates an island slightly earlier for keyboard users — which is the intended behaviour, not a regression. Live regions are empty elements in the initial HTML; they add tens of bytes and no execution. Focus preservation runs only when a mount replaces nodes, which a well-written mount rarely does. Labelling or hiding placeholders is markup. Taken together the whole set adds well under a kilobyte to a page and no measurable time to activation.

Against that, the failure it prevents is total for the affected visitor. A control that swallows a keypress is not a slow control, it is a broken one, and the visitor has no way to tell the difference or to work around it. That asymmetry — negligible cost, complete failure — is why this belongs in the definition of done for an islands implementation rather than in a backlog of improvements.

There is also a straightforward reliability argument that has nothing to do with assistive technology. Every remedy here is really a defence against activation being slow or never happening: a chunk that 404s after a bad deploy, a network that drops the request, a content blocker that stops the module loading. A page whose controls work before activation degrades gracefully under all of those conditions, and a page whose controls do not simply breaks. The accessibility work and the resilience work are the same work.

Failure Modes

1. The optimisation that removes the baseline. Someone replaces a server-rendered form with an empty container that the island fills, because it saves markup. The page now has no keyboard-usable state until the chunk arrives, and no state at all if it fails. Fix: treat “works without the island” as a review requirement, not an aspiration.

2. Focus stolen on activation. A component calls focus() on mount to be helpful. Multiply that by six islands activating at different moments and the visitor’s focus is dragged around the page. Fix: never move focus on mount unless the visitor’s own action caused the mount.

3. Assertive live regions for routine content. A region marked assertive interrupts whatever is being read to announce that some reviews finished loading. Fix: polite for everything except genuine errors and time-critical alerts.

4. Placeholders that outlive their purpose. A skeleton with a status role that is never cleared keeps announcing “loading” after the content arrived, because the mount replaced the content but not the status text. Fix: clear the status region as part of the mount, in the same function that removes the placeholder.

The Whole Checklist on One Screen Six items: a server-rendered baseline that works, focus added to the activation triggers, focus preserved across a mount without scrolling, a polite live region present from first paint, placeholders hidden or labelled, and a throttled keyboard pass before release. Six items, all of them small 1 · every control works from server markup alone 2 · focusin is an activation trigger, not only viewport 3 · focus restored by stable key, with preventScroll 4 · a polite live region exists before it is filled 5 · placeholders hidden or labelled · 6 · throttled keyboard pass before release

Frequently Asked Questions

Does deferring hydration hurt screen reader users specifically?

It hurts them more than sighted pointer users, because their interaction pattern reaches controls sooner. A screen reader user often tabs through a page immediately, arriving at a control several seconds before a sighted user would scroll to it — and a viewport-triggered island below the fold may not have activated at all. The fix is not to abandon deferral but to add focus to the trigger set, so moving keyboard focus into a region activates it exactly as scrolling does.

Should controls be disabled until their island activates?

No. A disabled control is removed from the tab order and announced as unavailable, which turns a short delay into an interface that appears broken, and it breaks the progressive-enhancement baseline where the control works as a link or a form submission. Leave controls enabled, make the server-rendered version do something real, and let the enhancement replace that behaviour when it arrives.

How should streamed content be announced?

With a polite live region that exists in the initial HTML and receives the content when it arrives. The region must be present before the update, because a live region created and populated in the same task is frequently not announced at all. Politeness matters too: assertive interrupts whatever the user is reading, which is almost never justified for content that simply finished loading.

Does a skeleton placeholder need an accessible name?

It needs a status role and a short label such as loading reviews, or it needs to be hidden from assistive technology entirely. What it must not be is a stack of unlabelled empty boxes exposed to the accessibility tree, which is announced as a series of meaningless groups. Hiding the placeholder and announcing the arrival in a live region is usually the cleanest combination.

← Back to Islands Performance Optimization