← All Articles
Last updated: 2026-03-30

Website Too Slow? How to Find and Fix the 3 Biggest Speed Issues

Find and fix the top website speed problems. Image optimization, render-blocking resources, caching — with PageSpeed Insights walkthrough.

TL;DR

Most slow websites suffer from three issues: unoptimized images, render-blocking resources, and missing cache headers. Fix images by converting to WebP and adding loading="lazy". Fix render-blocking by adding async/defer to scripts and inlining critical CSS. Fix caching by setting proper Cache-Control headers. These three changes alone can improve your PageSpeed score by 30–60 points.

Prerequisites

Step 1: Measure Your Current Speed

Google PageSpeed Insights

Go to pagespeed.web.dev and enter your URL. The report gives you separate scores for Mobile and Desktop. Focus on the Opportunities section — these are the actionable items sorted by estimated savings. The Diagnostics section provides deeper technical detail.

Key metrics to note down: Largest Contentful Paint (LCP), Cumulative Layout Shift (CLS), and Interaction to Next Paint (INP). These are your Core Web Vitals and they directly affect Google rankings.

Lighthouse in Chrome DevTools

For a more detailed local audit, use Lighthouse directly in Chrome:

  1. Open your website in Google Chrome.
  2. Press F12 (or Cmd+Option+I on Mac) to open DevTools.
  3. Click the Lighthouse tab (you may need to click >> to find it).
  4. Select Navigation mode, choose Mobile as the device, and check Performance.
  5. Click Analyze page load.
  6. Wait 30–60 seconds. The report appears with a score from 0–100.

Lighthouse runs locally on your machine, so close other tabs and extensions to get accurate results. Run it at least three times and average the scores.

Issue 1: Unoptimized Images

Images are the single biggest payload on most websites, often accounting for 50–70% of total page weight. Fixing images alone can cut load time in half.

Convert Images to WebP

WebP provides 25–35% smaller files than JPEG at equivalent quality. Install cwebp and convert your images:

# Install cwebp (macOS)
brew install webp

# Install cwebp (Ubuntu/Debian)
sudo apt install webp

# Convert a single image (quality 80 is a good balance)
cwebp -q 80 image.jpg -o image.webp

# Batch convert all JPEGs in a directory
for f in *.jpg; do cwebp -q 80 "$f" -o "${f%.jpg}.webp"; done

# Using ImageMagick (alternative)
magick input.jpg -quality 80 output.webp

# Batch convert with ImageMagick
for f in *.png; do magick "$f" -quality 80 "${f%.png}.webp"; done

Serve WebP with Fallback

Use the <picture> element to serve WebP to browsers that support it, with a fallback for older browsers:

<picture>
  <source srcset="hero.webp" type="image/webp">
  <source srcset="hero.jpg" type="image/jpeg">
  <img src="hero.jpg" alt="Hero image" width="1200" height="600">
</picture>

Add Lazy Loading

Images below the fold should not load until the user scrolls near them. Modern browsers support this natively:

<img src="product.webp" alt="Product" loading="lazy" width="400" height="300">

Important: Do not add loading="lazy" to your LCP image (usually the hero image or first visible image). That would delay your most important content.

Responsive Images with srcset

Serve different image sizes for different screen widths to avoid sending a 2000px image to a 375px phone screen:

<img 
  src="photo-800.webp" 
  srcset="photo-400.webp 400w, photo-800.webp 800w, photo-1200.webp 1200w"
  sizes="(max-width: 600px) 400px, (max-width: 1024px) 800px, 1200px"
  alt="Responsive photo"
  loading="lazy"
  width="1200" height="800"
>

Generate the resized versions with ImageMagick:

magick original.jpg -resize 400x -quality 80 photo-400.webp
magick original.jpg -resize 800x -quality 80 photo-800.webp
magick original.jpg -resize 1200x -quality 80 photo-1200.webp

Issue 2: Render-Blocking JavaScript & CSS

When the browser encounters a <script> or <link rel="stylesheet"> in the <head>, it stops rendering the page until that resource is downloaded and executed. This directly delays First Contentful Paint and LCP.

async and defer for Scripts

<!-- Blocks rendering (BAD) -->
<script src="analytics.js"></script>

<!-- Downloads in parallel, executes as soon as ready (GOOD for independent scripts) -->
<script src="analytics.js" async></script>

<!-- Downloads in parallel, executes after HTML parsing (GOOD for dependent scripts) -->
<script src="app.js" defer></script>

Use async for scripts that don't depend on DOM or other scripts (analytics, tracking). Use defer for scripts that need the DOM or must run in order.

Inline Critical CSS

Critical CSS is the minimal CSS needed to render above-the-fold content. Inline it directly in the <head> and load the full stylesheet asynchronously:

<head>
  <style>
    /* Critical CSS — only what's needed for above-the-fold */
    body { margin: 0; font-family: system-ui, sans-serif; }
    .header { background: #1a1a2e; color: #fff; padding: 1rem; }
    .hero { min-height: 60vh; display: flex; align-items: center; }
  </style>
  
  <!-- Load full CSS asynchronously -->
  <link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
  <noscript><link rel="stylesheet" href="styles.css"></noscript>
</head>

To extract critical CSS automatically, use the critical npm package:

npx critical https://yoursite.com --inline --minify > critical-output.html

Code Splitting

If you use a bundler like Webpack or Vite, enable code splitting to load only the JavaScript needed for the current page:

// Instead of importing everything at once
import { heavyChart } from './charts';

// Dynamically import only when needed
const { heavyChart } = await import('./charts');

Issue 3: No Caching Strategy

Without cache headers, browsers re-download every resource on every page visit. Proper caching can make repeat visits near-instant.

Apache (.htaccess)

# Enable mod_expires
<IfModule mod_expires.c>
  ExpiresActive On
  
  # Images — cache for 1 year
  ExpiresByType image/webp "access plus 1 year"
  ExpiresByType image/jpeg "access plus 1 year"
  ExpiresByType image/png "access plus 1 year"
  ExpiresByType image/svg+xml "access plus 1 year"
  
  # CSS and JS — cache for 1 month
  ExpiresByType text/css "access plus 1 month"
  ExpiresByType application/javascript "access plus 1 month"
  
  # Fonts — cache for 1 year
  ExpiresByType font/woff2 "access plus 1 year"
  
  # HTML — no cache (always fetch fresh)
  ExpiresByType text/html "access plus 0 seconds"
</IfModule>

# Cache-Control headers
<IfModule mod_headers.c>
  <FilesMatch "\.(jpg|jpeg|png|webp|gif|svg|ico|woff2)$">
    Header set Cache-Control "public, max-age=31536000, immutable"
  </FilesMatch>
  <FilesMatch "\.(css|js)$">
    Header set Cache-Control "public, max-age=2592000"
  </FilesMatch>
</IfModule>

# Configure ETags
FileETag MTime Size

Nginx

# Add to your server block in nginx.conf

# Static assets — 1 year cache
location ~* \.(jpg|jpeg|png|webp|gif|svg|ico|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    etag on;
}

# CSS/JS — 1 month cache
location ~* \.(css|js)$ {
    expires 30d;
    add_header Cache-Control "public";
    etag on;
}

# HTML — no cache
location ~* \.html$ {
    expires -1;
    add_header Cache-Control "no-store, no-cache, must-revalidate";
}

Tip: Use filename hashing (e.g., style.a3f9b2.css) so you can cache aggressively while still being able to deploy updates immediately.

Bonus: Enable Gzip/Brotli Compression

Text-based files (HTML, CSS, JS, SVG, JSON) compress by 60–80%. This is one of the easiest performance wins.

Apache

# Gzip compression
<IfModule mod_deflate.c>
  AddOutputFilterByType DEFLATE text/html text/css application/javascript
  AddOutputFilterByType DEFLATE application/json image/svg+xml
  AddOutputFilterByType DEFLATE text/xml application/xml
  AddOutputFilterByType DEFLATE font/woff2 application/font-woff2
</IfModule>

# Brotli (if mod_brotli is available — Apache 2.4.26+)
<IfModule mod_brotli.c>
  AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript
  AddOutputFilterByType BROTLI_COMPRESS application/json image/svg+xml
  BrotliCompressionQuality 6
</IfModule>

Nginx

# Gzip
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_types text/plain text/css application/javascript application/json image/svg+xml text/xml application/xml;

# Brotli (requires ngx_brotli module)
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/javascript application/json image/svg+xml;

Verify compression is working:

curl -H "Accept-Encoding: gzip, br" -sI https://yoursite.com | grep -i content-encoding
# Should return: content-encoding: br (or gzip)

Bonus: CDN Setup with Cloudflare

A CDN caches your static assets on servers worldwide, reducing latency for distant visitors.

  1. Create a free account at cloudflare.com.
  2. Add your domain and select the Free plan.
  3. Cloudflare scans your existing DNS records. Verify them and click Continue.
  4. Update your nameservers at your domain registrar to the ones Cloudflare provides (e.g., ada.ns.cloudflare.com, bob.ns.cloudflare.com).
  5. Wait for DNS propagation (usually 1–24 hours).
  6. In the Cloudflare dashboard, go to Speed → Optimization and enable Auto Minify (HTML, CSS, JS) and Brotli.
  7. Under Caching → Configuration, set the Browser Cache TTL to Respect Existing Headers if you have already configured them.

Understanding Core Web Vitals

Google uses three Core Web Vitals as ranking signals:

Database Query Optimization

If your site uses a database (WordPress, custom CMS), slow queries can add seconds to every page load.

Enable the Slow Query Log (MySQL/MariaDB)

# Add to /etc/mysql/my.cnf or /etc/my.cnf under [mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = 1

# Restart MySQL
sudo systemctl restart mysql

After running for a few hours, check which queries are slowest:

sudo mysqldumpslow -s t -t 10 /var/log/mysql/slow.log

Analyze Queries with EXPLAIN

EXPLAIN SELECT * FROM wp_posts WHERE post_status = 'publish' ORDER BY post_date DESC LIMIT 10;

Look for:

Add missing indexes:

ALTER TABLE wp_posts ADD INDEX idx_status_date (post_status, post_date);

Measuring with WebPageTest.org

WebPageTest.org provides filmstrip views, waterfall charts, and multi-step testing for free.

  1. Go to webpagetest.org and enter your URL.
  2. Select a test location close to your target audience.
  3. Choose Chrome on a Cable connection for desktop, or Mobile 4G for mobile testing.
  4. Under Advanced Settings → Test, set Number of Tests to Run: 3 and check First View and Repeat View.
  5. Click Start Test and wait for results.

The waterfall chart is the most useful view — it shows every request, how long each takes, and which ones block rendering. Look for long bars (slow resources), red bars (failed requests), and gaps (idle time where the browser is waiting).

Troubleshooting

"My score didn't improve after changes"

"WebP images are not being served"

"Gzip is not working"

"CLS is high but I can't find the cause"

Prevention & Ongoing Maintenance

Need Expert Help?

Want a professional audit with 3 actionable fixes? €39, delivered today.

Book Now — €39

100% money-back guarantee

HR

Harald Roessler

Infrastructure Engineer with 20+ years experience. Founder of DSNCON GmbH.