Skip to main content
HOME / TECHNICAL DISPATCH / PERFORMANCE ENGINEERING
PERFORMANCE ENGINEERING

Sub-200ms TTFB
on Drupal 10 at
Government Scale

 AUTHOR: Steve Zipfel
DATE: 2024-05-15
READ_TIME: 11 MIN
COMPLEXITY: 
STACK: DRUPAL 10 / REDIS / NGINX

We recently crossed a threshold that felt impossible two years ago: a Drupal 10 platform serving 4.2 million monthly sessions at a consistent sub-200ms Time to First Byte — on government infrastructure, with bilingual content, full WCAG compliance, and no edge-side caching.

This post breaks down the exact architecture decisions, Redis configuration, and Nginx tuning that got us there. No theory — just the specific settings, in the order we implemented them.

Baseline: Where we started and why it was slow

The existing platform was a Drupal 9 monolith. Every page request hit PHP, which hit MySQL, which ran 40–80 queries per page load. The internal page cache was enabled but misconfigured — it cached authenticated users, invalidating constantly. Average TTFB was sitting at 1,200ms.

METRIC
BEFORE
AFTER
DELTA
TTFB (p95)
1,240ms
148ms
−88%
DB queries / page
74 avg
3 avg
−96%
Cache hit rate
12%
94%
+82pp
PHP memory / req
128 MB
34 MB
−73%

The path from 1,240ms to 148ms wasn't a single change — it was eight layers of incremental improvements, each measured before and after. Here's each layer in sequence.

Layer 1: Redis as Drupal's primary cache backend

Drupal's default cache backend writes to MySQL. That means every cache read is a database query. For a high-traffic platform, you're trading slow uncached pages for slightly-less-slow cached pages.

Redis changes that completely. A Redis cache read is a memory operation — typically 0.1–0.3ms versus MySQL's 2–8ms for a cached row. At 74 cache lookups per page load, that's meaningful.

web/sites/default/settings.php PHP
// Redis cache backend — replaces MySQL for all cache bins
$settings['cache']['default'] = 'cache.backend.redis';
$settings['cache']['bins']['render'] = 'cache.backend.redis';
$settings['cache']['bins']['discovery'] = 'cache.backend.redis';
$settings['cache']['bins']['bootstrap'] = 'cache.backend.redis';

// Keep config in APCu — survives Redis flushes during deploys
$settings['cache']['bins']['config'] = 'cache.backend.apcu';

$settings['redis.connection']['host'] = '127.0.0.1';
$settings['redis.connection']['port'] = 6379;
$settings['redis.connection']['database'] = 0;
$settings['redis.connection']['password'] = getenv('REDIS_PASSWORD');
▸
Critical: Keep cache.bins.config in APCu, not Redis. Config cache is read before Redis connectivity is established during bootstrapping. Routing both through Redis causes a bootstrap deadlock during Redis restarts — a lesson we learned the hard way at 2am on a deployment.

Layer 2: Redis configuration — the settings that matter

Installing the Redis module and pointing Drupal at it gets you 60% of the gain. The other 40% is in Redis's own configuration. These are the settings we tune on every production deployment:

/etc/redis/redis.conf CONFIG
# Memory limit — set to 75% of available RAM reserved for Redis
maxmemory 6gb
maxmemory-policy allkeys-lru

# Persistence — disable AOF for cache-only workloads
# RDB snapshots are fine; cache loss on restart is acceptable
appendonly no
save 900 1
save 300 10

# Connection tuning
tcp-backlog 511
timeout 0
tcp-keepalive 300

# Lazy freeing — non-blocking key expiry, critical at high volume
lazyfree-lazy-eviction yes
lazyfree-lazy-expire yes
lazyfree-lazy-server-del yes
"The allkeys-lru eviction policy is non-negotiable at government scale. You will exceed your memory limit during traffic spikes — and when you do, you need Redis to gracefully evict the least-recently-used keys, not throw OOM errors and take the whole site down."

Layer 3: Nginx micro-caching + FastCGI tuning

Even with Redis, Drupal still executes PHP for every request that isn't in the internal page cache. For anonymous traffic at scale, Nginx's FastCGI cache with a 1-second micro-cache absorbs traffic spikes without serving stale content to logged-in users.

/etc/nginx/conf.d/studio613.conf NGINX
# FastCGI cache zone — 1GB shared memory, 7d content TTL
fastcgi_cache_path /var/cache/nginx/drupal
  levels=1:2
  keys_zone=DRUPAL:100m
  max_size=1g
  inactive=60m
  use_temp_path=off;

fastcgi_cache_key "$scheme$request_method$host$request_uri";

# Skip cache for authenticated users and POST requests
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
if ($http_cookie ~* "SESS") { set $skip_cache 1; }

fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache DRUPAL;
fastcgi_cache_valid 200 1s; # 1-second micro-cache
add_header X-FastCGI-Cache $upstream_cache_status;

The 1-second TTL sounds trivial, but at 500 requests/second it means only one request per URL actually hits PHP each second. During a ministerial announcement driving 2,000 concurrent users to a single page, this prevented the site from going down completely.

// END TRANSMISSION