Frontend Performance Optimization Techniques
A framework-agnostic checklist of techniques that move the needle on Core Web Vitals — LCP, CLS, and INP.
Most performance work isn’t exotic — it’s a short list of well-understood techniques applied consistently, plus the discipline to actually measure before and after. This is the checklist I go through on every performance pass, regardless of whether the stack underneath is Angular, React, or Next.js. The specifics differ; the targets (LCP, CLS, INP) and the underlying causes don’t.
Code Splitting and Dynamic Imports
Shipping your entire application as one JavaScript bundle means the browser has to download, parse, and execute code for routes and features the user hasn’t visited yet. Splitting by route is the highest-leverage, lowest-effort change available:
// React / Next.js — dynamic import via React.lazy
import { lazy, Suspense } from 'react';
const ReportsDashboard = lazy(() => import('./ReportsDashboard'));
function App() {
return (
<Suspense fallback={<Skeleton />}>
<ReportsDashboard />
</Suspense>
);
}
// Angular — lazy-loaded route
export const routes: Routes = [
{
path: 'reports',
loadComponent: () => import('./reports/reports.component').then(m => m.ReportsComponent),
},
];
Beyond routes, split anything heavy and not immediately needed: a charting library behind a tab, a rich text editor behind an “edit” button, a modal’s contents. Next.js’s next/dynamic and Angular’s @defer block both exist specifically for this kind of below-the-fold or behind-interaction splitting — see Angular’s @defer Explained for the Angular-specific mechanics.
Image Optimization and Lazy Loading
Images are usually the single largest contributor to page weight, and often the Largest Contentful Paint (LCP) element itself. Three things matter:
- Correct format and sizing — serve WebP/AVIF where supported, and serve an image sized for its actual display dimensions, not the original upload.
- Lazy loading for offscreen images — native
loading="lazy"for anything below the fold. - Eager loading for the LCP image — counterintuitively, the hero image should not be lazy-loaded, since that delays the very metric you’re optimizing for.
<!-- Below the fold: lazy -->
<img src="/thumb.webp" loading="lazy" width="400" height="300" alt="Product thumbnail" />
<!-- LCP candidate: eager, and preloaded in <head> -->
<img src="/hero.webp" loading="eager" fetchpriority="high" width="1200" height="600" alt="Hero" />
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
Note the explicit width and height on both — that’s not incidental, it’s the fix for a separate metric covered below. Next.js’s next/image component handles most of this automatically (responsive srcset, lazy loading by default, format negotiation); see Next.js Image Optimization for the specifics of getting it configured correctly rather than fighting its defaults.
Font Loading Strategy
Web fonts block text rendering by default, which directly hurts LCP when the LCP element is text. Three levers:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-display: swap; /* show fallback font immediately, swap when ready */
font-weight: 100 900;
}
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin />
font-display: swapprevents invisible text (FOIT) while the font loads — the browser renders with a fallback immediately.- Preloading the critical font file (the one used above the fold) skips the discovery delay of the browser finding it via CSS.
- Subsetting — shipping only the glyphs you actually use (or a variable font covering your weight range in one file, as above) — reduces the download size, especially relevant for non-Latin character sets.
Avoid loading more than one or two font weights/families if you can help it. Every additional font file is a render-blocking or layout-shifting liability.
Minimizing Main-Thread JavaScript
Interaction to Next Paint (INP) measures how responsive the page feels to actual input — clicks, taps, keystrokes. A busy main thread is the direct cause of bad INP, and third-party scripts are the most common source of a busy main thread that isn’t even your own code.
Practical steps:
- Audit third-party scripts. Analytics, chat widgets, A/B testing tools, ad tech — each one is JavaScript competing for the same thread. Remove what isn’t earning its cost; for the rest, load with
deferorasync, and where possible, delay initialization until after first interaction or viarequestIdleCallback. - Defer non-critical JS. Anything not needed for the initial render or immediate interaction shouldn’t be in the critical path.
- Break up long tasks. A single synchronous function running for 300ms blocks all input for that duration. Chunking expensive work (via
requestIdleCallback,setTimeout(fn, 0)yielding between chunks, or Web Workers for genuinely heavy computation) keeps the main thread available.
// Yielding between chunks of expensive synchronous work
function processInChunks(items, processFn) {
let i = 0;
function run() {
const end = Math.min(i + 50, items.length);
for (; i < end; i++) processFn(items[i]);
if (i < items.length) setTimeout(run, 0);
}
run();
}
Avoiding Layout Shift
Cumulative Layout Shift (CLS) is almost always caused by content whose final size isn’t known until after it loads — images, ads, embeds, web fonts, or content injected above existing content. The fix is consistently the same: reserve the space up front.
/* Reserve space for an ad slot before the ad loads */
.ad-slot {
min-height: 250px;
min-width: 300px;
}
<!-- Reserve aspect ratio for an embed (e.g. a YouTube iframe) -->
<div style="aspect-ratio: 16 / 9;">
<iframe src="..." loading="lazy"></iframe>
</div>
Combined with explicit width/height attributes on <img> tags (shown earlier), this covers the large majority of CLS causes. The remaining common offender is a web font causing a size shift between fallback and loaded font (FOUT) — matching the fallback font’s size-adjust or line-height to the real font’s metrics minimizes this, though it’s a smaller win than the others.
Measuring: Lighthouse, PSI, and DevTools
None of the above is worth doing blind. Measure first, fix the actual bottleneck, measure again.
- Lighthouse (built into Chrome DevTools, or
npx lighthouse <url>) gives a lab-based score and a prioritized list of opportunities — useful in CI as a regression gate, but it’s a single simulated run, not real user data. - PageSpeed Insights wraps Lighthouse with real-world field data from the Chrome User Experience Report (CrUX) when your site has enough traffic, which tells you what actual users experience, not just a simulated 4G connection.
- Chrome DevTools Performance tab is where you actually diagnose why — record a trace, look for long tasks on the main thread, check which scripts are blocking, and identify which specific DOM node is the LCP element.
npx lighthouse https://example.com --view --only-categories=performance
Run this against both a cold-cache and warm-cache load, and against a throttled network profile (Lighthouse does this by default) — un-throttled numbers on a fast office connection are not representative of your actual users.
Common Mistakes
- Lazy-loading the LCP image, which delays the very metric being optimized.
- Adding third-party scripts without auditing their main-thread cost, one at a time, until the page feels sluggish for no single obvious reason.
- Fixing CLS by setting
height: autoinstead of reserving actual space —autodoesn’t reserve anything. - Treating a single Lighthouse score as ground truth instead of also checking field data (CrUX) when available.
- Shipping every font weight “just in case,” each one a separate render-blocking or layout-shifting request.
Best Practices
- Split code by route first, then by interaction (behind tabs, modals, accordions).
- Reserve space for anything that loads asynchronously — images, ads, embeds.
- Preload the LCP image and the critical font; lazy-load everything else.
- Audit third-party scripts on a recurring basis, not just at launch.
- Measure with both lab tools (Lighthouse) and field data (PSI/CrUX) before and after each change.
Performance work compounds — code splitting reduces the JS the main thread has to parse, which helps INP; reserving image space fixes CLS; preloading the right image fixes LCP. Doing a few of these consistently across every page beats doing all of them once on a single landing page. These techniques work best layered on top of a codebase that’s already structured well — see Building Scalable Frontend Architecture for the structural side — and for Shopify storefronts specifically, Shopify Performance Optimization covers theme-specific constraints this general checklist doesn’t.
Related Reading
Written by
Tayyab Roy
Senior Frontend / Full-Stack Engineer
Senior Frontend & Full-Stack Engineer specializing in Angular, React, Next.js and enterprise eCommerce (Shopify, SAP Commerce / Hybris, Spartacus). I write about practical, production-tested frontend engineering.
Related Articles
Shopify Performance Optimization
Practical techniques for speeding up a Shopify storefront — image handling, render-blocking apps, and theme JS.
Next.js Image Optimization and Performance
How the Next.js Image component handles optimization automatically, and the mistakes that quietly hurt LCP and CLS.
Angular Performance Optimization: Practical Techniques
Practical, production-tested techniques for speeding up Angular applications — from change detection to bundle size.