Web Performance Checklist: 20 Things to Fix Before You Ship

2026-03-01 · Nico Brandt

Your site scores 47 on Lighthouse. Your PM wants it fixed by Friday.

You could guess. Compress some images, add a loading="lazy" attribute, hope for the best. Or you could work through a web performance checklist ordered by what actually moves the score – and skip the items that don’t matter for your codebase.

Here’s the thing: most performance advice treats all fixes as equal. They’re not. A single render-blocking script can cost you 30 Lighthouse points. A fancy font-display swap might get you 2. Knowing where to spend your time is the whole game.

This is a checklist of 20 fixes, ordered by impact. Each one: what the problem is, how to detect it, how to fix it with code. I’ve included real metrics where they matter. If you’ve ever wondered why your website is slow, start here.

The Big Three: Core Web Vitals

First, here’s what you’re optimizing for. Google uses three metrics at the 75th percentile of real user data:

INP replaced First Input Delay in March 2024. As of 2026, 43% of sites still fail the 200ms threshold. It’s the metric most teams struggle with.

Lighthouse gives you lab data. Chrome User Experience Report (CrUX) gives you field data. You need both. Lab data finds problems. Field data confirms they affect real users.

Now, the checklist. Highest-impact items first.

1. Eliminate Render-Blocking JavaScript

Impact: High (10-40 Lighthouse points) Metric affected: LCP, INP

Every <script> tag in your <head> without async or defer blocks the browser from rendering anything. This is the single most common performance killer I see in production.

How to check: open DevTools, run a Lighthouse audit, and look for “Eliminate render-blocking resources.” Or check the Network tab waterfall – any JS file that loads before First Contentful Paint is suspect.

<!-- Before: blocks rendering -->
<script src="/js/analytics.js"></script>
<script src="/js/app.js"></script>

<!-- After: doesn't block rendering -->
<script src="/js/analytics.js" defer></script>
<script src="/js/app.js" defer></script>

Use defer for scripts that need the DOM. Use async for scripts that don’t depend on anything (analytics, tracking). The difference matters – async executes as soon as it downloads, defer waits until HTML parsing finishes.

2. Compress and Serve Modern Image Formats

Impact: High (5-30 points depending on image count) Metric affected: LCP

If your largest contentful element is an image (it usually is), the format and size of that image dominates your LCP score. A 2MB hero JPEG on a 3G connection takes 8+ seconds to load. The same image as AVIF might be 200KB.

<picture>
  <source srcset="/img/hero.avif" type="image/avif">
  <source srcset="/img/hero.webp" type="image/webp">
  <img src="/img/hero.jpg" alt="Dashboard screenshot" width="1200" height="630">
</picture>

Use <picture> with AVIF first, WebP fallback, and JPEG as the default. AVIF compresses 50% better than WebP for photographic content. WebP is the safe middle ground with near-universal browser support.

Tools: sharp in Node, cwebp/cavif on the CLI, or Squoosh for one-off conversions. Automate this in your build pipeline. You will forget to do it manually.

3. Set Explicit Width and Height on Images and Embeds

Impact: High (directly fixes CLS) Metric affected: CLS

This one takes five minutes and fixes an entire Core Web Vital. When the browser doesn’t know the dimensions of an image before it loads, it allocates zero space, then shifts everything when the image arrives.

<!-- CLS nightmare -->
<img src="/img/photo.jpg" alt="Product">

<!-- CLS fixed -->
<img src="/img/photo.jpg" alt="Product" width="800" height="600">

Same applies to iframes, videos, and ads. For responsive images, the width and height attributes set the aspect ratio, not the display size. CSS still controls the actual dimensions. The browser uses the ratio to reserve the right amount of space.

4. Preload Your LCP Image

Impact: High (200ms-1.5s LCP improvement) Metric affected: LCP

The browser discovers your hero image late. It has to parse the HTML, then the CSS, then start the image download. <link rel="preload"> tells the browser to start downloading immediately.

<link rel="preload" as="image" href="/img/hero.avif" type="image/avif">

Only preload your LCP image. Preloading everything is the same as preloading nothing – you’re competing with yourself for bandwidth. Check which element is your LCP in Lighthouse under the “Largest Contentful Paint element” diagnostic.

5. Reduce JavaScript Bundle Size

Impact: High (affects LCP, INP, and Time to Interactive) Metric affected: LCP, INP

Here’s the thing: every kilobyte of JavaScript costs more than a kilobyte of anything else. The browser has to download it, parse it, compile it, and execute it. A 500KB JS bundle takes longer to process than a 500KB image.

How to check:

# Webpack
npx webpack-bundle-analyzer stats.json

# Vite
npx vite-bundle-visualizer

Common wins:

// Before: everything loads upfront
import { Chart } from 'chart.js';

// After: loads when needed
const { Chart } = await import('chart.js');

If your bundle is over 200KB gzipped, something is wrong. Audit it.

6. Enable Text Compression (Gzip/Brotli)

Impact: High (60-80% reduction in transfer size) Metric affected: LCP

If your server isn’t compressing responses, you’re transferring 3-5x more data than necessary. Brotli compresses 15-20% better than gzip for text content.

# Nginx
brotli on;
brotli_types text/html text/css application/javascript application/json;
brotli_comp_level 6;

# Fallback to gzip
gzip on;
gzip_types text/html text/css application/javascript application/json;

How to check: open DevTools, Network tab, look at the Content-Encoding response header. If it says br or gzip, you’re covered. If it’s missing, your server isn’t compressing.

Most CDNs handle this automatically. If you’re behind Cloudflare, Vercel, or Netlify, Brotli is on by default. But verify – I’ve seen misconfigurations.

7. Lazy Load Below-the-Fold Images

Impact: Medium-High (reduces initial page weight) Metric affected: LCP (indirectly, by freeing bandwidth)

Images the user can’t see shouldn’t compete for bandwidth with images they can. Native lazy loading is a single attribute.

<!-- Above the fold: load immediately -->
<img src="/img/hero.jpg" alt="Hero" width="1200" height="630" fetchpriority="high">

<!-- Below the fold: lazy load -->
<img src="/img/feature.jpg" alt="Feature" width="600" height="400" loading="lazy">

The tradeoff: don’t lazy load your LCP image. Use fetchpriority="high" on it instead. Lazy loading the hero image is a common mistake that tanks LCP by 500ms or more.

8. Minimize Main Thread Work

Impact: Medium-High (directly affects INP) Metric affected: INP

Long tasks (anything over 50ms) on the main thread block user input. The browser can’t respond to a click if it’s busy executing JavaScript. This is why INP is the hardest Core Web Vital to pass.

How to check: DevTools Performance tab. Record a page load, look for long yellow bars in the flame chart. Those are your long tasks.

// Before: one long task (300ms)
function processAllItems(items) {
  items.forEach(item => heavyComputation(item));
}

// After: broken into chunks with yielding
async function processAllItems(items) {
  for (const item of items) {
    heavyComputation(item);
    // Yield to the main thread every iteration
    await new Promise(resolve => setTimeout(resolve, 0));
  }
}

In production, use scheduler.yield() if your browser targets support it. It’s cleaner than the setTimeout trick and gives the browser explicit control over task scheduling.

9. Optimize Web Font Loading

Impact: Medium (affects LCP and CLS) Metric affected: LCP, CLS

Fonts are sneaky performance killers. A custom font file blocks text rendering until it downloads. Users see a blank space where text should be (FOIT), or text that shifts when the font swaps in (FOUT).

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-display: swap;
  font-weight: 100 900;
}

Use font-display: swap so text renders immediately with a fallback font, then swaps. Preload fonts you use above the fold:

<link rel="preload" as="font" href="/fonts/inter-var.woff2" type="font/woff2" crossorigin>

Use WOFF2. Always. It compresses 30% better than WOFF and has universal browser support. If you’re loading TTF or OTF files, convert them. And self-host your fonts instead of using Google Fonts CDN – one fewer DNS lookup, more cache control, better privacy.

10. Use Efficient CSS Selectors and Remove Unused CSS

Impact: Medium Metric affected: LCP, INP

A 300KB CSS file where you use 15% of the rules is dead weight. The browser still has to parse every line. This is one area where the Tailwind vs. vanilla CSS debate gets interesting – Tailwind with PurgeCSS can produce remarkably small stylesheets.

How to check:

# Check CSS coverage in Chrome DevTools
# Ctrl+Shift+P → "Show Coverage" → reload the page
# Red bars = unused CSS

Remove unused CSS with PurgeCSS or the built-in purge in Tailwind. For vanilla CSS, audit manually or use purgecss as a PostCSS plugin.

// postcss.config.js
module.exports = {
  plugins: [
    require('@fullhuman/postcss-purgecss')({
      content: ['./src/**/*.html', './src/**/*.js'],
    }),
  ],
};

11. Implement Resource Hints: Preconnect and DNS-Prefetch

Impact: Medium (100-300ms savings per origin) Metric affected: LCP

If your page loads resources from third-party origins (analytics, fonts, CDNs), the browser has to resolve DNS, establish a TCP connection, and negotiate TLS before downloading anything. That’s 100-300ms per origin.

<!-- Full connection warmup for critical origins -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://cdn.example.com" crossorigin>

<!-- DNS-only for less critical origins -->
<link rel="dns-prefetch" href="https://analytics.example.com">

Use preconnect for origins you know you’ll need immediately. Use dns-prefetch for origins that might be needed. Don’t preconnect to more than 4-6 origins – each open connection costs memory and CPU.

12. Defer Third-Party Scripts

Impact: Medium (varies wildly by script) Metric affected: LCP, INP

Analytics, chat widgets, ad scripts, social embeds. Third-party scripts are performance parasites. Some add 500ms+ to your load time.

<!-- Load after page is interactive -->
<script>
  window.addEventListener('load', function() {
    const script = document.createElement('script');
    script.src = 'https://analytics.example.com/tracker.js';
    document.body.appendChild(script);
  });
</script>

For non-critical third-party scripts, load them on the load event or on user interaction (scroll, click). Google Tag Manager should be loaded with defer at minimum. Chat widgets should load on scroll or after a delay.

The pragmatic answer: audit your third-party scripts with Lighthouse’s “Third-party usage” diagnostic. If a script adds more than 250ms of main-thread time, it needs to be deferred or replaced.

13. Optimize Critical Rendering Path

Impact: Medium Metric affected: LCP

The critical rendering path is the sequence of steps the browser takes to convert HTML, CSS, and JS into pixels. Shorter path, faster render.

Inline your critical CSS – the styles needed for above-the-fold content – directly in the <head>. Load the rest asynchronously.

<head>
  <style>
    /* Critical CSS: only what's needed for initial viewport */
    body { margin: 0; font-family: Inter, sans-serif; }
    .header { background: #18181b; color: #fff; padding: 1rem; }
    .hero { max-width: 1200px; margin: 0 auto; }
  </style>
  <!-- Non-critical CSS loaded async -->
  <link rel="preload" href="/css/full.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
  <noscript><link rel="stylesheet" href="/css/full.css"></noscript>
</head>

Tools like critical (npm) can extract critical CSS automatically. In practice, I’ve found that a hand-picked critical CSS of 10-15KB covers most above-the-fold needs.

14. Implement Proper Caching Headers

Impact: Medium (repeat visits only, but those matter) Metric affected: LCP

Caching doesn’t help first visits. But most of your users are return visitors, and without proper cache headers, they’re downloading the same assets every time.

# Static assets: cache for 1 year (use content hashing in filenames)
location ~* \.(js|css|png|jpg|webp|avif|woff2)$ {
  expires 1y;
  add_header Cache-Control "public, immutable";
}

# HTML: no cache (always get the latest)
location ~* \.html$ {
  add_header Cache-Control "no-cache";
}

The key: use content hashes in your asset filenames (app.a1b2c3.js). Then you can cache them forever. When the content changes, the filename changes, and the browser fetches the new version. HTML files should never be cached aggressively – you want users to always get the latest page.

15. Use content-visibility: auto for Long Pages

Impact: Medium (especially on content-heavy pages) Metric affected: INP, rendering performance

This CSS property tells the browser to skip rendering off-screen content until the user scrolls to it. On long pages with dozens of sections, this can cut initial rendering time significantly.

.article-section {
  content-visibility: auto;
  contain-intrinsic-size: auto 500px;
}

The contain-intrinsic-size gives the browser an estimated height so the scrollbar behaves correctly. Without it, the scrollbar jumps as sections render. In practice, this property works best on pages with repeated structures – article lists, product grids, comment threads.

16. Optimize Server Response Time (TTFB)

Impact: Medium Metric affected: LCP (everything waits on TTFB)

Time to First Byte should be under 200ms for static content, under 600ms for dynamic. If your TTFB is over a second, nothing else on this checklist matters much – everything downstream is delayed.

How to check:

curl -o /dev/null -s -w "TTFB: %{time_starttransfer}s\n" https://yoursite.com

Common fixes: use a CDN, enable server-side caching, optimize database queries, upgrade from shared hosting. If you’re running a static site, your TTFB should be under 50ms from a CDN edge node. If it’s not, something is misconfigured.

17. Avoid Excessive DOM Size

Impact: Medium-Low Metric affected: INP, CLS

Lighthouse flags DOM trees with more than 1,500 nodes. Every node costs memory, makes style recalculations slower, and slows down querySelectorAll and layout operations.

How to check: Lighthouse reports your DOM size in the “Diagnostics” section. Or in DevTools console:

document.querySelectorAll('*').length
// If this returns > 1500, audit your markup

The fix is usually structural. Remove wrapper <div>s that exist only for styling (use CSS Grid or Flexbox instead). Virtualize long lists with a library like @tanstack/virtual. Paginate content instead of rendering 200 items at once.

18. Avoid Layout Thrashing in JavaScript

Impact: Medium-Low Metric affected: INP

Layout thrashing happens when JavaScript reads a layout property, writes to the DOM, then reads again. Each read forces the browser to recalculate layout synchronously.

// Layout thrashing: reads and writes interleaved
elements.forEach(el => {
  const height = el.offsetHeight; // forces layout
  el.style.height = height + 10 + 'px'; // invalidates layout
});

// Fixed: batch reads, then batch writes
const heights = elements.map(el => el.offsetHeight);
elements.forEach((el, i) => {
  el.style.height = heights[i] + 10 + 'px';
});

Use requestAnimationFrame for DOM mutations that affect layout. Or better: use CSS transitions and transforms instead of JavaScript-driven animations. Transforms don’t trigger layout recalculation.

19. Add fetchpriority to Critical Resources

Impact: Low-Medium Metric affected: LCP

fetchpriority tells the browser which resources matter most. It’s a hint, not a guarantee, but it consistently shaves 100-300ms off LCP in my testing.

<!-- High priority: LCP image -->
<img src="/img/hero.webp" fetchpriority="high" alt="Hero image" width="1200" height="630">

<!-- Low priority: below-fold images -->
<img src="/img/footer-logo.svg" fetchpriority="low" alt="Logo" width="120" height="40">

Also works on <link> and <script> tags. Use fetchpriority="high" on your LCP image and critical CSS. Use fetchpriority="low" on non-critical resources that the browser might otherwise prioritize too highly.

20. Monitor Performance in CI

Impact: Low (but prevents regression) Metric affected: All

Everything above is pointless if a single PR can undo it. Set up Lighthouse CI in your pipeline. Fail the build if performance drops below your threshold.

# .github/workflows/lighthouse.yml
- name: Lighthouse CI
  uses: treosh/lighthouse-ci-action@v12
  with:
    urls: |
      https://yoursite.com/
      https://yoursite.com/critical-page/
    budgetPath: ./budget.json
    uploadArtifacts: true
// budget.json
[
  {
    "path": "/*",
    "timings": [
      { "metric": "largest-contentful-paint", "budget": 2500 },
      { "metric": "interactive", "budget": 3800 }
    ],
    "resourceSizes": [
      { "resourceType": "script", "budget": 200 },
      { "resourceType": "total", "budget": 500 }
    ]
  }
]

Define performance budgets for page weight, JavaScript size, and Core Web Vitals. When someone adds a 400KB charting library for a tooltip, the build fails. That’s the point.

The Order Matters More Than the List

Twenty items is a lot. You don’t need all of them, and you don’t need them all at once.

Here’s the pragmatic answer: run Lighthouse, look at the “Opportunities” section, and cross-reference with this checklist. Fix the top 3 items by impact. Re-measure. Repeat. Most sites get to a 90+ score by fixing items 1 through 6 on this list.

The tradeoff with performance work is always the same: time spent optimizing versus time spent building features. A Lighthouse score of 95 is not meaningfully better than 90 for your users. But 90 is meaningfully better than 60. Know where to stop.

If your site feels slow and you’re not sure where to start, work through why your website is slow first. It’ll help you diagnose whether the problem is network, rendering, or JavaScript – and that tells you which items on this checklist to prioritize.

Ship fast, then make it fast. But don’t ship slow and pretend nobody notices.