Most Core Web Vitals guides focus on minifying CSS or lazy-loading images. That's frontend work. But half the performance battle happens before the browser even starts rendering—at the server level, in your Apache or Nginx config, in how you compress responses and set cache headers.
I've seen hosting clients chase a passing CWV score for weeks, rewriting React components, only to discover their server was sending uncompressed HTML over HTTP/1.1 with no cache headers. A few config changes fixed it in an afternoon.
This guide walks through seven server-side changes that directly impact Largest Contentful Paint, First Input Delay, and Cumulative Layout Shift. No build pipeline required.
Why server configuration matters for CWV
Core Web Vitals measure user experience through three metrics. LCP tracks how quickly the largest visible element loads. FID measures input responsiveness. CLS quantifies unexpected layout shifts.
Server response time directly affects LCP—if your HTML takes two seconds to arrive, nothing else can start rendering. Compression ratio changes how much data the browser downloads. Cache headers determine whether repeat visitors reload everything or reuse resources. Protocol version (HTTP/1.1 vs HTTP/2) changes how many assets can load in parallel.
You can optimize images and defer scripts all day, but if the server takes 800ms to respond and sends 200KB of uncompressed HTML, your LCP will stay in the orange.
1. Enable HTTP/2 (or HTTP/3)
HTTP/1.1 opens one TCP connection per domain and loads resources sequentially. Six stylesheets means six round trips. HTTP/2 multiplexes dozens of requests over a single connection.
That changes the entire loading waterfall. Your header image, three fonts, and two CSS files can all arrive in parallel instead of queuing.
On Apache with mod_http2:
LoadModule http2_module modules/mod_http2.so
Protocols h2 h2c http/1.1
On Nginx (version 1.9.5+):
listen 443 ssl http2;
listen [::]:443 ssl http2;
Restart your web server and confirm with:
curl -I --http2 https://yourdomain.com
You should see HTTP/2 200 in the response. If you still see HTTP/1.1, check that your OpenSSL version supports ALPN and that your TLS certificate is valid—HTTP/2 requires HTTPS.
HTTP/3 (QUIC) takes this further by removing TCP handshake latency, but adoption is still growing and not all hosting panels make it trivial to enable.
2. Configure Brotli compression
Gzip has been the default for years. Brotli compresses text assets 15-20% smaller, which directly reduces LCP by cutting download time for HTML, CSS, and JavaScript.
On Apache, install mod_brotli (available in most recent repos):
<IfModule mod_brotli.c>
AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/xml text/css text/javascript application/javascript application/json
BrotliCompressionQuality 6
</IfModule>
On Nginx, compile with --with-http_brotli_module or install via package manager:
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
Quality level 6 balances compression ratio and CPU time. Level 11 gives maximum compression but can bottleneck on high-traffic shared hosting.
Confirm it works:
curl -H "Accept-Encoding: br" -I https://yourdomain.com
Look for Content-Encoding: br in the headers. Browsers that don't support Brotli will still receive gzip as a fallback if you keep that enabled alongside.
3. Set aggressive cache headers for static assets
Every repeat visit that redownloads the same logo and stylesheet wastes bandwidth and slows LCP. Cache-Control headers tell the browser to store these files locally.
For truly static resources—images, fonts, versioned CSS/JS bundles—set a one-year expiry:
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpg "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
</IfModule>
Nginx equivalent:
location ~* \.(jpg|jpeg|png|webp|woff2|css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
For HTML that changes occasionally, use shorter values:
location = /index.html {
expires 10m;
add_header Cache-Control "public, must-revalidate";
}
The immutable directive prevents the browser from revalidating even on refresh, which is safe for assets with content hashes in their filenames (like main.a3f8b2.css).
What if caching breaks updates?
Use versioned filenames or query strings. When you update style.css, rename it to style.v2.css or serve it as style.css?v=2. The browser treats that as a new resource and fetches it immediately.
This is standard practice in WordPress with plugin updates and theme changes. Most build tools (Webpack, Vite) do this automatically.
4. Reduce server response time (TTFB)
Time to First Byte measures how long the server takes to start sending HTML. A high TTFB delays everything—LCP, FID, even CLS if layout-critical styles arrive late.
Common causes:
- Slow database queries — index your WordPress postmeta table, or move to object caching (Redis/Memcached).
- Unoptimized PHP — enable OPcache to avoid recompiling scripts on every request.
- Distant server location — if your audience is in Europe and your VPS is in Singapore, consider a CDN with edge caching.
- Resource contention — check CPU and I/O wait with
toporhtop; a neighbor's cron job on shared hosting can spike your TTFB.
Enable OPcache in php.ini:
opcache.enable=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=8
opcache.max_accelerated_files=10000
opcache.revalidate_freq=60
Restart PHP-FPM and measure the difference:
curl -o /dev/null -s -w "Time to first byte: %{time_starttransfer}s\n" https://yourdomain.com
TTFB under 200ms is excellent. Between 200-600ms is acceptable for dynamic content. Above 600ms needs investigation.
5. Preconnect and DNS prefetch for third-party origins
If your site loads Google Fonts or analytics from a CDN, the browser must resolve DNS, establish a TCP connection, and complete a TLS handshake before downloading anything. That's three round trips.
Resource hints in your HTML <head> start these steps early, shaving 100-300ms off LCP:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="dns-prefetch" href="https://www.google-analytics.com">
preconnect does DNS + TCP + TLS. Use it for origins you will load resources from.
dns-prefetch only resolves DNS. Use it for origins you might load from (like optional widgets).
Don't overuse this. Each preconnect ties up CPU and memory. Limit it to two or three critical origins.
6. Serve fonts locally or with proper fallbacks
Web fonts are a frequent LCP bottleneck. Browsers block text rendering until fonts download (unless you've set font-display: swap). Worse, loading fonts from Google or Adobe adds another DNS lookup and connection.
Two fixes:
Option A: Download the font files and serve them from your own domain. Add this to your stylesheet:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-display: swap;
font-weight: 100 900;
}
Option B: If you must use Google Fonts, preconnect to both origins and add &display=swap to the URL:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
The swap parameter tells the browser to render text in a system font immediately, then swap in the web font once it arrives. That prevents invisible text and improves LCP.
7. Configure CDN caching for dynamic content
CDNs like Cloudflare or BunnyCDN usually cache static assets by default, but skip HTML because it changes. That's fine for logged-in users, but anonymous visitors who make up 90% of traffic all hit your origin server.
Cloudflare Page Rules let you cache HTML for anonymous users:
- Create a Page Rule matching
yourdomain.com/*. - Set Cache Level to "Cache Everything."
- Set Edge Cache TTL to 2 hours (or whatever fits your update frequency).
- Add a Cache-Control header on your origin:
Cache-Control: public, max-age=7200.
Now repeat visitors get sub-50ms TTFB from the CDN edge, which dramatically improves LCP. Purge the cache manually when you publish new content, or set up automatic purging via API.
For WordPress, consider a plugin that purges Cloudflare cache on post updates—many hosting providers bundle this.
Measuring the impact
After implementing these changes, measure your scores with real-user data (Chrome User Experience Report via Search Console) and lab data (PageSpeed Insights or Lighthouse).
Lab scores improve immediately. Real-user scores take 28 days to stabilize because they aggregate actual visitor experiences. If you're in a hurry, run a manual Lighthouse audit before and after to confirm the direction.
Pay attention to the diagnostics panel. It'll tell you exactly which resources are still slow, which connections took too long to establish, and whether your cache headers are working.
Check your baseline first
Before changing anything, run Lighthouse and note your current LCP, FID, and TTFB values. That gives you a clear before-and-after comparison and helps you prioritize which fixes deliver the most value.
These seven server-side changes don't require a frontend rewrite or a new framework. They're config edits you can deploy in an afternoon, and they often move the needle more than weeks of JavaScript optimization.
Start with HTTP/2 and compression—those two alone typically cut load time by 20-30%. Then layer in cache headers and TTFB improvements. Measure as you go.
