Skip to main content

    Lesson 44 • Advanced Track

    CSS Performance Optimization

    By the end of this lesson you'll be able to make a page load faster and feel smoother using nothing but HTML and CSS — sizing images so the layout never jumps, animating on the GPU instead of the layout engine, skipping work on off-screen content, and loading fonts and styles without blocking the first paint.

    What You'll Learn

    Read the three Core Web Vitals — LCP, CLS, and INP — and know what each measures
    Optimise images with loading="lazy", srcset, and modern formats
    Stop layout shift (CLS) by giving images explicit width and height
    Animate only transform and opacity so movement runs on the compositor
    Skip off-screen rendering work with content-visibility: auto
    Load CSS and fonts without blocking the first paint (font-display, critical CSS)
    Before this lesson: you should be comfortable writing CSS rules and transitions, and adding images and fonts to a page. If transitions or the transform property feel new, review CSS Transforms & 3D first.

    💡 Think of It Like This

    The browser draws your page like a theatre crew sets a stage. First the crew measures the floor and marks where every prop goes — that is layout. Then they paint the scenery — that is paint. Finally they slide finished flats around under the lights — that is composite.

    Sliding a finished flat (a transform) is cheap — the crew just moves something already built. But changing a prop's actual size mid-show (animating width) forces them to re-measure the whole floor and repaint on every single frame, and the show stutters. Performance work is mostly about staying in the cheap "slide the flats" stage — and not making the crew build things the audience cannot even see yet.

    1. The Three Core Web Vitals

    Before you optimise anything, you need to know what "fast" is measured by. Google's Core Web Vitals are three real-user metrics, and a surprising amount of each is controlled by plain HTML and CSS:

    MetricMeasuresGoodCSS/HTML lever
    LCP
    Largest Contentful Paint
    Time until the biggest element appears< 2.5sNon-blocking CSS, eager hero image
    CLS
    Cumulative Layout Shift
    How much the page jumps as it loads< 0.1width/height on images
    INP
    Interaction to Next Paint
    How fast the page responds to a click/tap< 200msCheap animations, less layout work

    The demo below is a live "scorecard" so you can see what the targets feel like. The numbers are illustrative — the point is to learn the three names and their thresholds, because every fix later in this lesson maps back to one of them.

    Worked example: a Core Web Vitals scorecard

    The three metrics, their targets, and what each one means

    Try it Yourself »
    Code Preview
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>Core Web Vitals</title>
      <style>
        body { background:#0f172a; color:#e5e7eb; font-family: system-ui, sans-serif; padding:24px; }
        .grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:16px; }
        .vital { background:#1e293b; border-radius:12px; padding:18px; border-top:4px solid var(--c); }
        .vital .name { font-size:1.6rem; font-weight:800; color: var(--c); }    /* big metric label
    ...

    2. Images: Size Them, Lazy-Load Them, Serve Less

    Images are usually the heaviest thing on a page, so they drive both LCP (the hero image is often the largest element) and CLS (an unsized image shoves the layout when it arrives). Four HTML/CSS habits fix most of it:

    • Always set width and height so the browser reserves the right box before the file downloads — this alone kills most CLS.
    • loading="lazy" on below-the-fold images so they download only when scrolled near. Keep your hero image eager.
    • srcset to offer multiple sizes so a phone downloads a small file and a desktop a large one.
    • Modern formats — WebP and AVIF are far smaller than JPEG/PNG at the same quality.

    Read the worked example. The first image is done right; the comments explain why each attribute is there and what would break without it.

    Worked example: a correctly optimised image

    Sized, lazy, responsive, and in a modern format

    Try it Yourself »
    Code Preview
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>Optimised images</title>
      <style>
        body { background:#0f172a; color:#e5e7eb; font-family: system-ui, sans-serif; padding:24px; }
        .frame { background:#1e293b; border-radius:12px; padding:16px; margin:16px 0; max-width:480px; }
        /* aspect-ratio reserves space even if the width/height attrs are missing. */
        img { width:100%; height:auto; border-radius:8px; aspect-ratio: 16 / 9; background:#334155; object-fit:cov
    ...

    3. Animate on the Compositor: transform & opacity

    To draw a frame, the browser may run up to four steps: Style (which rules apply) → Layout (sizes and positions) → Paint (fill in pixels) → Composite (slide finished layers around). At 60fps you have about 16ms per frame. The trick is to trigger as few of those steps as possible.

    transform and opacity are special: they only touch Composite, the cheapest step, and run on the GPU. Animating width, height, top, or left forces a full Layout recalculation every frame — that is what makes animations stutter. will-change: transform is a hint that lets the browser promote an element to its own GPU layer in advance — use it sparingly, only on things that actually animate.

    Worked example: smooth vs janky animation

    transform/opacity (cheap) versus width/height (forces layout)

    Try it Yourself »
    Code Preview
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>Animation cost</title>
      <style>
        body { background:#0f172a; color:#e5e7eb; font-family: system-ui, sans-serif; padding:24px; }
        .row { display:flex; gap:32px; flex-wrap:wrap; margin:16px 0; }
        .demo { text-align:center; }
        .box { width:120px; height:120px; border-radius:12px; cursor:pointer; }
    
        /* ✅ GOOD: only transform + opacity change -> Composite only, runs on GPU. */
        .good {
          background:#22c55
    ...

    4. Skip Off-Screen Work with content-visibility

    On a long page, the browser still does layout and paint work for sections far below the fold that nobody has scrolled to yet. content-visibility: auto tells it to skip rendering an element until it is near the viewport — a huge win for long feeds, articles, and lists.

    The catch: a skipped element has no size, so it can cause its own layout shift when it pops in. Pair it with contain-intrinsic-size to give the browser an estimated height to reserve, so the scrollbar stays stable.

    Worked example: content-visibility on long sections

    Skip rendering off-screen blocks, but reserve their height

    Try it Yourself »
    Code Preview
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>content-visibility</title>
      <style>
        body { background:#0f172a; color:#e5e7eb; font-family: system-ui, sans-serif; padding:24px; line-height:1.6; }
    
        .chunk {
          background:#1e293b; border-radius:12px; padding:20px; margin:16px 0;
          /* Skip rendering this block while it's far off-screen... */
          content-visibility: auto;
          /* ...but reserve ~300px so the scrollbar doesn't jump when it renders. */
         
    ...

    5. First Paint: Critical CSS & Font Loading

    A <link rel="stylesheet"> in the <head> is render-blocking: the browser refuses to paint anything until that file is downloaded and parsed, which delays LCP. Two habits cut that delay: inline the critical CSS (the few rules the above-the-fold view needs) directly in a <style> tag, and defer the rest with rel="preload". Avoid @import, which loads stylesheets one-by-one instead of in parallel.

    Fonts have the same problem. By default a downloading web font hides its text (a flash of invisible text). font-display: swap shows a system fallback immediately and swaps in your font when it arrives, so the reader is never staring at blank space.

    Worked example: non-blocking CSS and fonts

    Inline critical CSS, defer the rest, and font-display: swap

    Try it Yourself »
    Code Preview
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>First paint</title>
    
      <!-- 1) CRITICAL CSS inlined: the few rules needed for the first screen.
              Because it's inline, it blocks nothing — it paints instantly. -->
      <style>
        body { margin:0; font-family: system-ui, sans-serif; background:#0f172a; color:#e5e7eb; }
        .hero { min-height:60vh; display:grid; place-items:center; padding:24px; text-align:center; }
        .hero h1 { font-size:2.2rem; margin:0; }
    
        /*
    ...

    🎯 Your Turn #1 — Stop the layout shift

    This image has no reserved space, so when it loads it shoves the paragraph below it (bad CLS). Give the image explicit dimensions and lazy-load it. Fill in the blanks marked ___, then check the expected result in the comments.

    Your Turn #1: size and lazy-load an image

    Add width, height, and loading=lazy to prevent CLS

    Try it Yourself »
    Code Preview
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>Your Turn 1</title>
      <style>
        /* 🎯 YOUR TURN — fill in the blanks marked ___ */
        body { background:#0f172a; color:#e5e7eb; font-family: system-ui, sans-serif; padding:24px; max-width:480px; }
        img { width:100%; height:auto; border-radius:8px; background:#334155; }
        p { color:#94a3b8; }
      </style>
    </head>
    <body>
      <h1>Below-the-fold image</h1>
    
      <!-- 1) Add width and height so the browser reserves the box up 
    ...

    🎯 Your Turn #2 — Make the animation cheap

    This card slides up on hover by animating top — which forces a layout recalculation every frame and stutters. Rewrite it to move with transform instead, so it runs on the compositor. Fill in the blanks and verify the expected behaviour in the comments.

    Your Turn #2: switch from top to transform

    Animate transform/opacity instead of a layout-triggering property

    Try it Yourself »
    Code Preview
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>Your Turn 2</title>
      <style>
        /* 🎯 YOUR TURN — fill in the blanks marked ___ */
        body { background:#0f172a; color:#e5e7eb; font-family: system-ui, sans-serif; padding:40px; }
    
        .card {
          position: relative;
          width:200px; padding:20px; border-radius:12px;
          background:#1e293b; cursor:pointer;
    
          /* 1) Transition the CHEAP property instead of "top". */
          transition: ___ .3s;        /* 👉 repla
    ...

    🧩 Mini-Challenge — A fast-by-default hero

    Support is faded now — only an outline is given. Build a small hero section that applies the lesson's habits all at once. Use the worked examples in sections 2, 3, and 5 as your reference if you get stuck.

    Mini-Challenge: performance habits from scratch

    Sized hero image, compositor-friendly animation, and font-display

    Try it Yourself »
    Code Preview
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>Mini-Challenge</title>
      <style>
        /* 🧩 MINI-CHALLENGE: a hero that's fast by default
           1. Inline the critical styles for the hero right here (it's already inline — good).
           2. In an @font-face, add font-display: swap so text never goes invisible.
           3. Style a .cta button that lifts on hover using ONLY transform + opacity
                (transition: transform, opacity — never top/left/width).
           4. Below
    ...

    ⚠️ Common Errors (and the fix)

    • Animating width/top and getting jank. Transitioning width, height, top, or left forces a layout recalculation every frame, blowing the 16ms budget. Fix: express it as transform: translate() / scale() and animate opacity — Composite-only, GPU-driven.
    • Images with no width/height → CLS. Without reserved space the page jumps when the image arrives, wrecking your CLS score. Fix: set explicit width and height attributes (or an aspect-ratio) on every image, iframe, and ad slot.
    • Huge, unoptimised images. Shipping a 3000px JPEG to a phone wastes bandwidth and delays LCP. Fix: serve modern formats (WebP/AVIF), offer sizes with srcset, and compress — often a 10x size cut for no visible loss.
    • Lazy-loading the hero image. Putting loading="lazy" on your above-the-fold image delays your LCP element. Fix: keep the hero eager (the default); lazy-load only what is below the fold.
    • Render-blocking CSS via @import. @import url('a.css') loads stylesheets sequentially and blocks the first paint. Fix: use parallel <link> tags, inline critical CSS, and defer the rest.
    • Overusing will-change. Slapping it on everything promotes too many GPU layers and eats memory, making things slower. Fix: apply it only to elements that genuinely animate, and remove it when the animation is done.

    📋 Quick Reference

    GoalUse this
    Reserve image space (stop CLS)<img width="800" height="450">
    Defer below-the-fold images<img loading="lazy">
    Serve the right image sizesrcset="a-400.jpg 400w, a-800.jpg 800w"
    Animate smoothly (Composite only)transition: transform .3s, opacity .3s;
    Hint a GPU layer (sparingly)will-change: transform;
    Skip off-screen renderingcontent-visibility: auto;
    Reserve height for skipped blockscontain-intrinsic-size: auto 300px;
    Stop invisible text while a font loadsfont-display: swap;
    Defer a non-critical stylesheet<link rel="preload" as="style" ...>

    ❓ Frequently Asked Questions

    What are Core Web Vitals and why should I care about them as a front-end developer?

    Core Web Vitals are three real-user metrics Google uses to measure page experience: LCP (Largest Contentful Paint) is how long until the biggest thing on screen appears — aim for under 2.5 seconds; CLS (Cumulative Layout Shift) is how much the page jumps around as it loads — aim for under 0.1; and INP (Interaction to Next Paint) is how quickly the page responds when you click or type — aim for under 200ms. They matter because they affect both how the page feels to a real person and where it ranks in Google search. A huge amount of each score is controlled by plain HTML and CSS: sized images, non-render-blocking styles, and compositor-friendly animations. You do not need a framework to fix them.

    Why does my page jump around while it loads, and how do I stop it?

    That jumping is layout shift (the CLS in Core Web Vitals), and the usual culprit is an image or embed with no reserved space. The browser lays out the text first, then the image arrives, pushes everything down, and the reader loses their place. The fix is to always give images explicit width and height attributes (or an aspect-ratio in CSS) so the browser reserves the right-sized box before the file downloads. The same applies to ads, iframes, and any web font that swaps to a different-sized face — reserve the space up front and nothing shifts.

    What does loading="lazy" actually do, and should I put it on every image?

    loading="lazy" tells the browser not to download an image until it is about to scroll into view, which saves bandwidth and speeds up the initial load. Put it on images that are below the fold — further down the page. Do NOT put it on your hero image or anything visible on first paint, because lazy-loading your LCP element delays it and makes your LCP score worse. Rule of thumb: eager (the default) for above-the-fold, lazy for everything below.

    Why is animating width or top janky when transform looks smooth?

    Changing width, height, top, left, or margin forces the browser to re-run layout — it recalculates the size and position of that element and often everything around it — then repaint, on every single frame. At 60fps you only have about 16ms per frame, and layout blows through that budget, so the animation stutters. transform and opacity are special: they run on the compositor (often the GPU) and skip layout and paint entirely, so they stay smooth. The fix is almost always to express movement as transform: translate() instead of top/left, and scaling as transform: scale() instead of width/height.

    What is render-blocking CSS and how do I avoid it?

    When the browser finds a <link rel="stylesheet"> in the <head>, it stops rendering the page until that file has downloaded and parsed — the stylesheet is render-blocking. A big or slow CSS file therefore delays your first paint and hurts LCP. You reduce the impact by keeping your CSS small, inlining the tiny bit of critical CSS the above-the-fold view needs directly in a <style> tag, and avoiding @import (which loads stylesheets one after another instead of in parallel). The less CSS stands between the browser and the first paint, the faster the page feels.

    What does font-display: swap do, and why might text be invisible without it?

    By default, when a custom web font is still downloading, the browser hides the text that uses it — a flash of invisible text (FOIT) that can last seconds on a slow connection. font-display: swap tells the browser to show the text immediately in a fallback system font and then swap to your web font once it arrives. The reader can start reading right away. The trade-off is a brief flash of the fallback font (FOUT), which you minimise by picking a fallback with a similar size so the swap does not cause a layout shift.

    🎉 Lesson Complete

    You can now make a page measurably faster with HTML and CSS alone. The essentials:

    • ✅ The three vitals: LCP (<2.5s), CLS (<0.1), INP (<200ms)
    • ✅ Always set image width/height to kill layout shift
    • loading="lazy" below the fold, eager hero; use srcset + WebP/AVIF
    • ✅ Animate only transform and opacity — never width/top
    • content-visibility: auto skips off-screen rendering on long pages
    • ✅ Inline critical CSS, defer the rest, and use font-display: swap

    Next up: Progressive Enhancement, where you'll build pages that work everywhere and get better where the browser supports more.

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service