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
- Access to your web server (SSH or hosting control panel)
- Google Chrome browser (for Lighthouse)
- Command-line access with
cwebporimagemagickinstalled - Permission to edit
.htaccess(Apache) ornginx.conf(Nginx) - Basic familiarity with HTML and your site's file structure
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:
- Open your website in Google Chrome.
- Press F12 (or Cmd+Option+I on Mac) to open DevTools.
- Click the Lighthouse tab (you may need to click
>>to find it). - Select Navigation mode, choose Mobile as the device, and check Performance.
- Click Analyze page load.
- 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.
- Create a free account at cloudflare.com.
- Add your domain and select the Free plan.
- Cloudflare scans your existing DNS records. Verify them and click Continue.
- Update your nameservers at your domain registrar to the ones Cloudflare provides (e.g.,
ada.ns.cloudflare.com,bob.ns.cloudflare.com). - Wait for DNS propagation (usually 1–24 hours).
- In the Cloudflare dashboard, go to Speed → Optimization and enable Auto Minify (HTML, CSS, JS) and Brotli.
- 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:
- LCP (Largest Contentful Paint): How quickly the largest visible element (usually a hero image or heading) renders. Target: under 2.5 seconds. Fix by optimizing images, preloading the LCP resource, and removing render-blocking resources.
- INP (Interaction to Next Paint): Measures responsiveness — the delay between a user interaction (click, tap, keypress) and the next visual update. Target: under 200ms. Fix by breaking up long JavaScript tasks, using web workers, and reducing main-thread blocking.
- CLS (Cumulative Layout Shift): How much the page layout shifts unexpectedly during loading. Target: under 0.1. Fix by always setting
widthandheightattributes on images and videos, reserving space for ads/embeds, and avoiding dynamically injected content above the fold.
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:
- type: ALL — full table scan, needs an index.
- rows: a very large number means the query examines too many rows.
- Extra: Using filesort — consider adding an index on the ORDER BY column.
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.
- Go to webpagetest.org and enter your URL.
- Select a test location close to your target audience.
- Choose Chrome on a Cable connection for desktop, or Mobile 4G for mobile testing.
- Under Advanced Settings → Test, set Number of Tests to Run: 3 and check First View and Repeat View.
- 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"
- Clear your CDN cache (Cloudflare: Caching → Purge Everything).
- Run Lighthouse in an Incognito window with extensions disabled.
- Check if your server is applying the config:
curl -sI https://yoursite.com/style.css | grep -i cache-control
"WebP images are not being served"
- Check that your server sends the correct MIME type:
curl -sI https://yoursite.com/image.webp | grep content-type— should beimage/webp. - In Apache, add
AddType image/webp .webpto your.htaccess.
"Gzip is not working"
- Apache: Verify
mod_deflateis enabled:apachectl -M | grep deflate - Nginx: Ensure
gzip on;is in thehttpblock, not just aserverblock.
"CLS is high but I can't find the cause"
- In DevTools, go to Performance tab, record a page load, then look for the Layout Shift markers in the timeline. Click them to see which element shifted.
- The Lighthouse report also highlights CLS-causing elements under Diagnostics.
Prevention & Ongoing Maintenance
- Automate image optimization in your build pipeline using tools like
sharp(Node.js) orimagemin. - Set up performance budgets: use Lighthouse CI to fail builds when scores drop below a threshold.
- Monitor Core Web Vitals in production via Google Search Console → Core Web Vitals report.
- Run PageSpeed Insights monthly and track scores over time.
- Review third-party scripts quarterly. Each analytics tag, chat widget, or social embed adds load time. Remove anything not actively providing value.
- Keep software updated. CMS updates, plugin updates, and server software updates often include performance improvements.
- Use a staging environment to test performance impact before deploying new features to production.
Need Expert Help?
Want a professional audit with 3 actionable fixes? €39, delivered today.
Book Now — €39100% money-back guarantee