JavaScript Execution & Runtime Performance
Breaking Up Long Tasks: scheduler.yield() and scheduler.postTask()
JavaScript’s run-to-completion model means that once a task starts, it monopolizes the main thread until it finishes — blocking user input, animation frames, and rendering updates. Long tasks (>50ms) are the primary cause of poor INP scores, and the traditional workaround of yielding via setTimeout() has a critical flaw: the continuation goes to the back of the task queue, meaning other queued tasks can cut in and delay your work indefinitely.
scheduler.yield() (Prioritized Task Scheduling API, shipped in Chromium) solves this elegantly. Insert await scheduler.yield() anywhere in an async function, and it pauses execution, yields to the main thread (allowing the browser to process user input or render), then resumes with a prioritized continuation — your code runs before other same-priority tasks, not after them. This is the key difference from setTimeout: you yield without losing your place. The pattern is simple — break a loop of work into yielding chunks:
for (const item of workItems) {
processItem(item);
await scheduler.yield();
}
scheduler.postTask() provides more control when you need explicit priority levels ("user-blocking", "user-visible", "background"), cancellation via AbortSignal, or dynamic reprioritization via TaskController. Use postTask with priority: "background" for non-urgent work like analytics, telemetry, or deferred hydration. Airbnb documented significant improvements using this pattern for their rendering pipeline.
For cross-browser support, scheduler.yield() is currently Chromium-only. The scheduler-polyfill npm package emulates both APIs in other browsers. A pragmatic progressive enhancement: await globalThis.scheduler?.yield?.() — this yields in browsers that support it and becomes a no-op elsewhere. Google’s web.dev team now explicitly recommends scheduler.yield() over the deprecated isInputPending() API, which could incorrectly return false and only addressed input events, not animations or other rendering updates.
Resources:
- Use scheduler.yield() to Break Up Long Tasks — Chrome for Developers
- Optimize Long Tasks — web.dev
- Scheduler.yield() — MDN
- Prioritized Task Scheduling API — MDN
- Getting Started with scheduler.yield — DebugBear
- scheduler-polyfill — npm
Web Workers: Moving Heavy Work Off the Main Thread
Web Workers run JavaScript in a background thread, completely separate from the main thread. They cannot access the DOM, but they can handle any CPU-intensive work: data processing, parsing, encryption, image manipulation, complex computations, search/filter over large datasets, and WebAssembly execution. Because they run on a different thread, they never block user interactions or rendering — the UI stays responsive regardless of how long the worker takes.
The traditional barrier to Web Worker adoption has been the complexity of postMessage-based communication. Comlink (by the Chrome team) eliminates this friction by wrapping the worker in an RPC-style API — you call functions on the worker as if they were local async functions, and Comlink handles the serialization transparently. Modern bundlers (Vite, Rolldown, webpack) support importing workers with minimal configuration, making the setup nearly as simple as importing a regular module.
Common use cases for Web Workers in production applications include: offloading markdown or code syntax highlighting (e.g., running Shiki in a worker), processing CSV/Excel uploads, performing client-side search indexing (e.g., FlexSearch or Lunr in a worker), running WebAssembly modules for image processing or encoding, and pre-computing expensive derived state for React/Vue components. The Partytown library takes this concept further by relocating third-party scripts (analytics, marketing tags) into a worker, keeping them off the main thread entirely — covered in more detail in Section 18.
Resources:
- Web Workers API — MDN
- Comlink: Web Workers Made Easy — GitHub
- Use Web Workers to Run JavaScript Off the Browser’s Main Thread — web.dev
- Vite Worker Support
- Partytown — Move Third-Party Scripts to a Web Worker
OffscreenCanvas: Graphics Without Blocking the UI
Canvas rendering, animation, and user interaction all happen on the main thread by default. For applications with intensive canvas work — data visualizations, games, image processing, real-time video effects — this creates a direct conflict between smooth rendering and responsive UI. OffscreenCanvas decouples the Canvas API from the DOM, allowing it to be used inside a Web Worker.
The pattern is straightforward: call canvas.transferControlToOffscreen() on a <canvas> element to get an OffscreenCanvas object, then transfer it to a worker via postMessage. Inside the worker, you have full access to 2D and WebGL contexts, plus requestAnimationFrame() for animation loops. Because the rendering happens on a separate thread, the main thread stays free for user interactions — the busy main thread doesn’t affect the animation running on the worker, and vice versa.
OffscreenCanvas is now supported across all major browsers (Chrome, Firefox, Safari) and is particularly valuable for: Three.js/WebGL scenes that would otherwise cause jank during heavy rendering, real-time chart updates (e.g., financial data streaming), image resizing and format conversion in the browser (useful for service workers processing images at the edge), and any scenario where canvas operations compete with UI responsiveness. Libraries like Konva already support rendering inside Web Workers via OffscreenCanvas.
Resources:
- OffscreenCanvas — Speed Up Your Canvas Operations with a Web Worker — web.dev
- OffscreenCanvas — MDN
- Faster Three.js 3D Graphics with OffscreenCanvas and Web Workers — Evil Martians
Service Workers: Caching, Offline, and Network Interception
Service Workers are a distinct type of worker that acts as a programmable network proxy between your application and the network. They intercept every fetch request, enabling powerful caching strategies, offline functionality, and background processing. Unlike Web Workers, a Service Worker persists between page loads and can serve cached responses even when the network is unavailable.
The most common caching strategies are: Cache First (serve from cache, fall back to network — ideal for static assets like JS, CSS, fonts, images), Network First (try the network, fall back to cache — ideal for API responses and HTML), Stale-While-Revalidate (serve from cache immediately for speed while updating the cache in the background — the best balance for content that changes but doesn’t need to be perfectly fresh), and Cache Only or Network Only for special cases. Workbox (by Google) provides a high-level library for implementing these strategies declaratively, with build-time integration for Vite, webpack, and other bundlers.
For performance, the highest-impact service worker pattern is caching the app shell (critical HTML, CSS, JS) and serving it from cache on repeat visits, eliminating network latency entirely. Combine this with streaming: construct a response where the shell comes from cache but the body comes from the network, using the Streams API. Service workers also power the stale-while-revalidate pattern that underpins many of the “instant” loading experiences users now expect. Ensure proper CORS headers for cross-origin resources, avoid caching opaque responses (which can fill the storage quota), and always test the upgrade path for service worker versions.
Resources:
- Service Worker API — MDN
- Workbox: Service Worker Libraries — Google
- Service Workers — An Introduction — web.dev
- The Offline Cookbook — Jake Archibald
Speculative Prefetching and Prerendering
The Speculation Rules API enables intelligent prefetching and prerendering of future navigations — the closest the web has come to truly instant page loads. Unlike the older <link rel="prefetch"> hint (which only downloads the resource) or the deprecated <link rel="prerender"> (which had limited implementation), Speculation Rules provide a declarative JSON configuration with fine-grained control over what to speculate and when.
The API supports three levels of speculation: prefetch (downloads only the HTML document — safe, cheap, broadly applicable), prerender (downloads and fully renders the page in a hidden tab including all subresources and JavaScript — expensive but produces near-instant navigations), and the new prerender_until_script (origin trial from Chrome 144 — prerenders the page but holds back blocking scripts, a powerful middle ground that avoids analytics double-counting and JavaScript side effects while still fetching all subresources).
The eagerness setting controls what triggers the speculation: conservative (on mousedown/touchstart — the lowest cost, triggered just before the click event fires), moderate (on 200ms hover on desktop, or viewport heuristics on mobile — good balance), eager (10ms hover on desktop, or viewport entry on mobile — more aggressive), and immediate (as soon as rules are parsed). A proven pattern is to prefetch with eager (cheap, catches most navigation targets) and prerender with moderate (only when hover signals high intent):
<script type="speculationrules">
{
"prefetch": [{ "where": { "href_matches": "/*" }, "eagerness": "eager" }],
"prerender": [{ "where": { "href_matches": "/*" }, "eagerness": "moderate" }]
}
</script>
Shopify’s platform-wide deployment of Speculation Rules (June 2025) achieved up to 180ms faster TTFB, FCP, and LCP across all percentiles for speculated navigations, using conservative prefetch for all safe routes. The 2025 PerfPlanet article on prediction-based performance reports that RUM data from hundreds of e-commerce sites shows significant volumes of page loads now falling into the sub-300ms category thanks to this API. Chrome keeps up to 2 prerendered pages in memory at a time (FIFO on mobile), and won’t speculate on low battery, slow connections, or when the user has disabled preloading.
Resources:
- Speculation Rules API — MDN
- Prerender Pages in Chrome for Instant Page Navigations — Chrome for Developers
- Guide to Implementing Speculation Rules for More Complex Sites — Chrome for Developers
- Speculation Rules at Shopify — Performance @ Shopify
- Blazing Fast Websites with Speculation Rules — DebugBear
- Speculation Rules Improvements — HTMHell Advent Calendar 2025
- prerender_until_script Origin Trial — Chrome for Developers
Lazy Loading Strategies: Idle, On-Demand, and On-Hover
Not all code or content needs to be loaded upfront. The key is choosing the right trigger for loading deferred resources, and the spectrum runs from most aggressive (load during idle time) to most conservative (load only on explicit interaction):
Lazy load on idle uses requestIdleCallback or scheduler.postTask({ priority: 'background' }) to load non-critical resources when the browser has nothing else to do. This is ideal for below-the-fold images, deferred analytics, non-essential UI components, or progressive hydration of interactive elements. The risk is that on heavily loaded pages, idle time may never come (or come very late), so this strategy works best for truly optional enhancements.
Lazy load on viewport entry uses IntersectionObserver (or the native loading="lazy" attribute for images and iframes) to trigger loading when the element scrolls into (or near) the viewport. This is the most common strategy for images and heavy below-the-fold components. Set a rootMargin to start loading slightly before the element enters the viewport, giving the network time to fetch the resource before the user sees a blank space.
Lazy load on hover (also called “intent-based loading”) triggers a dynamic import() when the user hovers over a button, link, or interactive area — typically using onMouseEnter or onPointerEnter. This provides a ~200–300ms head start before the click event fires, which is often enough to fetch a small JavaScript chunk. This pattern is excellent for modals, dropdown menus, rich text editors, and any heavy component that the user explicitly invokes. On mobile (where there’s no hover), fall back to loading on touchstart or pointerdown, which still provides a small head start before the browser processes the full tap. The Speculation Rules API’s moderate eagerness essentially implements this pattern for page navigations at the browser level.
Lazy load on explicit interaction (onClick, onSubmit) is the most conservative — the resource only loads when the user takes the action. This guarantees zero wasted bandwidth but introduces a visible delay. Mitigate the delay by showing a loading spinner, skeleton, or transition animation while the chunk downloads. This is appropriate for features used by a small minority of users, like admin panels, export tools, or advanced settings.
A powerful combination: preload the resource on hover, then execute it on click. This way, by the time the click handler fires, the chunk is likely already in the browser cache:
function handleHover() {
// Start fetching but don't execute yet
import('./HeavyModal.js');
}
function handleClick() {
// By now the module is likely cached
import('./HeavyModal.js').then(m => m.open());
}
For images specifically, always use loading="lazy" for below-the-fold content, but never for above-the-fold images (especially the LCP element) — those should load eagerly with fetchpriority="high". Use content-visibility: auto with contain-intrinsic-size on large off-screen sections to skip their layout entirely until needed, improving both rendering performance and CLS.
Resources:
- Lazy Loading — web.dev
- IntersectionObserver API — MDN
- Import on Interaction — Addy Osmani
- Import on Visibility — Addy Osmani
- content-visibility — MDN
- Native Lazy Loading for Images and Iframes — web.dev
Script Streaming and V8 Code Caching
Beyond network transfer, JavaScript has a significant CPU cost: parsing and compilation. V8 (Chrome’s JavaScript engine) addresses this with two key mechanisms. Script streaming allows parsing to begin on a background thread as soon as the script starts downloading, rather than waiting for the download to complete — this is automatic for scripts larger than 30KB and requires no developer action. Code caching stores compiled bytecode in the browser’s cache so that on subsequent visits, scripts can skip the parsing step entirely.
To maximize these benefits: use <script defer> for large scripts (defer enables streaming and parallel parsing); split libraries from application code so that stable vendor chunks benefit from code caching across deploys; avoid inline scripts for large code blocks (inlined scripts are not eligible for code caching or streaming); and use content-hashed filenames so the cache is busted only when the code actually changes. For advanced optimization, you can hook into V8’s code caching by ensuring scripts are loaded consistently — the first load parses, the second load generates the code cache, and the third load uses the cached bytecode.
Resources: