Accessibility
Skip Links and INP: When Accessibility Patterns Hurt Latency
The pattern is familiar: a team gets dinged on an audit for missing a skip link, ships one in a sprint, and then watches Interaction to Next Paint drift upward over the next two weeks. Skip links, focus management, ARIA live regions, and keyboard shortcuts are non-negotiable accessibility features. They are also among the most common sources of avoidable INP regressions in 2026. This article unpacks why and shows the implementation patterns that keep both the accessibility audit and the Core Web Vitals dashboard green.
Why accessibility patterns and INP collide
INP measures the longest delay between any user input (click, tap, key press) and the next paint that reflects the result. The 200ms "good" threshold sounds generous until you consider what fits inside it on a mid-range Android device: input delay (waiting for the main thread to be free), event handler work, rendering, and the paint itself. A modern accessibility implementation often inserts extra work into the event handler portion. Each of these is small in isolation; collectively they push real interactions past the threshold.
The four common culprits we see during audits, in rough order of impact:
- Focus management routines that run synchronously after route changes in single-page apps.
- Keyboard shortcut libraries that attach listeners to many DOM nodes rather than delegating from a single root.
- ARIA live region updates that fire inside the click handler that triggered them.
- JavaScript-augmented skip links that scroll-smooth or fire analytics before yielding back to the browser.
Each of these patterns is solvable. The fix is almost always to do less inside the event handler and defer the rest. The shape of the solution stays the same across all four.
Skip links done wrong (and right)
The minimal correct skip link is two elements of HTML and one CSS rule:
<a href="#main-content" class="skip-link">Skip to content</a>
...
<main id="main-content"> ... </main>
That is it. The browser handles the rest. When focus lands on the link and the user presses Enter, the URL hash updates, the document scrolls to the target, and focus moves to #main-content if it is focusable (add tabindex="-1" to <main> if your target is a non-focusable element). No JavaScript runs on the keydown, so the handler portion of the INP measurement is zero.
The pattern starts to break when teams add behavior. Common additions:
- Smooth scrolling implemented in JavaScript with
scrollIntoView({ behavior: 'smooth' })inside a click handler. The smooth-scroll animation itself is fine, but the click handler often waits for the scroll to start before yielding, adding 30-80ms. - Analytics tracking that fires a custom event on activation. Synchronous
beacon()orfetch()calls block the handler return. Usenavigator.sendBeacon()after a microtask yield, or fire-and-forget the call. - Programmatic focus calls like
document.getElementById('main-content').focus()inside the click handler when the browser would have done this natively. Redundant work that costs you 5-15ms per click.
The correct pattern when you need JavaScript on the skip link is to do the absolute minimum synchronously and defer everything else:
skipLink.addEventListener('click', (e) => {
// Let the browser do its native thing first.
// Then do extra work after the next paint.
requestAnimationFrame(() => {
queueMicrotask(() => {
sendAnalytics('skip-link-used');
});
});
});
The double-defer (rAF then microtask) ensures the paint happens before any non-essential work. INP measurement stops at the paint, so the handler runtime is what matters.
Focus management on SPA route changes
The hardest INP regression to diagnose lives in single-page apps. On every route change, accessibility best practice says: move focus to the new view's main heading or landmark so screen reader users know they have navigated. Most accessibility libraries (react-aria, vue-axe, the focus-trap family) provide a helper for this. The problem is the timing.
If the focus shift runs synchronously inside the click handler that initiated the route change, the click duration includes everything between the click and the first paint of the new view. On a complex view with code-splitting and React Suspense, that is easily 300ms. The browser does not record a separate "navigation INP" — the click handler measurement keeps running until the new view paints and the focus is applied.
The fix is to split the work. Do the route transition synchronously in the click handler, then move focus inside a callback that runs after the new view has mounted and painted:
function navigateTo(path) {
router.push(path); // synchronous, schedules render
// After the new view paints, move focus.
requestAnimationFrame(() => {
const heading = document.querySelector('main h1');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus({ preventScroll: true });
}
});
}
The order matters. requestAnimationFrame runs before the next paint, but the focus call itself is cheap. The expensive part — rendering the new view — happens in React's commit phase, which is already scheduled. By the time the rAF callback fires, the new view is in the DOM and focusable. The click handler returned within a millisecond of the route change being initiated, and INP records that short duration. Pair this with preventScroll: true on focus to avoid the secondary jank of a scroll-to-element animation interfering with the route paint.
Keyboard shortcut handlers
Keyboard shortcut libraries like mousetrap, tinykeys, and the homegrown variant most companies eventually write all face the same trade-off. You can attach listeners to individual focusable elements (more precise scoping, more overhead) or you can delegate from window or document (single listener, must filter by target).
For INP, delegation always wins. A single keydown handler on the window receives every keystroke once, examines the event, and dispatches. A per-element approach can register hundreds of listeners on a complex interface, and although each is cheap individually, the cumulative attachment cost and the per-event dispatcher overhead show up on the long-tasks profile.
The second optimization is to keep the handler tight. A handler that runs application logic synchronously — opening a command palette, scrolling to a section, firing a network request — is going to push the input delay past 100ms on a saturated main thread. The pattern is to mark the intent inside the handler and run the implementation in the next microtask:
window.addEventListener('keydown', (e) => {
if (e.metaKey && e.key === 'k') {
e.preventDefault();
// Open the command palette in a microtask so the handler returns fast.
queueMicrotask(() => openCommandPalette());
}
});
The handler does two things synchronously: prevent default and schedule the work. Everything else, including DOM mutations and any state changes that trigger a re-render, happens after the handler returns and the browser has had a chance to update the rendering pipeline. The user perceives the response as instantaneous because the visual feedback paints in the same frame as the input. Our internal benchmarks on this pattern show a median INP reduction of 40-90ms versus the synchronous version, with no functional change visible to users.
ARIA live regions and announcement timing
Live regions are the screen reader's notification system. They appear simple: insert or update a div with aria-live="polite" and screen readers announce the new text. The complication for INP is that updating a live region in the same task as the user interaction means the browser has to compute the new accessibility tree, fire the assistive-technology event, and continue the rendering pipeline. On mid-range Android, we have measured 30-80ms added to click duration from a single live-region update inside the click handler.
The fix is consistent with the others: paint first, announce second.
function addToCart(product) {
cart.add(product); // state update
// Paint the new cart count, then announce.
requestAnimationFrame(() => {
liveRegion.textContent = `${product.name} added to cart`;
});
}
If the announcement is urgent and must arrive immediately (aria-live="assertive" or role="alert"), the trade-off changes — you accept the INP cost because the announcement timing is the user-facing contract. For polite announcements (the default case), deferring by one frame is invisible to users and recovers tens of milliseconds of INP budget.
How to measure these changes
Lab measurement of INP is unreliable because Lighthouse uses TBT (Total Blocking Time) as a proxy and WebPageTest's INP support depends on driving real interactions during the test run. The most reliable approach in 2026 is field measurement via the web-vitals library's onINP callback, with each interaction tagged so you can correlate INP values to the handler that caused them.
An attribution-enabled INP report tells you not just the slow interaction but which target element, which event type, and how long the input delay, processing time, and presentation delay components each took. If your slow interaction has a long processing component and a short delay component, that is the handler. If the processing time is short but the presentation delay is long, the bottleneck is rendering work that fired after your handler returned. Both shapes have different fixes, and you need attribution data to tell them apart. The Chrome team's web-vitals attribution build is the production-grade way to collect this. Our INP guide walks through the setup. The benchmark methodology page documents the field-versus-lab framing used across our case studies.
For pre-launch validation, the most useful tool is the Performance panel in Chrome DevTools with the Interactions track enabled. Trigger the interaction you care about and look at the resulting interaction bar: the colored segments correspond to input delay, processing time, and presentation delay. If your handler is the bottleneck, you will see a fat red "Script evaluation" block in the processing-time segment. That is the work to defer.
When deferring is the wrong call
Not every accessibility-related operation should be deferred. Three cases where synchronous work is correct:
- Form validation that prevents submission. A form-submit handler that needs to add or remove an
aria-invalidattribute before announcing the error has to do that synchronously, because the assistive-technology announcement must precede the user's next attempt to interact with the field. - Dialog opening with focus trap initialization. The focus trap must be live before the dialog paints to prevent a moment where focus could escape. Defer the analytics call and the focus restoration on dialog close, not the trap setup on open.
- Live region announcements with role="alert". By definition these are immediate. Optimize the surrounding work instead.
The general principle: defer side effects, never defer the operation the user requested. If a click is supposed to open something, open it now. If a key press is supposed to navigate, navigate now. Anything that runs around the user-requested operation is a candidate for deferral.
A four-point implementation checklist
- Skip links use plain anchor + fragment. JavaScript only where it adds user value, and the script work is deferred via
requestAnimationFrame. - SPA route-change focus shifts run inside
requestAnimationFrameafter the route push, not synchronously inside the click handler. - Keyboard shortcuts use a single delegated listener on
window, with all non-essential work deferred viaqueueMicrotask. - Polite ARIA live region updates run inside
requestAnimationFrameafter the visual paint. Assertive live regions stay synchronous.
None of these patterns weaken the accessibility guarantee. The screen reader still hears the announcement, focus still lands on the right element, the skip link still skips. The only thing that changes is when the work runs relative to the paint. That timing change is what keeps INP under the threshold.