Building Fast, Findable Web Apps: Where Development Meets Core Web Vitals and SEO
A builder's guide to shipping web apps that load fast and get found: rendering strategy, Core Web Vitals fixes with code, JavaScript SEO, performance budgets, and a measure-first workflow.
Most apps are built to work, not to load fast or be found
I have shipped a lot of web apps that worked perfectly and failed completely. They passed every functional test, the buttons did the right things, the data saved, the demo went well, and then the thing sat in production loading in five seconds on a mid-tier phone while Google indexed an empty div. Working and findable are two different jobs, and most of us are trained hard for the first and barely at all for the second.
The reason is structural. When you learn front-end development, the feedback loop rewards behavior. Does the component render, does the state update, does the API call resolve. Nobody grades you on time to first byte or Largest Contentful Paint in a code review, and no unit test fails because your bundle grew by 300 kilobytes. So we build apps that are correct and slow, apps that are interactive and invisible to crawlers, and we do not notice until traffic never shows up or a client asks why the beautiful site they paid for is on page four.
The reframe I wish someone had handed me earlier is this: performance and findability are not separate disciplines you bolt on after the app works. They are properties of the same decisions you are already making. The rendering strategy you pick determines both how fast the first paint is and if a crawler sees your content. The way you load an image affects your LCP and your ranking. The shape of your components decides if your headings are semantic and your structured data is present. You are already making these calls. You are just making them blind.
I think about it as three overlapping questions that a good web app has to answer yes to. Does it work, meaning the functionality is correct. Does it load, meaning a real user on a real network sees content fast and the page feels responsive. And is it found, meaning a crawler or an answer engine can reach it, read it, and trust it. The first question is where all our training points. The second and third are where the traffic and the revenue actually live, and they are the ones we skip.
What I like about this corner of the craft is that loading fast and being found are the most deterministic problems in web development. Design is taste. Product is judgment. But an LCP is either 2.1 seconds or 4.6 seconds, a page is either in the initial HTML or it is not, a schema either validates or throws. You can measure every bit of it, fix it with confidence, and prove the fix landed. For an engineer, that is the friendliest kind of problem there is, and it is sitting unaddressed in most codebases right now.
This article is the version of the talk I give to developers who already know how to build. I am not going to teach you React or how to write a fetch. I am going to connect the code you already write to the two outcomes it silently controls: how fast the app loads and if it gets found. We will go through rendering strategy, Core Web Vitals as an engineering problem with real fixes, how crawlers handle JavaScript, semantic HTML and structured data in a component world, performance budgets and bundle discipline, caching and the edge, accessibility, and a measure-first workflow, and then I will take a slow app and make it fast in front of you.
Rendering strategy: CSR, SSR, SSG, ISR and what each costs you
The single biggest lever on both speed and findability is where your HTML gets built. There are four common answers, and picking the wrong one for your content type is the most expensive mistake in the article, because it is baked into your architecture and painful to change later. Client-side rendering ships an empty shell and builds the page in the browser. Server-side rendering builds the HTML on each request. Static site generation builds it once at deploy time. Incremental static regeneration builds it statically but refreshes it on a schedule or on demand.
| Strategy | HTML built | First paint | Crawler sees content | Best for |
|---|---|---|---|---|
| CSR | In the browser | Slow | Only after render pass | Logged-in apps, dashboards |
| SSR | On each request | Fast | Immediately | Dynamic content that must rank |
| SSG | At build time | Fastest | Immediately | Blogs, docs, marketing |
| ISR | Build + revalidate | Fastest | Immediately | Large content sites that update |
Client-side rendering is the default you get from a plain single-page app, and it is the riskiest for findability. The server sends a near-empty document with a script tag, the browser downloads the bundle, runs it, fetches data, and only then paints your content. A user waits through all of that. A crawler gets the empty shell first and has to decide if it will queue a render pass, which Google will eventually do but which many AI answer engines and social scrapers never do at all. If your content only exists after the JavaScript runs, you are betting your rankings on someone else's render queue.
// Client-side rendered: the crawler's first look is an empty root
export default function App() {
const [data, setData] = useState(null);
useEffect(() => { fetch('/api/products').then(r => r.json()).then(setData); }, []);
if (!data) return <Spinner />; // this is what the initial HTML contains
return <ProductList items={data} />;
}Server-side rendering fixes the findability problem by sending real HTML in the first response. The server runs your components, fetches the data, and streams a document that already has your headline, your copy, your links, and your structured data in it. The crawler sees everything immediately, the user sees content sooner, and interactivity hydrates on top. The cost is server work on every request and a slightly more complex deployment, but for anything with content that needs to rank, this is the safe default in 2026.
Static site generation goes one step further for content that does not change per request. You render every page to HTML at build time and serve plain files from a CDN. It is the fastest possible response because there is no server computation, just a file coming off an edge node near the user. Marketing pages, blog posts, documentation, and product pages that change occasionally are perfect for this. The limit is obvious: if the content is personalized or updates constantly, rebuilding the whole site on every change does not scale.
Incremental static regeneration is the hybrid that resolves that tension, and it is what I reach for most on content sites. You serve a static page for speed, and you revalidate it in the background after a set interval or when the underlying data changes, so users get static speed with fresh content. The classic pattern in a framework like Next.js looks like this, and the mental model is 'static by default, refresh quietly when needed.'
// Next.js: static page that revalidates every 60 seconds
export const revalidate = 60;
export default async function Page() {
const products = await getProducts(); // runs at build + on revalidation
return <ProductList items={products} />;
}The decision is not about which is best in the abstract, it is about matching the strategy to the content. Personalized dashboards behind a login do not need to rank, so client-side rendering is fine there. Public content that has to be found should be server-rendered or static, always. The failure I see most is a team reaching for a full client-side SPA for a content-heavy marketing site because that is the stack they know, then wondering months later why the pages never rank. The rendering choice made that outcome inevitable on day one.
Core Web Vitals are an engineering problem, not a marketing metric
Core Web Vitals get filed under SEO, which means they usually land on the marketing team's desk, which is exactly why they rarely improve. They are not a marketing problem. They are three numbers with direct code-level causes, and the only people who can move them are the people writing the components, choosing the image formats, and shipping the JavaScript. When a marketer tells a developer 'we need to fix Core Web Vitals,' what they are really saying is 'there is an engineering task nobody has scoped.'
| Metric | Measures | Good | Poor | Owned by |
|---|---|---|---|---|
| LCP | Loading | < 2.5s | > 4.0s | Infra, images, critical CSS |
| INP | Responsiveness | < 200ms | > 500ms | Front-end JavaScript |
| CLS | Visual stability | < 0.1 | > 0.25 | Layout and CSS |
There are three metrics and they map cleanly onto three parts of your stack. Largest Contentful Paint measures loading: when the biggest element in the viewport, usually the hero image or the headline block, finishes rendering. Under 2.5 seconds is good, over 4 is poor, measured at the 75th percentile of your real users. LCP is an infrastructure and asset problem: slow server, heavy image, render-blocking CSS. Interaction to Next Paint measures responsiveness: from a tap to the next frame the browser paints. Under 200 milliseconds is good, over 500 is poor. INP is a JavaScript problem, almost every time. Cumulative Layout Shift measures visual stability: how much the page jumps around as it loads. Under 0.1 is good, over 0.25 is poor. CLS is a CSS and layout problem.
That mapping is the most useful thing in this whole article, so I will restate it. A bad LCP means look at your server, your images, and your critical CSS. A bad INP means look at your main-thread JavaScript. A bad CLS means look at how you reserve space in your layout. When you get a failing score, the metric itself tells you which door to knock on, which team owns the fix, and roughly what the fix will be. You do not guess. You read the number and you know.
A point that trips up developers who dismiss these as vanity: they are a confirmed ranking signal, and more importantly they correlate directly with money. Google folds Core Web Vitals into its page-experience assessment, where they act as a tiebreaker that decides close races, and there are millions of close races. Separately from ranking, faster pages convert better. The established relationship is roughly a one percent conversion drop for every 100 milliseconds of added latency on transactional pages. You should care about these numbers even if Google stopped ranking on them tomorrow, because your users already rank on them with their attention.
The measurement trap that ruins most CWV work is the gap between lab data and field data, and I will spend a full section on it later, but plant the flag now. Lab data is a synthetic test like Lighthouse running once on a simulated fast device. Field data is your actual users on their actual phones and networks, and it is what Google ranks on. A Lighthouse score of 95 can coexist with a failing field LCP of five seconds, because a third of your real users are on mid-tier Android hardware you never tested. Optimize for the lab and you will keep failing in the field and never understand why.
The next three sections are the engineering guts of this: how to diagnose and fix each vital with real code. I am going to be specific, because vague advice like 'optimize your images' is why these metrics stay red. You need to know which attribute to add, which resource to preload, which task to break up, and which CSS property reserves the space. That is the level where these numbers actually move.
Fixing LCP: server, images, and the render path
LCP decomposes into four sub-parts, and knowing which one dominates is most of the fix. Time to first byte is how long your server takes to respond. Load delay is how long before the browser even starts fetching the LCP element. Load time is how long that element takes to download. Render delay is how long after loading before it paints. Chrome DevTools and PageSpeed Insights break LCP into exactly these buckets. Read which bucket is largest and you know where to aim.
If time to first byte dominates, the fix is infrastructure, not front-end. A slow origin, uncached dynamic rendering, or a database query on the critical path means the browser waits before it can do anything. Cache aggressively behind a CDN, use HTTP/2 or HTTP/3, and server-render or statically generate so the response is ready HTML instead of a computation that runs every time. Shaving an origin from 800 milliseconds to 100 fixes LCP before you touch a single image, and it is the least glamorous, highest-impact work you can do.
When the LCP element is a hero image, and it usually is, image optimization is your biggest win. Serve a modern format with a fallback, ship responsive sizes so a phone downloads a phone-sized file, and tell the browser this image is the priority. The pattern I put in every project looks like this, and each attribute is doing real work: the AVIF source cuts weight in half, the srcset picks the right resolution, the width and height reserve space to protect CLS, and fetchpriority pulls the fetch forward.
<picture>
<source type="image/avif" srcset="/hero-800.avif 800w, /hero-1600.avif 1600w" sizes="(max-width:800px) 100vw, 50vw">
<source type="image/webp" srcset="/hero-800.webp 800w, /hero-1600.webp 1600w" sizes="(max-width:800px) 100vw, 50vw">
<img src="/hero-800.jpg" width="1600" height="900" alt="Descriptive alt text" fetchpriority="high" decoding="async">
</picture>The most common self-inflicted LCP wound is lazy-loading the wrong thing. Lazy loading is correct for below-the-fold images, where it saves bandwidth. It is a disaster on the LCP element, because you have explicitly told the browser to delay the one image the metric measures. I have fixed pages where the entire LCP problem was a well-meaning blanket rule that added loading="lazy" to every image including the hero. The rule is simple: eager-load and prioritize above the fold, lazy-load below it, never lazy-load the LCP element.
<!-- Below the fold: lazy is correct -->
<img src="/gallery-3.webp" width="600" height="400" loading="lazy" alt="...">
<!-- The LCP hero: never lazy, always eager and prioritized -->
<img src="/hero.avif" width="1600" height="900" fetchpriority="high" alt="...">Render-blocking resources are the last piece. A stylesheet in the head blocks rendering until it downloads and parses, and a synchronous script does the same, so your LCP element cannot paint until they clear. Inline the critical CSS that above-the-fold content needs and defer the rest. Defer or async your non-critical JavaScript. Preconnect to the origins of critical third-party resources and preload the LCP image and critical fonts so the browser starts early. Self-host fonts or preconnect to the font host, and use font-display: swap so text renders in a fallback immediately instead of waiting invisibly.
<link rel="preconnect" href="https://cdn.example.com">
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high">
<link rel="preload" as="font" href="/inter.woff2" type="font/woff2" crossorigin>Stacked together, these moves routinely turn a four-second LCP into a two-second one, and none of them require rearchitecting the app. Diagnose the dominant sub-part, fix the server if it is TTFB, optimize and prioritize the image if it is the hero, and clean up the render path if CSS or fonts are blocking. Then verify in the field, because the lab will lie to you about how much you helped.
Fixing INP: giving the main thread room to respond
INP is a main-thread problem, which makes it a JavaScript problem, which makes it ours. When a user taps, the browser has to run your event handler, update state, and paint the next frame. If the main thread is busy running a 300-millisecond task, the tap sits in a queue and the interaction feels dead. Every INP fix comes down to the same idea: stop hogging the main thread so the browser can respond to input quickly.
The first fix is breaking up long tasks. Any task over 50 milliseconds blocks input handling for its whole duration, so a single 300-millisecond function is far worse than six 50-millisecond ones with gaps between them. When you have heavy work to do, yield to the browser between chunks so it can service any pending interactions. A small scheduler helper makes this easy and readable.
// Yield to the main thread so input can be handled between chunks
async function processInChunks(items, work) {
for (let i = 0; i < items.length; i++) {
work(items[i]);
if (i % 50 === 0) await new Promise(r => setTimeout(r, 0));
}
}The second fix is deferring non-urgent work. Analytics, telemetry, personalization, and logging do not need to run the instant a user touches an input, and firing a barrage of them on first interaction is the single most common INP killer I find on marketing sites. Move that work into idle time with requestIdleCallback so it runs in the gaps between interactions instead of competing with them.
// Run non-critical work when the browser is idle, not on the hot path
function onIdle(fn) {
if ('requestIdleCallback' in window) requestIdleCallback(fn, { timeout: 2000 });
else setTimeout(fn, 1);
}
button.addEventListener('click', () => {
applyFilter(); // urgent: do it now
onIdle(() => sendAnalytics()); // not urgent: defer
});In React specifically, the tool that changed how I handle this is the transition API. When a click triggers both an urgent update, like showing that a tab is selected, and an expensive update, like re-rendering a huge filtered list, you can mark the expensive part as non-urgent so React keeps the interface responsive and renders the heavy update without blocking the tap. This is exactly the pattern for search-as-you-type and large filtered views.
import { useState, useTransition } from 'react';
function Filterable({ items }) {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const [results, setResults] = useState(items);
function onChange(e) {
setQuery(e.target.value); // urgent: keep the input responsive
startTransition(() => setResults(filter(items, e.target.value))); // non-urgent
}
return <><input value={query} onChange={onChange} />{isPending ? <Dim/> : <List data={results}/>}</>;
}The framework-specific INP trap that catches people is hydration. Server-rendered React ships HTML that looks interactive but is not, until the JavaScript downloads and hydrates it by attaching all the event handlers. During that hydration window the page looks ready, the user taps, and nothing happens because the main thread is saturated. The mitigations are shipping less client JavaScript, code-splitting so you hydrate less at once, and partial or streaming hydration so interactivity comes online progressively instead of in one blocking pass. If your INP is bad and you run a heavy client framework, hydration cost is almost certainly the reason.
The habit that keeps INP healthy is auditing what runs on interaction, not just what runs on load. Open the performance panel, record a tap, and look for long tasks in the flame chart. Every wide block is a chunk of main-thread time a user waited through. Break the big ones up, defer the ones that are not urgent, and stop firing analytics on the hot path. Do that and a 500-millisecond INP drops under 200 without you rewriting the app, just changing when the work happens.
Fixing CLS: reserve space for everything you know is coming
CLS is the easiest of the three vitals to fix, because every cause is the same mistake: you did not reserve space for something you knew was going to appear. Images that load and push content down, ads and embeds injected without a slot, fonts that swap and reflow the text, banners that slide in above existing content. In every case the browser laid out the page, then something arrived and shoved everything else, and the user tapped the wrong thing. Reserve the space up front and the shift never happens.
The biggest single source is images without dimensions. If you do not tell the browser how big an image will be, it reserves zero space, lays out the page as if the image is not there, then reflows everything when it loads. Setting width and height attributes, or an aspect-ratio in CSS, lets the browser compute the box before the image arrives and hold the space. This one fix clears most CLS problems on most sites.
<!-- The browser reserves a 16:9 box before the image loads -->
<img src="/card.webp" width="1600" height="900" alt="..." style="width:100%;height:auto">/* For responsive containers where you cannot set attributes directly */
.media { aspect-ratio: 16 / 9; width: 100%; }
.media img { width: 100%; height: 100%; object-fit: cover; }The second source is anything injected late: ad slots, third-party embeds, lazy-loaded modules, cookie banners, and consent bars. If you know a 90-pixel banner is going to appear, give it a 90-pixel reserved container from the start so its arrival fills a hole instead of pushing the page. Never inject content above content that is already visible, because everything below it lurches. If a notification or promo has to appear at the top, overlay it or reserve its height in the initial layout.
/* Reserve the slot so a late-loading ad or embed does not shove the page */
.ad-slot { min-height: 250px; }
.consent-bar { position: fixed; bottom: 0; } /* overlay, do not push layout */Fonts are the subtle one. When a web font loads and its metrics differ from the fallback, the text reflows as it swaps, and every line moves. You have two clean options. Use font-display: optional so the browser uses the fallback and only swaps the web font on the next navigation, avoiding the shift entirely, or match your fallback font's metrics to the web font so the swap does not change line heights. The size-adjust and related descriptors let you tune a fallback to line up almost exactly.
@font-face {
font-family: 'Inter';
src: url('/inter.woff2') format('woff2');
font-display: optional; /* no layout shift from a late font swap */
}
@font-face {
font-family: 'Inter-fallback';
src: local('Arial');
size-adjust: 107%; /* line up metrics so the swap does not reflow text */
}Animations and transitions can cause shifts too, and the fix is to animate properties that do not trigger layout. Transform and opacity are composited by the GPU and do not move surrounding content, while animating width, height, top, or margin forces the browser to re-lay-out the page on every frame, which is both a CLS risk and a performance drain. When you need to move or resize something, use transform: translate or scale, not positional properties.
The reason CLS is worth this attention is that it is pure user respect. A layout that jumps is a layout that makes people tap the wrong button, lose their place, and distrust the page, and it costs you nothing but discipline to prevent. Set dimensions on every image, reserve a slot for everything injected, handle fonts deliberately, and animate transform and opacity only. Do that and CLS goes to near zero and stays there, because you have designed the shifts out rather than chasing them after the fact.
JavaScript SEO: how crawlers actually render your app
There is a gap between the HTML your server sends and the HTML a user sees, and that gap is where JavaScript SEO lives. When a crawler fetches your page it gets the initial HTML response first. If your content is in that response, the crawler has everything immediately. If your content is assembled in the browser by JavaScript, the crawler sees a shell and has to come back later, render the page in a headless browser, and only then discover your content. Google can do this. It is expensive, so it is queued and delayed, sometimes by days, and it is not guaranteed to complete.
That two-wave model is the whole story. Wave one is the crawl of your raw HTML, which happens fast. Wave two is the render, which happens whenever Google's render queue gets to it. Anything that exists only after JavaScript runs, your main content, your internal links, your canonical tag, your structured data, is invisible until wave two, and on large or slow-to-render sites wave two can lag badly or stall. Meanwhile most AI answer engines and social preview scrapers do not run wave two at all. They read your raw HTML and leave.
The practical test takes thirty seconds and I run it on every project. Fetch the page with JavaScript disabled, or use the URL Inspection tool in Search Console and look at the rendered HTML Google actually captured. Is your headline there. Your body copy, your links, your JSON-LD. If the raw response is an empty div and everything appears only after the script runs, you are betting your rankings on the render queue clearing in your favor. Sometimes it does. Often, on the sites that most need the traffic, it does not.
# See what a crawler sees before any JavaScript runs
curl -s https://example.com/product/123 | grep -i "<h1\|application/ld+json"
# If your headline and schema are missing here, so are they for non-rendering crawlersThe fix is architectural and we already covered it: send meaningful HTML in the initial response with server-side rendering or static generation. But there are rendering rules that save real pain even inside a good setup. Make internal links real anchor tags with href attributes, not click handlers on divs, because a crawler follows hrefs and ignores onclick. A router that navigates via JavaScript still needs a real href on the link so the crawler can discover the destination.
// A crawler can follow this
<a href="/products/widget">Widget</a>
// A crawler cannot follow this
<div onClick={() => router.push('/products/widget')}>Widget</div>Keep your structured data server-rendered so it is present in wave one, not injected by a script in wave two. Do not lazy-load content that matters for SEO behind user interaction, because if it is important it should be in the DOM at load, not after a click or a scroll. And never block your JavaScript or CSS bundles in robots.txt, because if Google cannot fetch the JS it cannot render the page and will index the empty shell it saw in wave one. That last one is a surprisingly common own-goal from a copied robots file.
The mental model I want you to keep is that your raw HTML is your resume and the render is an interview you might not get. Some readers, the AI engines and scrapers, only ever see the resume. So put everything that matters, content, links, canonical, schema, into the initial HTML, and treat client-side rendering as an enhancement layered on top of a page that already works without it. Build the page to be complete before the JavaScript runs, and you stop depending on anyone's render queue to be found.
Semantic HTML, metadata, and structured data in a component world
Components made us forget HTML has meaning. When everything is a div with a class, the browser, the crawler, and the screen reader all lose the structure that used to be free. A heading is not just big text, it is a signal of document hierarchy. A nav is not a styled list, it is a landmark. A button is not a clickable div, it is a focusable, keyboard-operable control with a role. In a component world you have to choose semantics on purpose, because the abstraction makes it just as easy to render a meaningless div as a meaningful element.
Start with the elements. Use one h1 per page and a logical heading hierarchy under it, because crawlers and assistive tech both build an outline from your headings, and a page that is all h3s or skips levels reads as structureless. Use nav, main, article, section, header, and footer as landmarks instead of divs with class names, because they carry roles for free. Use button for actions and a for navigation, never a div with an onClick, because the real elements come with focus, keyboard support, and the correct role built in.
// Semantic by default: the outline and landmarks come for free
function Article({ post }) {
return (
<main>
<article>
<header><h1>{post.title}</h1><time dateTime={post.date}>{format(post.date)}</time></header>
<section>{post.body}</section>
</article>
</main>
);
}Metadata is the next thing components tend to drop, because a title tag does not live in your component tree by default. Modern frameworks give you a first-class way to set it per route, and you should treat title and description as required output of every page component, not an afterthought. A good title is under 60 characters with the primary keyword early and the brand at the end. A good description is under 160 characters written for a human deciding if a page is worth a click, not stuffed with keywords.
// Next.js metadata API: per-route title, description, and social tags
export const metadata = {
title: 'Ceramic Pour-Over Dripper | Northstar Coffee',
description: 'A single-cup ceramic dripper that brews a clean, bright pour-over in three minutes. Dishwasher safe, ships free.',
openGraph: { images: ['/og/dripper.png'] },
alternates: { canonical: 'https://example.com/products/ceramic-dripper' }
};Structured data is where the component model actually helps, because you can build schema as a reusable component that takes props and emits valid JSON-LD, so every page of a type gets correct markup with no hand-editing. The rule from the SEO side still holds: only mark up what is genuinely visible on the page, connect entities with @id references instead of repeating objects, and keep the script server-rendered so it is in wave one. This is the pattern I ship as a small component.
function ProductSchema({ product }) {
const json = {
'@context': 'https://schema.org', '@type': 'Product',
name: product.name, image: product.images,
brand: { '@type': 'Brand', name: product.brand },
offers: { '@type': 'Offer', priceCurrency: 'USD', price: product.price,
availability: `https://schema.org/${product.inStock ? 'InStock' : 'OutOfStock'}` },
aggregateRating: product.reviewCount ? { '@type': 'AggregateRating',
ratingValue: product.rating, reviewCount: product.reviewCount } : undefined
};
return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(json) }} />;
}Validate it and keep it from rotting. Run the Rich Results Test on one page of each template type, watch the Search Console Enhancements reports for errors at scale, and, best of all, add structured-data validation to your build so a template that emits broken JSON-LD fails the build the way a broken test would. Structured data is code, so version it, test it, and monitor it. The teams that treat it that way ship rich results reliably; the teams that hand-check a few pages before launch ship broken schema they discover months later when the stars vanish from their listings.
Performance budgets, bundle splitting, and image discipline
A performance budget is a number you refuse to cross, enforced by a machine so a human cannot quietly ignore it. Without one, bundles only grow, because every feature adds code and nothing ever removes it, and the regression is invisible until the app is slow. With one, an oversized bundle fails the build the same way a broken test does, and the person who added the weight has to justify it right then, while they still remember what they did. Budgets are the single most effective performance habit I know, because they turn a slow drift into an immediate, attributable failure.
Set budgets on the things that hurt users: JavaScript shipped to the client, total page weight, and image sizes. A reasonable starting point for a content site is 150 to 200 kilobytes of compressed JavaScript on the critical path, and you tighten from there. Wire the budget into your bundler or CI so it is checked on every pull request, not audited quarterly by someone who happens to remember.
// webpack: fail the build when a bundle exceeds the budget
module.exports = {
performance: {
maxEntrypointSize: 180000, // ~180 KB
maxAssetSize: 180000,
hints: 'error' // not 'warning' that everyone ignores
}
};Bundle splitting is how you stay under budget without shipping less product. The default sin is a single monolithic bundle that includes every route and every heavy dependency, so a user landing on the homepage downloads the code for the checkout, the dashboard, and the rich-text editor they will never open. Code-split by route so each page loads only its own code, and lazy-load heavy components so a chart library or a modal editor downloads only when it is actually used.
import { lazy, Suspense } from 'react';
// The 200 KB charting bundle loads only when the dashboard renders it
const Charts = lazy(() => import('./Charts'));
function Dashboard() {
return <Suspense fallback={<Skeleton />}><Charts /></Suspense>;
}Watch your dependencies, because a careless import is where bundles balloon. Importing a whole utility or date library to use one function can add tens of kilobytes, so import the single function or pick a lighter alternative, and run a bundle analyzer to see what is actually in your build. Nine times out of ten there is a surprise in there: a moment.js you forgot, a duplicate of the same library at two versions, an icon set imported whole for three icons.
// Ships the entire library
import _ from 'lodash';
const r = _.debounce(fn, 200);
// Ships one function
import debounce from 'lodash-es/debounce';
const r = debounce(fn, 200);Images are usually the heaviest thing on the page, and they deserve the same discipline as code. Serve modern formats, right-size with responsive srcset, lazy-load below the fold, and always set dimensions. Framework image components automate most of this: they generate the responsive sizes, choose the format, set width and height, and lazy-load by default, so you get the whole CWV-friendly pattern from one component instead of hand-writing picture elements everywhere.
import Image from 'next/image';
// Generates responsive AVIF/WebP, sets dimensions, lazy-loads below the fold
<Image src="/hero.jpg" width={1600} height={900} alt="..." priority /> // priority = eager for the LCP
<Image src="/thumb.jpg" width={400} height={300} alt="..." /> // lazy by defaultThe thread through all of it is that performance is a budget you spend, not a state you reach. Every kilobyte of JavaScript, every unoptimized image, every heavy dependency spends from a fixed budget of user attention and device capability, and the job is to spend it deliberately on things that matter. Enforce the budget in CI, split by route, lazy-load the heavy stuff, watch your dependencies, and let an image component handle the image pattern. Do that and the app stays fast as it grows, instead of getting slower with every feature you ship.
Caching, CDNs, and pushing work to the edge
The fastest response is the one you do not compute. Caching is the discipline of serving a stored answer instead of rebuilding it, and a CDN is the network of servers that holds those answers close to your users so the bytes travel a short distance. Together they are the biggest lever on time to first byte, which is the LCP sub-part most likely to be silently killing you, especially if your origin sits in one region and your users are spread across the world.
Start with cache headers, because they are free and most sites set them wrong or not at all. Static assets with hashed filenames, your JavaScript and CSS bundles, can be cached forever because a new deploy produces a new filename, so tell browsers and CDNs to keep them for a year and never revalidate. HTML and API responses need a different policy, because they change, and that is where stale-while-revalidate earns its keep: serve the cached version instantly while fetching a fresh one in the background, so users never wait for a rebuild and still get updated content on the next view.
# Immutable hashed assets: cache for a year, never revalidate
Cache-Control: public, max-age=31536000, immutable
# HTML or API: serve stale instantly, refresh in the background
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400A CDN turns those headers into speed. Instead of every request traveling to your origin, the CDN caches responses at edge locations near users and serves them from there, cutting the round-trip from hundreds of milliseconds to tens. Put every static asset behind it, and cache HTML there too wherever the content is not personalized, using the s-maxage directive to control the CDN copy separately from the browser copy. A well-configured CDN in front of a static or server-rendered site is the difference between a snappy global experience and a slow one for anyone not sitting near your data center.
The newer move is running your own code at the edge, not just cached files. Edge functions execute small pieces of logic on the CDN's network, close to the user, so things like personalization, redirects, A/B assignment, auth checks, and light data fetching happen without a trip to a distant origin. The constraint is that edge runtimes are lightweight and not meant for heavy computation or big dependencies, so you push the latency-sensitive, lightweight logic out to the edge and keep the heavy lifting at the origin.
// Edge middleware: geolocate and redirect at the CDN, no origin round-trip
export const config = { runtime: 'edge' };
export default function middleware(req) {
const country = req.geo?.country ?? 'US';
if (country === 'GB') return Response.redirect(new URL('/uk', req.url));
}A service worker is the client-side half of caching and it is underused. It sits between the app and the network and can serve cached assets on repeat visits, provide an offline fallback, and make the second load feel instant because the shell comes from the local cache instead of the network. You do not need a heavy framework for this; a small worker that caches your app shell and static assets meaningfully improves repeat-visit performance, which is most of your engaged users.
The way to think about the whole stack is as a series of increasingly close caches. The origin is far and slow, so you put a CDN in front of it, and you push lightweight logic to the edge, and you cache assets in the browser and the service worker so repeat visits barely touch the network at all. Each layer answers more requests without computing them, and each layer is closer to the user than the last. Set your cache headers correctly, put a CDN in front of everything, move latency-sensitive logic to the edge, and let a service worker handle repeat visits. Most of your TTFB problem disappears without touching a line of application code.
Accessibility is part of UX, and it overlaps with everything else
Accessibility gets treated as a compliance checkbox, which is both wrong and a missed opportunity, because the same work that makes an app usable for someone with a screen reader makes it more usable for everyone and more legible to machines. Semantic HTML, clear focus, real labels, and sensible structure are accessibility features, usability features, and SEO features at the same time. When you build accessibly you are not doing charity work, you are building a better, more findable app.
The overlap with the rest of this article is not a coincidence. The semantic HTML that gives a crawler a document outline gives a screen reader the same outline. The alt text that lets search understand an image lets a blind user understand it too. The keyboard-operable button that assistive tech can activate is the same real button element that carries the correct role. Do the semantic work once and you serve users on assistive tech, users on a keyboard, and the machines that read on everyone's behalf. This is one of the biggest wins in front-end development and almost nobody frames it that way.
Start with the things that fail most audits. Every interactive element needs an accessible name, so a button with only an icon needs an aria-label, and every form input needs an associated label. Every image needs alt text, empty for purely decorative images so screen readers skip them. Color contrast has to clear the minimum ratio so text is readable for low-vision users and in bright sunlight, which is everyone at some point. These are small, mechanical fixes with outsized impact.
// Icon-only button needs a name; input needs a real label
<button aria-label="Close dialog"><CloseIcon /></button>
<label htmlFor="email">Email</label>
<input id="email" type="email" name="email" />
// Decorative image: empty alt so screen readers skip it
<img src="/flourish.svg" alt="" />Focus management is where single-page apps quietly break for keyboard and screen-reader users. When navigation happens in JavaScript instead of a full page load, focus does not reset, so a keyboard user who activates a link can be left with focus in the wrong place and no announcement that the page changed. When you open a modal, focus has to move into it and be trapped there until it closes, then return to the trigger. These are not edge cases, they are the core interactions, and they are invisible if you only ever test with a mouse.
// On route change, move focus to the new page's heading and announce it
useEffect(() => {
const h1 = document.querySelector('h1');
if (h1) { h1.setAttribute('tabindex', '-1'); h1.focus(); }
}, [pathname]);Respect user preferences the platform already exposes. Someone who has turned on reduced motion is telling you animations make them uncomfortable or sick, and honoring that with a media query is a few lines. Do not trap people in animations they asked the operating system to stop. The same principle covers dark mode, font scaling, and forced colors: the platform hands you the user's stated preference, and honoring it is both an accessibility win and a sign of a well-built app.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}Test it the way affected users experience it, not just with an automated scanner. Automated tools like axe catch maybe a third of issues and are worth running in CI, but the real test is unplugging your mouse and operating the whole app with the keyboard, then turning on a screen reader and listening to a page. It is uncomfortable the first time and clarifying every time after. An app you can fully operate by keyboard, that announces its changes, that respects motion and contrast preferences, is an app that works for more people and reads more clearly to every machine that parses it. Accessibility is not a tax on good UX. It is what good UX means when you take all your users seriously.
A build-and-measure workflow that keeps the app fast
Performance and findability are not states you reach and keep, they are states that decay the moment you stop measuring, because every deploy adds code and every feature adds weight. The teams that stay fast are the ones that instrumented the regression, not the ones with the best intentions. So the last engineering piece is a workflow: measure in the lab before you ship, measure in the field after, and alert when either drifts, so a regression surfaces in a pull request or a dashboard instead of in a ranking drop three months later.
| Lab data | Field data | |
|---|---|---|
| Source | Lighthouse, WebPageTest, DevTools | CrUX, Search Console, web-vitals RUM |
| Conditions | One load, simulated device | Real users, real devices |
| Best for | Diagnosing and gating merges | Knowing if you really have a problem |
| Ranks? | No | Yes, at the 75th percentile |
Start in the lab, in CI, before code merges. Lighthouse CI runs Lighthouse on every pull request and can fail the build if a score or a specific metric crosses a threshold you set, which turns performance from a thing you hope for into a gate code has to pass. This is where you catch the obvious regressions, the new dependency that doubled the bundle, the unoptimized image someone dropped in, before they ever reach production.
{
"ci": {
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
}
}
}
}But lab data alone is a trap, and this is the point I keep hammering because it is the one that costs people the most. Lighthouse tests one load on simulated fast hardware. Your ranking depends on field data: your real users on real devices and networks, aggregated at the 75th percentile in the Chrome User Experience Report. A green lab score and a failing field score coexist all the time, because your test machine is a fast laptop and a third of your users are on mid-tier phones. Fix in the lab because it is fast and repeatable. Decide where to spend effort based on the field, because that is what is real.
So instrument the field yourself instead of waiting a month for CrUX to update. The web-vitals library measures LCP, INP, and CLS from your actual users and lets you send those numbers anywhere, so you see your own field data continuously, segmented by page template and device, and you know within a day if a deploy hurt real users. This is the highest-value hundred lines of code in the whole performance story, because it is the only thing that tells you the truth about what your users experience.
import { onLCP, onINP, onCLS } from 'web-vitals';
function send(metric) {
navigator.sendBeacon('/rum', JSON.stringify({ name: metric.name, value: metric.value, path: location.pathname }));
}
onLCP(send); onINP(send); onCLS(send); // real users, real numbers, every visitWith both halves in place, the workflow is a loop. In development you profile with the DevTools performance panel and diagnose with Lighthouse and WebPageTest. In CI you gate merges on lab thresholds so obvious regressions cannot ship. In production you collect field vitals with a RUM script and watch the Search Console Core Web Vitals report for the aggregated view Google ranks on. When the field data shows a template slipping, you reproduce it in the lab, fix it, and confirm the field recovers as real-user data accumulates over the following weeks.
The cadence matters as much as the tools. Wire the lab gate once and it protects you on every pull request forever. Install the RUM script once and it watches the field continuously. Put a monthly look at the field dashboard and the Search Console reports on the calendar so slow drift gets caught while it is small. This is not heroic work, it is boring, standing instrumentation, and boring standing instrumentation is exactly what keeps an app fast and findable long after the launch-day enthusiasm wears off.
The mistakes developers make, roughly in order of damage
Most of the damage to speed and findability is not dramatic. It is a sensible-looking default, shipped in a component, multiplied across a codebase, and left to compound until the app is slow and invisible and nobody remembers which decision did it. Here are the ones I find over and over, roughly ordered by how much silent harm they do, so you can check your own app against them tonight.
The biggest is choosing client-side rendering for content that needs to be found. It is the default of the popular starter templates, it feels modern, and it quietly guarantees that crawlers see an empty shell and answer engines see nothing. If your app has public content that should rank, and you picked a pure client-side SPA, that one architectural decision caps everything else you do. It is also the hardest to reverse later, which is exactly why it belongs at the top of the list: get the rendering strategy right first, before anything else.
Second is treating performance as someone else's job. Developers ship the bundle, choose the image format, and write the hydration, and then Core Web Vitals land on the marketing team who cannot touch any of those things. Performance is an engineering responsibility, and the fix is to own it in the workflow: budgets in CI, field vitals in production, a number you are accountable for. An app gets slow one unowned decision at a time, and the fix is to make speed owned.
Third is the cluster of image mistakes, because images are usually the heaviest thing on the page. Shipping multi-megabyte heroes nobody compressed, serving desktop-sized images to phones, lazy-loading the LCP image so the browser delays the one element the metric measures, and forgetting width and height so the layout shifts when they load. Every one is a one-line default with a large blast radius, and together they account for a huge share of failing LCP and CLS scores.
Fourth is shipping too much JavaScript and shipping it badly. A monolithic bundle with every route in it, heavy dependencies imported whole for one function, no code splitting, and hydration that saturates the main thread on load and kills INP. The user downloads and executes code for features they will never open, on a phone slower than your laptop, and the app feels sluggish for reasons that never show up in a functional test. Budgets, route splitting, and lazy loading are the whole fix, and none of them are hard.
Fifth is the findability details that never throw an error: internal links as onClick divs a crawler cannot follow, structured data injected client-side so it misses wave one, title and description not set per route, robots.txt blocking the JS the page needs to render, and schema describing things the user cannot see. None of these break the app for a human, so they survive for months, silently keeping the pages from being found while everyone assumes the problem is content.
The meta-mistake underneath all of them is building for the demo instead of the deploy. The demo runs on your fast machine, on a fast network, in a fresh browser, and it looks great, so the work feels done. The deploy runs on a mid-tier phone on a congested network for a user who has never seen your app, and on a crawler that reads raw HTML and moves on. Build for that second reality: real HTML in the first response, budgets enforced in CI, field vitals watched in production, and semantics and structure treated as required output. Do that and the app that works in the demo is also the app that loads fast and gets found in the world, which is the only place that pays.
A worked example: taking an app from slow to fast
Let me make this concrete with a composite of jobs I have done, the kind of app that looks fine in the demo and falls apart in the field. A React storefront, a few hundred product pages, built as a pure client-side single-page app because that was the team's default. It worked perfectly. It also loaded in about 5.8 seconds on a mid-tier phone, ranked for almost nothing, and converted poorly, and the team was convinced they needed a redesign. They did not. They needed the plumbing this article is about.
The first problem was rendering, and it was upstream of everything else. The initial HTML was an empty div with a script tag, so crawlers saw no content and the browser did all the work after download. I moved the app to server-side rendering with static generation for the product and category pages, so real HTML, headings, copy, links, and structured data now shipped in the first response. That one change made the pages crawlable and pulled the first contentful paint forward by seconds, because the browser had content to show before the JavaScript finished.
The second problem was images, which were destroying LCP. The product hero images averaged 2.6 megabytes, were served at desktop size to every device, and, worst of all, had a blanket lazy-load applied to them including the hero. I converted them to AVIF with a WebP fallback and responsive srcset, added fetchpriority high to the main product image and removed the lazy attribute from it, and set width and height on everything so the layout stopped shifting. LCP on the product template dropped from roughly 4.6 seconds in the field to about 2.1, and CLS went from a janky 0.28 to under 0.05.
<!-- Before: one giant image, lazy-loaded, no dimensions -->
<img src="/product-1600.jpg" loading="lazy">
<!-- After: responsive AVIF, eager and prioritized, dimensions set -->
<picture>
<source type="image/avif" srcset="/product-800.avif 800w, /product-1600.avif 1600w" sizes="(max-width:800px) 100vw, 50vw">
<img src="/product-800.jpg" width="1600" height="1600" fetchpriority="high" alt="...">
</picture>The third problem was JavaScript, which was wrecking INP. The app shipped one 640-kilobyte bundle with every route in it, and hydration saturated the main thread on load so early taps did nothing. I code-split by route so each page loaded only its own code, lazy-loaded the heavy review widget and image gallery behind Suspense, and replaced a whole date library imported for one function with a single-function import. Client JavaScript on the product page fell from 640 kilobytes to about 190, and INP moved from around 480 milliseconds to 140.
The fourth problem was the findability details that never threw an error. Internal links were onClick handlers on divs, so the crawler could not follow them and the site had no discoverable structure. Structured data was injected client-side, so it missed wave one entirely. Titles and descriptions were not set per route, so every page shared the same generic tag. I made the links real anchors with hrefs, moved the Product and Breadcrumb schema into server-rendered components, set per-route metadata, and validated it all with the Rich Results Test. The stars and prices started appearing in listings within a few weeks as Google recrawled.
Then I instrumented it so it could not regress: Lighthouse CI gating merges on an LCP and CLS budget, a web-vitals RUM script reporting field numbers by template, and a monthly look at the Search Console Core Web Vitals and Enhancements reports. The results landed over the following quarter, in sequence, because the fixes compound. Indexation recovered as crawlers finally saw content, rich results returned after the schema fix, and the field vitals moved into the green as real-user data accumulated on the faster pages. Organic traffic rose about 45 percent and conversion improved around 25 percent over two quarters. Not one product was rephotographed and not one word of copy changed. The app that already worked was made to load fast and get found, and that was the whole difference.
What's next: the edge, React Server Components, and streaming
The direction of front-end architecture is clear even if the specifics keep moving: less JavaScript shipped to the browser, more work done on the server and at the edge, and content that arrives in a stream instead of a single blocking wave. Every one of these shifts pushes in the same direction this whole article has argued for, toward apps that ship real content fast and depend less on the client doing everything. The future is not a new set of tricks. It is the fundamentals getting easier to follow by default.
React Server Components are the biggest change to how we build, and they resolve the client-versus-server tension that has shaped front-end work for a decade. Server Components run on the server and send rendered output, not JavaScript, so the data-fetching, content-heavy parts of your app add zero to the client bundle, while the genuinely interactive pieces stay as client components. The practical effect is that the default is now server-rendered and lightweight, and you opt into client JavaScript only where you need interactivity, which is exactly the inversion that fixes the CSR-by-default problem at the root.
// Server Component: runs on the server, ships no JS to the client
async function ProductPage({ id }) {
const product = await db.product(id); // data fetch on the server
return <><ProductInfo product={product} /><AddToCart id={id} /></>; // AddToCart is a client component
}Streaming is the delivery model that pairs with this. Instead of waiting for the whole page to be ready before sending anything, the server streams HTML as it becomes available, so the shell and above-the-fold content arrive first and slower parts fill in as they resolve, wrapped in Suspense boundaries. The user sees meaningful content sooner, which helps LCP, and the crawler still gets complete HTML, and the slow parts of the page no longer hold the fast parts hostage. Streaming turns the all-or-nothing page load into a progressive one.
The edge keeps eating latency. As edge runtimes get more capable, more of the app runs close to the user: rendering, personalization, auth, data fetching from edge-located stores. The origin round-trip that dominates TTFB for globally distributed users shrinks as the computation moves to a node near them. The trend is toward a world where the distance between your user and the code serving them keeps collapsing, and the slow, distant origin becomes a fallback for heavy work rather than the default path for every request.
The part that does not change is the part I want you to hold onto. Every one of these advances is in service of the same three outcomes: ship real content fast, keep the app responsive, and make it easy for machines to read. Server Components ship less JavaScript so the app is lighter and faster. Streaming delivers content sooner so it is found and felt sooner. The edge cuts the distance so the first byte comes quicker. The tools get better, but they are getting better at the exact things this article measured: LCP, INP, CLS, crawlability, and structure.
So the closing advice is the same advice that opened: do not chase every new framework feature as a novelty, adopt the ones that make the fundamentals easier to honor. An app that ships real HTML, loads fast on a real phone, responds to input without lag, holds its layout still, and hands crawlers and answer engines clean, structured content will win in classic search, in AI answers, and in whatever surface comes next, because all of them have to reach it, read it, and not give up waiting on it. The frameworks will keep changing. The job stays the same: build it to work, build it to load, and build it to be found.
Frequently asked questions
What makes a web app fast and findable, in one sentence?
Ship real HTML in the first response so crawlers and users get content immediately, keep Core Web Vitals in the green with disciplined images, bundles, and layout, and treat semantics, metadata, and structured data as required output of your components.
Which rendering strategy is best for SEO?
Server-side rendering or static generation, because both put real content in the initial HTML response that crawlers and AI answer engines read. Static generation, and incremental static regeneration for large sites that update, is fastest since files come off a CDN.
Are Core Web Vitals really a ranking factor?
Yes. Google folds LCP, INP, and CLS into its page-experience assessment as a confirmed ranking signal that acts as a tiebreaker in close races, and there are millions of close races.
How do I fix a slow LCP in a React app?
Diagnose which LCP sub-part dominates, then attack it. Cut time to first byte by caching behind a CDN and server-rendering, convert the hero to AVIF or WebP with responsive srcset, add fetchpriority high and never lazy-load the LCP image, and inline critical CSS while deferring the rest.
Why is my Lighthouse score high but my Core Web Vitals still failing?
Lighthouse is lab data tested on fast simulated hardware, while your ranking depends on field data from real users, many on mid-tier phones and congested networks. A 95 in the lab can coexist with a failing field LCP of five seconds.
Does JavaScript hurt SEO?
It can, if your content only exists after client-side JavaScript runs. Google renders JavaScript in a delayed second wave that is queued and not guaranteed to complete, and many AI answer engines and social scrapers do not render at all.
How do I fix a bad INP score?
INP is a main-thread problem, so give the browser room to respond. Break long tasks into chunks under 50 milliseconds and yield between them, defer non-urgent work like analytics into requestIdleCallback, mark expensive re-renders as transitions in React, and reduce hydration cost by shipping less client JavaScript and code-splitting.
What is a performance budget and how do I enforce it?
A performance budget is a hard limit on things like client JavaScript and image size that a machine enforces so a human cannot ignore it.
How does structured data work in a component-based app?
Build schema as a reusable component that takes props and emits valid JSON-LD, so every page of a type gets correct markup automatically.
How do I stop layout shift (CLS)?
Reserve space for everything you know is coming. Set width and height or aspect-ratio on every image, give ad slots and embeds a fixed minimum height, never inject content above content already on screen, and handle fonts with font-display optional or metric-matched fallbacks so a swap does not reflow text.
Is accessibility related to SEO and performance?
Heavily. Semantic HTML gives a crawler a document outline and gives a screen reader the same outline. Alt text serves search and blind users at once. Real button and anchor elements carry roles for free and are keyboard-operable.
What are React Server Components and why do they matter for speed?
React Server Components run on the server and send rendered output rather than JavaScript, so data-fetching and content-heavy parts of your app add nothing to the client bundle while interactive pieces stay as client components.
I'm Frederick Sona, and I've spent most of my career chasing one question: why do some brands break through while others, often the better ones, don't? I've looked for the answer as a marketer, a designer, a technologist, a salesperson, and a founder, and the honest answer is that it takes all of it: being easy to find, easy to trust, and easy to buy from. Search Everywhere Optimization is one piece of how I think about that, but this blog covers the whole picture, from search and technology to brand, design, and the work of turning attention into revenue. If any of this was useful, come say hello at fredericksona.com.