Measure and improve loading, rendering, interaction responsiveness, caching, and perceived speed without sacrificing correctness or accessibility.
Measure user-centered performance with repeatable evidence
Set budgets for network, JavaScript, rendering, and interaction cost
Optimize assets and delivery around the critical rendering path
Diagnose long tasks, layout work, and memory leaks
Protect performance improvements with ongoing monitoring
Performance work begins with a user scenario, a device and network profile, and a metric tied to the experience. A fast developer laptop can hide the cost paid by real users.
Combine controlled lab traces with field measurements. Lab data makes changes reproducible, while field data reveals the devices, networks, and interactions that actually occur.
Define the user journey and environment
Use lab evidence for diagnosis and field evidence for reality
Compare distributions rather than one best run
Journey: signed-in learner opens /learning on mid-tier mobile
Network: simulated 4G with warm and cold cache runs
Question: does the primary course list become usable within the agreed budget?
Evidence: navigation trace plus p75 field metricBudgets turn performance from a vague aspiration into an engineering constraint. Include transfer size, JavaScript execution, key rendering milestones, interaction latency, and layout stability.
Choose thresholds for the product's audience and critical routes, then enforce them in review or CI with enough tolerance to avoid noise masking real regressions.
Budget both bytes and main-thread time
Track loading, responsiveness, and visual stability
Apply route-specific budgets to representative hardware
{
"route": "/learning",
"javascriptKbGzip": 180,
"largestContentfulPaintMsP75": 2500,
"interactionToNextPaintMsP75": 200,
"cumulativeLayoutShiftP75": 0.1
}The browser must receive HTML, discover critical assets, construct styles, and paint useful content. Blocking scripts, deeply imported styles, and late-discovered hero assets delay that path.
Prioritize the resources required for the initial viewport and defer work that belongs to later interactions. Preload sparingly because every high-priority resource competes for bandwidth.
Deliver useful HTML early
Keep render-blocking styles focused
Preload only proven critical resources
<link rel="preload" as="image" href="/learning-hero.avif" imagesrcset="/learning-hero-640.avif 640w, /learning-hero-1280.avif 1280w" imagesizes="100vw">
<img src="/learning-hero.avif" alt="" width="1280" height="640" fetchpriority="high">Serve images near their rendered dimensions in an efficient format, with intrinsic width and height to reserve space. Responsive sources prevent narrow screens from downloading desktop assets.
Subset font families and weights, preload only critical faces, and choose a font-display strategy that avoids invisible text while controlling layout shifts with compatible fallbacks.
Encode and size images for their display context
Reserve media dimensions to prevent layout shift
Ship only the font files and glyphs the route needs
<img
src="/course-800.avif"
srcset="/course-400.avif 400w, /course-800.avif 800w, /course-1200.avif 1200w"
sizes="(min-width: 64rem) 33vw, 100vw"
width="800"
height="450"
loading="lazy"
alt="Course interface preview"
>JavaScript costs more than its transferred bytes because the browser must parse, compile, and execute it on the main thread. Remove unused dependencies and avoid shipping code for interactions a route does not offer.
Split at route and expensive feature boundaries, then prefetch only when user intent makes future navigation likely. Too many tiny chunks can add request and coordination overhead.
Measure parse and execution cost as well as bundle size
Split code at meaningful user-facing boundaries
Prefer platform features over large dependencies for small tasks
const openEditor = document.querySelector("[data-open-editor]");
openEditor?.addEventListener("click", async () => {
const { mountEditor } = await import("./editor.js");
await mountEditor(document.querySelector("[data-editor-root]"));
}, { once: true });Reading layout after writing styles can force the browser to calculate geometry synchronously. Group reads before writes and avoid loops that alternate between them.
Animate transform and opacity when they fit the design, but do not promote every element to its own layer. Compositing consumes memory and can create different bottlenecks.
Batch layout reads before writes
Reserve geometry-changing animations for deliberate cases
Use compositor hints only after profiling
const cards = [...document.querySelectorAll("[data-card]")];
const widths = cards.map((card) => card.getBoundingClientRect().width);
requestAnimationFrame(() => {
cards.forEach((card, index) => {
card.style.setProperty("--measured-width", `${widths[index]}px`);
});
});Long synchronous work blocks input, rendering, and assistive technology updates. Use a performance trace to locate the responsible task before changing scheduling.
Process large workloads in chunks, yield between them, or move CPU-heavy pure computation to a worker. Preserve ordering and cancellation so deferred work cannot update stale UI.
Find long tasks in a trace
Chunk work around an explicit time budget
Move suitable CPU work off the main thread
async function processInChunks(items, visit) {
for (let index = 0; index < items.length; index += 100) {
items.slice(index, index + 100).forEach(visit);
await new Promise((resolve) => setTimeout(resolve, 0));
}
}Cache immutable fingerprinted assets for a long time and revalidate mutable documents and API responses according to their ownership. A cache policy must define freshness, invalidation, and personalization.
Avoid publicly caching responses that vary by authenticated identity or entitlement. Use private no-store behavior where a shared cache could expose learner state.
Match cache lifetime to content mutability
Use content hashes for immutable assets
Keep personalized and entitlement-bearing responses out of shared caches
Versioned app.js: public, max-age=31536000, immutable
Public roadmap definitions: public, short max-age with validation
Authenticated learning overview: private, no-store; Vary: Authorization
Protected guide detail: private, no-storeRendering thousands of complex rows can increase scripting, layout, memory, and accessibility cost. Pagination, incremental loading, or virtualization can reduce simultaneous DOM work.
Virtualization changes focus, find-in-page, printing, and screen-reader behavior. First simplify row markup and measure; then choose a strategy that preserves the product's interaction requirements.
Measure DOM and rendering cost before virtualizing
Prefer pagination when it matches the information model
Test keyboard and assistive behavior across windowed content
type Page<T> = {
readonly items: readonly T[];
readonly nextCursor: string | null;
readonly hasMore: boolean;
};
function canLoadMore<T>(page: Page<T>): boolean {
return page.hasMore && page.nextCursor !== null;
}Detached DOM nodes, retained closures, unremoved listeners, observers, timers, and object URLs can keep data alive after a view is gone. Memory pressure often appears only after repeating a workflow.
Record heap snapshots before and after repeated navigation, inspect retaining paths, and fix the lifecycle owner. Explicit cleanup is clearer than hoping garbage collection can infer ownership.
Repeat real workflows when profiling memory
Inspect retaining paths instead of guessing
Dispose listeners, observers, timers, and object URLs
function previewFile(image, file) {
const url = URL.createObjectURL(file);
image.src = url;
image.addEventListener("load", () => {
URL.revokeObjectURL(url);
}, { once: true });
}A local improvement can regress as content, dependencies, experiments, and traffic change. Record the metric, population, release, and route so trends remain attributable.
Alert on meaningful sustained regressions rather than every noisy sample. Pair automated monitoring with a documented response that identifies an owner and rollback threshold.
Track field distributions by route and release
Enforce stable lab budgets during development
Assign owners and rollback criteria to regressions
type WebMetric = {
name: "LCP" | "INP" | "CLS";
value: number;
route: string;
release: string;
};
function reportMetric(metric: WebMetric) {
navigator.sendBeacon("/telemetry/web-vitals", JSON.stringify(metric));
}