TTFB Drupal

Fix TTFB in Drupal

Drupal has three caching layers built into core and a mature reverse-proxy integration story that most sites never turn on. On a stock Drupal 10 install with the default database cache backend, anonymous TTFB routinely sits between 700 and 1,200 ms because every request lands on PHP, walks the render pipeline, and pulls cache_render out of MySQL. The same site, with Internal Page Cache and BigPipe enabled, cache backends moved to Redis, PHP opcache tuned for the module footprint, and Fastly or Cloudflare fronting the origin with tag-based purges, delivers anonymous TTFB under 100 ms warm and authenticated TTFB in the 200-400 ms band. This guide walks through the five moves that actually move the needle on Drupal TTFB in production.

Expected results

Before

920ms

TTFB (Needs Improvement) -- Drupal 10 default install, cache bins in MySQL, opcache defaults, no CDN, mixed anonymous and authenticated traffic

After

85ms

TTFB (Good) -- Internal Page Cache + BigPipe enabled, cache_render and cache_dynamic_page_cache on Redis, opcache tuned (256 MB, 20000 files, JIT), Fastly reverse proxy with tag purge

Step-by-step fix

Enable Internal Page Cache, Dynamic Page Cache, and BigPipe

Drupal ships three complementary caching layers in core. Internal Page Cache serves full-page HTML for anonymous users. Dynamic Page Cache serves the shared parts of authenticated pages and swaps in per-role or per-context placeholders. BigPipe streams the cacheable shell to authenticated users and inlines personalized fragments as they render. All three are shipped in core; the only work is enabling them and confirming they are actually running.

Terminal -- enable core caching modules with drush
# 1) Enable the three core caching modules
$ drush pm:enable page_cache dynamic_page_cache big_pipe -y

# 2) Confirm they are enabled and running
$ drush pm:list --type=module --status=enabled | grep -E 'page_cache|big_pipe'
# expected:
# BigPipe                        big_pipe                 Enabled
# Dynamic Page Cache             dynamic_page_cache       Enabled
# Internal Page Cache            page_cache               Enabled

# 3) Confirm cache max-age is not clamped to 0 in system.performance
$ drush config:get system.performance cache.page.max_age
# expected: >= 300 (seconds). If 0, anonymous caching is effectively OFF.

$ drush config:set system.performance cache.page.max_age 900 -y

# 4) Rebuild the container so the new modules register their services
$ drush cache:rebuild

Move cache backends from database to Redis

Out of the box, Drupal stores every cache bin in the SQL database (cache_render, cache_dynamic_page_cache, cache_default, cache_config, cache_menu, and more). Under load, cache reads compete for the same connection pool as content queries, and a large cache_render table can push MySQL memory pressure through the roof. Move the hot bins to Redis with the Redis module. Cache reads drop from 15-40 ms to 1-3 ms and the database stays free for actual content work.

sites/default/settings.php -- Redis cache backend for hot bins
<?php
// composer require drupal/redis
// composer require predis/predis  # (or install phpredis PHP extension)

// Point Drupal at the Redis instance
$settings['redis.connection']['interface'] = 'PhpRedis'; // or 'Predis'
$settings['redis.connection']['host']      = '127.0.0.1';
$settings['redis.connection']['port']      = 6379;

// Use Redis as the default cache backend
$settings['cache']['default'] = 'cache.backend.redis';

// Bootstrap needs a small subset of caches early; wire the class loader here
$class_loader->addPsr4('Drupal\\redis\\', 'modules/contrib/redis/src');
$settings['bootstrap_container_definition'] = [
  'parameters' => [],
  'services' => [
    'redis.factory' => [
      'class' => 'Drupal\\redis\\ClientFactory',
    ],
    'cache.backend.redis' => [
      'class' => 'Drupal\\redis\\Cache\\CacheBackendFactory',
      'arguments' => ['@redis.factory', '@cache_tags_provider.container', '@serialization.phpserialize'],
    ],
    'cache_tags_provider.container' => [
      'class' => 'Drupal\\redis\\Cache\\RedisCacheTagsChecksum',
      'arguments' => ['@redis.factory'],
    ],
    'serialization.phpserialize' => [
      'class' => 'Drupal\\Component\\Serialization\\PhpSerialize',
    ],
  ],
];

// Optional: keep the small, session-scoped bins in the DB if Redis eviction is aggressive
$settings['cache']['bins']['bootstrap']  = 'cache.backend.chainedfast';
$settings['cache']['bins']['discovery']  = 'cache.backend.chainedfast';
$settings['cache']['bins']['config']     = 'cache.backend.chainedfast';

Tune PHP opcache for a Drupal codebase

Drupal loads several hundred PHP files on a cold request. The default PHP opcache settings (128 MB memory, 10000 max files, timestamp validation every 2 seconds) leave Drupal starved -- cache evictions cause the same files to be re-parsed multiple times per minute. Tune opcache for the module footprint, disable timestamp validation in production, and enable the JIT tracing engine on PHP 8.1+ for measurable savings on twig-heavy render paths.

/etc/php/8.2/fpm/conf.d/10-opcache.ini -- production opcache tuning
; PHP 8.2 opcache settings tuned for a Drupal 10 site with contrib modules
opcache.enable=1
opcache.enable_cli=0

; Memory: 256 MB is comfortable for Drupal core + 100-200 contrib modules
opcache.memory_consumption=256

; Interned strings: bump for twig-heavy render pipelines
opcache.interned_strings_buffer=32

; File count: Drupal core alone touches ~5000 files; contrib modules add more
opcache.max_accelerated_files=20000

; Do not check file timestamps on every request in production;
; run drush cache:rebuild as part of the deploy to reset opcache instead
opcache.validate_timestamps=0
opcache.revalidate_freq=0

; Fail fast if opcache cannot start
opcache.fast_shutdown=1

; PHP 8.1+: enable the JIT tracing engine for CPU-bound render code
opcache.jit=tracing
opcache.jit_buffer_size=100M

; Log opcache warnings to a dedicated file for prod monitoring
opcache.error_log=/var/log/php/opcache-error.log

; ---
; Deploy hook (systemd or CI):
;   drush cache:rebuild && systemctl reload php8.2-fpm
; This clears both Drupal caches and opcache in one deploy step.

Front the site with a reverse-proxy CDN and wire Drupal cache tags to purge

Drupal emits cache tags on every response in the X-Drupal-Cache-Tags header (things like node:123, taxonomy_term:45, block_view). A reverse proxy that speaks these tags can cache the response at the edge and evict the exact set of URLs affected when content changes. Install the Purge module plus a driver (Fastly, Cloudflare, Varnish, Akamai) and wire the API credentials in settings.php. The result: warm edge cache HITs on anonymous requests without touching PHP or MySQL, plus surgical purges on content edits instead of full-cache flushes.

Fastly + Purge module -- settings.php and drush wiring
<?php
// composer require drupal/purge drupal/fastly
// drush pm:enable purge purge_ui purge_processor_cron purge_queuer_coretags fastly -y

// Fastly credentials in settings.php (never commit these; load from env)
$config['fastly.settings']['api_key']  = getenv('FASTLY_API_KEY');
$config['fastly.settings']['service_id'] = getenv('FASTLY_SERVICE_ID');

// Instant purge on content save; falls back to soft-purge for high-churn sites
$config['fastly.settings']['purge_method'] = 'instant';
$config['fastly.settings']['stale_while_revalidate'] = 60;
$config['fastly.settings']['stale_if_error']         = 3600;

// Tell Drupal it is behind a reverse proxy so it trusts X-Forwarded-* headers
$settings['reverse_proxy'] = TRUE;
$settings['reverse_proxy_addresses'] = ['127.0.0.1']; // populate from your Fastly IP list

// Emit surrogate keys so Fastly VCL can key cache entries on Drupal tags
$settings['http_response_headers']['Surrogate-Control'] = 'max-age=86400';
Terminal -- verify purge queue and confirm tag purges fire on node edits
# Check purge queue processors are running
$ drush purge:processor:ls
$ drush purge:queue:stats

# Fire a manual test purge for a single node tag
$ drush purge:queue:add node:123
$ drush purge:queue:work
# expected: 'Processed 1 items' with no errors

# Watch a node edit trigger the queue (in a second terminal)
$ watch -n 1 'drush purge:queue:stats'
# edit node 123 in admin -> queue length should tick up, then drain

Verify TTFB improvements with drush and curl

After all four changes above, rebuild caches, warm the edge, and inspect the response headers. Drupal exposes cache state on two headers: X-Drupal-Cache (Internal Page Cache HIT/MISS) and X-Drupal-Dynamic-Cache (Dynamic Page Cache HIT/MISS). The CDN exposes its own header (Cache-Status for Fastly, cf-cache-status for Cloudflare, X-Cache for Varnish). A healthy site returns HIT on both Drupal layers and HIT at the edge on warm anonymous requests.

Terminal -- warm and inspect the Drupal cache stack
# 1) Rebuild Drupal caches and warm the site
$ drush cache:rebuild
$ curl -s -o /dev/null https://example.com/         # warm the homepage
$ curl -s -o /dev/null https://example.com/about    # warm a content page

# 2) Confirm both Drupal cache layers hit on the second request
$ curl -sI -H 'User-Agent: Mozilla/5.0' https://example.com/ | \
    grep -iE 'x-drupal-cache|x-drupal-dynamic-cache|cache-status|cf-cache'

# expected (warm):
# X-Drupal-Cache: HIT
# X-Drupal-Dynamic-Cache: HIT
# Cache-Status: Fastly; hit
# (or) cf-cache-status: HIT

# 3) If X-Drupal-Cache stays MISS, look for max-age=0 emissions
$ curl -sI https://example.com/ | grep -iE 'cache-control|surrogate-control'
# common offender: a rendered block calling ::mergeCacheMaxAge(0) somewhere in code

# 4) Measure warm TTFB across 5 anonymous runs from a CDN POP
$ for i in 1 2 3 4 5; do \
    curl -o /dev/null -s -w 'ttfb=%{time_starttransfer}s\n' https://example.com/; \
  done
# expected on the after-state stack: 0.05-0.10s warm from a nearby POP

Quick checklist

  • page_cache, dynamic_page_cache, and big_pipe are all enabled
  • system.performance.cache.page.max_age is at least 300 seconds
  • Hot cache bins (cache_render, cache_dynamic_page_cache, cache_default) live on Redis, not MySQL
  • PHP opcache is tuned for the codebase (256 MB, 20000 files, validate_timestamps=0, JIT on 8.1+)
  • A reverse-proxy CDN fronts the site and the Purge module fires tag purges on content edits
  • curl -I returns X-Drupal-Cache: HIT and X-Drupal-Dynamic-Cache: HIT on a warm anonymous request

Frequently asked questions

The three most common causes are database-backed cache bins under load (every render pulls cache_render out of MySQL), missing PHP opcache tuning (Drupal loads many small PHP files per request), and content-editor-heavy sites where BigPipe and Dynamic Page Cache are not enabled so every authenticated page renders synchronously. Enabling Internal Page Cache, Dynamic Page Cache, and BigPipe plus moving cache_render to Redis eliminates the majority of TTFB issues on a stock Drupal 10 install.

Both work well as fast cache backends for Drupal, and both are supported through community modules. Redis has slightly richer semantics for cache tag invalidation and is easier to run in HA configurations. Memcached is marginally faster for pure key-value reads and uses less memory per entry. If you already run Memcached and it is healthy, keep it. If you are choosing fresh, Redis is the more common recommendation because the Drupal Redis module handles cache tags natively and integrates cleanly with the Purge module.

Google rates TTFB as Good under 800 ms, Needs Improvement between 800 and 1,800 ms, and Poor above 1,800 ms. On a well-tuned Drupal 10 site with Internal Page Cache plus Redis, anonymous TTFB typically lands between 80 and 200 ms warm. Behind a CDN with tag-based edge caching (Fastly, Cloudflare, Varnish), warm anonymous TTFB routinely drops under 50 ms. Authenticated TTFB with BigPipe enabled is usually 200-400 ms because the shell streams first and personalized fragments follow.

Not directly. BigPipe is designed for authenticated pages where Internal Page Cache cannot serve a full-page HIT because the response varies per user (cart contents, personalized blocks, editor toolbars). BigPipe streams the cacheable shell immediately and swaps in personalized placeholders as they render, which improves perceived TTFB and LCP for logged-in users. For anonymous pages, Internal Page Cache already returns a full-page HIT so BigPipe is not on the hot path.

Send an anonymous curl request and inspect the response headers. X-Drupal-Cache: HIT means the Internal Page Cache served the response. X-Drupal-Dynamic-Cache: HIT means Dynamic Page Cache served it (the layer that swaps in per-role or per-context placeholders). If both are MISS on repeated requests, some module or setting is disabling caching -- common culprits are max-age=0 emitted by a rendered block, cache-defeating query parameters, or a session-bearing cookie on what should be anonymous traffic.

Aggregation reduces the number of subsequent requests and improves LCP by shrinking the critical resource waterfall, but it does not change TTFB, which is measured on the initial HTML response. If your TTFB is high, look at cache backends, opcache, and reverse-proxy caching first. Aggregation is worth enabling for its LCP and INP effects but should not be the first knob you turn to move TTFB.

Continue learning