A user taps your page on a mid-range phone. Three seconds of white screen. Then the layout jumps, the button they wanted moves, and they tap the wrong thing. They leave.
Google’s own research found that the chance of a visitor leaving rises by about 32% when load time goes from 1 to 3 seconds. Speed is a feature, and you lose users quietly when you ignore it.
The good news: frontend slowness is rarely mysterious. The same seven causes show up again and again. This guide walks through each one with a real failure story, the code, and the fix. Reading time: about 12 minutes.
The big picture: where does the time go?
Before the causes, understand the journey. Every page goes through the same pipeline, and each cause below hurts one stage.
Google measures the result with three Core Web Vitals:
| Metric | Plain meaning | “Good” target |
|---|---|---|
| LCP (Largest Contentful Paint) | When the main content appears | under 2.5 s |
| INP (Interaction to Next Paint) | How fast the page reacts to a tap or click | under 200 ms |
| CLS (Cumulative Layout Shift) | How much the page jumps around | under 0.1 |
Keep these three in your head. Every cause below maps to one of them.
Cause #1: shipping too much JavaScript
Real-world failure. A team built a product page with a charting library, a date library, and a utility library. Each was added with a simple import. Nobody checked the bundle. It grew to 2.4 MB. On a fast office laptop it felt fine. On a budget Android phone over 4G, the page was blank for 6 seconds, and mobile conversions dropped.
JavaScript is the most expensive thing you ship. An image only needs to be downloaded and decoded, but JavaScript must be downloaded, parsed, compiled, and executed, all on the user’s slow CPU.
The bad code:
// Imports the ENTIRE library (~70 KB) just to use one function
import _ from "lodash";
const names = _.uniq(users.map((u) => u.name));
The fix: import only what you use, and split code by route.
// Only the one function is bundled
import uniq from "lodash/uniq";
const names = uniq(users.map((u) => u.name));
// React: load the heavy chart only when the user opens that page
import { lazy, Suspense } from "react";
const SalesChart = lazy(() => import("./SalesChart"));
export default function Dashboard() {
return (
<Suspense fallback={<p>Loading chart...</p>}>
<SalesChart />
</Suspense>
);
}
Prevent it: set a performance budget (for example, “initial JS under 200 KB gzipped”) and fail the build when it is exceeded. Use a bundle analyzer (webpack-bundle-analyzer or vite-bundle-visualizer) every few weeks.
Cause #2: unoptimized images
Real-world failure. A marketing team uploaded a 4 MB hero photo straight from a designer. It was displayed at 400 px wide. The page’s LCP was 7 seconds, because the biggest element on screen was also the biggest download.
Images are usually the largest part of a page’s weight. The usual mistakes: wrong size, wrong format, and loading everything at once.
The bad code:
<!-- 4000x3000 JPEG, shown at 400px, loaded even if far below the fold -->
<img src="/images/hero.jpg">
The fix:
<img
src="/images/hero-800.webp"
srcset="/images/hero-400.webp 400w,
/images/hero-800.webp 800w,
/images/hero-1600.webp 1600w"
sizes="(max-width: 600px) 100vw, 400px"
width="800" height="600"
alt="Team working at a whiteboard"
fetchpriority="high"
>
<!-- Below the fold: let the browser wait -->
<img src="/images/team.webp" width="800" height="600"
alt="Our team" loading="lazy" decoding="async">
Four habits to remember:
- Use WebP or AVIF. They are often 30 to 50% smaller than JPEG.
- Use
srcsetso phones do not download desktop-sized images. - Add
loading="lazy"to anything below the fold. Never to your main hero image. - Always set
widthandheight. This also fixes Cause #5.
Cause #3: blocking the main thread
Real-world failure. An e-commerce site had a search box that filtered 20,000 products as you typed. Every keystroke ran a heavy loop. On a laptop it was fine. On a phone, typing felt like wading through mud, with each letter appearing a second late. That is a bad INP score.
The browser has one main thread that does everything: runs your JavaScript, handles clicks, and paints the screen. If your code runs for 300 ms, the page is frozen for 300 ms.
The bad code:
input.addEventListener("input", (e) => {
// Runs on EVERY keystroke, blocks the thread
const results = products.filter((p) =>
expensiveMatch(p, e.target.value)
);
render(results);
});
The fix: debounce the input, and move heavy work off the main thread.
function debounce(fn, wait = 250) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
input.addEventListener(
"input",
debounce((e) => render(search(e.target.value)))
);
// search.worker.js: heavy work runs in a Web Worker, not the UI thread
self.onmessage = ({ data: { query, products } }) => {
self.postMessage(products.filter((p) => expensiveMatch(p, query)));
};
// main.js
const worker = new Worker("search.worker.js");
worker.postMessage({ query, products });
worker.onmessage = (e) => render(e.data);
Cause #4: re-rendering too much
Real-world failure. A dashboard had a clock in the header that updated every second. The clock’s state lived in the top-level component, so every second the entire dashboard, with 500 table rows, re-rendered. The laptop fan spun up and scrolling stuttered.
In frameworks like React, changing state re-renders that component and all of its children. If state lives too high, small changes cause huge work.
The bad code:
function Dashboard() {
const [now, setNow] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
return (
<>
<Clock time={now} />
<HugeTable rows={rows} /> {/* re-renders every second! */}
</>
);
}
The fix: keep state close to where it is used, and memoize expensive children.
function Clock() {
const [now, setNow] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
return <span>{now.toLocaleTimeString()}</span>;
}
const HugeTable = memo(function HugeTable({ rows }) {
return rows.map((r) => <Row key={r.id} row={r} />);
});
function Dashboard() {
return (
<>
<Clock /> {/* only this re-renders each second */}
<HugeTable rows={rows} />
</>
);
}
Also virtualize long lists. If you have 10,000 rows, render only the ~20 visible ones with a library like react-window. The DOM stays small, and a small DOM is a fast DOM.
Tip: do not wrap everything in
memo. Measure with the React DevTools Profiler first. Moving state down is usually the free win.
Cause #5: layout shifts (the page that jumps)
Real-world failure. A news site loaded an ad banner after the article text. The banner pushed the text down just as readers were about to tap a link. They hit an ad instead, and angry support emails followed. This is a bad CLS score.
The browser does not know how big an image, ad, or embed will be until it loads, so it reserves no space. When content arrives, everything below it jumps.
The fix:
/* Reserve space for anything that loads late */
img, video { max-width: 100%; height: auto; }
.hero-img { aspect-ratio: 4 / 3; }
.ad-slot { min-height: 250px; } /* the ad may load late, the space won't */
<!-- Width and height let the browser compute the ratio before download -->
<img src="/a.webp" width="800" height="600" alt="...">
Fonts cause shifts too. Add font-display: swap and preload your main font so text does not jump when the web font arrives.
Cause #6: too many (or too slow) network requests
Real-world failure. A landing page loaded 14 third-party scripts: analytics, two chat widgets, A/B testing, heatmaps, and more. One of them, a tag manager, had an outage. Because it was loaded as a blocking script in the <head>, the whole page waited for it and stayed blank for 10 seconds. A third-party problem became the team’s outage.
Every request costs time, and any render-blocking resource delays the first paint.
The bad code:
<head>
<!-- Blocks HTML parsing until downloaded AND executed -->
<script src="https://widgets.example.com/chat.js"></script>
<link rel="stylesheet" href="/all-styles-for-every-page.css">
</head>
The fix:
<head>
<!-- Connect early to a critical origin -->
<link rel="preconnect" href="https://cdn.example.com">
<!-- Preload the one thing the first screen needs -->
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<!-- defer: download in parallel, run after HTML is parsed -->
<script src="/app.js" defer></script>
<!-- async: for independent scripts like analytics -->
<script src="https://analytics.example.com/a.js" async></script>
</head>
Prevention checklist for the network:
- Cache aggressively. Use hashed file names (
app.3f9a1c.js) withCache-Control: max-age=31536000, immutable. - Use a CDN so files come from a server near the user.
- Compress with Brotli or gzip.
- Audit third parties every quarter. Ask of each one: “What does this cost in milliseconds, and is it worth it?”
- Fetch data in parallel, not one after another:
// Slow: waterfall (each request waits for the previous one)
const user = await fetchUser();
const orders = await fetchOrders();
const prefs = await fetchPrefs();
// Fast: all three run at the same time
const [user, orders, prefs] = await Promise.all([
fetchUser(),
fetchOrders(),
fetchPrefs(),
]);
Cause #7: forced reflows and memory leaks
Two quieter killers that bite after the page has loaded.
Forced reflow (layout thrashing). Reading a layout value (like offsetHeight) right after changing a style forces the browser to recalculate layout immediately. Do it inside a loop and you do it hundreds of times.
// Bad: read, write, read, write... forces layout on every iteration
items.forEach((el) => {
el.style.width = box.offsetWidth / 2 + "px"; // read then write, repeated
});
// Good: read once, then write
const half = box.offsetWidth / 2;
items.forEach((el) => {
el.style.width = half + "px";
});
Memory leaks. A single-page app that is left open all day slowly gets slower, then crashes the tab. The classic cause is a listener or timer that is never cleaned up.
// Bad: the listener lives forever, even after the component is gone
useEffect(() => {
window.addEventListener("resize", onResize);
}, []);
// Good: always return a cleanup function
useEffect(() => {
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
Rule of thumb: anything you start (listener, interval, subscription, observer), you must stop.
How to find the problem (before guessing)
Do not optimize by gut feeling. Follow this order:
Lighthouse and the browser DevTools are free and built in. Also track real user data (for example with the web-vitals library), because your laptop is not your customer’s phone:
import { onLCP, onINP, onCLS } from "web-vitals";
const send = (m) => navigator.sendBeacon("/analytics", JSON.stringify(m));
onLCP(send);
onINP(send);
onCLS(send);
Prevention checklist (copy this into your PR template)
- Initial JavaScript is within budget; heavy routes are lazy-loaded.
- Images are resized, modern format,
lazybelow the fold, withwidthandheight. - No long tasks over 50 ms on user input; heavy work is debounced or in a worker.
- State lives as low as possible; long lists are virtualized.
- Space is reserved for images, ads, embeds, and fonts.
- Scripts use
deferorasync; third parties are audited. - API calls run in parallel, not in a waterfall.
- Every listener, timer, and subscription is cleaned up.
- Lighthouse runs in CI and fails the build on regression.
Key takeaways
- Slow pages are slow at a specific stage. Download, run, render, or interact. Find which.
- JavaScript is the most expensive byte. Send less, split the rest.
- Images are usually the heaviest asset. Resize, compress, lazy-load.
- One main thread. Keep tasks short, or move work to a worker.
- Keep state low so a tiny change does not re-render the world.
- Reserve space so the page does not jump.
- Be careful with third-party scripts. Their outage becomes your outage.
- Measure on real devices, set a budget, and let CI guard it.
You do not need a rewrite or a new framework to make a site fast. You need to measure, fix the biggest cause, and keep the habit. Start with one page this week: run Lighthouse, fix the top issue, and see the number move.
Thanks for reading. If this helped, share it with a teammate who just added “one more small library.”