Drupal’s entity system is powerful, but without proper caching, it can become a performance bottleneck. Understanding entity and render caching is crucial for building high-performance Drupal websites that can handle complex content structures efficiently.
In this blog, we’ll uncover the strategies that made this transformation possible.
Entity Caching fundamentals
In-depth explanation
Drupal’s entity caching system is a multi-layered mechanism that optimises performance by reducing redundant database queries and PHP processing. To understand its depth, let’s break down each layer and explore how it works under the hood.
1. Entity load Caching
What it does
When an entity (e.g., a node, user, or taxonomy term) is requested, Drupal typically queries the database to load its raw data. Entity Load Caching stores this raw data in the cache_entity bin (by default) to avoid repeated database calls for the same entity. The cache key is typically in the format entity:{entity_type}:{id}:{langcode} (e.g., entity:node:123:en).
Technical mechanics
- Entity storage system: Entities are loaded via the EntityStorageInterface (e.g., SqlContentEntityStorage). When an entity is first requested, it is fetched from the database and stored in the cache bin.
- Cache keys: The cache key is derived from the entity type, ID, and language (e.g., node:123:en).
- Invalidation: When an entity is updated or deleted, its cache entry is cleared using cache tags (e.g., node:123).
Why It matters
- Reduces database load: Eliminates redundant queries for frequently accessed entities.
- Speeds up entity access: Directly retrieves data from memory instead of parsing SQL results.
2. Render Caching
What it does
After an entity is loaded, Drupal generates a render array (a structured PHP array defining how the entity should be displayed). Render Caching stores these arrays to avoid rebuilding them on every request.
Technical mechanics
- Render arrays: Arrays like ['#markup' => 'Hello', '#cache' => [...]] are generated during entity rendering.
- Cache bin: Render arrays are stored in cache_render with keys based on entity type, ID, view mode, and cache contexts (e.g., user.roles).
- Cache contexts: Determine when to vary the cache (e.g., different render arrays for admins vs. anonymous users).
Why it matters
- Avoids rebuilding: Skips expensive operations like field rendering, template preprocessing, and access checks.
- Granular control: Uses cache tags (e.g., node:123) and contexts (e.g., url.path) to ensure freshness.
Example:
// Add cache metadata to a render array.
$build['my_element'] = [
'#markup' => 'Cached Content',
'#cache' => [
'contexts' => ['user.roles'],
'tags' => ['node:123'],
'max-age' => 3600,
],
];
3. View mode Caching
What it does
Entities are often displayed in different formats (e.g., “teaser” for listings and “full” for detail pages). View Mode Caching stores separate render arrays for each view mode to avoid redundant processing.
Technical mechanics
- View modes: View Modes: Defined in hook_entity_type_build() or via the UI (e.g., teaser, full, custom_mode).
- Cache key structure: Combines entity type, ID, view mode, and language (e.g., node:123:teaser:en).
- Customisation: Developers can alter view mode rendering via hook_entity_view() or hook_ENTITY_TYPE_view().
Why it matters
- Efficient Multi-Format Rendering: Prevents rebuilding the same entity for different contexts.
- Consistency: Ensures view modes like “teaser” remain fast even if the “full” view is complex.
Example hook:
function mymodule_node_view(array &$build, \Drupal\Core\Entity\EntityInterface $entity, \Drupal\Core\Entity\Display\EntityViewDisplayInterface $display, $view_mode) {
if ($view_mode === 'teaser') {
$build['#cache']['max-age'] = 7200;
}
}
4. Field-level Caching
What it does
Fields (e.g., a “body” text field or an “image” field) can be cached individually to avoid reprocessing static content.
Technical mechanics
- Field formatters: Each field’s display is handled by a formatter (e.g., text_default, image).
- Cache granularity: Developers can specify caching for individual fields using hook_field_formatter_view_alter(). Note: In practice, field formatter plugins should set cache metadata directly on render arrays. The hook is for last-resort alterations.
- Dependencies: Field caching relies on render arrays and cache metadata bubbled up from the field level.
Why it matters
- Optimises static fields: Caches rarely changing fields (e.g., author bio) while dynamic fields (e.g., stock status) remain uncached.
- Reduces overhead: Avoids reprocessing fields with expensive calculations (e.g., computed fields).
Example hook:
function mymodule_field_formatter_view_alter(array &$element, array $context) {
if ($context['field_name'] === 'field_price') {
$element['#cache'] = [
'contexts' => ['user.roles'],
'tags' => ['field:price'],
'max-age' => 1800,
];
}
}
Use Case 1: Hybrid Caching for a product catalogue
Scenario
An e-commerce site needs fast delivery of product pages for anonymous users but must update instantly when inventory changes.
Goal
Combine entity load caching with tag-based invalidation to reduce database queries while keeping content fresh.
Sample implementation
/**
* Implements hook_node_view().
*
* Adds cache metadata for product nodes with tag-based invalidation.
*/
function mymodule_node_view(array &$build, \Drupal\Core\Entity\EntityInterface $entity, \Drupal\Core\Entity\Display\EntityViewDisplayInterface $display, $view_mode) {
if ($entity->bundle() === 'product' && $view_mode === 'full') {
$build['#cache']['tags'][] = 'product_list';
$build['#cache']['max-age'] = 3600;
}
}
Explanation:
This caches product entities for 1 hour (max-age) but clears the cache when the entity is edited (node:$nid) or when the product list changes (product_list).
Render pipeline overview
In-depth explanation
The render pipeline is the sequence of steps Drupal uses to transform entities into HTML. Let’s dissect each stage and explore its role in caching.
1. Entity loading
What happens
- Database query: Drupal queries the database for entity data (e.g., node title, body).
- Field data: Retrieves field values from field_data_* tables.
- Access checks: Verifies user permissions to view the entity.
Caching interaction
- Entity load Caching: If the entity is already in the cache_entity bin, it bypasses the database.
- Performance impact: Avoids 1–5 SQL queries per entity.
2. Field value loading
What happens
- Field definitions: Loads field definitions from core.entity_field_manager.
- Field values: Gathers values for all fields (e.g., field_image, field_tags).
- Field formatters: Determines how each field should be rendered (e.g., image style, link format).
Caching interaction
- Field-level Caching: If a field’s formatter is cached, its render array is pulled from cache_render.
- Performance impact: Reduces time spent processing field data for static fields.
3. Render array generation
What happens
- Structured array: Builds a render array with elements like #type, #markup, and #theme.
- Preprocessing: Invokes hook_preprocess_HOOK() functions to add variables for templates.
- Cache metadata: Aggregates cache contexts (e.g., user.roles) and tags (e.g., node:123).
Caching interaction
- Render Caching: The final render array is stored in cache_render for reuse.
- Performance impact: Avoids rebuilding complex arrays for subsequent requests.
4. Cache Metadata collection
What happens
- Contexts: Determine when to vary the cache (e.g., language, user.roles).
- Tags: Track dependencies (e.g., node:123, user:456).
- Max-age: Sets the cache lifetime (e.g., 3600 seconds).
Technical details
- Bubbling: Child elements (e.g., blocks, fields) bubble their metadata to parent containers.
- Merge logic: Parent elements inherit the most restrictive max-age and combine contexts/tags.
Example:
// Bubbling metadata from a child element to a parent.
$parent['child_element'] = [
'#markup' => 'Cached Child',
'#cache' => [
'contexts' => ['user.roles'],
'tags' => ['node:123'],
'max-age' => 3600,
],
];
$parent['#cache'] = [
'contexts' => ['url.path'],
'tags' => ['page:home'],
'max-age' => 7200,
];
// After bubbling, the parent's metadata includes:
// contexts = ['url.path', 'user.roles']
// tags = ['page:home', 'node:123']
// max-age = 3600 (the lower of 7200 and 3600).
5. Final rendering
What happens
- Theme resolution: Maps render arrays to Twig templates or PHP callbacks.
- HTML generation: Converts the render array into HTML (e.g., <div>Cached Content</div>).
- Output: Sent to the browser or stored in the page cache (cache_page).
Caching interaction
- Dynamic Page Cache: For authenticated users, fragments are cached in cache_dynamic_page_cache.
- Internal Page Cache: For anonymous users, the full HTML is stored in cache_page.
Performance impact
- Anonymous Users: Serves HTML directly from cache_page without bootstrapping Drupal.
- Authenticated Users: Reuses cached fragments from cache_dynamic_page_cache.
Use Case 2: Debugging slow page loads
Scenario
A news site’s article pages take 2+ seconds to load. Profiling shows repeated field processing.
Goal
Add cache metadata to preprocess functions to reduce redundant processing.
Sample implementation
* Implements hook_preprocess_node().
*
* Adds cache metadata to node templates.
*/
function mymodule_preprocess_node(array &$variables) {
$node = $variables['node'];
$variables['#cache'] = [
'contexts' => ['user.roles', 'language'],
'tags' => ['node:' . $node->id(), 'content_type:article'],
'max-age' => 1800,
];
}
Explanation:
This caches the rendered HTML for 30 minutes, varying by user role and language. Tags ensure the cache clears when the article is updated.
Cache contexts and tags
In-depth explanation
Drupal’s caching system relies on cache contexts and cache tags to balance performance with precision. These mechanisms ensure cached content is both fast and correct, avoiding stale data while minimizing redundant computation. Let’s dissect their mechanics, interactions, and real-world implications.
1. Cache contexts: when to vary the Cache
What they do
Cache contexts define when cached content should differ. They act as variation keys—if any context value changes, Drupal generates a new cache entry.
Technical mechanics
- Variation logic: Contexts like user.roles, language, or url.path are resolved at runtime. Their values (e.g., ["anonymous"], "en", "/blog") become part of the cache key.
- Context stack: Drupal uses a prioritised list of available contexts (defined via \Drupal\Core\Cache\Context\CacheContextInterface). Common ones include:
- user.roles: Different content for admins vs. editors.
- language: Language-specific versions for multilingual sites.
- url.path: Unique caching for each page (e.g., /home vs. /about).
- theme: Separate caching for mobile vs. desktop themes.
- Context Conflicts: Avoid overly broad contexts like url (includes query parameters) unless necessary. Use url.path instead to reduce cache bloat.
Why it matters
- Precision: Prevents serving admin menus to anonymous users or mixing language outputs.
- Efficiency: Reduces redundant cache entries by choosing the minimal set of contexts required.
Example:
// Cache a block differently for editors vs. anonymous users.
$build['my_block'] = [
'#markup' => 'Welcome, ' . \Drupal::currentUser()->getAccountName(),
'#cache' => [
'contexts' => ['user.roles'],
'tags' => ['user:' . \Drupal::currentUser()->id()],
'max-age' => 900,
],
];
2. Cache tags: how to invalidate the Cache
What they do
Cache tags define what triggers cache invalidation. They tie cached data to entities or external dependencies (e.g., node:123, config:system.site). When a tag is invalidated, all cache entries referencing it are cleared.
Technical mechanics
- Tag resolution: Tags are resolved via the CacheableMetadata class. Entities automatically bubble their tags (e.g., a node’s node:123), but developers can add custom tags manually.
- Tag inheritance: Parent elements inherit tags from children through cache bubbling. For example, a node with a field_image will inherit the image field’s tags.
- Granularity: Tags can be entity-specific (node:123), bundle-level (content_type:article), or even custom (e.g., weather_api:city=paris).
Why it matters
- Freshness: Ensures updates to an entity invalidate only relevant caches, not the entire site.
- Scalability: Avoids full-cache clears during bulk operations (e.g., updating 100 nodes only invalidates 100 tags).
Example:
// Invalidate a cached view when any article is updated.
$build['my_view'] = [
'#markup' => $rendered_view,
'#cache' => [
'tags' => ['content_type:article'],
'max-age' => 3600,
],
];
3. Interplay between contexts and tags
How they work together
- Contexts ≠ Tags: Contexts vary the cache (e.g., “show different content”), while tags control invalidation (e.g., “clear when something changes”).
- Performance impact: A render array with contexts: ['user.roles'] and tags: ['node:123'] creates a unique cache entry per role but invalidates all role-specific versions when the node updates.
Example:
'#type' => 'form',
'#form' => '\Drupal\comment\Form\CommentForm',
'#cache' => [
'contexts' => ['user', 'session'], // Varies for logged-in users.
'tags' => ['node:123'], // Invalidates when the node changes.
'max-age' => 0, // Never cached for anonymous users.
],
];
Use case 3: multilingual Caching pitfalls
Scenario
A multilingual site serves French and English articles. Without proper caching, users occasionally see mixed-language content.
Goal
Cache article pages by language to prevent mixed outputs.
Sample implementation
/**
* Implements hook_entity_view_alter().
*
* Ensures multilingual content is cached per language.
*/
function mymodule_entity_view_alter(array &$build, \Drupal\Core\Entity\EntityInterface $entity, $view_mode) {
// Initialize cache metadata if not set.
if (!isset($build['#cache'])) {
$build['#cache'] = [];
}
// Merge cache contexts.
$build['#cache']['contexts'] = isset($build['#cache']['contexts'])
? CacheableMetadata::mergeContexts($build['#cache']['contexts'], ['languages:language_interface'])
: ['languages:language_interface'];
// Merge cache tags.
$node_tag = 'node:' . $entity->id();
$language_tag = 'language:' . $entity->language()->getId();
$build['#cache']['tags'] = isset($build['#cache']['tags'])
? CacheableMetadata::mergeTags($build['#cache']['tags'], [$node_tag, $language_tag])
: [$node_tag, $language_tag];
// Optionally set max-age (default permanent).
if (!isset($build['#cache']['max-age'])) {
$build['#cache']['max-age'] = CacheableMetadata::CACHE_PERMANENT;
}
}
Explanation:
This ensures the cache varies by language, preventing French users from seeing English content and vice versa.
Field-level Caching
In-depth explanation
Field-level caching optimises performance by storing the rendered output of individual fields (e.g., text, images, computed values) independently. This allows static fields to be cached for extended periods while dynamic fields remain fresh. Unlike entity-level caching, field-level caching operates at a granularity that avoids reprocessing expensive formatters (e.g., geolocation maps, computed prices) unnecessarily.
1. Technical mechanics of field Caching
A. field formatters and render arrays
Fields are rendered via formatters, which are plugins implementing FieldFormatterInterface. Each formatter generates a render array with attached cache metadata.
Example formatter flow:
- Field definition: Retrieved via EntityFieldManagerInterface.
- Formatter plugin: Resolved from configuration (e.g., text_default, image).
- Render array: Generated by the viewElements() method, including #cache metadata.
// Example of a field formatter's viewElements() method.
public function viewElements(FieldItemListInterface $items, $langcode) {
$elements = [];
foreach ($items as $delta => $item) {
$elements[$delta] = [
'#markup' => $this->sanitizeValue($item->value),
'#cache' => [
'contexts' => ['user.roles'],
'tags' => ['field:custom_text'],
'max-age' => 3600,
],
];
}
return $elements;
}
B. Cache metadata bubbling
Field-level cache metadata (contexts, tags, max-age) is bubbled up to parent containers (e.g., entities, pages) during render pipeline processing. This ensures invalidation cascades correctly.
Example:
A node’s field_image with #cache['tags'] = ['node:123'] ensures the entire node’s cache clears when the image updates.
Use case 4: conditional Caching for user-specific fields
Scenario
A price field must show discounted prices to logged-in users but static pricing for guests.
Goal
Cache the field for 30 minutes, but vary by user role.
Sample implementation
/**
* Implements hook_field_formatter_view_alter().
*/
function mymodule_field_formatter_view_alter(&$element, $context) {
$element['#cache'] = [
'contexts' => ['user.roles'],
'tags' => ['field:price', 'user:' . \Drupal::currentUser()->id()],
'max-age' => 1800,
];
}
Explanation:
This creates separate cache entries for different user roles while invalidating the cache when the price field changes.
Cache bubbling
In-depth explanation
Cache bubbling is the process by which cache metadata (contexts, tags, and max-age) from deeply nested render elements (e.g., fields, blocks, forms) propagates upward to parent containers (e.g., entities, pages). This mechanism ensures that the caching behaviour of a composite element reflects all its dependencies, preventing stale content and maintaining cache coherence across the entire render tree.
Without cache bubbling, a parent container might cache independently of its children, leading to situations where an updated field or block remains invisible until the parent’s cache expires. Bubbling guarantees that invalidation cascades correctly and that cache variations (e.g., role-specific content) are preserved at every level.
1. Technical mechanics of Cache bubbling
A. How metadata propagates
Cache metadata flows upward through the render array hierarchy:
- Child elements: Fields, blocks, or subcomponents define their own #cache properties.
- Parent aggregation: The renderer merges child metadata into the parent’s #cache array during processing.
- Conflict resolution:
- Contexts: Combined into a union (e.g., ['user.roles', 'url.path']).
- Tags: Merged into a single list (e.g., ['node:123', 'block:sidebar']).
- Max-age: The lowest value wins (e.g., 3600 and 1800 → 1800).
Example:
// A render array with a cached child element.
$parent = [
'#type' => 'container',
'#cache' => [
'contexts' => ['url.path'],
'tags' => ['page:home'],
'max-age' => 7200,
],
'child' => [
'#markup' => 'Cached Content',
'#cache' => [
'contexts' => ['user.roles'],
'tags' => ['node:123'],
'max-age' => 3600,
],
],
];
// After bubbling, the parent's metadata becomes:
$parent['#cache'] = [
'contexts' => ['url.path', 'user.roles'], // Union of contexts.
'tags' => ['page:home', 'node:123'], // Combined tags.
'max-age' => 3600, // Lowest max-age.
];
B. Bubbleable metadata interface
Drupal core uses the BubbleableMetadata class to manage metadata propagation. Developers can manually manipulate it:
use Drupal\Core\Render\BubbleableMetadata;
// Create a BubbleableMetadata object.
$metadata = BubbleableMetadata::createFromRenderArray($element);
// Merge metadata from a child element.
$metadata->merge(BubbleableMetadata::createFromRenderArray($child_element));
// Apply merged metadata back to the parent.
$metadata->applyTo($parent_element);
2. Why Cache bubbling matters
- Correctness: Ensures updates to a field or block invalidate all cached parents referencing it.
- Efficiency: Avoids full-site cache clears during partial updates (e.g., editing a single block).
- Granularity: Allows dynamic components (e.g., CSRF tokens) to disable caching for their parents.
Example:
A node with a field_comments block will inherit the block’s comment:* tags. Updating a comment invalidates the node’s cache.
Use Case 5: Debugging Cache bubbling
Scenario
A custom block inside an article page isn’t invalidated when the article is updated.
Goal
Log cache metadata to identify missing dependencies.
Sample implementation
function mymodule_debug_cache_bubbling($element) {
$cache_contexts = $element['#cache']['contexts'] ?? [];
$cache_tags = $element['#cache']['tags'] ?? [];
\Drupal::logger('cache_debug')->info(
'Cache Contexts: @contexts, Tags: @tags',
[
'@contexts' => implode(', ', $cache_contexts),
'@tags' => implode(', ', $cache_tags)
]
);
}
Explanation:
This logs cache metadata to help identify if the block is missing a critical tag like node:123.
Performance optimisation techniques
In-depth explanation
Optimising caching in Drupal requires balancing speed and correctness. By applying targeted strategies, developers can reduce server load while ensuring users see up-to-date content. Below are battle-tested techniques, complete with code examples and real-world applications.
1. Use granular Cache tags
Why it matters
Broad tags like content_type:article invalidate caches unnecessarily. Granular tags (e.g., node:123, field: price) ensure only relevant content updates.
Technical implementation
- Entity-specific tags: Attach entity:$type:$id tags to components referencing a single entity.
- Bundle-level tags: Use content_type:$bundle for bulk invalidations (e.g., updating a shared field formatter).
Example:
/**
* Implements hook_block_view_alter().
*
* Cache a product block with entity-specific tags.
* Example assumes 'field:product_promo' is a custom tag for the promo field.
*/
function mymodule_block_view_alter(array &$build, \Drupal\Core\Block\BlockPluginInterface $block) {
if ($block->getPluginId() === 'product_promo_block') {
$product_id = \Drupal::routeMatch()->getParameter('product');
$build['#cache'] = [
'tags' => ['node:' . $product_id, 'field:product_promo'],
'max-age' => 3600,
];
}
}
2. Minimise Cache contexts
Why it matters
Each context multiplies the number of cache variants. For example, URL context creates separate caches for /page vs. /page?foo=bar, even if the content doesn’t vary by query parameters.
Technical implementation
- Replace broad contexts: Use url.path instead of url unless query parameters affect output.
- Avoid redundant contexts: Skip user if role-based variations suffice (e.g., user.roles).
Example:
/**
* Implements hook_preprocess_block().
*
* Cache a block by path, not full URL.
*/
function mymodule_preprocess_block(&$variables) {
$variables['#cache'] = [
'contexts' => ['url.path', 'language'],
'tags' => ['block:promo_banner'],
'max-age' => 7200,
];
}
3. Leverage Render Cache Selectively
Why it matters
Not all content benefits from caching. Static fields (e.g., author bios) gain speed, while dynamic content (e.g., form validation errors) must bypass caching.
Technical implementation
- Static content: Set a long max-age for rarely changing fields.
- Dynamic content: Use max-age: 0 or #cache['contexts'][] = 'session' for per-user data.
Example:
/**
* Implements hook_form_alter().
*
* Prevent CSRF token caching in forms.
*/
function mymodule_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) {
if ($form_id === 'contact_form') {
$form['#cache'] = [
'contexts' => ['user'],
'max-age' => 0,
];
}
}
4. Avoid Caching sensitive data
Why it matters
Caching session-specific or PII (Personally Identifiable Information) risks exposing private data. Always vary by user or disable caching entirely.
Technical implementation
- User-specific content: Add user context to ensure per-user cache variants.
- Session data: Use session context for temporary state (e.g., shopping cart items).
Example:
/**
* Implements hook_preprocess_node().
*
* Prevent caching of user-specific content.
*/
function mymodule_preprocess_node(&$variables) {
$node = $variables['node'];
if ($node->getType() === 'private_document') {
$variables['#cache'] = [
'contexts' => ['user'],
'tags' => ['node:'. $node->id()],
];
}
}
5. Monitor and benchmark Cache effectiveness
Why it matters
Caching optimisations are only valuable if they reduce database load and improve page speed. Use metrics to validate changes.
Technical implementation
- SQL query counting: Use the Devel module to track queries per page.
- Cache headers: Check X-Drupal-Cache and X-Drupal-Cache-Tags in HTTP responses.
- Performance tools: Integrate New Relic or Blackfire.io for granular profiling.
Example:
// Log cache hit/miss statistics via a custom service.
class CacheStatsLogger {
public function logCacheStatus($cid, $cache_hit) {
$logger = \Drupal::logger('cache_stats');
if ($cache_hit) {
$logger->info('Cache hit for @cid', ['@cid' => $cid]);
} else {
$logger->warning('Cache miss for @cid', ['@cid' => $cid]);
}
}
}
Common pitfalls and how to avoid them