A Multi-Step Strategy for better Full Page Caching

A full page cache is a powerful performance optimization for web-applications with numerous visitors.

Instead of performing all the PHP and database work to render the same page over and over again, you cache the resulting HTML once and serve that time and again for a period.

But there is room for even more improvement, especially in e-commerce applications that use full page caching.

Edge Side Includes (ESI) is a standard for full page caching that has a clever approach to the problem of multi step caching. It allows storing different parts of a page as different cache entries in such a way that they can even have different cache lifetimes and dynamic invalidation without affecting each other.

The upcoming Shopware 6.7 moves from a single-step full page cache to using ESI for header and footer navigation, serving as an interesting real-world experiment to observe the benefits.

Single-Step Full Page Caching

Traditionally, with a full page cache, the complete HTML Output is either cached or not cached at the time of a new request coming in:

In the case of a cache miss, database and external systems such as Elasticsearch are queried to generate the response dynamically.

The output is then cached and on the next request to that same product page, most database, and external system calls as well as all processing of the data to HTML is skipped.

This usually leads to a huge performance improvement as well as a conscious use of processing resources.

In e-commerce applications, frequent changes in stock, prices, or dynamic features like segmentation often require invalidating the cache for product and category pages – leading to a low cache hit rate and frequent full page regeneration.

Generally, there are three solutions to improve this situation:

  1. Put in effort to reduce the number of cache invalidations, and the number of different cache entries due to customer segmentation. This is usually the most technically challenging approach because it requires in-depth understanding of the concrete requirements of the shop application you are optimizing.
  2. Warmup the cache using a background process. Whenever an entry gets invalidated, notify a background process that regenerates this page into the cache. This effectively offloads the burden of the “slow page” to affect only a robot, not a real user. But it requires plenty of computational resources to pull off, and will come at the wasteful cost of generating a lot of cached pages that are never looked at by a real user.
  3. Accept the low page cache ratio, and substantially reduce the response time for uncached pages. There is an even more radical approach here, where you build the shop application specifically with performance of product and category pages in mind, so that no caching is required at all.

Before you do all that, there are some simple first steps when trying to reduce the response time for cached pages that even a generic shop framework like Shopware (or Magento, WooCommerce for that matter) could implement first.

With multi-step caching, you can reduce the time to generate the uncached page by re-using HTML for common parts across all pages.

Conceptually, every web page can be grouped into different sections that show different high-level or detailed content of the site. The four main sections are usually header, sidebar, main content and footer.

In the context of full page caching, it’s only logical to think about caching these four sections separately.

In the case of a Shopware application and their default layout, the header section is the most interesting section because it contains the navigation menu that is known to take a significant amount of time to render – 50ms, 100ms, but also high three-digit sums are not unusual.

Then every time an uncached product or category gets rendered, at least we don’t incur the performance loss of rendering these common sections over and over again.

The tricky part to solve is cache expiry and invalidation of these sections. If the header cache entry expires because a category entry in the navigation gets deleted, for example, do all full page cache entries need to get invalidated as well?

This problem is solved by Edge-Side Includes. Instead of storing the sub-section into the output of the fully cached page, it caches only a reference to it using a special <esi:include> tag.

When rendering the full page, the software that implements the ESI rendering checks for these references and dynamically builds together the final output from the different cache entries. This could be Symfony HTTP Cache or Varnish for example.

This comes with two benefits: Invalidation of a sub-section such as the header does not require invalidation of all cache entries that make use of it.

Cache lifetime of the sections can be different, and the user still always sees the most recently cached output of every section of the page.

How it’s implemented in Shopware 6.7

Shopware is using the Symfony framework under the hood. This enables using either a PHP-based implementation or Varnish for the full page caching with ESI support.

The entry point for this functionality can be found in the base.html.twig template:

The frontend.header route maps to a new controller action NavigationController::header:

And the actual performance gain is then visible from a code-perspective in the diff for GenericPageLoader::load where all the code for loading and rendering the navigation was removed. When header and footer are cached, then all this processing is skipped because HeaderLoader::load and FooterLoader::load are not called anymore.

Measuring the Performance Impact

Looking at the upcoming Shopware change to its full page cache, we can compare the performance impact for a demo application to get a rough gauge for real-world impact.

Comparing two aggregated traces with callgraph data for 100 profiling traces on uncached product pages with Shopware 6.6 and 6.7 we can see a 110ms improvement for our demo shop: 65,3ms saved not rendering the navigation Twig template and 45.7ms loading and processing the category and navigation data from the database.

You can access this trace comparison directly in Tideways

This is a significant improvement that will directly benefit all Shopware shops. Using ESI for header and footer is a simple and safe way to make full page caching more effective and I am keen to see the impact this change has on our Shopware benchmark report over the next quarters when people upgrade to version 6.7 of Shopware.

For you readers not using Shopware, this should hopefully serve as a good starting point on how to implement a full-page cache with muilti-step rendering to get a better overall cache hit ratio and faster responses for partially uncached pages.

What’s next?

  1. Sign Up for our Newsletter if you don’t want to miss the next post on our blog.
  2. Start your 14 days free trial of Tideways for effortless performance insights into your PHP application.