loading="lazy" on LCP images in core templates hurts PageSpeed scores

new_view

Member
Licensed customer
Core templates apply loading="lazy" to LCP images, hurting PageSpeed scores

XenForo's bb_code_tag_attach, bb_code_tag_img, and attachment_macros templates apply loading="lazy" to all images indiscriminately, including the first visible image on a page, which is typically the Largest Contentful Paint (LCP) element.

This causes two PageSpeed Insights failures:
  • "lazy load not applied" (flagged as incorrect usage)
  • "fetchpriority=high should be applied."

The issue affects every default XenForo installation. In fact, xenforo.com itself triggers the same warnings; you can verify by running https://xenforo.com/community/forums/have-you-seen.47/ through PageSpeed Insights.

The browser's preload scanner reads loading="lazy" from the raw HTML and deprioritises the image fetch before any JavaScript can intervene, which directly delays LCP.

Google's own guidance is clear on this: the LCP image should never have loading="lazy" and should ideally have fetchpriority="high".

Reference: https://web.dev/articles/lcp-lazy-loading

A possible fix would be to omit loading="lazy" from the first rendered image in a thread or post view, or to provide an option/template variable that allows the first attachment image to render without the lazy attribute. Modern browsers already defer offscreen images natively without the attribute, so removing it from above-the-fold images has no downside.

For now, a template modification removing loading="lazy" from these three templates resolves the issue, but it would be better handled in core.

Affected templates:
  • bb_code_tag_attach
  • bb_code_tag_img
  • attachment_macros

Tested on XenForo 2.3.x. Happy to provide more details if needed.
 

Attachments

  • xenforo site page speed.webp
    xenforo site page speed.webp
    26 KB · Views: 5
Upvote 0
The site is now flying even with Google Ads, vibe-coded with Claude using Gemini Pro.

Currently running this Varnish VCL, any suggestions?

Code:
vcl 4.0;

import std;

# ═════════════════════════════════════════════
# Backend definition
# ═════════════════════════════════════════════
backend default {
    .host = "127.0.0.1";
    .port = "8080";
    .first_byte_timeout    = 600s;
    .connect_timeout       = 5s;
    .between_bytes_timeout = 60s;

    # Health probe disabled — if the probe URL (/) returns a redirect or
    # non-200, Varnish marks the backend as sick and serves 503 to all
    # requests.  To re-enable, create a /health-check.php that returns
    # HTTP 200, then uncomment:
    #
    # .probe = {
    #     .url       = "/health-check.php";
    #     .timeout   = 3s;
    #     .interval  = 5s;
    #     .window    = 5;
    #     .threshold = 3;
    # }
}

# ═════════════════════════════════════════════
# ACL — hosts allowed to issue PURGE / BAN
# ═════════════════════════════════════════════
acl purger {
    "localhost";
    "127.0.0.1";
    "172.17.0.1";
}


# ═════════════════════════════════════════════
# vcl_recv — Inbound request handling
# ═════════════════════════════════════════════
sub vcl_recv {

    # ── Restarts: force a cache miss so we re-fetch from the backend ──
    if (req.restarts > 0) {
        set req.hash_always_miss = true;
    }

    # ── PURGE / BAN ──
    if (req.method == "PURGE") {
        if (client.ip !~ purger) {
            return (synth(405, "Method not allowed"));
        }
        if (req.http.X-Cache-Tags) {
            ban("obj.http.X-Cache-Tags ~ " + req.http.X-Cache-Tags);
        } else {
            # Ban-lurker-friendly expression: uses obj.http.* headers that
            # are set in vcl_backend_response so the lurker can evaluate
            # bans in the background without waiting for new requests.
            ban("obj.http.X-Host == " + req.http.host
              + " && obj.http.X-Url == " + req.url);
        }
        return (synth(200, "Purged"));
    }

    # ── Reject non-standard methods ──
    if (req.method != "GET"     &&
        req.method != "HEAD"    &&
        req.method != "PUT"     &&
        req.method != "POST"    &&
        req.method != "TRACE"   &&
        req.method != "OPTIONS" &&
        req.method != "DELETE") {
        return (pipe);
    }

    # Only GET and HEAD are cacheable
    if (req.method != "GET" && req.method != "HEAD") {
        return (pass);
    }

    # ── Grace-period tracking header ──
    set req.http.grace = "none";

    # ── Strip leading scheme (http:// / https://) from the URL ──
    set req.url = regsub(req.url, "^http[s]?://", "");

    # ── Collapse multiple Cookie headers into one ──
    std.collect(req.http.Cookie);


    # ─────────────────────────────────────────
    #  XENFORO  (/community/)
    # ─────────────────────────────────────────
    if (req.url ~ "^/community($|/)") {

        # Admin area — never cache
        if (req.url ~ "^/community/admin") {
            return (pass);
        }

        # Auth / account / messaging pages — never cache
        if (req.url ~ "^/community/(login|register|account|conversations|admin\.php|index\.php\?login)") {
            return (pass);
        }

        # ── Attachments ──
        # Must be checked BEFORE the action-bypass regex, which would
        # otherwise match the substring "attachment" and pass them.
        if (req.url ~ "^/community/attachments/") {
            # FIX: normalise trailing slash so /foo.64731 and /foo.64731/
            # resolve to the same cache object, eliminating the 301
            # redirect round-trip to the backend that was inflating misses.
            if (req.url !~ "/$") {
                set req.url = req.url + "/";
            }
            unset req.http.Cookie;
            return (hash);
        }

        # ── Compiled CSS & versioned JS ──
        # Cache-busting query params (&k= for CSS, ?_v= for JS) change
        # on theme/version update, so long TTLs are safe.
        if (req.url ~ "^/community/css\.php\?css=" ||
            req.url ~ "^/community/(js|data/js)/.*\?_v=") {
            unset req.http.Cookie;
            return (hash);
        }

        # POST-like actions encoded in URLs — never cache
        if (req.url ~ "^/community/.*(add-reply|edit|delete|save|react|vote|report|warn|approve|merge|move|upload)") {
            return (pass);
        }

        # Internal data paths — never cache
        if (req.url ~ "^/community/(data|internal_data)/") {
            return (pass);
        }

        # Logged-in XenForo users — never cache.
        # Note: xf_csrf, xf_notice_dismiss, and xf_push_notice_dismiss are
        # set for ALL visitors (including guests) and do NOT indicate a
        # logged-in session.
        if (req.http.Cookie ~ "xf_session" ||
            req.http.Cookie ~ "xf_user"    ||
            req.http.Cookie ~ "xf_logged_in") {
            return (pass);
        }

        # ── Guest XenForo HTML pages — pass to backend, do NOT cache ──
        # XenForo embeds a unique xf_csrf token in both the Set-Cookie
        # header and in hidden HTML form fields on every response.
        # Caching these pages would serve every guest the same stale
        # CSRF token, breaking form submissions (login, register, reply)
        # and undermining anti-CSRF protection.  Guest HTML is lightweight
        # compared to attachments and static assets, so passing it
        # through to nginx has minimal performance impact.
        return (pass);
    }


    # ─────────────────────────────────────────
    #  WORDPRESS
    # ─────────────────────────────────────────

    # Admin / login / cron / previews — never cache
    if (req.url ~ "^/wp-(admin|login|cron)" ||
        req.url ~ "/wp-json/"               ||
        req.url ~ "preview=true"            ||
        req.url ~ "/xmlrpc\.php"            ||
        req.url ~ "/wp-comments-post\.php") {
        return (pass);
    }

    # WooCommerce dynamic pages — never cache
    if (req.url ~ "^/(cart|my-account|checkout|addtocart|add-to-cart)" ||
        req.url ~ "\?(add-to-cart|wc-api|wc-ajax)"                    ||
        req.url ~ "/wc-api/") {
        return (pass);
    }

    # PayPal callback — never cache
    if (req.url ~ "/paypal/") {
        return (pass);
    }

    # WordPress logged-in / WooCommerce session cookies — never cache
    if (req.http.Cookie ~ "wordpress_logged_in_"       ||
        req.http.Cookie ~ "wordpress_sec_"             ||
        req.http.Cookie ~ "wp-settings-"               ||
        req.http.Cookie ~ "wp_woocommerce_session_"    ||
        req.http.Cookie ~ "woocommerce_cart_hash"      ||
        req.http.Cookie ~ "woocommerce_items_in_cart"  ||
        req.http.Cookie ~ "comment_author_") {
        return (pass);
    }

    # Bearer-token auth — never cache
    if (req.http.Authorization ~ "^Bearer") {
        return (pass);
    }


    # ─────────────────────────────────────────
    #  SHARED — Accept-Encoding normalisation
    # ─────────────────────────────────────────
    if (req.http.Accept-Encoding) {
        # Already-compressed formats — encoding header is pointless
        if (req.url ~ "\.(jpg|jpeg|png|gif|gz|tgz|bz2|tbz|mp3|mp4|ogg|swf|flv|woff|woff2|webp|avif)$") {
            unset req.http.Accept-Encoding;
        } elsif (req.http.Accept-Encoding ~ "gzip") {
            set req.http.Accept-Encoding = "gzip";
        } elsif (req.http.Accept-Encoding ~ "deflate" && req.http.user-agent !~ "MSIE") {
            set req.http.Accept-Encoding = "deflate";
        } else {
            unset req.http.Accept-Encoding;
        }
    }

    # ─────────────────────────────────────────
    #  SHARED — strip tracking / marketing query params
    # ─────────────────────────────────────────
    if (req.url ~ "(\?|&)(gclid|cx|ie|cof|siteurl|zanpid|origin|fbclid|mc_[a-z]+|utm_[a-z]+|_bta_[a-z]+|_ga|_ke|msclkid|dclid|yclid|gad_source)=") {
        set req.url = regsuball(req.url, "(gclid|cx|ie|cof|siteurl|zanpid|origin|fbclid|mc_[a-z]+|utm_[a-z]+|_bta_[a-z]+|_ga|_ke|msclkid|dclid|yclid|gad_source)=[-_A-z0-9+()%.]+&?", "");
        set req.url = regsub(req.url, "[?|&]+$", "");
    }

    # ─────────────────────────────────────────
    #  SHARED — strip __SID / noCache params
    # ─────────────────────────────────────────
    if (req.url ~ "(\?|&)(__SID|noCache)=") {
        set req.url = regsuball(req.url, "(__SID|noCache)=[-_A-z0-9+()%.]+&?", "");
        set req.url = regsub(req.url, "[?|&]+$", "");
    }

    # ─────────────────────────────────────────
    #  SHARED — static file extensions (long cache, strip cookies)
    # ─────────────────────────────────────────
    if (req.url ~ "\.(css|js|jpg|jpeg|png|gif|ico|webp|avif|svg|woff|woff2|ttf|eot|mp3|mp4|ogg|pdf|zip|gz)(\?.*)?$") {
        unset req.http.Cookie;
        return (hash);
    }

    # ── Everything else: strip cookies, cache as guest content ──
    unset req.http.Cookie;
    return (hash);
}


# ═════════════════════════════════════════════
# vcl_hash — Cache-key construction
# ═════════════════════════════════════════════
sub vcl_hash {
    hash_data(req.url);

    if (req.http.host) {
        hash_data(req.http.host);
    } else {
        hash_data(server.ip);
    }

    return (lookup);
}


# ═════════════════════════════════════════════
# vcl_backend_response — Backend reply handling
# ═════════════════════════════════════════════
sub vcl_backend_response {

    # ── Ban-lurker metadata ──
    # Stored on every cached object so ban expressions using obj.http.*
    # can be evaluated in the background without incoming requests.
    set beresp.http.X-Url  = bereq.url;
    set beresp.http.X-Host = bereq.http.host;

    # ── Grace: serve stale content for up to 3 days while re-fetching ──
    set beresp.grace = 3d;

    # ── ESI support for text content ──
    if (beresp.http.content-type ~ "text") {
        set beresp.do_esi = true;
    }

    # ── Compress text-based responses in the cache ──
    if (beresp.http.content-type ~ "(text|application/json|application/javascript|application/xml|text/xml|text/css)") {
        set beresp.do_gzip = true;
    }


    # ─────────────────────────────────────────
    #  XENFORO  (/community/)
    # ─────────────────────────────────────────
    if (bereq.url ~ "^/community($|/)") {

        # Responses that set session cookies — never cache
        if (beresp.http.set-cookie ~ "xf_session" ||
            beresp.http.set-cookie ~ "xf_user") {
            set beresp.uncacheable = true;
            set beresp.ttl = 120s;
            return (deliver);
        }

        # ── Attachments (200 + 301) ──
        # Checked BEFORE the general redirect block so that attachment
        # 301s (trailing-slash redirects) are cached rather than marked
        # uncacheable.  The vcl_recv trailing-slash normalisation should
        # prevent most 301s from reaching here, but this serves as a
        # safety net for edge cases.
        if (bereq.url ~ "^/community/attachments/") {
            if (beresp.status == 200) {
                unset beresp.http.set-cookie;
                set beresp.http.Cache-Control = "public, max-age=604800";
                set beresp.ttl = 7d;
                return (deliver);
            }
            if (beresp.status == 301) {
                # Cache the trailing-slash redirect itself for 7 days
                set beresp.ttl = 7d;
                return (deliver);
            }
        }

        # General XenForo redirects (login, post-action, etc.) — never cache
        if (beresp.status == 301 || beresp.status == 302 || beresp.status == 303) {
            set beresp.uncacheable = true;
            set beresp.ttl = 120s;
            return (deliver);
        }

        # Static assets (extension-based) — 7-day TTL
        if (bereq.url ~ "\.(css|js|jpg|jpeg|png|gif|ico|webp|svg|woff|woff2|ttf|eot)(\?.*)?$") {
            unset beresp.http.set-cookie;
            set beresp.ttl = 7d;
            return (deliver);
        }

        # Compiled CSS & versioned JS — 30-day TTL, immutable
        # Cache-busting params (&k= / ?_v=) change on theme/version
        # update, so stale copies are impossible.  "immutable" tells
        # browsers to skip conditional requests entirely.
        if ((bereq.url ~ "^/community/css\.php\?css=" ||
             bereq.url ~ "^/community/(js|data/js)/.*\?_v=") && beresp.status == 200) {
            unset beresp.http.set-cookie;
            set beresp.http.Cache-Control = "public, max-age=2592000, immutable";
            set beresp.ttl = 30d;
            return (deliver);
        }

        # ── All other XenForo responses — do not cache ──
        # This catches guest HTML pages, error responses, and anything
        # not matched above.  Guest pages carry unique xf_csrf tokens
        # in both Set-Cookie and hidden form fields; caching them would
        # serve every guest the same stale token, breaking forms and
        # undermining CSRF protection.
        set beresp.uncacheable = true;
        set beresp.ttl = 120s;
        return (deliver);
    }


    # ─────────────────────────────────────────
    #  WORDPRESS / general backend responses
    # ─────────────────────────────────────────

    # Static assets (extension-based) — 7-day TTL
    if (bereq.url ~ "\.(css|js|jpg|jpeg|png|gif|ico|webp|avif|svg|woff|woff2|ttf|eot|mp3|mp4|ogg|pdf|zip|gz)(\?.*)?$") {
        unset beresp.http.set-cookie;
        set beresp.ttl = 7d;
        return (deliver);
    }

    # Non-200/404 responses — do not cache
    if (beresp.status != 200 && beresp.status != 404) {
        set beresp.uncacheable = true;
        set beresp.ttl = 120s;
        return (deliver);
    }

    # Backend explicitly says private / no-cache / no-store — honour it
    if (beresp.http.Cache-Control ~ "private"  ||
        beresp.http.Cache-Control ~ "no-cache" ||
        beresp.http.Cache-Control ~ "no-store") {
        set beresp.uncacheable = true;
        set beresp.ttl = 120s;
        return (deliver);
    }

    # Strip Set-Cookie from cacheable GET/HEAD responses
    if (beresp.ttl > 0s && (bereq.method == "GET" || bereq.method == "HEAD")) {
        unset beresp.http.set-cookie;
    }

    # No Cache-Control at all — do not cache
    if (!beresp.http.Cache-Control) {
        set beresp.ttl = 0s;
        set beresp.uncacheable = true;
    }

    return (deliver);
}


# ═════════════════════════════════════════════
# vcl_deliver — Final response to the client
# ═════════════════════════════════════════════
sub vcl_deliver {

    # ── Debug headers ──
    set resp.http.X-Cache-Age = resp.http.Age;
    unset resp.http.Age;

    if (obj.hits > 0) {
        set resp.http.X-Cache = "HIT";
    } else {
        set resp.http.X-Cache = "MISS";
    }

    # ── Prevent browser caching of dynamic HTML ──
    # Static assets keep their backend Cache-Control so browsers cache
    # them locally without re-downloading on every page load.
    if (resp.http.Cache-Control !~ "private" && resp.http.Content-Type ~ "text/html") {
        set resp.http.Pragma        = "no-cache";
        set resp.http.Expires       = "-1";
        set resp.http.Cache-Control = "no-store, no-cache, must-revalidate, max-age=0";
    }

    # ── Strip internal / server-info headers ──
    unset resp.http.X-Powered-By;
    unset resp.http.Server;
    unset resp.http.X-Varnish;
    unset resp.http.Via;
    unset resp.http.Link;

    # Ban-lurker headers — needed internally, not by clients
    unset resp.http.X-Url;
    unset resp.http.X-Host;
}


# ═════════════════════════════════════════════
# vcl_hit — Cache-hit handling
# ═════════════════════════════════════════════
sub vcl_hit {
    # Varnish's native grace + background-fetch logic handles stale
    # serving automatically as long as beresp.grace is set (3 d).
    return (deliver);
}


# ═════════════════════════════════════════════
# vcl_synth — Synthetic responses (errors, purge confirmations)
# ═════════════════════════════════════════════
sub vcl_synth {
    if (resp.status == 200) {
        set resp.http.Content-Type = "text/plain; charset=utf-8";
        synthetic("Success: " + resp.reason);
        return (deliver);
    }
}
 

Attachments

  • Screenshot (368).webp
    Screenshot (368).webp
    21.2 KB · Views: 11
  • Screenshot (369).webp
    Screenshot (369).webp
    9.3 KB · Views: 6
Last edited:
Back at it again, made some changes to Varnish and Cloudflare, it seems like we are like Usain Bolt. Thanks to Claude and Gemini. Just vibing, back and forth.


Code:
vcl 4.0;

import std;

# ═════════════════════════════════════════════
# Backend definition
# ═════════════════════════════════════════════
backend default {
    .host = "127.0.0.1";
    .port = "8080";
    .first_byte_timeout    = 600s;
    .connect_timeout       = 5s;
    .between_bytes_timeout = 60s;

    # Health probe disabled — if the probe URL (/) returns a redirect or
    # non-200, Varnish marks the backend as sick and serves 503 to all
    # requests.  To re-enable, create a /health-check.php that returns
    # HTTP 200, then uncomment:
    #
    # .probe = {
    #     .url       = "/health-check.php";
    #     .timeout   = 3s;
    #     .interval  = 5s;
    #     .window    = 5;
    #     .threshold = 3;
    # }
}

# ═════════════════════════════════════════════
# ACL — hosts allowed to issue PURGE / BAN
# ═════════════════════════════════════════════
acl purger {
    "localhost";
    "127.0.0.1";
    "172.17.0.1";
}


# ═════════════════════════════════════════════
# vcl_recv — Inbound request handling
# ═════════════════════════════════════════════
sub vcl_recv {

    # ── Restarts: force a cache miss so we re-fetch from the backend ──
    if (req.restarts > 0) {
        set req.hash_always_miss = true;
    }

    # ── PURGE / BAN ──
    if (req.method == "PURGE") {
        if (client.ip !~ purger) {
            return (synth(405, "Method not allowed"));
        }
        if (req.http.X-Cache-Tags) {
            ban("obj.http.X-Cache-Tags ~ " + req.http.X-Cache-Tags);
        } else {
            # Ban-lurker-friendly expression: uses obj.http.* headers that
            # are set in vcl_backend_response so the lurker can evaluate
            # bans in the background without waiting for new requests.
            ban("obj.http.X-Host == " + req.http.host
              + " && obj.http.X-Url == " + req.url);
        }
        return (synth(200, "Purged"));
    }

    # ── Reject non-standard methods ──
    if (req.method != "GET"     &&
        req.method != "HEAD"    &&
        req.method != "PUT"     &&
        req.method != "POST"    &&
        req.method != "TRACE"   &&
        req.method != "OPTIONS" &&
        req.method != "DELETE") {
        return (pipe);
    }

    # Only GET and HEAD are cacheable
    if (req.method != "GET" && req.method != "HEAD") {
        return (pass);
    }

    # ── Grace-period tracking header ──
    set req.http.grace = "none";

    # ── Strip leading scheme (http:// / https://) from the URL ──
    set req.url = regsub(req.url, "^http[s]?://", "");

    # ── Collapse multiple Cookie headers into one ──
    std.collect(req.http.Cookie);


    # ─────────────────────────────────────────
    #  XENFORO  (/community/)
    # ─────────────────────────────────────────
    if (req.url ~ "^/community($|/)") {

        # Admin area — never cache
        if (req.url ~ "^/community/admin") {
            return (pass);
        }

        # Auth / account / messaging pages — never cache
        if (req.url ~ "^/community/(login|register|account|conversations|admin\.php|index\.php\?login)") {
            return (pass);
        }

        # ── Attachments ──
        # Must be checked BEFORE the action-bypass regex, which would
        # otherwise match the substring "attachment" and pass them.
        if (req.url ~ "^/community/attachments/") {
            # FIX: normalise trailing slash so /foo.64731 and /foo.64731/
            # resolve to the same cache object, eliminating the 301
            # redirect round-trip to the backend that was inflating misses.
            if (req.url !~ "/$") {
                set req.url = req.url + "/";
            }
            unset req.http.Cookie;
            return (hash);
        }

        # ── Compiled CSS & versioned JS ──
        # Cache-busting query params (&k= for CSS, ?_v= for JS) change
        # on theme/version update, so long TTLs are safe.
        if (req.url ~ "^/community/css\.php\?css=" ||
            req.url ~ "^/community/(js|data/js)/.*\?_v=") {
            unset req.http.Cookie;
            return (hash);
        }

        # POST-like actions encoded in URLs — never cache
        if (req.url ~ "^/community/.*(add-reply|edit|delete|save|react|vote|report|warn|approve|merge|move|upload)") {
            return (pass);
        }

        # Internal data paths — never cache
        # NOTE: /community/data/ contains avatars and thumbnails.  These
        # are static, but some may be permission-gated.  If you want to
        # cache public avatars, add a dedicated block for
        # /community/data/avatars/ above this rule.
        if (req.url ~ "^/community/(data|internal_data)/") {
            return (pass);
        }

        # Logged-in XenForo users — never cache.
        # Note: xf_csrf, xf_notice_dismiss, and xf_push_notice_dismiss are
        # set for ALL visitors (including guests) and do NOT indicate a
        # logged-in session.
        if (req.http.Cookie ~ "xf_session" ||
            req.http.Cookie ~ "xf_user"    ||
            req.http.Cookie ~ "xf_logged_in") {
            return (pass);
        }

        # ── Guest XenForo HTML pages — pass to backend, do NOT cache ──
        # XenForo embeds a unique xf_csrf token in both the Set-Cookie
        # header and in hidden HTML form fields on every response.
        # Caching these pages would serve every guest the same stale
        # CSRF token, breaking form submissions (login, register, reply)
        # and undermining anti-CSRF protection.  Guest HTML is lightweight
        # compared to attachments and static assets, so passing it
        # through to nginx has minimal performance impact.
        return (pass);
    }


    # ─────────────────────────────────────────
    #  WORDPRESS
    # ─────────────────────────────────────────

    # Admin / login / cron / previews — never cache
    if (req.url ~ "^/wp-(admin|login|cron)" ||
        req.url ~ "/wp-json/"               ||
        req.url ~ "preview=true"            ||
        req.url ~ "/xmlrpc\.php"            ||
        req.url ~ "/wp-comments-post\.php") {
        return (pass);
    }

    # WooCommerce dynamic pages — never cache
    if (req.url ~ "^/(cart|my-account|checkout|addtocart|add-to-cart)" ||
        req.url ~ "\?(add-to-cart|wc-api|wc-ajax)"                    ||
        req.url ~ "/wc-api/") {
        return (pass);
    }

    # PayPal callback — never cache
    if (req.url ~ "/paypal/") {
        return (pass);
    }

    # WordPress logged-in / WooCommerce session cookies — never cache
    if (req.http.Cookie ~ "wordpress_logged_in_"       ||
        req.http.Cookie ~ "wordpress_sec_"             ||
        req.http.Cookie ~ "wp-settings-"               ||
        req.http.Cookie ~ "wp_woocommerce_session_"    ||
        req.http.Cookie ~ "woocommerce_cart_hash"      ||
        req.http.Cookie ~ "woocommerce_items_in_cart"  ||
        req.http.Cookie ~ "comment_author_") {
        return (pass);
    }

    # Bearer-token auth — never cache
    if (req.http.Authorization ~ "^Bearer") {
        return (pass);
    }


    # ─────────────────────────────────────────
    #  SHARED — Accept-Encoding normalisation
    # ─────────────────────────────────────────
    if (req.http.Accept-Encoding) {
        # Already-compressed formats — encoding header is pointless
        if (req.url ~ "\.(jpg|jpeg|png|gif|gz|tgz|bz2|tbz|mp3|mp4|ogg|swf|flv|woff|woff2|webp|avif)$") {
            unset req.http.Accept-Encoding;
        } elsif (req.http.Accept-Encoding ~ "gzip") {
            set req.http.Accept-Encoding = "gzip";
        } elsif (req.http.Accept-Encoding ~ "deflate" && req.http.user-agent !~ "MSIE") {
            set req.http.Accept-Encoding = "deflate";
        } else {
            unset req.http.Accept-Encoding;
        }
    }

    # ─────────────────────────────────────────
    #  SHARED — strip tracking / marketing query params
    # ─────────────────────────────────────────
    # FIX: changed A-z to A-Za-z in the character class.  A-z includes
    # six ASCII characters between Z (0x5A) and a (0x61): [\]^_`
    # which could cause unexpected matches on malformed URLs.
    # FIX: changed [?|&] to [?&] — the pipe inside a character class
    # is a literal "|", not alternation.  It would strip a trailing
    # pipe from the URL, which is harmless but incorrect.
    if (req.url ~ "(\?|&)(gclid|cx|ie|cof|siteurl|zanpid|origin|fbclid|mc_[a-z]+|utm_[a-z]+|_bta_[a-z]+|_ga|_ke|msclkid|dclid|yclid|gad_source)=") {
        set req.url = regsuball(req.url, "(gclid|cx|ie|cof|siteurl|zanpid|origin|fbclid|mc_[a-z]+|utm_[a-z]+|_bta_[a-z]+|_ga|_ke|msclkid|dclid|yclid|gad_source)=[-_A-Za-z0-9+()%.]+&?", "");
        set req.url = regsub(req.url, "[?&]+$", "");
    }

    # ─────────────────────────────────────────
    #  SHARED — strip __SID / noCache params
    # ─────────────────────────────────────────
    # FIX: same A-z → A-Za-z and [?|&] → [?&] corrections as above.
    if (req.url ~ "(\?|&)(__SID|noCache)=") {
        set req.url = regsuball(req.url, "(__SID|noCache)=[-_A-Za-z0-9+()%.]+&?", "");
        set req.url = regsub(req.url, "[?&]+$", "");
    }

    # ─────────────────────────────────────────
    #  SHARED — static file extensions (long cache, strip cookies)
    # ─────────────────────────────────────────
    # NOTE: this block only fires for non-/community/ URLs because
    # the XenForo section above returns (pass) for everything that
    # isn't an attachment or compiled CSS/JS.  For WordPress and
    # other paths, this is the primary static-asset entry point.
    if (req.url ~ "\.(css|js|jpg|jpeg|png|gif|ico|webp|avif|svg|woff|woff2|ttf|eot|mp3|mp4|ogg|pdf|zip|gz)(\?.*)?$") {
        unset req.http.Cookie;
        return (hash);
    }

    # ── Everything else: strip cookies, cache as guest content ──
    # For WordPress pages this means guest HTML is eligible for
    # caching, subject to backend Cache-Control headers.
    unset req.http.Cookie;
    return (hash);
}


# ═════════════════════════════════════════════
# vcl_hash — Cache-key construction
# ═════════════════════════════════════════════
sub vcl_hash {
    hash_data(req.url);

    if (req.http.host) {
        hash_data(req.http.host);
    } else {
        hash_data(server.ip);
    }

    return (lookup);
}


# ═════════════════════════════════════════════
# vcl_backend_response — Backend reply handling
# ═════════════════════════════════════════════
sub vcl_backend_response {

    # ── Ban-lurker metadata ──
    # Stored on every cached object so ban expressions using obj.http.*
    # can be evaluated in the background without incoming requests.
    set beresp.http.X-Url  = bereq.url;
    set beresp.http.X-Host = bereq.http.host;

    # ── Grace: serve stale content for up to 3 days while re-fetching ──
    set beresp.grace = 3d;

    # ── ESI support for text content ──
    if (beresp.http.content-type ~ "text") {
        set beresp.do_esi = true;
    }

    # ── Compress text-based responses in the cache ──
    if (beresp.http.content-type ~ "(text|application/json|application/javascript|application/xml|text/xml|text/css)") {
        set beresp.do_gzip = true;
    }


    # ─────────────────────────────────────────
    #  XENFORO  (/community/)
    # ─────────────────────────────────────────
    if (bereq.url ~ "^/community($|/)") {

        # Responses that set session cookies — never cache
        if (beresp.http.set-cookie ~ "xf_session" ||
            beresp.http.set-cookie ~ "xf_user") {
            set beresp.uncacheable = true;
            set beresp.ttl = 120s;
            return (deliver);
        }

        # ── Attachments (200 + 301) ──
        # Checked BEFORE the general redirect block so that attachment
        # 301s (trailing-slash redirects) are cached rather than marked
        # uncacheable.  The vcl_recv trailing-slash normalisation should
        # prevent most 301s from reaching here, but this serves as a
        # safety net for edge cases.
        if (bereq.url ~ "^/community/attachments/") {
            if (beresp.status == 200) {
                unset beresp.http.set-cookie;
                # FIX: XenForo sets a legacy Expires header from 1981 on
                # all responses.  This conflicts with our Cache-Control
                # and prevents Cloudflare from caching these assets
                # (cfCacheStatus = DYNAMIC).  Removing Expires lets the
                # Cache-Control header take precedence at every layer.
                unset beresp.http.Expires;
                set beresp.http.Cache-Control = "public, max-age=604800";
                set beresp.ttl = 7d;
                return (deliver);
            }
            if (beresp.status == 301) {
                # Cache the trailing-slash redirect itself for 7 days
                set beresp.ttl = 7d;
                return (deliver);
            }
        }

        # General XenForo redirects (login, post-action, etc.) — never cache
        if (beresp.status == 301 || beresp.status == 302 || beresp.status == 303) {
            set beresp.uncacheable = true;
            set beresp.ttl = 120s;
            return (deliver);
        }

        # ── Compiled CSS & versioned JS — 30-day TTL, immutable ──
        # FIX: moved ABOVE the extension-based static asset block.
        # Previously, a URL like /community/js/xf/core.js?_v=12345
        # matched the extension block first (7d) and never reached
        # this rule (30d immutable).  Order matters: specific rules
        # must come before generic ones.
        # Cache-busting params (&k= / ?_v=) change on theme/version
        # update, so stale copies are impossible.  "immutable" tells
        # browsers to skip conditional requests entirely.
        if ((bereq.url ~ "^/community/css\.php\?css=" ||
             bereq.url ~ "^/community/(js|data/js)/.*\?_v=") && beresp.status == 200) {
            unset beresp.http.set-cookie;
            unset beresp.http.Expires;
            set beresp.http.Cache-Control = "public, max-age=2592000, immutable";
            set beresp.ttl = 30d;
            return (deliver);
        }

        # Static assets (extension-based) — 7-day TTL
        if (bereq.url ~ "\.(css|js|jpg|jpeg|png|gif|ico|webp|svg|woff|woff2|ttf|eot)(\?.*)?$") {
            unset beresp.http.set-cookie;
            unset beresp.http.Expires;
            set beresp.ttl = 7d;
            return (deliver);
        }

        # ── All other XenForo responses — do not cache ──
        # This catches guest HTML pages, error responses, and anything
        # not matched above.  Guest pages carry unique xf_csrf tokens
        # in both Set-Cookie and hidden form fields; caching them would
        # serve every guest the same stale token, breaking forms and
        # undermining CSRF protection.
        set beresp.uncacheable = true;
        set beresp.ttl = 120s;
        return (deliver);
    }


    # ─────────────────────────────────────────
    #  WORDPRESS / general backend responses
    # ─────────────────────────────────────────

    # Static assets (extension-based) — 7-day TTL
    if (bereq.url ~ "\.(css|js|jpg|jpeg|png|gif|ico|webp|avif|svg|woff|woff2|ttf|eot|mp3|mp4|ogg|pdf|zip|gz)(\?.*)?$") {
        unset beresp.http.set-cookie;
        set beresp.ttl = 7d;
        return (deliver);
    }

    # Non-200/404 responses — do not cache
    if (beresp.status != 200 && beresp.status != 404) {
        set beresp.uncacheable = true;
        set beresp.ttl = 120s;
        return (deliver);
    }

    # Backend explicitly says private / no-cache / no-store — honour it
    if (beresp.http.Cache-Control ~ "private"  ||
        beresp.http.Cache-Control ~ "no-cache" ||
        beresp.http.Cache-Control ~ "no-store") {
        set beresp.uncacheable = true;
        set beresp.ttl = 120s;
        return (deliver);
    }

    # Strip Set-Cookie from cacheable GET/HEAD responses
    if (beresp.ttl > 0s && (bereq.method == "GET" || bereq.method == "HEAD")) {
        unset beresp.http.set-cookie;
    }

    # No Cache-Control at all — do not cache
    if (!beresp.http.Cache-Control) {
        set beresp.ttl = 0s;
        set beresp.uncacheable = true;
    }

    return (deliver);
}


# ═════════════════════════════════════════════
# vcl_deliver — Final response to the client
# ═════════════════════════════════════════════
sub vcl_deliver {

    # ── Debug headers ──
    set resp.http.X-Cache-Age = resp.http.Age;
    unset resp.http.Age;

    if (obj.hits > 0) {
        set resp.http.X-Cache = "HIT";
    } else {
        set resp.http.X-Cache = "MISS";
    }

    # ── Prevent browser caching of dynamic HTML ──
    # Static assets keep their backend Cache-Control so browsers cache
    # them locally without re-downloading on every page load.
    if (resp.http.Cache-Control !~ "private" && resp.http.Content-Type ~ "text/html") {
        set resp.http.Pragma        = "no-cache";
        set resp.http.Expires       = "-1";
        set resp.http.Cache-Control = "no-store, no-cache, must-revalidate, max-age=0";
    }

    # ── Strip internal / server-info headers ──
    unset resp.http.X-Powered-By;
    unset resp.http.Server;
    unset resp.http.X-Varnish;
    unset resp.http.Via;
    unset resp.http.Link;

    # Ban-lurker headers — needed internally, not by clients
    unset resp.http.X-Url;
    unset resp.http.X-Host;

    # ── FIX: deduplicate response headers ──
    # XenForo / nginx sometimes send duplicate X-Frame-Options,
    # X-Content-Type-Options, and Referrer-Policy headers.  Browsers
    # handle duplicates inconsistently, so we normalise to one each.
    if (resp.http.X-Frame-Options) {
        set resp.http.X-Frame-Options = "SAMEORIGIN";
    }
    if (resp.http.X-Content-Type-Options) {
        set resp.http.X-Content-Type-Options = "nosniff";
    }
    if (resp.http.Referrer-Policy) {
        set resp.http.Referrer-Policy = "strict-origin-when-cross-origin";
    }
}


# ═════════════════════════════════════════════
# vcl_hit — Cache-hit handling
# ═════════════════════════════════════════════
sub vcl_hit {
    # Varnish's native grace + background-fetch logic handles stale
    # serving automatically as long as beresp.grace is set (3 d).
    return (deliver);
}


# ═════════════════════════════════════════════
# vcl_synth — Synthetic responses (errors, purge confirmations)
# ═════════════════════════════════════════════
sub vcl_synth {
    if (resp.status == 200) {
        set resp.http.Content-Type = "text/plain; charset=utf-8";
        synthetic("Success: " + resp.reason);
        return (deliver);
    }
}
 

Attachments

  • Screenshot (383).webp
    Screenshot (383).webp
    16.8 KB · Views: 3
  • Screenshot (384).webp
    Screenshot (384).webp
    16.8 KB · Views: 5
  • Screenshot (385).webp
    Screenshot (385).webp
    23.6 KB · Views: 8
I honestly suggest you to ditch both Gemini and Claude on the web. Use Claude Code (the CLI tool). You can also use it through Antigravity, VS Code, or Cursor with the Opus 4.6 model, personally I think the pure CLI is the way to go though.

The web chat versions, even Claude's, are working blind. You paste snippets, lose context, and end up babysitting it through every step. Claude Code is a completely different beast. It sits in your project directory, reads your actual files, runs commands, edits code directly, and just holds the full picture of what you're working on.

I develop XenForo addons locally with it and it has direct access to my entire XF source tree, core templates, entities, controllers, all of it. When I need a template modification that strips loading="lazy" from above-the-fold images, it doesn't guess, it reads bb_code_tag_attach.html, bb_code_tag_img.html, and attachment_macros.html, understands the rendering context, and writes the template mod with the correct find/replace. Same deal with VCL configs, it reads the actual file, knows about XF-specific gotchas (CSRF tokens, session cookies, _xfToken in POST bodies), and edits in place.

For your Varnish + Cloudflare + Redis stack, having an AI that can see your full config, your XF templates, and your server setup all at once instead of getting context-dumped snippets in a chat window, it's a huge difference. No more copy-pasting back and forth hoping it remembers what you showed it 10 messages ago.

Seriously, Claude Code. Once you try it you won't go back to web chat for anything ;)
 
Back
Top Bottom