Your website being slow is not a technical problem. It's a revenue problem.
A one-second delay in page load time correlates with a 7% decline in conversion rates. For a business doing $100,000 in daily sales, that single second costs $7,000 every 24 hours.
Over half of mobile users leave a site if it takes more than three seconds to load. They don't come back. They don't file a complaint. They just leave — and your analytics shows it as a bounce rate you've been trying to explain for months.
The frustrating part is that most slow websites are slow for the same five or six reasons. This post covers what they are, how to diagnose them, and exactly what to fix.
First: How to Actually Measure Slowness
Before fixing anything, you need to know what's actually slow. Gut feeling is wrong more often than not.
Two tools. Both required.
PageSpeed Insights (pagespeed.web.dev)
Paste your URL. Get real-world field data from Chrome users — not a simulated lab score. The field data is what Google actually uses for rankings and what your real users are experiencing. A perfect Lighthouse score in lab mode means nothing if field data shows a slow site.
Chrome DevTools → Network tab
Open DevTools (F12), go to Network, throttle to "Slow 4G" (under the throttling dropdown), hard reload. Watch the waterfall. You'll see exactly which resources are loading, in what order, and how long each takes.
What you're looking for in the waterfall:
Long green bar → slow server response (TTFB problem)
Long purple bar → slow CSS blocking render
Many sequential requests → render-blocking resources
Giant image requests → unoptimized images
Late-loading fonts → layout shift or invisible text
The metrics that matter in 2026:
The three Core Web Vitals thresholds: LCP (Largest Contentful Paint) under 2.5 seconds, INP (Interaction to Next Paint) under 200ms, and CLS (Cumulative Layout Shift) under 0.1. INP replaced FID as the responsiveness metric in March 2024 — if your monitoring still shows FID, update your tooling.
Cause 1: Your Images Are Too Heavy
Over three-quarters of a webpage's total weight comes from images — the single biggest performance problem for most sites.
An unoptimized hero image can weigh 3-4MB. A properly optimized one for the same visual quality: under 150KB. That's a 20x difference in what the browser has to download before the page feels loaded.
The fix:
<!-- Wrong: PNG or JPEG with no size attributes -->
<img src="hero.jpg">
<!-- Right: WebP with fallback, explicit dimensions, priority loading -->
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img
src="hero.jpg"
width="1200"
height="600"
alt="Hero image"
fetchpriority="high"
loading="eager"
>
</picture>
<!-- Every other image: lazy load -->
<img
src="product.webp"
width="400"
height="300"
alt="Product"
loading="lazy"
>
Three rules for every image on your site:
1. Use WebP or AVIF. Hero images should be in WebP or AVIF format, under 200KB. AVIF compresses 30-50% better than WebP at the same quality. WebP is the safe default for broad browser support.
2. Always set explicit width and height. Without these, the browser doesn't know how much space to reserve — elements shift around as images load, destroying your CLS score and making the page feel unstable.
3. fetchpriority="high" on your LCP image only. This tells the browser to load this image before anything else. Use it on exactly one image — your hero or above-the-fold image. Using it on multiple images cancels out the benefit.
For existing images at scale, run them through Squoosh (squoosh.app) for one-off optimization or Sharp (npm) for automated pipeline processing:
// Sharp — batch image optimization in your build pipeline
const sharp = require('sharp');
const path = require('path');
async function optimizeImage(inputPath, outputDir) {
const filename = path.basename(inputPath, path.extname(inputPath));
// Generate WebP
await sharp(inputPath)
.resize(1200, null, { withoutEnlargement: true })
.webp({ quality: 82 })
.toFile(`${outputDir}/${filename}.webp`);
// Generate AVIF for modern browsers
await sharp(inputPath)
.resize(1200, null, { withoutEnlargement: true })
.avif({ quality: 65 })
.toFile(`${outputDir}/${filename}.avif`);
console.log(`Optimized: ${filename}`);
}
Cause 2: Render-Blocking Resources
The browser builds your page in order. When it hits a <script> or <link rel="stylesheet"> in the <head>, it stops everything and downloads that file before rendering anything.
This is why a slow-loading third-party script (analytics, chat widget, A/B testing tool) can make your entire page feel slow — even if your own code is fast.
The fix:
<!-- Wrong: blocks rendering -->
<head>
<script src="analytics.js"></script>
<script src="chat-widget.js"></script>
<link rel="stylesheet" href="non-critical.css">
</head>
<!-- Right: defer non-critical scripts, inline critical CSS -->
<head>
<!-- Critical CSS inlined — no render block, no extra request -->
<style>
/* Only what's needed to render above-the-fold content */
body { font-family: system-ui; margin: 0; }
.hero { background: #f4fde8; padding: 4rem 2rem; }
h1 { font-size: 2.5rem; color: #1a1a1a; }
</style>
<!-- Non-critical CSS loads after render -->
<link
rel="preload"
href="styles.css"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
>
<!-- Scripts deferred — don't block HTML parsing -->
<script src="analytics.js" defer></script>
<script src="chat-widget.js" defer></script>
</head>
The defer vs async distinction matters:
defer → downloads in parallel, executes after HTML is parsed, in order
async → downloads in parallel, executes immediately when ready, out of order
Use defer for: scripts that depend on each other or on the DOM
Use async for: completely independent scripts (analytics, ads)
For third-party scripts specifically — chat widgets, heatmaps, marketing tools — consider loading them on user interaction rather than page load:
// Load chat widget only when user shows intent (scroll or click)
let chatLoaded = false;
function loadChatWidget() {
if (chatLoaded) return;
chatLoaded = true;
const script = document.createElement('script');
script.src = 'https://chat-provider.com/widget.js';
script.async = true;
document.head.appendChild(script);
}
// Load on first scroll or mouse move
window.addEventListener('scroll', loadChatWidget, { once: true });
window.addEventListener('mousemove', loadChatWidget, { once: true });
The chat widget loads only when the user starts interacting — not during the critical first render. Users never notice the difference.
Cause 3: Slow Server Response (TTFB)
Time to First Byte (TTFB) is how long it takes your server to start sending a response after the browser requests a page. Google recommends under 200ms.
If TTFB is consistently above 500ms, no amount of frontend optimization will make your site feel fast. You're waiting on the server before the browser can even start rendering.
Diagnose it:
# Measure TTFB from the command line
curl -o /dev/null -s -w \
"TTFB: %{time_starttransfer}s\nTotal: %{time_total}s\n" \
https://yoursite.com
The most common TTFB culprits:
No CDN. If your server is in us-east-1 and a user is in Singapore, they're waiting for a round trip across the planet. A CDN serves cached responses from edge nodes close to the user.
Without CDN: Singapore user → us-east-1 server → ~250ms TTFB
With CDN: Singapore user → Singapore edge → ~20ms TTFB
Cloudflare's free tier eliminates this problem for most sites. For more control, CloudFront (AWS) or Fastly.
Uncached database queries on every page load. If your homepage runs 8 database queries to render, and each query takes 30ms, you're spending 240ms before a single byte goes to the browser.
# Wrong — queries on every request
def get_homepage_data():
featured_products = db.query("SELECT * FROM products WHERE featured = true LIMIT 6")
categories = db.query("SELECT * FROM categories WHERE active = true")
testimonials = db.query("SELECT * FROM testimonials ORDER BY created_at DESC LIMIT 3")
stats = db.query("SELECT COUNT(*) FROM orders WHERE status = 'completed'")
return {...}
# Right — cache data that changes infrequently
import redis
import json
r = redis.Redis()
def get_homepage_data():
cached = r.get('homepage:data')
if cached:
return json.loads(cached)
data = {
'featured_products': db.query("SELECT * FROM products WHERE featured = true LIMIT 6"),
'categories': db.query("SELECT * FROM categories WHERE active = true"),
'testimonials': db.query("SELECT * FROM testimonials ORDER BY created_at DESC LIMIT 3"),
'stats': db.query("SELECT COUNT(*) FROM orders WHERE status = 'completed'")
}
# Cache for 5 minutes — homepage data doesn't need to be real-time
r.setex('homepage:data', 300, json.dumps(data))
return data
Not using HTTP/2. HTTP/1.1 sends one request at a time per connection. HTTP/2 multiplexes multiple requests over a single connection. If your server is still on HTTP/1.1, browsers are downloading your resources in a queue instead of in parallel. Most modern hosting handles this automatically — verify with the Chrome DevTools Network tab (look for the Protocol column).
Cause 4: Too Much JavaScript
JavaScript is the most expensive resource on a web page — not just to download, but to parse, compile, and execute. A 200KB JavaScript file is significantly more expensive than a 200KB image because the image is just decoded once, while JS is parsed and executed by the CPU on every page load.
Moving from client-side rendering to server-side rendering or static site generation dramatically improves both LCP and indexing reliability.
For most SaaS marketing sites and content pages, serving pre-rendered HTML is faster than hydrating a React app on the client.
For JavaScript-heavy apps that can't be server-rendered, code splitting is non-negotiable:
// Wrong — entire app loads on first page
import Dashboard from './Dashboard';
import Analytics from './Analytics';
import Settings from './Settings';
import Reports from './Reports';
// Right — load only what's needed for the current route
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const Dashboard = lazy(() => import('./Dashboard'));
const Analytics = lazy(() => import('./Analytics'));
const Settings = lazy(() => import('./Settings'));
const Reports = lazy(() => import('./Reports'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
<Route path="/reports" element={<Reports />} />
</Routes>
</Suspense>
);
}
A user visiting /dashboard no longer downloads the Analytics, Settings, and Reports bundles. Each route loads only what it needs.
Audit your bundle for what's actually large:
# Analyze your webpack bundle
npx webpack-bundle-analyzer stats.json
# Or with Vite
npx vite-bundle-visualizer
Common bundle bloat offenders:
-
moment.js— replace withdate-fnsorday.js(10x smaller) - Full
lodashimport — use named imports orlodash-es - Icon libraries importing the entire set for 3 icons
- Duplicate dependencies from different package versions
Cause 5: Layout Shift (CLS)
Cumulative Layout Shift measures how much the page jumps around as it loads. It's the experience of going to click a button and having it move a centimeter just before your finger makes contact.
Every image, video, iframe, and ad slot needs explicit width and height attributes to prevent layout shift.
<!-- Causes layout shift — browser doesn't know the image dimensions -->
<img src="product.webp" alt="Product">
<!-- No shift — browser reserves space before the image loads -->
<img src="product.webp" width="400" height="300" alt="Product">
The other common CLS causes:
Web fonts loading late — text renders in a fallback font, then jumps to the web font when it loads:
<!-- Preload the font so it's ready before text renders -->
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin
>
<style>
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-display: optional; /* Don't show fallback — wait for font */
}
</style>
font-display: optional tells the browser not to render fallback text — it waits for the web font or uses a cached version. Eliminates font-related CLS entirely at the cost of potential invisible text on very slow connections.
Dynamically injected content — banners, cookie notices, and chat widgets that push content down after the page has rendered:
/* Reserve space for the cookie banner before it loads */
.cookie-banner-placeholder {
min-height: 60px; /* Match your banner's height */
}
/* Position chat widget absolutely — doesn't affect document flow */
.chat-widget {
position: fixed;
bottom: 24px;
right: 24px;
/* Never inject into the page flow */
}
The Diagnostic Checklist
Run through this before touching any optimization:
Measure first
Check PageSpeed Insights field data (not just lab score)
Run Chrome DevTools with Slow 4G throttling
Identify your actual LCP element (what's the largest thing on screen?)
Check TTFB with curl or WebPageTest
Images
Hero/LCP image in WebP or AVIF, under 200KB
fetchpriority="high" on LCP image only
All images have explicit width and height attributes
Non-hero images have loading="lazy"
Server
TTFB under 200ms (check with curl)
CDN in place for static assets
Homepage queries cached (not running on every request)
HTTP/2 enabled (verify in DevTools Network → Protocol column)
JavaScript
Route-based code splitting implemented
Third-party scripts deferred or lazy-loaded on interaction
Bundle analyzed for oversized dependencies
Fonts & Layout
Web fonts preloaded
font-display: optional or swap set
Cookie banners and chat widgets don't shift content on load
The Business Case for Doing This Now
Speed optimization is not a nice-to-have engineering project. It's a revenue recovery project.
Bounce rates increase by 32% when load time reaches three seconds. When load time goes from 1 to 5 seconds, bounce rates jump by 90%.
Swappie improved Core Web Vitals and increased mobile revenue by 42%. Renault improved LCP by one second and saw a 13% rise in conversions.
These are not outliers. The consistent pattern across case studies shows 5–61% conversion improvements and 15–53% revenue increases from Core Web Vitals optimization.
The work is not glamorous. Compressing images, deferring scripts, and adding a CDN is not the kind of engineering that gets talked about at conferences. But it is the kind of engineering that shows up directly in your revenue numbers within 30 days of shipping.
Start with images and TTFB. They account for the majority of slowness on most websites and are the fastest to fix.
This post is part of OutworkTech's engineering series. Related reading: CI/CD Pipelines Explained for Growing Startups and How to Deploy Applications on AWS Without Downtime.
OutworkTech builds and scales SaaS products and backend systems for companies that need engineering depth without the overhead. If your site performance is costing you conversions — let's talk.
Top comments (0)