
Last term, a student turned in a simple e-commerce page. The HTML was clean, the server responded in 80ms. But the DevTools waterfall showed a 4.7-second load before the hero image appeared. The problem wasn't the server. It was three render-blocking CSS files, a 2.8MB hero image, and no cache headers at all.
We see that pattern every batch. Most slow sites aren't slow because of bad code. They're slow because of three specific, fixable bottlenecks. Let's look at each one, with the exact numbers you'll see in your own browser.
Render-blocking resources
When a browser loads a page, it builds two trees: the DOM (from HTML) and the CSSOM (from CSS). Both must be complete before the first paint. The browser stops parsing HTML the moment it hits a <link rel="stylesheet"> tag. It fetches the file, parses it, and only then continues.
So if your <head> has three CSS files, that's three round trips. On a 4G connection, each round trip adds roughly 100-200ms. On a slow 3G connection, it's more like 400ms. Three files could easily add a second of delay before the page even draws a single pixel.
The fix is simple in most cases: combine your CSS into one file. But the bigger win is for JavaScript. A <script> tag without defer or async blocks parsing too. The browser has to download and execute the script before it can continue building the DOM.
<script src="app.js" defer></script>
The defer attribute tells the browser to download the script in parallel and execute it only after the document has been parsed. async is different: it executes as soon as the download finishes, which can interrupt the DOM build. Use defer for scripts that touch the DOM. Use async only for analytics or other scripts that don't depend on the page structure.
We tell students to audit their <head> first. Open DevTools, go to the Performance tab, and look at the waterfall. If you see a row of CSS and JS requests before the first vertical line (the First Contentful Paint), those are your render-blocking resources. Move them out of the critical path.
A second fix here is inlining your critical CSS. For a small site, a few hundred bytes of CSS that sets the hero background and font size can be placed directly in a <style> tag. The browser paints immediately, then loads the full stylesheet in the background. It's not elegant, but it cuts the first paint time dramatically.
Images: the weight you don't feel until it's too late
Images are the biggest contributor to page weight. Our students' projects routinely have 15 images at 2MB each. That's 30MB of data. On a mobile connection, 30MB takes about 15 seconds to download. The page looks fine on a desktop fiber connection. On a phone, it's unusable.
The first fix is format. JPEG and PNG are old. WebP and AVIF compress far better for the same visual quality. A typical JPEG hero image at 2MB becomes a WebP at 300KB. AVIF often gets it under 200KB. The tools are free: cwebp from Google, or online converters like Squoosh.
The second fix is responsive sizes. If your image is displayed at 400px wide on a phone, why are you shipping a 1200px image? The browser needs to know which size to pick. That's what srcset and sizes do.
<img src="hero-400.jpg"
srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1200.jpg 1200w"
sizes="(max-width: 600px) 100vw, 50vw"
alt="A product photo">
The browser reads sizes to figure out how wide the image will be on the current screen. Then it picks the closest match from srcset. On a phone, it downloads the 400w file. On a desktop, it might download the 800w file. You still need a src fallback for very old browsers, but those are rare now.
The third fix is lazy loading. The loading="lazy" attribute on <img> tags tells the browser to wait until the image is near the viewport before fetching it. The hero image should never be lazy. But the 12 product images below the fold? They can wait. This cuts the initial download from 30MB to maybe 2MB.
One thing we do not know for sure is how lazy loading affects every search engine's crawler. Google says it handles it fine, and we've seen no evidence of a penalty. But if your main content is below the fold and you lazy-load it, you're asking for trouble. Keep the critical content in the initial HTML.
Caching: making the second visit nearly free
The first visit to your site is always slower than the second. That's normal. The server has to send everything. But most sites don't tell the browser what can be reused. So the second visit downloads the same 200KB CSS file again. And the third. And the fourth.
HTTP caching solves this with two headers: Cache-Control and ETag.
Cache-Control tells the browser how long it can keep a file without asking the server. For static assets that have a hash in their filename (like style-a1b2c3.css), you can set max-age=31536000 (one year). The browser won't even send a request for a year. If you update the file, you change the hash, and the URL changes, forcing a fresh download.
For HTML files, you want a short max-age or none at all. HTML changes often, and you don't want a stale page.
ETag is a validator. The browser sends a If-None-Match header with the old ETag. The server responds with 304 Not Modified if nothing changed. That response is tiny, a few hundred bytes, compared to the full file.
Here's a typical setup for Nginx:
location ~* \.(css|js|jpg|jpeg|png|webp|avif)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
The immutable flag tells the browser it doesn't even need to revalidate. That's safe only for fingerprinted files. If you don't hash your filenames, don't use immutable. Use max-age and let the browser revalidate with ETag.
We had a client who complained their site was "slow" even after we optimized images. The waterfall showed the CSS file downloading on every single request, 200KB each time. They had no cache headers. After adding the config above, the repeat visit dropped from 1.2 seconds to 0.3 seconds. The server wasn't slow. The browser was just downloading the same file over and over.
Common questions
How do I measure my site's speed if I'm not a developer?
Open Chrome DevTools (F12), go to the Lighthouse tab, and run a performance audit. It gives you a score and shows the largest contentful paint (LCP) and total blocking time. For a more detailed waterfall, use WebPageTest.org. Both are free and take less than a minute.
Is a CDN always necessary for good performance?
No. A CDN helps when your visitors are spread across large distances. If your audience is in one city and your server is there too, a CDN adds little. Measure your server's response time and your audience's location first. If the server responds in under 50ms, a CDN won't move the needle much.
Does lazy loading hurt SEO?
It can if you lazy-load the main content. Lazy loading below-the-fold images is safe and recommended. Google's crawler can render JavaScript, but it's still better to have the critical content in the initial HTML. Keep the hero image and any text content eager-loaded. Use lazy only for things far down the page.
What's the quickest win for a small site?
Compress your images and add a cache header. Those two changes take under an hour and usually cut load time by half. The render-blocking fixes require more care, but image compression and caching are almost free wins that work on any stack.
Courses this relates to
Come and sit a class before you decide
Reading about it only gets you so far. Pick a day, sit in on a class that is already running, write some code, and talk to the students in it. Free, and nothing to pay afterwards unless you want to join.