DEV Community

Magevanta
Magevanta

Posted on • Originally published at magevanta.com

Magento 2 Full Page Cache Deep Dive: Hole Punching, Cache Tags, and Invalidation

Magento 2's Full Page Cache (FPC) is one of the most powerful performance tools in your stack — and one of the most misunderstood. Most guides stop at "enable Varnish and configure Redis." But if you want to squeeze every millisecond out of your store, you need to understand what's happening beneath the surface: how pages are cached, what breaks the cache, and how to surgically invalidate only what needs refreshing.

This post goes deep. By the end you'll understand FPC internals, ESI hole punching, cache tag architecture, and how to build an invalidation strategy that doesn't nuke your entire cache every time a product description changes.

How Magento 2 FPC Actually Works

Magento 2's FPC is built on top of Magento\Framework\App\PageCache. When a request comes in, Magento checks the FPC storage (either the built-in file/database cache, or Varnish) for a cached response matching that request's context.

The cache key is composed of:

  • URL (including query parameters — carefully)
  • Customer group
  • Store view
  • Currency
  • HTTP Vary headers (like X-Magento-Vary)

The X-Magento-Vary header is a hashed cookie containing the customer's context: logged-in state, group, currency, and any registered context variables. Two visitors with different Vary values get different cache entries — even for the same URL.

// Magento calculates X-Magento-Vary from context data
$data = $this->customerSession->getCustomerGroupId();
// + currency, store, etc.
$hash = hash('sha256', serialize($contextData));
Enter fullscreen mode Exit fullscreen mode

If your Vary contexts explode (e.g., too many unique customer groups, personalized data baked into the hash), your cache hit rate plummets. Keep context data minimal.

Cache Tags: The Key to Granular Invalidation

Every cached page response carries X-Magento-Tags response headers listing all entity tags associated with that response. For example, a product detail page might carry:

X-Magento-Tags: cat_p,cat_p_1234,cat_c,cat_c_56,cms_b,cms_b_12
Enter fullscreen mode Exit fullscreen mode

These tags say: "this cached page depends on product 1234, category 56, and CMS block 12." When product 1234 is updated, Magento fires a cache invalidation that purges every entry tagged cat_p_1234 — and only those entries.

This is Magento's tag-based invalidation system, and it's what separates smart cache management from sledgehammer purges.

How Tags Are Registered

Cache tags come from Magento\Framework\DataObject\IdentityInterface. Any block that implements this interface exposes a getIdentities() method returning its tags:

class Product extends AbstractProduct implements IdentityInterface
{
    public function getIdentities(): array
    {
        return [
            self::CACHE_TAG . '_' . $this->getProduct()->getId(),
            Category::CACHE_TAG . '_' . $this->getCategoryId(),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Magento collects all identities from all blocks rendered on the page and writes them into the X-Magento-Tags header before storing the cached response. If a third-party extension's blocks don't implement IdentityInterface, those blocks' dependencies are invisible to the cache — a silent bug that causes stale content.

Always implement IdentityInterface in custom blocks that render entity-specific data.

ESI Hole Punching: Cache the Page, Not the Exceptions

The classic FPC problem: you want to cache the entire page, but some blocks are user-specific (mini-cart, welcome message, wishlist counter). Without hole punching, you either:

  • Cache the page per-user → cache explodes
  • Don't cache the page → no FPC benefit

ESI (Edge Side Includes) solves this. You cache the full page skeleton, but inject ESI tags for the dynamic fragments:

<esi:include src="/customer/section/load/?sections=cart,customer" />
Enter fullscreen mode Exit fullscreen mode

Varnish (or any ESI-capable reverse proxy) fetches those fragments separately, then assembles the full response. The main page stays cached for all users; only the dynamic fragments are fetched per-session.

Magento's Section-Based Approach

Magento 2 doesn't use true ESI for its customer data by default. Instead, it uses a JavaScript-driven sections system. After the initial (cached) page load, the browser makes a lightweight AJAX call to /customer/section/load/ to fetch customer-specific sections (cart, messages, etc.) and hydrates the frontend.

This means Magento's FPC can cache pages for everyone, then patch in the dynamic data client-side. It's effective but does mean an extra HTTP round-trip on first load. For stores targeting Core Web Vitals, be careful this AJAX call doesn't block LCP.

Custom Private Content Blocks

If you're building a block with user-specific data:

class MyBlock extends AbstractBlock implements PrivateContentInterface
{
    public function getPrivateCacheData(): array
    {
        return [
            'ttl' => 60,
            'type' => 'my_custom_section',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Implement PrivateContentInterface and register your section data provider in di.xml. Magento will handle the AJAX hydration automatically.

Cache Invalidation Strategies

Bad invalidation strategy = slow responses after any catalog update. Here's how to think about it:

1. Tag-Based Partial Purge (Preferred)

When an entity is saved, Magento dispatches a clean_cache_by_tags event. Varnish (via the Magento Varnish config) responds by purging only responses tagged with that entity's cache tag.

Result: updating product 1234 purges only pages that display product 1234. The rest of your cache is untouched.

This is the default Magento behavior — but it only works properly if:

  • Your Varnish default.vcl includes the tag-based ban logic
  • All your custom blocks implement IdentityInterface
  • Your invalidation is not being short-circuited by a mis-configured full flush

2. Full Cache Flush (Use Sparingly)

php bin/magento cache:flush full_page wipes the entire FPC. Appropriate for:

  • Major theme deploys
  • Configuration changes that affect all pages
  • Emergency stale-content recovery

Never trigger full flushes for single-entity updates. If your deploy scripts flush the full page cache on every deploy, consider switching to a tag flush of affected entity types instead.

3. Scheduled Invalidation via TTL

Every cache entry has a TTL. Magento's default FPC TTL is 86400 seconds (24 hours) for public pages. You can tune this in Admin → System → Full Page Cache → TTL.

For high-traffic stores where content freshness matters, a shorter TTL (e.g., 3600–7200 seconds) with good tag invalidation gives you both freshness and performance. Don't set TTL to 0 — that disables expiration entirely and relies solely on manual invalidation.

Debugging FPC Issues

Check Cache Status Headers

With Varnish, add a Varnish-Cache or X-Cache debug header in your VCL:

sub vcl_deliver {
    if (obj.hits > 0) {
        set resp.http.X-Cache = "HIT";
    } else {
        set resp.http.X-Cache = "MISS";
    }
}
Enter fullscreen mode Exit fullscreen mode

For the built-in FPC, enable developer mode and watch for X-Magento-Cache-Debug: HIT / MISS headers.

Common Cache Miss Culprits

  • Form keys — Magento injects a per-session form key into forms. If your layout renders this server-side in a cacheable block, every request will be a miss. Move form keys to private content or JS.
  • Random content — any block that calls rand() or uses microtime() will produce a new cache entry every time.
  • Missing Vary alignment — if the X-Magento-Vary cookie changes between requests for the same page (e.g., currency switcher JS), Magento will see each as a unique request.
  • HTTPS/HTTP mix — always normalise to HTTPS before the cache layer.

Inspect Tags on Live Pages

curl -sI https://yourstore.com/some-product.html | grep X-Magento-Tags
Enter fullscreen mode Exit fullscreen mode

If the tags list is empty, FPC is not running or this block isn't tagged. If it's enormous (hundreds of tags per page), you may be hitting header size limits — consider compressing tags via a Varnish ban lurker.

Varnish Ban Lurker for Large Tag Sets

Magento stores with large catalogs can generate enormous X-Magento-Tags headers, eventually hitting Varnish's header size limit (default 8KB). The solution: ban lurker.

Instead of purging via tags in the response header, store bans in Varnish's ban list and let the lurker process them asynchronously:

sub vcl_recv {
    if (req.method == "BAN") {
        ban("obj.http.X-Magento-Tags ~ " + req.http.X-Magento-Tags);
        return(synth(200, "Banned"));
    }
}
Enter fullscreen mode Exit fullscreen mode

Enable ban_lurker_sleep in your Varnish params for the lurker to clean up expired bans efficiently.

Practical Checklist

Before calling your FPC setup "production ready":

  • [ ] Varnish VCL matches your Magento version's template
  • [ ] X-Magento-Tags headers visible on cached responses
  • [ ] Tag-based purge tested: update a product, confirm only its pages were purged
  • [ ] All custom blocks implement IdentityInterface
  • [ ] Private content blocks use PrivateContentInterface or JS sections
  • [ ] Form keys removed from server-rendered cacheable blocks
  • [ ] TTL set to a sensible value (not 0, not 86400 for fast-changing catalogs)
  • [ ] FPC hit/miss headers enabled in staging for ongoing debugging

Conclusion

Magento 2's Full Page Cache is a sophisticated system — not just "cache the whole page and hope for the best." Understanding cache tags, X-Magento-Vary, ESI hole punching, and granular invalidation gives you a cache that's both fast and accurate.

The stores that get FPC right aren't the ones that flush everything on every deploy. They're the ones that understand what each page depends on, tag it correctly, and invalidate only what changed. That's the difference between a 90% cache hit rate and a 99% one — and in production traffic, that margin matters enormously.

Top comments (0)