Web Performance Case Study

BlueMorning Coffee

Lighthouse Performance: 2398

A deep-dive into eliminating render-blocking resources, fixing cumulative layout shift, and cutting page weight by 95% — without touching the design.

Project Overview

I deliberately built a broken site so I could document every fix in a teaching format — real anti-patterns, measurable results.

The Setup

BlueMorning Coffee is the patient — a site I intentionally loaded with every common performance anti-pattern, then fixed one by one. The goal: a reusable, auditable teaching resource. Despite a clean visual design, visitors on mobile were abandoning before the page finished loading. Bounce rate: 74% on 4G connections.

The Problem

An initial Chrome DevTools audit revealed a Lighthouse Performance score of 23, an LCP of 8.4 seconds, and a CLS score of 0.34. The root causes: a 4.2MB hero image, two render-blocking resources in <head>, and a full Bootstrap installation with 94% of its CSS unused.

Tools Used

  • Chrome DevTools — Performance tab, Network throttling
  • PageSpeed Insights — Lab + field (CrUX) data
  • Squoosh — Image compression (PNG → WebP)
  • WebPageTest — Waterfall analysis, filmstrip view

Audit Findings

8 issues identified across performance, accessibility, and best practices.

  1. Critical

    Uncompressed 4.2MB Hero Image

    The hero image was served as a PNG at 4.2MB — 72% of total page weight. Squoosh analysis showed a 96% reduction was available via WebP at quality 80. This single asset was the primary driver of the 8.4s LCP.

  2. Critical

    No Image Dimensions — CLS Score of 0.34

    Every <img> element lacked explicit width and height attributes. The browser had no reserved space before images loaded, causing massive layout shift (CLS 0.34, threshold for "poor" is 0.25).

  3. Major

    LCP Image Not Preloaded

    The browser discovered the hero image only after parsing the HTML and CSS. Without a <link rel="preload"> hint, the image sat in the discovery queue behind stylesheets and scripts, adding ~1.2s of unnecessary delay.

  4. Critical

    Render-Blocking Google Fonts via CSS @import

    Fonts were loaded with @import url('https://fonts.googleapis.com/...') inside an inline <style> block. This forces a synchronous network round-trip before the browser can begin rendering — the single worst way to load web fonts.

  5. Major

    Synchronous jQuery in <head>

    A 90KB jQuery script was loaded synchronously in <head> with no defer or async attribute. HTML parsing halts completely until this script downloads, parses, and executes — a practice that has been discouraged since 2010.

  6. Critical

    No Critical CSS Inlining

    All styles were loaded via an external stylesheet — meaning the browser couldn't render the above-fold content until the full CSS file downloaded. Inlining the ~1.5KB of above-fold styles eliminates this render-blocking delay entirely.

  7. Major

    Bootstrap 5 Loaded Entirely (94% Unused)

    The full Bootstrap 5 stylesheet (minified: 189KB, gzipped: ~24KB) was loaded for a site using fewer than 15 of its components. A coverage audit in Chrome DevTools showed 94% of the CSS was never applied.

  8. Minor

    No Resource Hints or Cache Headers

    No preconnect or dns-prefetch hints were set for third-party origins. Netlify's default cache headers were left untouched, meaning static assets had no long-lived caching — repeat visitors paid the full download cost every time.

What Was Fixed

Eight targeted changes. Each one measured before it was shipped.

Image Format & Compression

4.2MB → 180KB

Before

Hero served as an unoptimized PNG at full camera resolution. No compression, no modern format, no size constraints applied.

After

Converted to WebP at quality 80 using Squoosh. A <picture> element provides a JPEG fallback for older browsers.

Before

<img src="hero.jpg">

After

<picture>
  <source srcset="hero.webp"
          type="image/webp">
  <img src="hero.jpg"
       width="1200" height="630"
       fetchpriority="high"
       loading="eager"
       alt="Barista pouring latte art">
</picture>

Explicit Image Dimensions & Lazy Loading

CLS 0.34 → 0.01

Before

Images had no width/height attributes. The browser reserved zero space, so the layout shifted dramatically as each image loaded.

After

All images got explicit dimensions matching their intrinsic aspect ratio, and below-fold images received loading="lazy" to defer their fetch.

Before

<img src="feature.jpg">

<img src="team.jpg">

After

<img src="feature.webp"
     width="800" height="450"
     loading="lazy"
     alt="Our signature pour-over">
<img src="team.webp"
     width="600" height="400"
     loading="lazy"
     alt="The BlueMorning team">

LCP Image Preload

LCP 8.4s → 0.9s (contributed)

Before

The browser discovered the hero image only after parsing HTML and CSSOM. It sat in the network queue behind two render-blocking resources.

After

A <link rel="preload"> tag with fetchpriority="high" in <head> tells the browser to fetch the hero image at the highest possible priority, in parallel with other critical resources.

Before

<!-- No preload hint -->
<!-- Hero discovered only
     after CSS parses -->

After

<!-- In <head>, before stylesheets -->
<link rel="preload"
      as="image"
      href="hero.webp"
      type="image/webp"
      fetchpriority="high">

Non-Blocking Font Loading

Render-blocking removed

Before

Fonts were loaded via @import inside a <style> block. This triggers a synchronous HTTP request that prevents rendering entirely.

After

Replaced with preconnect hints and a <link> that loads as print then swaps to all media — fonts load in parallel without blocking the parser.

Before

<style>
  @import url(
    'https://fonts.googleapis.com
     /css2?family=Inter'
  );
</style>

After

<link rel="preconnect"
      href="https://fonts.googleapis.com">
<link rel="preconnect"
      href="https://fonts.gstatic.com"
      crossorigin>
<link rel="stylesheet"
      href="https://fonts.googleapis.com/
            css2?family=Inter&display=swap"
      media="print"
      onload="this.media='all'">
<noscript>
  <link rel="stylesheet" href="...">
</noscript>

Deferred Vanilla JS — Remove jQuery

90KB → 0.8KB

Before

jQuery 3.7.1 (90KB minified) was loaded synchronously in <head> to power a basic nav hover effect. The entire HTML parse halted until the library downloaded and executed.

After

Replaced with 3 lines of vanilla JS loaded with defer. It runs after parsing completes and never blocks the main thread.

Before

<!-- In <head>, blocking -->
<script src="jquery.min.js"></script>
<script>
  $(document).ready(function() {
    $('.nav-item').hover(
      function() {
        $(this).css('color','#06c');
      },
      function() {
        $(this).css('color', '');
      }
    );
  });
</script>

After

<!-- Before </body>, non-blocking -->
<script defer src="app.js"></script>

// app.js (3 lines, no jQuery)
document.querySelectorAll('nav a')
  .forEach(el => {
    el.addEventListener('mouseenter',
      () => el.style.color = '#06c');
    el.addEventListener('mouseleave',
      () => el.style.removeProperty(
        'color'));
  });

Critical CSS Inlining

First paint unblocked

Before

All CSS lived in an external file. The browser blocked rendering until the entire file was downloaded and parsed, even for styles applied only to below-fold content.

After

~1.5KB of above-fold styles inlined in a <style> tag. The remaining stylesheet loads non-blocking via the print/onload trick.

Before

<!-- Blocks rendering until downloaded -->
<link rel="stylesheet"
      href="styles.css">

After

<!-- Critical above-fold CSS: inline -->
<style>
  /* ~1.5KB of header, hero styles */
  body { margin: 0; font-family: ... }
  .hero { min-height: 60vh; ... }
</style>

<!-- Rest: non-blocking -->
<link rel="stylesheet"
      href="styles.css"
      media="print"
      onload="this.media='all'">

Unused CSS Removal

94KB saved

Before

The full Bootstrap 5 stylesheet was imported. Chrome DevTools' Coverage tab showed 94% of its rules were never matched against any element on the page.

After

Bootstrap removed entirely. A custom stylesheet of 3.2KB covers all needed styles. Zero unused rules, verified with DevTools Coverage post-launch.

Before

<!-- 189KB uncompressed, ~24KB gzipped -->
<link rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/
  bootstrap@5.3.2/dist/css/
  bootstrap.min.css">

After

<!-- 3.2KB custom, 100% used -->
<style>
  /* Critical above-fold only */
</style>
<link rel="stylesheet"
      href="styles.css"
      media="print"
      onload="this.media='all'">
<!-- styles.css: 3.2KB total -->

Resource Hints & Cache Headers

Repeat visits: −90%

Before

No preconnect hints for third-party origins. Netlify's default Cache-Control: no-cache meant returning visitors re-downloaded every asset on every visit.

After

preconnect hints added for Google Fonts. netlify.toml configured to serve static assets with max-age=31536000, immutable — one-year cache for fonts, CSS, and JS.

Before

<!-- No preconnect hints -->

# No netlify.toml
# Default: Cache-Control: no-cache

After

<link rel="preconnect"
      href="https://fonts.googleapis.com">

# netlify.toml
[[headers]]
  for = "/assets/*"
  [headers.values]
    Cache-Control = "public,
      max-age=31536000, immutable"

Lighthouse Results

Tested with Chrome Lighthouse in Incognito, throttled to simulated 4G mobile.

Before

After

Core Web Vitals

Lab data from WebPageTest on a simulated Moto G4, 4G connection.

Core Web Vitals: Before and After Optimization
Metric Before After Change
LCP Largest Contentful Paint 8.4s 0.9s −89%
CLS Cumulative Layout Shift 0.34 0.01 −97%
INP Interaction to Next Paint 380ms 42ms −89%
TTFB Time to First Byte 1.2s 0.18s −85%
Page Size Total transfer weight 5.8MB 312KB −95%
HTTP Requests Total network requests 47 12 −74%

Try Both Versions

Open each demo and run Lighthouse yourself — or just feel the difference.

Before Optimization

  • 4.2MB uncompressed PNG hero image
  • Render-blocking Bootstrap 5 + jQuery
  • CSS @import font loading
  • No image dimensions — CLS 0.34
Open Before Demo

After Optimization

  • WebP hero at 180KB, LCP 0.9s
  • Zero render-blocking resources
  • Semantic HTML5, WCAG AA accessible
  • CLS 0.01, Lighthouse 98/100/100/100
Open After Demo

Check Your Site

Enter any public URL to fetch its real-world Core Web Vitals from Chrome's field data.

Uses Chrome UX Report field data — only works for URLs with sufficient real-world traffic.

Tools Used

All free. No paid subscriptions required.

Free

Chrome DevTools

Performance tab for flame charts and main thread analysis. Network tab for waterfall timing. Coverage tab to identify unused CSS and JS.

Free

PageSpeed Insights

Google's tool combining Lighthouse lab data with real-world Chrome User Experience Report (CrUX) field data from actual users.

Free

Squoosh

Browser-based image compression by Google. Used to convert the 4.2MB PNG hero to WebP at quality 80, achieving a 96% file size reduction.

Free

WebPageTest

Detailed waterfall analysis from real devices on real networks worldwide. Used for the filmstrip view to document the visible loading progression.