Runtime Performance¶
Runtime performance is how quickly your PWA responds and how smoothly it renders after it has loaded: how long a tap takes to produce visible feedback, whether scrolling and animations hold their frame rate, and whether the app is still fast after hours of use. It matters more for PWAs than for ordinary websites because installed apps are opened like native apps and kept open for long sessions, their users compare them with native apps, and caching makes loading so fast that the main thread, not the network, becomes the bottleneck. This page explains the browser's scheduling and rendering model at the level you need to reason about it, then covers the techniques that fix runtime problems: yielding, off-main-thread work, rendering discipline, containment, virtualization and memory hygiene.
Key takeaways
- Every task longer than 50 ms is a long task; while it runs, the page cannot respond to input or paint. Interaction to Next Paint (INP) is mostly a measure of how much long-task work sits between an input and the next frame.
scheduler.yield()(Chrome 129, Firefox 142) is the best way to break up long tasks: the continuation runs ahead of other queued tasks.scheduler.postTask()(Chrome 94, Firefox 142) adds priorities, abort and delay. Neither is in Safari as of September 2026, so ship a fallback.- The Long Animation Frames API (
long-animation-frame, Chromium 123+) replaces the Long Tasks API for diagnosis: it reports slow frames with per-script attribution, forced layout time andblockingDuration. - Move CPU-heavy work (search, parsing, diffing, image processing) into a dedicated Web Worker, ideally behind Comlink; draw heavy canvases from a worker with OffscreenCanvas. Do not use the service worker for computation.
- Avoid layout thrashing (interleaved DOM reads and writes), animate only
transformandopacity, and usecontent-visibility: auto(in all engines since Safari 18) and CSS containment to limit the scope of style and layout. - Installed PWAs run for hours or days, so memory leaks (listeners, detached DOM, unbounded caches in JavaScript) turn into slowness and tab discards. Use
AbortControllerto tie listeners to component lifetimes, and heap snapshots to find leaks.
The main thread, tasks and frames¶
Almost everything that makes a page feel responsive happens on one thread: JavaScript execution, event dispatch, style calculation, layout, paint recording, and most of the browser's own bookkeeping for the page. The HTML event loop runs that thread as a sequence of tasks (a script, a timer callback, an event dispatch, a postMessage delivery), each followed by a microtask checkpoint (promise reactions, queueMicrotask, MutationObserver callbacks). Between tasks, when it is time to show a new frame, the browser runs a rendering opportunity: requestAnimationFrame callbacks, style, layout, ResizeObserver and IntersectionObserver processing, paint, and hand-off to the compositor.
sequenceDiagram
participant Q as Task queues
participant M as Main thread
participant C as Compositor / GPU
Q->>M: Task (e.g. click handler)
M->>M: Microtasks (promise reactions)
Q->>M: Next task (timer, message)
M->>M: Microtasks
Note over M: Rendering opportunity
M->>M: rAF callbacks, style, layout, paint
M->>C: Commit layer tree
C-->>C: Raster, composite, present frame Three consequences shape all runtime optimization:
- A task cannot be interrupted. If a click arrives while a 300 ms task is running, the click handler waits. The rendering opportunity waits too, so the user sees nothing change.
- Microtasks do not yield. An
asyncfunction thatawaits only already-resolved promises runs in a single task, however manyawaits it contains. Yielding requires scheduling a new task. - Rendering happens only between tasks. DOM changes made by a handler become visible only after the handler's task (and any tasks the browser runs before the next rendering opportunity) completes.
Budgets: 50 ms tasks, 16 ms frames¶
Two numbers matter:
- 50 ms is the threshold for a long task in the Long Tasks API, and the basis of Total Blocking Time (TBT) in the lab: the sum, over long tasks, of the part above 50 ms. It comes from the RAIL model's goal of responding to input within 100 ms: if no task is longer than 50 ms, an input arriving at any time waits at most 50 ms before its handler starts, leaving 50 ms for the handler and rendering.
- The frame budget: 16.7 ms at 60 Hz, 8.3 ms at 120 Hz. For smooth animations and scrolling driven by the main thread, all of a frame's work, including the browser's style, layout and paint, must fit. In practice, keep main-thread JavaScript for an animation frame under about 10 ms at 60 Hz. Animations that run entirely on the compositor (see compositor animations) are exempt, which is why they are the default choice.
The INP thresholds (good at 200 ms or less, poor above 500 ms, at the 75th percentile) are defined in Core Web Vitals. This page is about how to stay inside them.
Finding long tasks and slow frames¶
The Long Tasks API¶
The original API, PerformanceObserver with type: "longtask", reports every task over 50 ms. It is available only in Chromium, and its attribution is weak: it tells you the task's duration and the frame (container) it ran in, not which script was responsible.
The Long Animation Frames API¶
The Long Animation Frames API (LoAF), shipped in Chrome 123, measures what users actually experience: a frame whose rendering was delayed beyond 50 ms, whether by one long task, several medium tasks, or a slow rendering phase. Each long-animation-frame entry is a PerformanceLongAnimationFrameTiming with:
| Property | Meaning |
|---|---|
startTime, duration | Start of the first task in the frame, and total duration up to the end of rendering |
renderStart | Start of the rendering phase (requestAnimationFrame callbacks, style, layout, observers) |
styleAndLayoutStart | Start of style and layout calculation within rendering |
blockingDuration | Total time the main thread was blocked from responding to high-priority work, such as input |
firstUIEventTimestamp | Time of the first UI event (mouse, keyboard) handled during the frame |
paintTime, presentationTime | When the rendering phase ended, and when the frame was presented (added in Chrome 145; feature-detect) |
scripts | Array of PerformanceScriptTiming entries for scripts that ran for 5 ms or longer |
MDN defines blockingDuration as the sum, over tasks in the frame longer than 50 ms, of task duration − 50 ms, with the rendering time added to the longest task. Each PerformanceScriptTiming entry has:
| Property | Meaning |
|---|---|
invoker | How the script was entered, for example BUTTON#save.onclick, Window.requestAnimationFrame, Response.json.then |
invokerType | "classic-script", "module-script", "event-listener", "user-callback", "resolve-promise" or "reject-promise" |
executionStart | When compilation finished and execution began |
forcedStyleAndLayoutDuration | Time spent in synchronous style and layout forced by this script (layout thrashing shows up here) |
pauseDuration | Time spent in synchronous pauses such as alert() or synchronous XHR |
sourceURL, sourceFunctionName, sourceCharPosition | Where the entry point is |
windowAttribution | "self", "descendant", "ancestor", "same-page" or "other": which window the script belongs to |
Script attribution is available for scripts running on the main thread in the page and same-origin iframes. Work in cross-origin iframes, extensions, Web Workers and the service worker is not attributed.
A production observer that reports the worst frames with attribution:
// Reports long animation frames with script attribution. Chromium 123+ only;
// other engines simply never call the observer.
const REPORT_THRESHOLD_MS = 150; // only frames that users will clearly feel
const MAX_REPORTS_PER_PAGE = 10;
let reported = 0;
function summarize(entry) {
const scripts = [...entry.scripts]
.sort((a, b) => b.duration - a.duration)
.slice(0, 3)
.map((script) => ({
invoker: script.invoker,
invokerType: script.invokerType,
source: script.sourceURL ? new URL(script.sourceURL).pathname : "(inline)",
fn: script.sourceFunctionName || "(anonymous)",
duration: Math.round(script.duration),
forcedLayout: Math.round(script.forcedStyleAndLayoutDuration),
}));
return {
start: Math.round(entry.startTime),
duration: Math.round(entry.duration),
blocking: Math.round(entry.blockingDuration),
// Time from the start of rendering to the end of the frame.
// renderStart is 0 when the frame had no rendering phase.
render: entry.renderStart
? Math.round(entry.startTime + entry.duration - entry.renderStart)
: 0,
styleLayout: entry.styleAndLayoutStart
? Math.round(entry.startTime + entry.duration - entry.styleAndLayoutStart)
: 0,
hadInput: entry.firstUIEventTimestamp > 0,
scripts,
};
}
if (PerformanceObserver.supportedEntryTypes?.includes("long-animation-frame")) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration < REPORT_THRESHOLD_MS) continue;
if (reported++ >= MAX_REPORTS_PER_PAGE) {
observer.disconnect();
return;
}
queueReport({ type: "loaf", route: location.pathname, ...summarize(entry) });
}
});
// buffered: true delivers frames that happened before this module loaded.
observer.observe({ type: "long-animation-frame", buffered: true });
}
// Batch and send on page hide; see measuring.md for a full RUM pipeline.
const pending = [];
function queueReport(report) {
pending.push(report);
}
addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden" && pending.length) {
navigator.sendBeacon("/rum/loaf", JSON.stringify(pending.splice(0)));
}
});
The web-vitals attribution build joins LoAF entries to the INP interaction for you, which is usually what you want in production; see Core Web Vitals and Measuring Performance.
Yielding to the main thread¶
Breaking a long task into shorter ones lets the browser handle input and render between the pieces. The question is only how to schedule the continuation, and the options differ in where the continuation lands in the queue.
setTimeout, MessageChannel and their costs¶
The classic approach is await new Promise((r) => setTimeout(r, 0)). It works in every browser, but:
- The continuation goes to the back of the task queue. Any other tasks already queued (third-party scripts, timers, messages) run first, so a yielding task can be starved.
- The HTML standard clamps timers to at least 4 ms once they are nested more than five levels deep, so a loop that yields with
setTimeoutthousands of times accumulates clamping delays. - Timers in background tabs are throttled heavily (to once per second or less), which matters for PWAs that keep processing while hidden.
A MessageChannel round trip avoids clamping, but still queues behind other tasks:
// A yield that avoids setTimeout's nesting clamp. Still lands behind other tasks.
const channel = new MessageChannel();
const resolvers = [];
channel.port1.onmessage = () => resolvers.shift()?.();
export function yieldViaMessage() {
return new Promise((resolve) => {
resolvers.push(resolve);
channel.port2.postMessage(null);
});
}
scheduler.yield()¶
scheduler.yield() returns a promise that resolves in a new task, scheduled in a boosted queue: the continuation runs ahead of other tasks of the same priority, so the browser gets a rendering opportunity and a chance to process input, but unrelated queued work does not jump ahead of you. Called inside a scheduler.postTask() callback, it inherits that task's priority and abort signal. It is available in Chrome and Edge 129 and Firefox 142; Safari does not support it as of September 2026.
/**
* Yields to the main thread. Prefers scheduler.yield() (continuation runs ahead
* of other queued tasks), then falls back to setTimeout.
*/
export function yieldToMain() {
if (globalThis.scheduler?.yield) {
return globalThis.scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
/**
* Processes items in chunks, yielding only when the current chunk has used up its
* time budget. Yielding after every item wastes time on scheduling overhead;
* never yielding blocks input.
*/
export async function processInChunks(items, processItem, { budgetMs = 8, signal } = {}) {
let deadline = performance.now() + budgetMs;
const results = [];
for (const item of items) {
signal?.throwIfAborted(); // cancellation: stale work should stop early
results.push(processItem(item));
if (performance.now() >= deadline) {
await yieldToMain();
deadline = performance.now() + budgetMs;
}
}
return results;
}
web.dev's long-task guide uses a 50 ms deadline, the long-task threshold, as a common starting point. A smaller budget of 5–10 ms per chunk keeps input delay lower and leaves room in each frame for rendering, at the cost of more scheduling overhead; tune it for your workload.
scheduler.postTask() and task priorities¶
scheduler.postTask(callback, options) schedules a new task with one of three priorities and returns a promise for the callback's return value:
| Priority | Use for | Ordering |
|---|---|---|
"user-blocking" | Work the user is waiting on right now (rendering the result of an input) | Highest |
"user-visible" (default) | Work with visible effects that is not urgent (rendering below-the-fold content) | Normal |
"background" | Work nobody is waiting for (analytics batching, prefetching, cache cleanup, indexing) | Lowest; runs when nothing else is pending |
The options:
priority: fixed priority for this task. If set, the signal's priority is ignored.signal: anAbortSignal(to cancel) or aTaskSignalfrom aTaskController(to cancel and change priority later withcontroller.setPriority(), which firesprioritychangeon the signal).delay: minimum delay in milliseconds before the task becomes runnable (default 0).
If the signal is aborted before the task runs, the task never runs and the promise rejects with the signal's reason (an AbortError DOMException by default). postTask() is available in Chrome and Edge 94 and Firefox 142, in windows and workers; Safari does not support it. The scheduler-polyfill package from Google Chrome Labs implements both postTask() and yield() on top of MessageChannel and setTimeout.
A PWA-flavored example: search-as-you-type over an offline index, where each keystroke supersedes the previous search and non-urgent work runs in the background:
import { processInChunks } from "./yield.js";
let current; // TaskController (or AbortController fallback) for the in-flight search
const hasPostTask = typeof globalThis.scheduler?.postTask === "function";
export function onSearchInput(query, index, renderResults) {
// Cancel the previous search: its results are stale the moment the user types.
current?.abort();
current = hasPostTask ? new TaskController({ priority: "user-blocking" }) : new AbortController();
const { signal } = current;
const run = async () => {
const matches = await processInChunks(index.entries, (entry) => entry.match(query), {
budgetMs: 6,
signal,
});
renderResults(matches.filter(Boolean).slice(0, 50));
};
const task = hasPostTask ? scheduler.postTask(run, { signal }) : run();
task.catch((error) => {
if (error.name !== "AbortError") console.error(error);
});
// Recording the query for "recent searches" can wait until the browser is idle.
if (hasPostTask) {
scheduler
.postTask(() => saveRecentSearch(query), { priority: "background", delay: 1000 })
.catch(console.error);
}
}
For a large index, even chunked matching on the main thread is the wrong design; the same controller can post the query to a Web Worker instead (see below).
requestIdleCallback and isInputPending¶
Two older APIs are still around:
requestIdleCallback(callback, { timeout })runs a callback when the browser is idle, with anIdleDeadlinewhosetimeRemaining()never exceeds 50 ms. Chrome and Firefox support it; Safari does not ship it (it is disabled by default through current releases). Usescheduler.postTask(..., { priority: "background" })where available, and a timeout-based fallback elsewhere.navigator.scheduling.isInputPending()(Chromium 87+ only) reports whether input is waiting. It encourages "yield only when needed" loops, but web.dev's guidance on optimizing long tasks no longer recommends it: it can returnfalseeven though the user has interacted, and input is not the only reason to yield (rendering and animations matter too). Yield on a time budget withscheduler.yield()instead.
Optimizing interactions (INP)¶
INP splits each interaction into input delay (the handler cannot start because another task is running), processing duration (your event handlers), and presentation delay (rendering the next frame after the handlers). Core Web Vitals defines them and shows how to collect them; the fixes map to this page as follows:
| Phase that is slow | Typical cause in a PWA | Fix |
|---|---|---|
| Input delay | Start-up and hydration tasks, sync processing after a cache read, timers, third-party scripts | Break up start-up work with a yield budget; defer non-critical initialization with postTask({ priority: "background" }); move processing to a worker |
| Processing duration | Handlers that update state, re-render large trees, write to IndexedDB, and log analytics synchronously | Do the visible update first, await scheduler.yield(), then do the rest; debounce input-driven work |
| Presentation delay | Large DOM, expensive selectors, forced layouts, big layout scope, requestAnimationFrame callbacks doing too much | Shrink the DOM, contain layout, virtualize lists, avoid layout thrashing |
The pattern "visual feedback, then yield, then everything else" deserves one more example, because frameworks tend to hide the yield point:
import { yieldToMain } from "./yield.js";
export function bindLikeButton(button, { postId, store }) {
button.addEventListener("click", async () => {
// 1. The only work before the next paint: flip the visible state.
const liked = button.getAttribute("aria-pressed") !== "true";
button.setAttribute("aria-pressed", String(liked));
button.querySelector(".count").textContent = String(store.likeCount(postId) + (liked ? 1 : -1));
// 2. Give the browser the chance to present that frame.
await yieldToMain();
// 3. The rest: state store updates (which may re-render other components),
// persistence for offline sync, analytics.
try {
store.setLiked(postId, liked);
await store.persist(); // IndexedDB; queued for background sync if offline
} catch (error) {
// Roll back the optimistic update so the UI never lies.
button.setAttribute("aria-pressed", String(!liked));
store.setLiked(postId, !liked);
console.error(error);
}
});
}
The rendering pipeline¶
When JavaScript or CSS changes something, the browser works through a pipeline:
flowchart LR
JS["JavaScript / CSS change"] --> ST["Style: recalculate computed styles"]
ST --> LA["Layout: compute geometry"]
LA --> PA["Paint: record draw operations"]
PA --> CO["Composite: rasterize and combine layers (compositor thread, GPU)"] Which stages run depends on what changed:
| Change | Style | Layout | Paint | Composite | Examples |
|---|---|---|---|---|---|
| Geometry | ✓ | ✓ | ✓ | ✓ | width, height, top, margin, padding, font-size, inserting DOM nodes, changing text |
| Paint-only | ✓ | ✓ | ✓ | color, background-color, box-shadow, outline, visibility | |
| Compositor-only | ✓ | ✓ | transform, opacity (and, in Chromium, filter in many cases) on an element with its own layer |
The cost of each stage scales with the amount of DOM affected. Style recalculation cost grows with the number of elements whose styles must be recomputed and the complexity of the selectors matched against them; layout cost grows with the number of boxes in the affected layout scope. A 10,000-node DOM makes every stage slower, which is why DOM size shows up in Lighthouse (the dom-size-insight audit) and why virtualization and containment matter.
Layout thrashing and forced synchronous layout¶
Normally the browser defers style and layout until the rendering opportunity, batching all changes made during a task. But if JavaScript reads a layout-dependent value after writing something that invalidates layout, the browser must run style and layout synchronously to give an accurate answer. That is a forced synchronous layout. Doing it in a loop, write, read, write, read, is layout thrashing, and it can turn a 2 ms update into a 200 ms one.
Properties and methods that force style or layout include offsetTop/offsetLeft/offsetWidth/offsetHeight, clientTop/clientWidth/clientHeight, scrollTop/scrollLeft/scrollWidth/scrollHeight, getBoundingClientRect(), getClientRects(), getComputedStyle() (for layout-dependent properties), innerText, focus() (it may scroll), scrollIntoView(), and window.scrollX/scrollY/innerWidth/innerHeight.
// Bad: each iteration writes a style, then reads layout, forcing a synchronous
// layout per card: O(n) layouts.
function equalizeHeightsSlow(cards) {
for (const card of cards) {
card.style.height = "auto"; // write (invalidates layout)
const height = card.getBoundingClientRect().height; // read (forces layout)
card.style.height = `${Math.ceil(height / 8) * 8}px`; // write
}
}
// Good: all writes, then all reads, then all writes: two layouts total.
function equalizeHeights(cards) {
for (const card of cards) card.style.height = "auto"; // writes
const heights = cards.map((card) => card.getBoundingClientRect().height); // reads (one layout)
cards.forEach((card, i) => {
card.style.height = `${Math.ceil(heights[i] / 8) * 8}px`; // writes
});
}
In larger apps, reads and writes come from different components, so the ordering cannot be enforced by one function. A tiny read/write scheduler, the idea popularized by the FastDOM library, batches them per frame:
// Collects DOM reads and writes and runs them in two phases per frame:
// all reads first (at most one layout), then all writes.
const reads = [];
const writes = [];
let scheduled = false;
function flush() {
scheduled = false;
try {
while (reads.length) reads.shift()();
while (writes.length) writes.shift()();
} finally {
// Writes may schedule new reads; they run in the next frame.
if (reads.length || writes.length) schedule();
}
}
function schedule() {
if (!scheduled) {
scheduled = true;
requestAnimationFrame(flush);
}
}
export function measure(fn) {
return new Promise((resolve, reject) => {
reads.push(() => {
try { resolve(fn()); } catch (error) { reject(error); }
});
schedule();
});
}
export function mutate(fn) {
return new Promise((resolve, reject) => {
writes.push(() => {
try { resolve(fn()); } catch (error) { reject(error); }
});
schedule();
});
}
LoAF's forcedStyleAndLayoutDuration per script and the Performance panel's Forced reflow insight (Lighthouse's forced-reflow-insight) point at thrashing code directly.
Observers are the structural fix: ResizeObserver delivers element sizes computed during the rendering step (no forced layout), and IntersectionObserver delivers visibility without reading getBoundingClientRect() on scroll.
Using requestAnimationFrame correctly¶
requestAnimationFrame(callback) runs callback in the next rendering opportunity, before style and layout, with a timestamp for the frame. Use it for visual updates that must happen every frame, and for batching writes, as above. Rules:
- Do not do heavy work in rAF callbacks. They run inside the frame; slow callbacks delay the frame and show up as presentation delay in INP.
- Use the timestamp, not
Date.now(), and compute animation progress from elapsed time, so that animations run at the same speed on 60 Hz and 120 Hz displays and survive dropped frames. - rAF is paused in hidden documents, which is usually what you want: an installed PWA minimized to the dock stops animating. Do not use rAF for work that must continue in the background.
- Scroll handlers should be passive and cheap. Mark
touchstart,touchmoveandwheellisteners{ passive: true }unless they callpreventDefault(), so scrolling does not wait for JavaScript; move scroll-linked visuals to rAF,IntersectionObserver, or CSS scroll-driven animations.
CSS containment and content-visibility¶
CSS containment lets you promise the browser that a subtree is independent of the rest of the page, so that style, layout and paint work can be limited to that subtree, or skipped entirely.
The contain property¶
| Value | Promise | Effect |
|---|---|---|
layout | Nothing inside affects layout outside, and vice versa | Layout changes inside stay inside; the element becomes a containing block and a formatting context |
paint | Descendants do not paint outside the element's bounds | Off-screen contained elements can skip painting; overflow is clipped |
size | The element's size does not depend on its contents | Layout of the element can be done without laying out children; you must set a size |
inline-size | Like size, for the inline axis only | Needed for container queries |
style | Counters and quotes do not escape the subtree | Rarely a performance factor |
content | layout paint style | Safe default for independent widgets |
strict | size layout paint style | Requires an explicit size |
contain: content on independent components (cards, list items, widgets, chat messages) is a low-risk improvement: a DOM change inside one card no longer invalidates layout of the whole page.
content-visibility: auto¶
content-visibility goes further: it lets the browser skip rendering work for off-screen content entirely.
visible(default): normal rendering.hidden: the content is not rendered, likedisplay: none, but its rendering state is preserved, so showing it again is cheaper than re-creating it. Useful for hidden tabs or views in a single-page PWA.auto: the element gets layout, style and paint containment always, and size containment while it is off-screen; its contents are not styled, laid out or painted until it approaches the viewport. Unlikehidden, the contents remain in the DOM, in the accessibility tree and searchable with find-in-page.
Because a skipped subtree has no size, pair it with contain-intrinsic-size, which provides a placeholder size. The auto keyword makes the browser remember the last rendered size, so scroll positions stay stable after an element has been seen once:
/* Each feed item renders only when near the viewport. */
.feed-item {
content-visibility: auto;
/* Estimated size until rendered; afterwards the browser remembers the real size. */
contain-intrinsic-size: auto 320px;
}
/* A hidden view in a single-page PWA keeps its rendering state for fast return. */
.view[hidden-view] {
content-visibility: hidden;
}
content-visibility is supported in Chrome 85, Firefox 125 and Safari 18. When an auto element starts or stops being skipped, it receives a contentvisibilityautostatechange event whose skipped property tells you which, so you can pause canvas drawing or polling for off-screen widgets (Baseline 2024).
Caveats:
- The scrollbar can jump if placeholder sizes are far off; measure typical heights.
- Calling layout-reading APIs on skipped content forces the browser to render it, defeating the purpose.
- It is most effective for long pages with many independent sections (feeds, documentation, chat histories); for lists of tens of thousands of items, virtualization is still needed because the DOM itself is too big.
Animating on the compositor¶
The compositor thread can animate transform and opacity on elements that have their own compositing layer without involving the main thread at all. Such animations stay smooth even while JavaScript is running a long task. Animations of width, height, top, left, margin or box-shadow run on the main thread and repaint or re-layout every frame.
/* Slide a navigation drawer in: compositor-only. */
.drawer {
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.2, 0, 0, 1);
}
.drawer[open] {
transform: translateX(0);
}
/* Respect users who prefer less motion. */
@media (prefers-reduced-motion: reduce) {
.drawer { transition: none; }
}
The Web Animations API (element.animate()) runs the same compositor animations from JavaScript and gives you promises (animation.finished) and control (pause, reverse, playback rate) without per-frame JavaScript.
will-change tells the browser to create a layer in advance for a property you are about to animate. Use it sparingly and temporarily: every layer costs GPU memory, and on memory-constrained phones hundreds of layers make things slower. Setting will-change: transform on every list item is a common mistake.
Scroll-driven animations (animation-timeline: scroll() and view()) let CSS tie an animation's progress to a scroll position, replacing scroll event handlers for effects like progress bars, parallax and reveal-on-scroll, and they can run on the compositor. They are supported in Chrome 115 and Safari 26; Firefox support is still in development, so treat them as progressive enhancement.
For page and view changes, View Transitions animate snapshots on the compositor, with their own performance considerations.
Moving work off the main thread¶
The most robust way to keep the main thread responsive is to not use it for heavy work. A dedicated Web Worker runs JavaScript on its own thread, with no access to the DOM, communicating with the page through postMessage. Good candidates in PWAs:
- Full-text search over an offline dataset, and building the index.
- Parsing and transforming large JSON or CSV payloads, diffing sync payloads against IndexedDB state (see Offline-First Data & Sync).
- Image processing (resizing uploads, applying filters), with
OffscreenCanvasandcreateImageBitmap. - Cryptography, compression (
CompressionStreamworks in workers), Markdown rendering, syntax highlighting. - Anything written in WebAssembly that takes more than a few milliseconds.
The service worker is not a compute thread
It is tempting to reuse the service worker for background computation because it is already there. Do not: there is one service worker for all tabs of your origin, it may be terminated whenever it is idle (and it loses in-memory state when it is), every millisecond it spends computing delays the fetch events it must answer for every page, and browsers limit how long an event can keep it alive. Use a dedicated worker per page (or a SharedWorker where supported) for computation, and keep the service worker for networking, caching and push.
Messaging costs: structured clone and transfer¶
postMessage copies its argument with the structured clone algorithm. Cloning is synchronous on the sending thread and proportional to the size of the data: posting a 20 MB object graph costs tens of milliseconds on the main thread, which defeats the purpose. Options:
- Transfer
ArrayBuffers,MessagePorts,ImageBitmaps,OffscreenCanvases and streams instead of copying them:worker.postMessage(message, [buffer]). The sender loses access; the transfer itself is nearly free. - Send less. Keep the large dataset in the worker (loaded there from IndexedDB or
fetch), and send only queries and small results. SharedArrayBuffershares memory between threads without copying, but requires cross-origin isolation (Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corporcredentialless), which constrains third-party embeds.
Comlink: workers as async objects¶
Raw postMessage protocols become unwieldy quickly. Comlink (4.4.2 at the time of writing), a small library from Google Chrome Labs, wraps a worker in an ES6 Proxy, so that calling a worker function looks like calling an async function:
import * as Comlink from "comlink";
// State lives in the worker: the page never has to copy the index.
let index = null;
const api = {
async load(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`Index download failed: ${response.status}`);
const docs = await response.json();
index = buildIndex(docs); // expensive: tokenizing thousands of documents
return docs.length;
},
search(query, limit = 20) {
if (!index) throw new Error("Index not loaded");
return queryIndex(index, query).slice(0, limit); // small, clone-friendly result
},
// Transferable input: a large ArrayBuffer is moved, not copied.
async thumbnail(buffer, maxSize) {
const bitmap = await createImageBitmap(new Blob([buffer]));
const scale = Math.min(1, maxSize / Math.max(bitmap.width, bitmap.height));
const canvas = new OffscreenCanvas(Math.round(bitmap.width * scale), Math.round(bitmap.height * scale));
canvas.getContext("2d").drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
const blob = await canvas.convertToBlob({ type: "image/webp", quality: 0.8 });
const out = await blob.arrayBuffer();
return Comlink.transfer(out, [out]);
},
};
Comlink.expose(api);
function buildIndex(docs) {
const map = new Map();
for (const doc of docs) {
for (const token of new Set(doc.text.toLowerCase().split(/\W+/))) {
if (!token) continue;
if (!map.has(token)) map.set(token, []);
map.get(token).push({ id: doc.id, title: doc.title });
}
}
return map;
}
function queryIndex(map, query) {
const tokens = query.toLowerCase().split(/\W+/).filter(Boolean);
if (!tokens.length) return [];
// Intersect posting lists; start with the shortest for speed.
const lists = tokens.map((t) => map.get(t) ?? []).sort((a, b) => a.length - b.length);
const [first, ...rest] = lists;
return first.filter((hit) => rest.every((list) => list.some((h) => h.id === hit.id)));
}
import * as Comlink from "comlink";
// Module workers: supported in Chrome 80, Firefox 114, Safari 15. Bundlers such
// as Vite and webpack recognize this exact new URL(..., import.meta.url) pattern
// and emit the worker as a separate chunk.
const worker = new Worker(new URL("./search.worker.js", import.meta.url), { type: "module" });
const search = Comlink.wrap(worker);
worker.addEventListener("error", (event) => {
console.error("Search worker failed:", event.message);
});
export async function initSearch() {
const count = await search.load("/data/search-index.json");
console.info(`Search index ready: ${count} documents`);
}
export function runSearch(query) {
// Looks synchronous-ish, but runs entirely on the worker thread.
return search.search(query, 20);
}
export async function makeThumbnail(file) {
const buffer = await file.arrayBuffer();
// Transfer the input buffer so it is moved, not cloned.
const out = await search.thumbnail(Comlink.transfer(buffer, [buffer]), 320);
return new Blob([out], { type: "image/webp" });
}
Comlink also supports callbacks (Comlink.proxy(fn), for progress reporting) and releasing proxies (proxy[Comlink.releaseProxy]()). Remember that every call is still a message round trip: chatty APIs with thousands of tiny calls are slow. Design worker APIs around coarse operations.
Precache the worker scripts. A worker script is fetched when the worker is constructed; in an offline PWA it must be in the service worker's precache like any other chunk, or the feature fails offline.
OffscreenCanvas: drawing from a worker¶
OffscreenCanvas decouples canvas rendering from the DOM. canvas.transferControlToOffscreen() hands a visible <canvas> to a worker, which can then draw with the 2D or WebGL context, and its frames appear on screen without main-thread involvement. Charts, maps, games and signature pads stay smooth while the main thread is busy. It is supported in Chrome 69, Firefox 105 and Safari 16.4; Safari added WebGL and WebGL 2 contexts on OffscreenCanvas in Safari 17, so a WebGL renderer needs Safari 17 while a 2D renderer works from 16.4.
const canvas = document.querySelector("#live-chart");
const worker = new Worker(new URL("./chart.worker.js", import.meta.url), { type: "module" });
if ("transferControlToOffscreen" in canvas) {
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ type: "init", canvas: offscreen, dpr: devicePixelRatio }, [offscreen]);
// Keep the drawing buffer sized to the element: size changes are messages.
new ResizeObserver(([entry]) => {
const { inlineSize, blockSize } = entry.contentBoxSize[0];
worker.postMessage({ type: "resize", width: inlineSize, height: blockSize });
}).observe(canvas);
} else {
// Very old engines: fall back to main-thread drawing.
import("./chart-main-thread.js").then(({ drawOnMainThread }) => drawOnMainThread(canvas));
}
let ctx;
let canvas;
let dpr = 1;
const points = [];
self.onmessage = ({ data }) => {
switch (data.type) {
case "init":
canvas = data.canvas;
dpr = data.dpr;
ctx = canvas.getContext("2d");
loop();
break;
case "resize":
canvas.width = Math.round(data.width * dpr);
canvas.height = Math.round(data.height * dpr);
break;
case "point":
points.push(data.value);
if (points.length > 500) points.shift();
break;
}
};
// requestAnimationFrame exists in dedicated workers in Chrome 69, Firefox 99 and
// Safari 16.4; fall back to a timer otherwise.
const nextFrame = self.requestAnimationFrame?.bind(self) ?? ((cb) => setTimeout(() => cb(performance.now()), 16));
function loop() {
draw();
nextFrame(loop);
}
function draw() {
if (!ctx) return;
const { width, height } = canvas;
ctx.clearRect(0, 0, width, height);
ctx.beginPath();
points.forEach((value, i) => {
const x = (i / 499) * width;
const y = height - value * height;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.lineWidth = 2 * dpr;
ctx.strokeStyle = "#1a73e8";
ctx.stroke();
}
Rendering large lists: virtualization¶
A list with 10,000 rows is 10,000 (often 50,000+) DOM nodes: every style recalculation, layout and memory snapshot pays for all of them. Virtualization (windowing) renders only the rows in and near the viewport and positions them inside a container sized to the full list, recycling DOM nodes as the user scrolls.
A minimal fixed-row-height virtual list, framework-free:
/**
* Renders only visible rows of a fixed-height list.
* container: a scrollable element with a fixed height.
*/
export function createVirtualList(container, { rowHeight, count, renderRow, overscan = 6 }) {
const spacer = document.createElement("div");
spacer.style.position = "relative";
spacer.style.height = `${count * rowHeight}px`;
spacer.setAttribute("role", "list");
container.replaceChildren(spacer);
const pool = new Map(); // index -> element currently rendered
let frame = 0;
function update() {
frame = 0;
const first = Math.max(0, Math.floor(container.scrollTop / rowHeight) - overscan);
const visible = Math.ceil(container.clientHeight / rowHeight) + overscan * 2;
const last = Math.min(count - 1, first + visible);
// Remove rows that scrolled out of the window.
for (const [index, el] of pool) {
if (index < first || index > last) {
el.remove();
pool.delete(index);
}
}
// Add rows that scrolled in. One layout for the whole batch (no reads between writes).
const fragment = document.createDocumentFragment();
for (let i = first; i <= last; i++) {
if (pool.has(i)) continue;
const el = renderRow(i);
el.setAttribute("role", "listitem");
el.setAttribute("aria-setsize", String(count)); // assistive tech knows the full size
el.setAttribute("aria-posinset", String(i + 1));
el.style.cssText += `position:absolute;top:0;left:0;right:0;height:${rowHeight}px;transform:translateY(${i * rowHeight}px);`;
pool.set(i, el);
fragment.append(el);
}
spacer.append(fragment);
}
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
container.addEventListener("scroll", onScroll, { passive: true });
const resize = new ResizeObserver(onScroll);
resize.observe(container);
update();
return {
destroy() {
container.removeEventListener("scroll", onScroll);
resize.disconnect();
cancelAnimationFrame(frame);
},
};
}
Production lists need variable row heights (measure rendered rows and cache their sizes), keyboard navigation and focus management (a focused row must not be recycled), and scroll restoration. Libraries such as TanStack Virtual (framework-agnostic core with React, Vue, Svelte, Solid and Lit adapters) handle those. Two accessibility caveats apply to all virtualization: content that is not rendered cannot be found with the browser's find-in-page, and screen reader users navigate by the rendered items only, hence aria-setsize/aria-posinset. For lists of a few hundred to a few thousand items, content-visibility: auto on each item is often enough and keeps everything in the DOM; see Accessibility.
Memory in long-lived PWAs¶
A website tab typically lives for minutes. An installed PWA in its own window, pinned on a desktop or kept in the app switcher on a phone, may run for days without a reload. Small leaks that are invisible in a browsing session accumulate: garbage-collection pauses grow (they show up as long tasks), the app slows down, and on mobile the OS eventually kills or discards the process, which the user experiences as the app "restarting" and losing state.
Common leak sources¶
| Leak | Why it leaks | Fix |
|---|---|---|
Event listeners on long-lived targets (window, document, a store) added by components that are later removed | The listener closure references the component and its DOM | Remove listeners on teardown; use an AbortController per component |
| Detached DOM trees | A removed subtree still referenced from JavaScript (a cache, a closure, an array of "previous views") | Drop references; use WeakMap for DOM-keyed metadata |
| Unbounded in-memory caches | A Map of API responses or rendered views that is never pruned | Bound size (LRU) and let persistent data live in IndexedDB or Cache Storage |
| Timers and intervals | setInterval callbacks keep closures alive forever | Clear them on teardown; prefer scheduling that stops when hidden |
| Observers | IntersectionObserver, ResizeObserver, MutationObserver not disconnected | Call disconnect() on teardown |
BroadcastChannel, MessagePort, EventSource, WebSocket | Open channels keep their handlers alive | close() them |
| Object URLs | URL.createObjectURL(blob) keeps the blob alive until revoked | URL.revokeObjectURL() when the image has loaded or the view is closed |
| Growing arrays for logs and analytics | Debug logs, RUM queues that are never flushed | Cap and flush |
AbortController is the most practical tool for listener hygiene. A single signal removes every listener registered with it:
export class InboxView {
#controller = new AbortController();
#observer;
mount(root, store) {
const { signal } = this.#controller;
// All listeners registered with this signal are removed by one abort().
store.addEventListener("change", () => this.render(root, store), { signal });
addEventListener("online", () => this.sync(), { signal });
root.addEventListener("click", (event) => this.onClick(event), { signal });
const timer = setInterval(() => this.refreshTimestamps(root), 60_000);
signal.addEventListener("abort", () => clearInterval(timer));
this.#observer = new IntersectionObserver((entries) => this.onVisible(entries));
root.querySelectorAll("[data-lazy]").forEach((el) => this.#observer.observe(el));
signal.addEventListener("abort", () => this.#observer.disconnect());
}
unmount() {
this.#controller.abort(); // listeners, timer and observer are all released
}
render() {}
sync() {}
onClick() {}
refreshTimestamps() {}
onVisible() {}
}
WeakMap and WeakSet hold keys weakly, so metadata attached to DOM nodes disappears with the nodes. WeakRef and FinalizationRegistry exist for caches of expensive objects, but their timing is unpredictable; do not build program logic on them.
Finding leaks¶
The Chrome DevTools Memory panel is the main tool:
- Load the app and perform the suspected leaking action once (open and close a view) to warm up caches.
- Take a heap snapshot (the panel forces garbage collection first).
- Repeat the action several times, then take another snapshot.
- Select the second snapshot and switch to the Comparison view against the first: classes whose count grows by the number of repetitions are leak candidates. Type
Detachedin the class filter to see DOM nodes that are no longer in the document but still retained, and follow the Retainers pane to the reference that keeps them alive.
The Allocation instrumentation on timeline profile shows which allocations survive over time, and the Performance panel's Memory checkbox plots JS heap, DOM node and listener counts during a recording: a sawtooth that trends upward across repeated actions is a leak.
In the field, performance.measureUserAgentSpecificMemory() (Chromium 89+) returns an estimate of memory used by the page, its iframes and workers, with a breakdown by realm. It requires a secure context and cross-origin isolation (crossOriginIsolated === true) and may take a while to resolve because it waits for garbage collection. Sample it at random intervals in long sessions and chart the trend by session length. The older performance.memory is non-standard, Chromium-only and imprecise; avoid building on it.
Lifecycle states: hidden, frozen, discarded¶
Browsers aggressively reduce the cost of pages the user is not looking at: timers are throttled in hidden pages, Chromium can freeze background pages (the Chromium-only freeze and resume events on document signal this), and mobile browsers and operating systems discard pages under memory pressure. When the user returns to a discarded page, it reloads; document.wasDiscarded (Chromium) tells you it happened, and web-vitals reports such loads with navigationType: "restore". For a PWA:
- Save important in-progress state (drafts, form input, scroll positions) on
visibilitychangetohidden, not onunloadorbeforeunload(which are unreliable on mobile and block the back/forward cache). - Stop polling and animations when hidden, and resume on
visible. - Treat a lower memory footprint as a way to be discarded less often.
Runtime work specific to PWAs¶
Several runtime costs are created by PWA features themselves:
- Boot work on cached launches. When the shell comes from Cache Storage, the page is interactive-looking within a few hundred milliseconds, and the user starts tapping while your framework is still hydrating, your store is loading from IndexedDB, and your update check is running. This is where most PWA INP problems come from. Chunk boot work with a yield budget, render interactive controls last or disable them visibly until they work, and defer everything non-essential with
postTask({ priority: "background" }). - Large reads from IndexedDB and Cache Storage. The APIs are asynchronous, but the work around them is not:
JSON.parseof a 5 MB cached response, or turning 10,000 IndexedDB records into view models, is a long task. Paginate reads with cursors orgetAll(query, count), and parse large payloads in a worker. See IndexedDB. - Service worker messaging storms. A worker that
postMessages every cache update to every client can flood the main thread with message tasks. Batch messages, and let clients pull state when visible. See Messaging & the Clients API. - Update prompts and install banners. Inserting a toast into the document flow causes a layout shift and a style recalculation of the whole page; render them in a fixed-position layer that is already in the DOM.
- Background sync handlers run in the service worker and do not block the page, but the page's reaction to a completed sync (re-rendering a large list) does.
Browser support¶
Support data as of September 2026. See MDN and caniuse for live data: Scheduler, Long animation frame timing, content-visibility, OffscreenCanvas.
| API | Chrome / Edge | Firefox | Safari (macOS, iOS) |
|---|---|---|---|
scheduler.postTask(), TaskController | ✅ 94 | ✅ 142 | ❌ |
scheduler.yield() | ✅ 129 | ✅ 142 | ❌ |
requestIdleCallback() | ✅ 47 | ✅ 55 | ❌ |
navigator.scheduling.isInputPending() | ✅ 87 | ❌ | ❌ |
Long Tasks API (longtask) | ✅ | ❌ | ❌ |
Long Animation Frames (long-animation-frame) | ✅ 123 | ❌ | ❌ |
content-visibility | ✅ 85 | ✅ 125 | ✅ 18 |
contentvisibilityautostatechange event | ✅ 108 | ✅ 130 | ✅ 18 |
CSS contain | ✅ | ✅ | ✅ |
Scroll-driven animations (animation-timeline: scroll()) | ✅ 115 | ❌ (in development) | ✅ 26 |
Module workers ({ type: "module" }) | ✅ 80 | ✅ 114 | ✅ 15 |
OffscreenCanvas | ✅ 69 | ✅ 105 | ✅ 16.4 ⚠️ |
performance.measureUserAgentSpecificMemory() | ✅ 89 | ❌ | ❌ |
freeze / resume events | ✅ | ❌ | ❌ |
⚠️ Safari 16.4 supports OffscreenCanvas with the 2D and bitmaprenderer contexts; WebGL and WebGL 2 contexts on OffscreenCanvas arrived in Safari 17. Edge, Opera and Samsung Internet follow their Chromium version.
Common pitfalls¶
- Awaiting resolved promises and calling it yielding.
await somethingSync()orawait Promise.resolve()does not end the task. Yield withscheduler.yield()or a timer. - Yielding after every tiny unit of work. Scheduling overhead dominates; yield on a time budget.
- Heavy work in
requestAnimationFrame. It runs inside the frame and delays presentation. - Reading layout inside loops that write styles. The classic layout thrash; batch reads before writes.
- Animating layout properties.
top/left/width/heightanimations run on the main thread; usetransformandopacity. will-changeeverywhere. Layers cost GPU memory; promote only what animates, only while it animates.- Posting huge objects to workers. Structured clone happens on the sending thread; transfer buffers or keep data in the worker.
- Using the service worker for computation. It serves every tab's requests and can be terminated at any time.
- Forgetting the worker script offline. Worker chunks must be precached.
- Components that never clean up. Listeners on
windowor on a global store leak the whole component tree in long sessions. - Relying on
unload. It is unreliable on mobile and prevents back/forward cache restores; usevisibilitychangeandpagehide.
Debugging¶
- Performance panel (Chrome DevTools). Record an interaction with CPU throttling (use the calibrated low-tier or mid-tier mobile presets). Long tasks have red corners in the Main track; the Interactions track shows each interaction's input delay, processing and presentation delay; the Insights sidebar includes INP breakdown, forced reflow, DOM size and layout shift culprits. Enable CSS selector stats to find expensive selectors in style recalculation.
- Rendering drawer. Paint flashing shows what repaints, Layout shift regions shows shifts, Layer borders shows compositing layers, and Frame rendering stats shows dropped frames live.
- Memory panel. Heap snapshot comparison, the
Detachedfilter and allocation timelines for leaks, as described above. - Worker threads. Dedicated workers appear as separate tracks in the Performance panel and as separate contexts in the Console's context selector;
consoleoutput from workers appears in the page's console. - Firefox Profiler (built into Firefox DevTools) and Safari Web Inspector's Timelines give equivalent views in the other engines; Safari's JavaScript & Events and Layout & Rendering timelines are the tools for finding iOS-specific runtime problems, since LoAF is not available there.
- In the field, collect INP with attribution and LoAF summaries; see Measuring Performance.
Further reading¶
On this site
- Core Web Vitals: INP definition, phases and attribution
- Loading Performance: code splitting and lazy loading that reduce start-up work
- Measuring Performance: collecting LoAF and INP data from real users
- App Shell Model: the boot sequence that creates most PWA runtime problems
- Offline-First Data & Sync and IndexedDB: data work that belongs in workers
- View Transitions: animating navigations on the compositor
- Messaging & the Clients API: communication between pages and the service worker
- Browser DevTools: profiling workflows
External references