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.
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.
Step-by-Step Integration Pattern
-
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.
-
Add
focusinto every deferred trigger. One line per island, or one line in a shared loader. This alone resolves the largest keyboard-specific failure. -
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. -
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.
-
Label or hide placeholders. A skeleton with
aria-hidden="true"plus arole="status"sibling saying “loading reviews” is the combination that reads well. -
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.
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.
Related
- Keeping Focus Stable During Island Activation — the record-and-restore pattern in full, including selection state.
- Announcing Streamed Content with Live Regions — what to announce, when, and how politely.
- Progressive Enhancement in Modern Frameworks — the baseline every remedy here depends on.
- Preventing Layout Shift with Skeleton Placeholders — the visual half of the same arrival problem.
- Measuring Hydration and Interactivity Metrics — quantifying how long the inert window actually lasts.
← Back to Islands Performance Optimization