Back to Guides
GuideAdvanced

Web Performance Optimization Guide

Learn how to measure, analyze, and improve your web application's performance for better user experience.

March 15, 202435 min read
PerformanceWeb VitalsOptimization
Performance is a feature. A 100ms improvement in load time can meaningfully affect engagement and conversion. More importantly, it is a matter of respect — not everyone has a fast device and a good connection. This guide covers how to measure accurately, what to optimize first, and the techniques that actually move the needle. MEASURING WITH CORE WEB VITALS Google's Core Web Vitals are the baseline performance metrics for user experience. The three that matter are LCP, INP, and CLS. LCP stands for Largest Contentful Paint. It measures when the largest visible element finishes rendering. The target is under 2.5 seconds. The LCP element is almost always a hero image, large heading, or video poster. To improve LCP, preload the LCP resource, use a CDN, and eliminate render-blocking resources that delay the browser from starting to paint. INP stands for Interaction to Next Paint, and it replaced the older FID metric. It measures the latency of all interactions throughout the page lifetime, not just the first one. The target is under 200ms. INP is hurt by long JavaScript tasks. Break them up with scheduler.yield(), defer non-critical work, and avoid layout thrashing (reading and writing DOM properties in alternating loops). CLS stands for Cumulative Layout Shift. It measures unexpected layout shifts that make users click the wrong thing. The target is under 0.1. The most common causes are images without explicit width and height attributes, ads or embeds inserted without reserved space, and web fonts causing text to reflow when they load. Measure in the field by installing the web-vitals npm package and sending metric data to your analytics endpoint using onLCP, onINP, and onCLS. Pass a callback that sends metric name, value, and rating to your backend via a POST request with keepalive set to true so the request completes even if the user navigates away. Lab data from Lighthouse tells you what is wrong. Field data from real users tells you how bad it actually is. You need both. LIGHTHOUSE AND CHROME DEVTOOLS Run Lighthouse in incognito mode to eliminate browser extension interference. In Chrome DevTools, go to the Lighthouse tab, select Mobile device (this is the harder target and closer to the median user), and click Analyze page load. Read the Opportunities section — Lighthouse shows potential savings in milliseconds so you can prioritize the biggest wins first. The Performance panel in DevTools is your microscope for diagnosing specific interactions. Click Record, reproduce the interaction you are investigating, and stop recording. Look for Long Tasks (shown with red triangles) — any JavaScript task over 50ms blocks the main thread and degrades INP. Look for Layout events that take significant time, which are caused by forced synchronous layout reads followed by writes. Look for Paint events during scroll, which trigger expensive repaints. In the Network panel, throttle your connection to Fast 4G to simulate a real mobile connection. Look for render-blocking resources — scripts and stylesheets in the head that delay the first paint. Identify sequential fetches in the waterfall that could be parallelized or initiated earlier. CODE SPLITTING AND LAZY LOADING Every byte of JavaScript costs twice: once to download, and once to parse and execute. Code splitting lets you pay only for what the current page needs. Route-based splitting is the easiest win. Import route components using React's lazy function combined with dynamic import syntax. Vite handles the bundle splitting automatically — each lazy import becomes its own chunk that is loaded on demand when the user navigates to that route. Wrap your router in a Suspense boundary with a loading fallback. Component-level splitting works well for heavy components that are not needed on initial render — rich text editors, chart libraries, map components, and similar. Lazy-import them and gate their rendering on a user action like clicking a button. The component only loads when the user actually needs it. For components the user is very likely to navigate to, preload the chunk on hover over the navigation link. This triggers the dynamic import call before the user clicks, so the chunk is already in the browser cache by the time navigation happens. IMAGE OPTIMIZATION Images are typically the largest assets on any page and the highest-leverage optimization available. Use modern formats. AVIF offers the best compression for modern browsers. WebP has great compression and broad support. Use a picture element with source elements for AVIF and WebP, and an img fallback for older browsers. Always specify explicit width and height attributes on img elements. This lets the browser reserve the correct space before the image loads, preventing layout shift. For responsive images, use the srcset attribute to provide multiple sizes and the sizes attribute to tell the browser which size to use at each viewport width. The LCP image must never lazy-load — add loading="eager" and fetchpriority="high" to ensure the browser prioritizes it immediately. All images below the fold should use loading="lazy" so they only download when the user scrolls near them. If you are serving images through Cloudflare, use Image Resizing to transform images at the edge. You can request a specific width, format (auto selects the best format the browser supports), and quality level through the cf.image options on a fetch call. This eliminates the need for build-time image optimization pipelines. FONT LOADING STRATEGIES Fonts are a common source of FOIT (Flash of Invisible Text) and CLS. Show text immediately in the fallback font, then swap in the web font without layout shift. Use link rel="preconnect" for the font domain to establish the TCP connection early. Use link rel="preload" for the actual font file with as="font" and crossorigin attributes so the browser fetches it at high priority. In your font-face declaration, set font-display to swap. This tells the browser to show the fallback font immediately and swap in the web font when it finishes loading, rather than hiding text until the web font arrives. To minimize layout shift on the font swap, use size-adjust and override descriptors on a fallback font-face that references a local system font. Adjust size-adjust, ascent-override, and descent-override until the fallback metrics closely match your web font. This way the swap causes minimal visual change. Self-host your fonts when possible. Hosting fonts on your own domain eliminates a DNS lookup and connection to a third-party service, which typically costs 100 to 200 milliseconds on a cold page load. CACHING Set Cache-Control headers correctly and consistently. Content-hashed assets like JavaScript bundles and CSS files can be cached forever with public, max-age=31536000, immutable. The hash in the filename changes when the content changes, so the browser always gets the latest version. HTML files should never have a long max-age. Use public, max-age=0, must-revalidate so the browser always validates with the server before using a cached copy. A long cache on your index.html is how users end up with stale JavaScript reference files that 404 because the hash changed. Service workers give you fine-grained caching control and enable offline support. For API requests, use a network-first strategy — try the network and fall back to cache. For static assets, use a cache-first strategy — serve from cache immediately and optionally revalidate in the background. Use cache versioning with a cache name constant so you can invalidate the entire cache on a new deployment. BUNDLE ANALYSIS AND TREE SHAKING Install rollup-plugin-visualizer and add it to your Vite plugins array. Running your production build opens an interactive treemap showing every module in your bundle, its gzipped size, and which chunks it belongs to. Look for unexpectedly large dependencies, duplicate packages loaded multiple times, and dev-only code that leaked into the production bundle. The two most common wins are replacing moment.js with date-fns and switching from default imports to named imports on ES module packages. Moment is 67KB gzipped and not tree-shakeable. Importing a single function from date-fns costs about 2KB. Similarly, importing groupBy from lodash-es (the ES module version) only bundles that one function, while importing the default export from lodash pulls in everything. Audit your dependencies for unnecessary polyfills. Some libraries bundle polyfills for fetch, Promise, or URL. If you are targeting modern browsers, configure your bundler to exclude them. SERVER-SIDE RENDERING SSR renders HTML on the server so the browser can display content without waiting for JavaScript to parse and execute. This directly improves LCP and makes content indexable by search engines without a JavaScript runtime. With React Router v7 or Remix, server rendering is the default. You export a loader function that runs on the server, fetches data, and returns it. Your component receives the data already populated — no loading state, no client-side fetch, no hydration lag. The HTML the browser receives is already fully rendered. React 18 streaming SSR improves on this further by flushing HTML progressively. The shell of your page renders immediately and streams to the browser. Data-dependent sections wrapped in Suspense boundaries stream in independently as their data becomes available. This means the user sees the page structure almost instantly, even if some sections are still loading. EDGE COMPUTING WITH CLOUDFLARE WORKERS Running logic at the edge eliminates the round-trip to a central origin server. For dynamic content, this can cut time to first byte by 100 to 300 milliseconds because the computation happens at the Cloudflare data center closest to the user. Workers can modify HTML responses in flight using the HTMLRewriter API. You can inject user-specific content like a localized currency, personalized greetings, or feature flags without requiring a full server round-trip. The pattern is to fetch the cached HTML response, then pipe it through HTMLRewriter to transform specific elements before sending it to the user. For caching dynamic content, implement stale-while-revalidate at the edge. When a cached response exists, serve it immediately. If the cached version is older than your threshold, kick off a background revalidation using ctx.waitUntil to fetch a fresh copy and update the cache. This way users always get a fast response while the cache stays reasonably fresh. The ctx.waitUntil method lets the background task complete even after the response has been sent to the user. WRAPPING UP Performance optimization is a measurement discipline first. Profile before you optimize, measure after, and repeat. The highest-leverage wins in most apps are fixing LCP (usually an image preload or removing a render-blocking resource), lazy loading below-fold assets, setting correct cache headers on your static files, and moving to a CDN or edge runtime. Most apps ship with at least two or three of these left on the table — check Lighthouse before assuming your app is fast.

Related Content