WordPress .htaccess Performance: Enable Gzip Compression, Expires Headers, and Cache-Control

WordPress itself generates lean HTML, but every uncached page load triggers PHP, MySQL, and file I/O. The web server layer — Apache or Nginx — can eliminate significant latency before PHP even starts, by serving compressed responses and instructing browsers to cache static assets locally. For Apache-based hosting (the vast majority of shared and managed WordPress hosts), these optimisations live in .htaccess. Enabling gzip or Brotli compression for text assets, setting long-lived Expires and Cache-Control headers for versioned static files, and connecting asset versioning to WordPress’s built-in cache busters are three independent performance improvements that each reduce page weight and round-trip count. Together they typically cut time-to-interactive by 30–50 % on a production site with no caching plugin installed.

Problem: Google PageSpeed Insights flags "Enable text compression" and "Serve static assets with an efficient cache policy" on a shared Apache host. The site scores 45 on mobile. Plugins for caching are not an option due to hosting restrictions.

Solution: Add gzip compression via mod_deflate, long-lived Expires headers via mod_expires, and cache-control headers via mod_headers to .htaccess. Use WordPress's wp_enqueue_style() / wp_enqueue_script() version parameter to bust browser caches on asset updates.

# .htaccess — add AFTER the WordPress rewrite block

# ── 1. Gzip compression (mod_deflate) ────────────────────────────────
<IfModule mod_deflate.c>
    AddOutputFilterByType DEFLATE text/html text/plain text/css text/javascript
    AddOutputFilterByType DEFLATE application/javascript application/json
    AddOutputFilterByType DEFLATE application/xml application/xhtml+xml
    AddOutputFilterByType DEFLATE image/svg+xml font/woff2 font/woff
    # Don't compress already-compressed formats
    SetEnvIfNoCase Request_URI \.(gz|br|zip|png|jpg|webp|mp4)$ no-gzip
</IfModule>

# ── 2. Browser caching — Expires headers (mod_expires) ───────────────
<IfModule mod_expires.c>
    ExpiresActive On
    ExpiresDefault                          "access plus 1 month"
    # HTML — short TTL so changes propagate quickly
    ExpiresByType text/html                 "access plus 0 seconds"
    # CSS + JS — long TTL; use versioned query strings in WordPress
    ExpiresByType text/css                  "access plus 1 year"
    ExpiresByType application/javascript    "access plus 1 year"
    # Fonts
    ExpiresByType font/woff2                "access plus 1 year"
    ExpiresByType font/woff                 "access plus 1 year"
    # Images
    ExpiresByType image/jpeg                "access plus 6 months"
    ExpiresByType image/png                 "access plus 6 months"
    ExpiresByType image/webp                "access plus 6 months"
    ExpiresByType image/svg+xml             "access plus 6 months"
    ExpiresByType image/x-icon              "access plus 1 year"
</IfModule>

# ── 3. Cache-Control headers (mod_headers) ───────────────────────────
<IfModule mod_headers.c>
    # Prevent caching of dynamic WordPress pages
    <FilesMatch "\.(php)$">
        Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
        Header set Pragma "no-cache"
    </FilesMatch>
    # Long cache for versioned static assets
    <FilesMatch "\.(css|js|woff2?|ttf|eot|svg|ico|png|jpe?g|webp|gif)$">
        Header set Cache-Control "public, max-age=31536000, immutable"
    </FilesMatch>
    # Security headers (bonus)
    Header set X-Content-Type-Options "nosniff"
    Header set X-Frame-Options "SAMEORIGIN"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>

<?php
// ── WordPress side: version your assets so caches bust on update ──────
// Use plugin/theme version or filemtime() as the version argument
add_action( 'wp_enqueue_scripts', function () {
    // File modification time — updates automatically when file changes
    $css_ver = filemtime( get_stylesheet_directory() . '/assets/style.css' );
    $js_ver  = filemtime( get_stylesheet_directory() . '/assets/app.js' );

    wp_enqueue_style(
        'my-theme-style',
        get_stylesheet_directory_uri() . '/assets/style.css',
        [],
        $css_ver   // browser sees ?ver=1710000000 and re-fetches on change
    );

    wp_enqueue_script(
        'my-theme-app',
        get_stylesheet_directory_uri() . '/assets/app.js',
        [ 'jquery' ],
        $js_ver,
        true
    );
} );

NOTE: The Expires and Cache-Control: max-age=31536000 headers only work safely for static assets because WordPress appends a ?ver= query string to every enqueued script and style. When the version changes, the browser treats it as a new URL and ignores the cached copy. Never set long-lived cache headers on .php files or WordPress's main URL — doing so causes users to see stale HTML after you publish new posts. Test your configuration with curl -I https://example.com/wp-content/themes/my-theme/style.css and verify the response headers before deploying.