Drupalwoo ☉

How To Use Drupal Cache Contexts For Personalised Content

Drupal’s render cache allows a page or component to be reused instead of rebuilt for every request. That efficiency becomes more complicated when content changes according to the visitor, their permissions, the URL, language, or another request property. Cache contexts tell Drupal which values can produce different versions of the same renderable output.

Used correctly, cache contexts let a Drupal website serve relevant content without disabling caching. This matters for Australian sites that may serve customers across Sydney, Melbourne, Brisbane, and regional areas while still needing fast page delivery. The key is to describe every meaningful variation accurately, then keep that variation as narrow as possible.

What Cache Contexts Actually Control

A cache context is a named variation dimension. For example, the user context tells Drupal that a render array can be different for every logged-in account. The user.roles context indicates that output varies according to the visitor’s roles, while languages:language_interface separates cached output by the selected interface language.

Cache contexts belong in renderable arrays under #cache. They do not fetch personal data, perform access checks, or decide what a visitor should see. They simply add information to the cache key so Drupal does not reuse an unsuitable cached result.

$build['account_message'] = [
  '#markup' => $this->accountMessage(),
  '#cache' => [
    'contexts' => ['user'],
  ],
];

With this declaration, Drupal stores a separate cached result for each user context value. An anonymous visitor and an authenticated visitor will receive different variants. Two authenticated users will also receive separate variants, which can become expensive on a busy website.

This distinction is important because cache contexts are different from cache tags and cache lifetime. A context answers “which versions exist?”, a tag answers “which cached items should be invalidated when data changes?”, and max-age answers “how long can this result remain valid?”

Choose The Narrowest Useful Variation

Use the least specific context that still describes the output. If a banner is identical for every member of a subscriber role, use user.roles rather than user. If the content changes for each account because it contains a name, order history, or private notification, use user or move that fragment into a personalised placeholder.

A useful example is a wholesale pricing block. If all approved trade customers see the same wording and prices, user.roles may be sufficient. If prices are negotiated per account, a role context is unsafe because two users with the same role could receive different values. The component must vary by user, or use a safer uncached or lazy-rendered approach.

$build['trade_price'] = [
  '#theme' => 'trade_price',
  '#price' => $price,
  '#cache' => [
    'contexts' => ['user'],
    'tags' => ['product:42'],
    'max-age' => 3600,
  ],
];

URL-based variation is often more efficient than user-based variation for regional or campaign content. url.path can separate a component by route, while url.query_args:utm_campaign varies only by one query-string parameter. Avoid using the broad url.query_args context when one parameter is responsible for the difference, because unrelated query parameters will create additional cache variants.

Personalisation requirement Suitable cache context Typical risk or cost
Different output for each account user Many cache variants and larger storage
Same output for a group of permissions user.roles or user.permissions Incorrect results if individual account data matters
Different campaign banner url.query_args:utm_campaign Visitors can create many campaign variants
Different page or route display url.path or route Usually predictable and relatively efficient
Interface language variation languages:language_interface Required for translated labels and strings
Local time-sensitive message timezone More variants and possible time-bound changes
Session-specific state session Can reduce page cache effectiveness
Custom delivery region A custom cache context Requires a reliable, cache-aware definition of region

Australian ecommerce websites often use postcode or delivery-zone selection to show availability for areas such as Perth, Adelaide, or regional New South Wales. A postcode stored in a cookie is not automatically a safe cache context. Drupal must be told how that value affects the cache key, otherwise one visitor’s delivery message may be served to another visitor.

Add Cacheability Metadata In Code

Controllers, block plugins, field formatters, and preprocess functions can all produce cacheable render arrays. The metadata should be attached as close as possible to the component that varies. This makes the behaviour easier to inspect and prevents a parent render array from hiding an incomplete declaration.

A block plugin might vary by the current user’s roles and expire every five minutes because membership information can change during the day:

public function build(): array {
  return [
    '#theme' => 'member_notice',
    '#account' => $this->currentUser()->id(),
    '#cache' => [
      'contexts' => ['user.roles'],
      'tags' => ['config:my_module.settings'],
      'max-age' => 300,
    ],
  ];
}

The cache tag in this example handles changes to configuration. If the component displays a node, taxonomy term, or commerce product, add the relevant entity cache tag so Drupal can invalidate the output when that entity changes. A cache tag does not create a separate version for each user; it only marks related cached items for invalidation.

When a service supplies cacheable dependencies, merge their metadata rather than replacing it. Drupal’s cacheability APIs help preserve contexts, tags, and expiration values:

$access_result = $this->accessChecker->access($node, $this->currentUser());

$build = [
  '#theme' => 'node_teaser',
  '#node' => $node,
];

$build['#cache']['contexts'] = ['user.permissions'];
$build['#cache']['tags'] = $node->getCacheTags();
$build['#cache']['max-age'] = $node->getCacheMaxAge();

Access results can carry cacheability metadata of their own. If access depends on permissions, roles, or an entity state, ignoring that metadata can expose incorrect output. Always use Drupal’s access APIs and attach the resulting cacheability information rather than manually assuming that a boolean result is sufficient.

Handle Personalised Blocks Safely

Whole-page personalisation is often the least efficient option. If one small block contains account-specific information, varying the entire page by user can create a separate page cache entry for every visitor. Drupal’s Dynamic Page Cache and BigPipe can improve this pattern by allowing the shared page shell to remain cacheable while a personalised component is rendered separately.

A lazy builder is a common solution for account menus, saved-item counts, dashboards, and private notifications. The page can be cached as a common response, then Drupal generates the placeholder content for the current visitor. The lazy-built render array still needs correct cacheability metadata, especially when the result is stored or reused.

$build['saved_items'] = [
  '#lazy_builder' => [
    'my_module.saved_items:build',
    [],
  ],
  '#create_placeholder' => TRUE,
  '#cache' => [
    'contexts' => ['user'],
  ],
];

BigPipe can replace that placeholder after the initial HTML arrives. This is useful for a Drupal membership site serving users in different Australian time zones, such as Perth and Sydney, where a private “next appointment” or account alert should not delay delivery of the public page. The browser receives the shared structure quickly, while the personalised fragment is produced separately.

Do not put secret values in markup and assume cache contexts will make them secure. A cache context is part of cache variation, not an authorisation boundary. Private data still requires proper entity access, route access, HTTPS, and suitable response handling. For highly sensitive responses, use appropriate private caching or an uncached response rather than relying on a broadly shared page cache.

Test Cache Behaviour In Production

Cache bugs often appear only after a site has been deployed behind several layers. Test with Drupal’s Internal Page Cache, Dynamic Page Cache, reverse proxies, and any CDN in front of the site. A component may be correctly varied inside Drupal while an external cache ignores the relevant cookie or response header.

Inspect the render cache metadata and response headers while testing. Drupal’s cache debug headers can reveal contexts, tags, and cacheability decisions during development. Check the same route as an anonymous visitor, a logged-in customer, an administrator, and users with different roles. Repeat requests after changing permissions, language, campaign parameters, and account state.

A practical test matrix for a Drupal commerce site might include:

Look for two opposite problems. If the response is identical when it should differ, a required context is missing. If every small request variation produces a new cache entry, the context is too broad or the personalisation belongs in a lazy fragment. Response headers such as X-Drupal-Cache and X-Drupal-Dynamic-Cache can help identify whether Drupal served a cached page, although a CDN may add its own cache indicators.

Keep Personalisation Maintainable

Start by documenting what actually changes the component: account identity, role, permission, route, language, session, timezone, or a custom regional value. Then select a matching core context and attach it to the smallest render array possible. Avoid adding user simply because it is convenient; doing so can make an otherwise public website difficult to cache efficiently.

For custom contexts, create a cache context service that returns a stable, normalised value. A delivery region should be represented by a controlled value such as metro-sydney, regional-victoria, or unknown, rather than an unbounded address or raw postcode. The service must also define its cacheability and be consistent with how the preference is stored and updated.

Review personalised components after installing new modules or changing reverse-proxy settings. Drupal modules can add cache metadata to render arrays, and a missing merge can remove important variation information. Keep development diagnostics enabled while building the feature, then disable verbose cache output on production systems.

The strongest implementation usually has a shared, cacheable page; a small number of predictable cache contexts; correct entity tags; and lazy rendering for genuinely private data. That arrangement gives visitors relevant content while preserving the performance benefits Drupal’s caching system is designed to provide.