On a Shopify homepage, Sanity stacked async sections and next-intl held the dictionary. A skip link is a tiny string for keyboard users who want to jump past the hero carousel. If it painted late, the fold jumped and tab order arrived after the picture. The dictionary was fine. The extra Suspense child was the jump.
The carousel reserved height, then a skip-link popped in. CLS on a homepage, and a visitor who wanted to tab past the slider waited for a lookup that was already in cache. I kept staring at Trustpilot latency while the string that caused the jump was one cached key. Accessibility QA filed it as focus order, not performance. They were right about the symptom even though the root cause was me treating a cached string like network I/O.
The skip link became a second async child
Sanity CMS sections are async server components. They sit behind a client <Suspense> error boundary. That boundary is the right place to wait for real I/O: Trustpilot, campaigns, Sanity.
I still called await translate('skipSlider') inside the section. translate is next-intl getTranslations. It is async. The row became a second async child of that <Suspense>.
The sized fallback painted, then the skip-link streamed in. Layout jumped. INP stayed late. I had already reserved height for the slider, so the skip-link was the extra paint.
Cached lookups still suspend
The dictionary was already in the next-intl request cache. Sanity keys were loaded. I was not fetching copy. I was awaiting a cached lookup.
useTranslate() reads the same cache. It is sync, no extra promise, no extra child, so the skip-link can render with the fallback, not after it. getTranslations suspends even when the messages are warm. useTranslate does not.
One more await felt free inside an already async section. It is not free when that await is a sibling of the content <Suspense> is holding. The boundary does not know that Trustpilot is the slow part and skipSlider is a string. It waits for every child that returns a promise.
The skip-link is a leaf
The section stays async because Trustpilot, campaigns, and Sanity still need a network. A tiny TranslatedTerm helper calls useTranslate for that one key. The helper is a leaf. The section can keep awaiting the work that actually needs a network.
When the whole component can be sync, I call useTranslate at the top: search grid, slider card, rating, no helper needed. Those views already have every Sanity key they need. Making them async only to call getTranslations would recreate the same extra child.
Interpolated terms still go through translate with options. Counts, names, dates. useTranslate is for the single cached key that was turning the fold into a second suspend.
In the demo, toggle await getTranslations versus useTranslate leaf. With await, the skip link appears after the fallback (late jump). With the leaf helper, it is in the first paint with the skeleton.
Skip-link behind Suspense
Sized fallback painted. Skip-link is still a pending async child of Suspense.
const TranslatedTerm = ({ term, fallback }: { term: string; fallback: string }) => {
const translate = useTranslate();
return translate(term, { fallback });
};
// skip link inside an async section; no extra suspend
return <TranslatedTerm term='skipSlider' fallback='Skip slider' />;A homepage can wait on Trustpilot. It should not wait on "Skip slider." The fix did not touch the carousel, the Sanity schema, or the dictionary files. It only moved one key onto a sync leaf so the Suspense boundary waits on work that actually needs a round trip.
Happy coding! Sander