Work through the steps in order. Each one is small enough to do in under an hour, produces a measurable delta on PageSpeed Insights, and costs nothing beyond your own time. Step 10 covers server-level hardening — not strictly a “speed” fix, but a site getting hammered by bad bots is a slow site, so it belongs on the same list.
New to the metric names below? See Core Web Vitals: what PageSpeed is actually measuring first — LCP, INP, CLS, TTFB, and TBT are explained there in plain terms.
Prefer to see the destination first? Every HTML-level pattern below is applied and annotated in a working reference page at /samples/tuned-page/. View source and grep for
WHY:comments — each one links the pattern to the step here that produced it. Point PageSpeed Insights at it to see the numbers this checklist produces.
Step 1: Audit first — know your baseline
Before changing anything, measure.
- PageSpeed Insights — Google’s Lighthouse report, no account needed. Run it on your homepage and one interior page.
- WebPageTest — more detailed than PSI. Run from a Dublin or Frankfurt node if your audience is Irish or EU.
- Chrome DevTools → Network tab — load your page with DevTools open. Sort by Size. The largest requests are your first targets.
Note your Core Web Vitals scores (LCP, INP, CLS) before touching anything so you can prove improvement later. Screenshot the PSI report if you like — makes the before/after conversation with stakeholders trivial.
Step 2: Fix your images first
Images are almost always the biggest win on any site audit.
Convert to WebP or AVIF. JPEG and PNG are larger than they need to be. WebP delivers comparable quality at 25–35% smaller file sizes; AVIF goes further at ~50% but requires a slightly newer browser baseline (all modern browsers now support it). Use Squoosh — free, browser-based, no account. Drag your images in, export, replace the originals.
Add width and height attributes. If your <img> tags are
missing these, the browser can’t reserve space for the image before
it loads. That causes layout shift — your CLS score suffers and
the page feels unstable.
1<!-- Before -->
2<img src="hero.webp" alt="Homepage hero">
3
4<!-- After -->
5<img src="hero.webp" alt="Homepage hero" width="1200" height="630">
Use lazy loading for below-fold images. The browser defers loading images the user hasn’t scrolled to yet:
1<img src="product.webp" alt="Product shot" width="800" height="600" loading="lazy">
Do not add loading="lazy" to your largest above-fold image
— that’s your LCP element and it needs to load immediately.
Step 3: Stop blocking the page with scripts
JavaScript loaded in <head> without attributes forces the browser
to pause HTML parsing until the script downloads and executes. That
hits your LCP directly.
1<!-- Blocks rendering — avoid -->
2<script src="/js/analytics.js"></script>
3
4<!-- Deferred: executes after HTML is parsed -->
5<script src="/js/analytics.js" defer></script>
6
7<!-- Async: executes as soon as downloaded, order not guaranteed -->
8<script src="/js/widget.js" async></script>
Use defer for most scripts. Use async only when a script has no
dependencies on other scripts.
Move scripts to the bottom of <body> if you can’t add defer
— same effect for legacy code.
Step 4: Enable compression at the server level
If your server isn’t compressing responses, it’s sending far more data than necessary. One-line fix.
Caddy (Brotli + gzip on by default; explicit form):
1encode zstd gzip
Nginx:
1gzip on;
2gzip_types text/plain text/css application/javascript application/json image/svg+xml;
3gzip_min_length 1024;
For Brotli on Nginx you’ll need the ngx_brotli module and
equivalent brotli on; block — worth the setup for the
~15–20% smaller text payloads it produces vs gzip.
Apache (.htaccess):
1<IfModule mod_deflate.c>
2 AddOutputFilterByType DEFLATE text/html text/css application/javascript
3</IfModule>
Check it’s working with GiftOfSpeed — it’ll confirm whether Brotli or gzip is active.
While you’re here, turn on HTTP/3. Caddy supports it natively; a
recent Nginx build supports it via the http_v3_module. HTTP/3 is a
meaningful mobile-network win because it removes head-of-line
blocking that plagues HTTP/2 over lossy connections. Your PSI
mobile score will thank you.
See nginx-tls-2026 for the full Nginx TLS baseline that pairs with this.
Step 5: Set proper cache headers on static assets
CSS, JS, fonts, images don’t change on every request. Tell the browser to cache them.
Caddy:
1@static {
2 file
3 path *.css *.js *.woff2 *.png *.webp *.avif *.jpg *.svg
4}
5header @static Cache-Control "public, max-age=31536000, immutable"
Nginx:
1location ~* \.(css|js|woff2|png|webp|avif|jpg|svg)$ {
2 expires 1y;
3 add_header Cache-Control "public, immutable";
4}
immutable tells the browser not to revalidate even on a forced
refresh. Only pair this with cache-busting filenames (e.g.
app.v2.css, or hash-suffixed names from a build pipeline). Ship
immutable on a filename you plan to overwrite in place and stale
copies will haunt returning visitors for a year.
WordPress operators — core doesn’t hash asset filenames
automatically. Either a plugin does it for you (most caching plugins
handle this), or leave immutable off and use a shorter max-age
(a week is safer than a year for un-versioned filenames).
Step 6: Fix your font loading
Google Fonts is convenient but it adds an external DNS lookup, a connection, and a render-blocking request. Self-host instead.
- Download your font files from google-webfonts-helper — generates the CSS and the files.
- Host the
.woff2files on your own server. - Add
font-display: swapso text renders immediately in a fallback font while your custom font loads.
1@font-face {
2 font-family: 'Inter';
3 src: url('/fonts/inter-v13-latin-regular.woff2') format('woff2');
4 font-weight: 400;
5 font-display: swap;
6}
Removes the third-party dependency entirely and gives you full control over caching.
Step 7: Audit your third-party scripts
This is where most sites lose 500 ms–2 s without realising.
Open Chrome DevTools → Network tab → filter by third-party requests. Common offenders:
- Live chat widgets loading on every page, including pages no one chats from.
- Tag Manager containers with abandoned tags still firing.
- Social sharing buttons that phone home on load.
- Multiple analytics tools doing the same job.
For each, ask: does this need to load on every page, above the fold, immediately? If not:
- Lazy-load it — trigger after user interaction or after
window.onload. - Remove it — abandoned tags are silent performance tax.
- Replace it — Umami (self-hosted) or Plausible replaces Google Analytics with zero third-party requests.
Step 8: Add resource hints for what you can’t remove
If a third-party resource must stay, at least tell the browser about it early.
1<!-- Establish connection early to third-party origins -->
2<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
3<link rel="dns-prefetch" href="https://cdn.example.com">
4
5<!-- Preload your LCP image -->
6<link rel="preload" as="image" href="/images/hero.webp">
preconnect is stronger — it opens the TCP connection and TLS
handshake. dns-prefetch is a lighter hint. Don’t overuse
preconnect; limit it to two or three critical origins.
Why the LCP preload matters: without it, the browser only
discovers the hero image when it finishes parsing the HTML and hits
the <img> tag. The <link rel="preload"> in <head> starts the
image fetch immediately — often shaving 200–500 ms off
LCP on real connections.
Step 9: Minify your HTML, CSS, and JS
Unminified code ships comments, whitespace, and long variable names the browser doesn’t need.
- CSS: cssnano or clean-css (npm).
- JS: Terser (npm).
- HTML: html-minifier-terser (npm).
If you’re not using a build pipeline, run these as one-off commands before deployment. Savings are modest on small files but compound across a full site and reduce bandwidth costs on high-traffic pages.
Step 10: Block malicious traffic before it hits your app
A slow server is often a server under load from bad bots, scrapers, and automated scanners — not just a configuration problem. Dropping junk traffic at the server layer reduces load and improves response times for real users.
For Apache: the 7G Firewall
Jeff Starr’s 7G Firewall
is a free .htaccess ruleset that’s been refined over more than a
decade. It blocks:
- Malicious query strings.
- Request method abuse.
- Known bad user agents and bots.
- Directory traversal attempts.
- Referrer spam.
Download the free version from
Perishable Press, drop it
into your .htaccess, and you’re done. No plugin, no subscription.
Check the changelog on that page before dropping it in — a
long-unmaintained ruleset gets brittle against modern attack
patterns; a fresh version is what you want.
For Nginx: community-ported rules
The 7G rules are Apache-specific, but the underlying logic has been
ported to Nginx. Search GitHub for “7G Nginx” — maintained
community versions exist. At minimum, add bad-bot blocking to your
nginx.conf:
1# Block common bad bots
2if ($http_user_agent ~* (wget|curl|libwww|python|nikto|sqlmap|nmap|masscan)) {
3 return 403;
4}
The full community port covers query-string filtering and request-method restrictions in the same way 7G does for Apache.
Pair this with rate limiting — see nginx-ratelimit for the pattern.
For Caddy: native request filtering
Caddy doesn’t have a direct 7G equivalent, but you can build meaningful protection with native directives:
1@badbots {
2 header User-Agent *sqlmap*
3 header User-Agent *nikto*
4 header User-Agent *masscan*
5 header User-Agent *python-requests*
6}
7respond @badbots 403
8
9# Block suspicious query strings
10@malicious_qs {
11 query *<script*
12 query *union+select*
13 query *etc/passwd*
14}
15respond @malicious_qs 400
Won’t match 7G’s depth out of the box, but covers the most common automated attack patterns.
Country-level filtering
If your traffic problem is regional — ad-fraud from specific countries, sanctions compliance, or just no legitimate audience in a region — country blocking is a coarser filter that pairs well with the above. See Blocking countries from your website for the Apache + MaxMind and Cloudflare Worker implementations.
Cloudflare free tier
If you’re not already routing traffic through Cloudflare, the free tier is worth enabling:
- Bot Fight Mode — challenges known bot traffic automatically.
- DDoS mitigation — absorbs volumetric attacks before they reach your server.
- WAF free rules — a limited but useful set of managed rules.
- Caching at the edge — static assets served from Cloudflare’s network, not your VPS.
Highest-leverage single change for most small-to-medium sites. Your server only sees traffic Cloudflare passes through, which by itself reduces load significantly.
Putting it together: a free audit checklist
Run through this before spending a penny on performance tooling:
- Baseline score captured (PageSpeed Insights + WebPageTest).
- All images converted to WebP / AVIF and sized correctly.
-
loading="lazy"on below-fold images; LCP image not lazy-loaded. - Scripts have
deferor moved to bottom of<body>. - Gzip or Brotli compression enabled at server level.
- HTTP/3 enabled (Caddy default; Nginx
http_v3_module). - Cache headers set on static assets (1 year +
immutable, with cache-busted filenames). - Fonts self-hosted with
font-display: swap. - Third-party scripts audited; unused ones removed.
- Resource hints added for remaining third-party origins;
preloadon the LCP image. - HTML / CSS / JS minified.
- Bad-bot blocking in place (7G, Nginx rules, or Caddy matchers).
- Cloudflare free tier enabled.
After working through this list, re-run PageSpeed Insights and WebPageTest. The gap between before and after is often substantial — and none of it costs anything.
For a side-by-side reference, /samples/tuned-page/ is a working example with every HTML-level trick above applied and annotated in source. If your numbers don’t match, diff your page against that one until they do.
The next step after this checklist is TTFB — Time to First Byte. If your server is slow to respond even after the above, the bottleneck is likely your hosting tier or application code. That’s a separate conversation, but it’s also free to diagnose.