Skip to content

How-to guideFor module developers, site buildersDrupal 11

Performance and caching

This page is about operating Drupal’s caching in production: which cache layer serves which visitor, how to confirm each one is working, and where to put the cache data so a real traffic load stays fast. The metadata that drives all of it (cache tags, contexts, and max-age) is explained on the caching concept page; here you turn the layers on, read their headers, and choose a backend.

Serve most requests from a cache and keep the rest fast, without serving stale or leaked output. By the end you can name which layer handled a given response, confirm it from the response headers, and move the cache storage off the database onto Redis or Memcache when the database becomes the bottleneck. The wrong time to reach for this page is when a specific change is not appearing: that is almost always a missing cache tag, which the caching concept page and Debug a Drupal site cover.

  • A working Drupal 11 site you can send requests to and read response headers from.
  • The caching concept page read, so cache tags, contexts, and max-age are familiar.
  • Drush for enabling and inspecting modules.
  • For the backend section: a Redis or Memcache server reachable from the web nodes, and the matching contributed module installed with Composer.

Drupal layers three HTTP-level caches over the render cache. Each targets a different visitor, and each is a separate module you can confirm and tune.

The layers stack. The internal page cache answers anonymous hits before anything else runs; on a miss, the request continues into Dynamic Page Cache, which serves authenticated visitors too.

  • Internal Page Cache (the page_cache module) serves whole HTML pages to anonymous visitors. It runs as an HTTP middleware (http_middleware.page_cache) before the kernel boots fully, so a hit returns in microseconds without routing or rendering. It stores responses keyed by URL and only stores responses that carry cacheability metadata (a \Drupal\Core\Cache\CacheableResponseInterface). It invalidates by cache tag and ignores bubbled max-age: an anonymous page lives until a tag invalidates it.
  • Dynamic Page Cache (the dynamic_page_cache module) serves pages to authenticated visitors and to anonymous visitors when the internal page cache cannot. It runs as a kernel event subscriber after routing, because the contexts a page varies by (user, user.permissions, route) can only be known then. It stores each page in a \Drupal\Core\Cache\VariationCache under the contexts the page declares, so one logical page has one cached variant per context combination. Uncacheable fragments are replaced with placeholders before the page is stored, so the cacheable shell is reused while the personalized parts are rebuilt per request.
  • BigPipe (the big_pipe module) is not a separate store; it is a delivery strategy for the placeholders Dynamic Page Cache leaves behind. It flushes the cacheable page shell to the browser immediately, then streams each placeholder’s rendered content as it finishes. The visitor sees the page frame at once instead of waiting for the slowest fragment.

Confirm which modules are active:

Terminal window
ddev drush pm:list --status=enabled --type=module | grep -E 'page_cache|dynamic_page_cache|big_pipe'

page_cache, dynamic_page_cache, and big_pipe are all installed by the standard profile. Leave them enabled in production. The internal page cache is the single largest performance win for anonymous traffic.

Every page response reports how each cache layer treated it. Request a page and inspect the headers.

Terminal window
curl -sI https://example.com/ | grep -i x-drupal
X-Drupal-Cache: HIT
X-Drupal-Dynamic-Cache: MISS
X-Drupal-Cache-Tags: block_view config:block_list ...
X-Drupal-Cache-Contexts: languages:language_interface theme url.site ...
  • X-Drupal-Cache is the internal page cache (anonymous only): HIT, MISS, or UNCACHEABLE with a reason. The reasons come straight from the middleware: UNCACHEABLE (request policy) for a request with an open session, UNCACHEABLE (no cacheability) for a response that is not a CacheableResponseInterface, UNCACHEABLE (response policy) when a policy denied it.
  • X-Drupal-Dynamic-Cache is Dynamic Page Cache, with the same HIT / MISS / UNCACHEABLE vocabulary. UNCACHEABLE (poor cacheability) means the page declared a high-cardinality context or a high-frequency tag that makes whole-page caching pointless.
  • X-Drupal-Cache-Tags and X-Drupal-Cache-Contexts are emitted on debug-enabled environments and list what the response depends on and varies by. They are how you find a missing tag.

The cacheability headers are gated behind the http.response.debug_cacheability_headers container parameter; enable it in services.yml on non-production environments only, since the tag list can be large. Debug a Drupal site walks through reading them.

Code that produces genuinely uncacheable output flips the page-cache kill switch. It marks the current response uncacheable for both page caches for this request only.

// Fragment: $this->killSwitch is the page_cache_kill_switch service.
$this->killSwitch->trigger();

Trigger it only when no tag, context, or max-age can describe the variation, because it disables whole-page caching for that request. The \Drupal\Core\PageCache\ResponsePolicy\KillSwitch response policy reads the flag and denies caching. Prefer correct metadata; reach for the kill switch last.

By default the cache bins are stored in the database (cache.backend.database). Under load, cache reads and writes contend with content queries on the same database. Moving the high-traffic bins to an in-memory store removes that contention.

Install the Redis or Memcache contributed module with Composer and enable it, then point cache bins at its backend in settings.php. The pattern is the same for either; this makes Redis the default backend:

// settings.php (the redis module is installed and enabled).
$settings['redis.connection']['host'] = '127.0.0.1';
$settings['redis.connection']['port'] = 6379;
$settings['cache']['default'] = 'cache.backend.redis';
$settings['container_yamls'][] = 'modules/contrib/redis/example.services.yml';

The cache.backend.redis service exists only once the module is enabled, so add these lines after enabling it. The container_yamls line moves cache tag checksums, locks, and flood control to Redis as well; without it, every cache read still queries the cachetags table in the database. The module’s settings.redis.example.php and README cover the full set of options.

$settings['cache']['default'] sets the backend for every bin that does not declare its own default; $settings['cache']['bins']['render'] = 'cache.backend.redis' overrides a single bin. Each bin is a service named cache.BIN implementing \Drupal\Core\Cache\CacheBackendInterface, so swapping the backend is a settings change with no code change. The render and dynamic-page-cache data are the bins that benefit most. The bootstrap, config, and discovery bins are small and read constantly, so core already runs them on cache.backend.chainedfast, which keeps a per-server APCu copy in front of the default backend: with Redis as the default, they become APCu in front of Redis with no extra setting.

A reverse proxy (Varnish, or a content delivery network) caches whole anonymous responses outside PHP entirely, so a hit never reaches Drupal. When Drupal sits behind a proxy or load balancer, tell it so, or it will see the proxy’s IP and the wrong scheme on every request.

// settings.php (Drupal is behind a trusted reverse proxy).
$settings['reverse_proxy'] = TRUE;
$settings['reverse_proxy_addresses'] = ['192.0.2.10'];

With reverse_proxy set, Drupal reads the client IP, host, and protocol from the forwarded headers sent by the addresses you list, which keeps ip and url cache contexts, logging, and HTTPS detection correct. The proxy itself caches by URL and respects the Cache-Control and Vary headers Drupal sets; a tag-aware proxy integration can additionally purge by cache tag when content changes, the same invalidation the internal page cache uses. List only proxy addresses you control: these settings make Drupal trust forwarded headers, and trusting an attacker’s headers is a spoofing hole. The security checklist covers the host-header side of the same concern.

  • Anonymous caching works: a second curl -sI https://example.com/ shows X-Drupal-Cache: HIT.
  • Authenticated caching works: with a session cookie, a repeated request shows X-Drupal-Dynamic-Cache: HIT.
  • BigPipe streams: load a page with placeholders (the admin toolbar’s user fragment, for example) in a browser and watch the shell paint before the personalized parts fill in.
  • The backend moved: after setting cache.backend.redis, vendor/bin/drush cache:rebuild succeeds and redis-cli dbsize grows, confirming bins are landing in Redis rather than the database.
  • The proxy is trusted: behind the proxy, logged requests show real client IPs, not the proxy’s address.

Instead of changing the default, move only the large bins to the shared store: $settings['cache']['bins']['render'] = 'cache.backend.redis' (and the same for dynamic_page_cache and page) leaves every other bin in the database. Core’s chainedfast bins keep their APCu layer either way.

Anonymous 404 and 403 responses are cached for an hour by default to keep them from filling the cache, controlled by $settings['cache_ttl_4xx']. Lower it on sites that generate many distinct missing URLs; set it to 0 to stop caching client errors entirely.

  • Caching for the cache tags, contexts, and max-age that decide whether any of these layers can store a response.
  • Debug a Drupal site for reading the X-Drupal-Cache-Tags and X-Drupal-Cache-Contexts headers and finding a missing tag.
  • Deployment workflow for rebuilding a shared cache backend safely across multiple web nodes.
  • The Cache API topic on api.drupal.org for the bin and backend reference.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.