A third-party embed that sits below the fold is hydrating during initial load — you marked it client:load or client:idle — so its script executes seconds before the user could ever see it, showing up as a long task that inflates Interaction to Next Paint (INP) and Total Blocking Time (TBT).
Triage: Confirming the Embed Hydrates Before It Is Seen
Work through these steps to prove the directive is firing early rather than the embed simply being heavy once visible.
-
Record a throttled Performance trace. Open DevTools → Performance, set CPU to 4× slowdown and network to Fast 3G, and record a page load without scrolling. Let the trace run 5–6 seconds.
-
Find the embed’s script in the trace. Look for a
Evaluate Scriptblock naming the vendor host (e.g.disqus.com,platform.twitter.com) and check its timestamp. If it evaluates in the first few seconds while the page is still at scroll position 0, the embed is hydrating before it is on screen. -
Confirm the network request timing. In the Network panel, with no scrolling, note when the vendor script is requested. A
client:loadisland requests it immediately; aclient:idleisland requests it during the first idle window — both before any scroll. A correctly-deferredclient:visibleisland requests nothing until you scroll. -
Attribute the long task. In the Performance trace, click the long task overlapping the embed’s evaluation and read its attribution. A task over 50 ms attributed to the vendor script, occurring pre-scroll, is the defect. Every millisecond of that task counts toward TBT (the sum of blocking time above 50 ms between First Contentful Paint and Time to Interactive), and if the user does interact during that window the same task inflates INP by delaying the next paint.
-
Run the reproduction checklist:
Root Cause: The Directive Fires Regardless of Viewport Position
The five client:* directives schedule hydration on different triggers, and the trigger is decoupled from whether the element is visible for every directive except client:visible. This is the crux: client:load fires on page load and client:idle fires on requestIdleCallback — neither consults the element’s position. An embed placed at the bottom of a long article will therefore hydrate, fetch its vendor script, and execute it during initial load even though the user is looking at the top of the page. That execution is pure waste until the user scrolls, and it lands squarely in the window that determines TBT and the page’s early INP. The mechanics of each trigger, and why only one is viewport-aware, are covered in the governing guide to hydrating third-party embeds with Astro islands.
client:idle is a genuine improvement over client:load — it waits for the main thread to be idle, so it does not compete with the initial hydration pass. But “idle” arrives quickly on a fast machine, often within a second of load, and once it fires the full vendor script runs. For a below-the-fold embed the user may never reach, client:idle still pays the entire cost. The directive answers “when is the thread free?” not “does the user need this yet?”. client:visible, by contrast, is backed by an IntersectionObserver: hydration is armed but dormant, and the vendor script is fetched and executed only when the element crosses into the viewport (optionally a little early, via rootMargin). If the user never scrolls to the embed, its cost is never paid at all.
The decision therefore reduces to the embed’s distance from the fold:
| Embed position at load | Directive | Why |
|---|---|---|
| Above the fold, immediately interactive | client:load |
User needs it now; eager is correct |
| Just below the fold (near-fold, likely seen fast) | client:idle |
Idle time arrives before the user scrolls the short distance; hides cost behind first paint without an observer |
| Well below the fold (may never be seen) | client:visible |
Only pay the cost on demand; skipped entirely if the user never scrolls there |
| Hidden on this breakpoint | client:media |
Skip the cost where the embed is not rendered at all |
The dividing line between client:idle and client:visible is roughly “will most users see this within a second of the page settling?” If yes, client:idle avoids the small observer overhead and has the embed ready without a scroll-triggered flash. If the embed is deep in the page — comments under a long article, a footer map, a related-content rail — client:visible is correct because most sessions never reach it.
It helps to make the IntersectionObserver semantics concrete. Astro’s client:visible registers an observer on the island’s root element with a threshold of 0, meaning the callback fires as soon as a single pixel of the element (expanded by rootMargin) intersects the viewport. At that instant Astro fetches the component chunk, boots the renderer, and runs the component — which is when the vendor script inside the embed finally injects. Because the observer is passive until intersection, an unscrolled session registers the observer and nothing else: no component chunk, no vendor request, no evaluation. client:idle has no such gate. Its requestIdleCallback fires on the first idle window after load — on a desktop that is frequently 200–800 ms in — and there is no second condition. This is why two identical embeds, one client:idle and one client:visible, produce completely different traces on a no-scroll load: the idle one shows a full vendor evaluation, the visible one shows nothing.
There is also a data-collection consequence worth naming. Analytics or ad embeds hydrated eagerly will count a below-the-fold impression the moment they run, even though the element was never seen — inflating viewability metrics and firing measurement pings for content the user never looked at. client:visible aligns the embed’s execution with an actual view, which is both more honest telemetry and less wasted main-thread work.
Resolution: Switch the Below-Fold Embed to client:visible
Change the directive on the offending island from client:load/client:idle to client:visible, and use rootMargin to begin hydration slightly before the element enters the viewport so the embed is ready by the time it is on screen. The component itself does not change — only the directive on the mount does.
---
// src/pages/blog/[slug].astro
import Layout from '../../layouts/Layout.astro';
import CommentsEmbed from '../../components/CommentsEmbed.jsx';
---
<Layout>
<article><!-- long server-rendered post body --></article>
<!-- BEFORE: hydrated on idle — the vendor script ran ~1s after load,
long before the reader scrolled to the comments.
<CommentsEmbed client:idle shortname="my-blog" /> -->
<!-- AFTER: hydrated only when the thread scrolls into view.
rootMargin arms the IntersectionObserver 200px early so the
widget is interactive by the time it is fully visible. -->
<CommentsEmbed
client:visible={{ rootMargin: '200px' }}
shortname="my-blog"
/>
</Layout>
If the embed is only just below the fold — a subscribe box directly under a short hero, say — keep client:idle but cap the wait so it cannot hydrate before more urgent work settles, and so it still hydrates on very fast machines only once the thread is quiet:
---
import NewsletterEmbed from '../../components/NewsletterEmbed.jsx';
---
<!-- Near-fold: idle is fine, but bound the wait. Astro forces
hydration after 2000ms if no idle window has opened by then. -->
<NewsletterEmbed client:idle={{ timeout: 2000 }} />
The rule to internalize: rootMargin tunes how early a client:visible island wakes; timeout tunes how long a client:idle island is allowed to wait. Neither turns client:idle into a viewport-aware trigger — if the embed can genuinely go unseen, client:visible is the only directive that skips its cost entirely.
Verification
Re-record the throttled Performance trace without scrolling. The vendor script’s Evaluate Script block and its network request must now be absent from the no-scroll trace entirely; they should appear only after you scroll the embed into view. Confirm the pre-scroll long task attributed to the vendor is gone, and that Lighthouse TBT drops by the embed’s former main-thread cost (commonly 50–200 ms for a comments or social embed). The single decisive check: with no scrolling, the vendor domain makes zero requests in the Network panel.
Then verify the deferred behaviour is still correct, not merely absent. Scroll the embed into view and confirm it hydrates and becomes interactive within a frame or two of appearing — if the widget is visibly blank when it reaches the viewport, your rootMargin is too small and should be widened toward one viewport height. Finally, in field data (RUM or the Chrome UX Report), watch the page’s INP over the following days: shifting a below-fold embed off the critical window typically moves the p75 INP out of the “needs improvement” band on interaction-heavy pages, because the main thread is no longer blocked by an embed the user had not yet reached.
Common Pitfalls
- Using
client:idleas a blanket “make it faster” fix. Idle hydration defers past first paint but still runs the script whether or not the user ever sees the embed. For anything deep in the page,client:idlepays full cost for a widget most sessions never reach — reach forclient:visibleinstead. - Setting
rootMargintoo large. ArootMarginof1000pxonclient:visiblehydrates the embed almost as early asclient:idle, defeating the point. Keep it to roughly one viewport (200px–400px) so hydration starts just before the element is seen, not on load. - Leaving the vendor script at the module top level. If the embed’s third-party code runs at import time rather than inside a hydration lifecycle, changing the directive does nothing — the script executes when the module is evaluated regardless. Inject it inside
useEffect/onMounted, as the islands guide shows, so the directive actually controls timing.