Strapi Performance Optimization β Caching, CDN, Database Tuning, and Clustering
In this tutorial, you will learn how to optimize Strapi performance β implementing response caching with Redis, integrating a CDN for media delivery, tuning database queries, scaling with PM2 cluster mode, and compressing API responses for faster frontend loading.
What You'll Learn
- How to implement API response caching with Redis
- How to configure a CDN for media and API responses
- How to optimize database queries and indexes
- How to scale Strapi with PM2 cluster mode
- How to enable response compression
- How to monitor performance bottlenecks
Why It Matters
A slow API hurts user experience and search rankings. A Strapi endpoint that takes 500ms without caching can take 5ms with caching β a 100x improvement. As traffic grows, an unoptimized Strapi backend becomes slower and more expensive to run. Performance optimization reduces server costs, improves frontend load times, and keeps your site ranking well in search engines.
Real-World Use
A travel blog using Strapi serves 200,000 daily visitors. Without caching, each article page triggered two Strapi API calls (article content and related articles), taking 300ms total. With Redis caching, the same data loads in 8ms for cached responses. The CDN serves images from edge locations instead of the Strapi server, reducing image load time from 2 seconds to 200ms. The Strapi server runs 4 cluster instances, handling the traffic with CPU usage below 40%.
Learning Path
flowchart LR A["CI/CD"] --> B["Performance
-- You are here"]:::current B --> C["Security & Monitoring"] classDef current fill:#4945ff,color:#fff,stroke-width:2px
API Response Caching with Redis
Caching stores API responses in memory so repeated requests skip the database entirely:
# Install Redis and the Koa cache middleware
npm install koa-redis-cache
// config/middlewares.js β Add caching middleware
module.exports = [
"strapi::logger",
"strapi::errors",
"strapi::security",
"strapi::cors",
"strapi::poweredBy",
"strapi::query",
"strapi::body",
"strapi::session",
"strapi::favicon",
"strapi::public",
{
name: "strapi::compression",
config: {
enabled: true,
threshold: 1024, // Compress responses larger than 1KB
gzip: true,
br: false,
},
},
{
name: "global::api-cache",
config: {
enabled: true,
},
},
];
// src/middlewares/api-cache.js β Custom caching middleware
const cache = require("koa-redis-cache");
module.exports = (config, { strapi }) => {
return cache({
redis: {
host: process.env.REDIS_HOST || "localhost",
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD || undefined,
},
expire: 300, // Cache for 5 minutes (in seconds)
routes: [
// Cache GET requests to API endpoints
{ path: "/api/articles", method: "GET" },
{ path: "/api/articles/(.*)", method: "GET" },
{ path: "/api/categories", method: "GET" },
{ path: "/api/categories/(.*)", method: "GET" },
{ path: "/api/homepage", method: "GET" },
{ path: "/api/global", method: "GET" },
],
// Do not cache authenticated requests
exclude: ["/api/users", "/api/auth"],
// Cache key prefix for easy invalidation
prefix: "strapi-cache",
// Only cache successful responses
onError: (error) => {
strapi.log.error("Cache error:", error);
},
});
};
// Cache invalidation in lifecycle hooks
// src/api/article/content-types/article/lifecycles.js
module.exports = {
async afterCreate(event) {
// Invalidate the articles list cache when a new article is created
const { strapi } = event;
await clearCachePattern(strapi, "/api/articles*");
},
async afterUpdate(event) {
// Invalidate both the list and individual article cache
const { strapi, result } = event;
await clearCachePattern(strapi, "/api/articles*");
await clearCachePattern(strapi, `/api/articles/${result.id}*`);
},
async afterDelete(event) {
const { strapi } = event;
await clearCachePattern(strapi, "/api/articles*");
},
};
async function clearCachePattern(strapi, pattern) {
try {
const redis = strapi.redis || require("redis");
const client = redis.createClient({
url: process.env.REDIS_URL || "redis://localhost:6379",
});
await client.connect();
// Find all cache keys matching the pattern
const keys = await client.keys(`strapi-cache${pattern}`);
if (keys.length > 0) {
await client.del(keys);
strapi.log.debug(`Cleared ${keys.length} cache entries for ${pattern}`);
}
await client.quit();
} catch (error) {
strapi.log.warn("Cache invalidation failed:", error.message);
}
}
The cache invalidation strategy keeps the cache fresh. When content changes, related cache entries are cleared so the next request fetches fresh data.
CDN Integration
A CDN (Content Delivery Network) serves media and cached API responses from edge locations near your users:
Without CDN:
User in Tokyo β request β Strapi server (US) β response (300ms latency)
With CDN:
User in Tokyo β request β CDN edge (Tokyo) β response (10ms latency)
// config/plugins.js β Configure upload provider to use CDN URLs
module.exports = ({ env }) => ({
upload: {
config: {
provider: "aws-s3",
providerOptions: {
accessKeyId: env("AWS_ACCESS_KEY_ID"),
secretAccessKey: env("AWS_ACCESS_SECRET"),
region: env("AWS_REGION", "us-east-1"),
params: {
Bucket: env("AWS_S3_BUCKET"),
},
// CDN URL for served files
baseUrl: env("CDN_URL", "https://cdn.example.com"),
},
},
},
});
Set up Cloudflare as a reverse proxy for API caching:
Cloudflare configuration for Strapi:
1. Point DNS to your Strapi server IP
2. Enable "Proxied" (orange cloud) for API domain
3. Create cache rules:
- Cache API GET responses for 5 minutes
- Bypass cache for admin routes (/admin/*)
- Bypass cache for authenticated requests
4. Enable Auto Minify for HTML, CSS, JS
5. Enable Brotli compression
6. Set security level to Medium
Cloudflare Cache Rule:
URL: api.example.com/api/*
Cache level: Cache Everything
Edge TTL: 5 minutes
Bypass cookie: *token*, *jwt*, *session*
Database Query Optimization
Optimize database queries for faster responses:
Slow query example:
SELECT * FROM articles WHERE published_at IS NOT NULL
ORDER BY created_at DESC
-- Took 850ms, scanning 50,000 rows
Optimized query with index:
CREATE INDEX idx_articles_published_created
ON articles(published_at, created_at DESC);
-- Took 15ms, using index scan
Create indexes for common query patterns:
# Connect to PostgreSQL and create indexes
psql strapi_production
# Index for filtered queries
CREATE INDEX idx_articles_published
ON articles(published_at)
WHERE published_at IS NOT NULL;
# Index for sorting
CREATE INDEX idx_articles_created_desc
ON articles(created_at DESC);
# Index for relation queries
CREATE INDEX idx_articles_author
ON articles(author_id);
# Index for text search (if using search)
CREATE INDEX idx_articles_title_search
ON articles
USING gin(to_tsvector('english', title));
// Optimize API queries by limiting returned fields
// Instead of:
// GET /api/articles (returns all fields)
// Use field selection:
// GET /api/articles?fields[0]=title&fields[1]=createdAt&populate[author][fields][0]=name
// Frontend receives only needed data, reducing payload size
Use Strapi's entity service with selective population:
// src/api/article/controllers/article.js
const { createCoreController } = require("@strapi/strapi").factories;
module.exports = createCoreController("api::article.article", ({ strapi }) => ({
async find(ctx) {
// Add pagination defaults if not provided
if (!ctx.query.pagination) {
ctx.query.pagination = { page: 1, pageSize: 10 };
}
// Limit populate to only needed relations
if (!ctx.query.populate) {
ctx.query.populate = {
author: { fields: ["name", "avatar"] },
tags: { fields: ["name"] },
};
}
const { data, meta } = await super.find(ctx);
return { data, meta };
},
async findOne(ctx) {
// Populate everything for single article view
ctx.query.populate = {
author: { populate: ["profile"] },
tags: { fields: ["name"] },
category: true,
comments: {
populate: ["user"],
sort: { createdAt: "desc" },
pagination: { page: 1, pageSize: 50 },
},
};
const { data, meta } = await super.findOne(ctx);
return { data, meta };
},
}));
PM2 Cluster Mode
Scale Strapi across multiple CPU cores:
// ecosystem.config.js β Cluster mode
module.exports = {
apps: [
{
name: "strapi",
script: "npm",
args: "start",
env: { NODE_ENV: "production" },
exec_mode: "cluster",
instances: "max", // Use all CPU cores
max_memory_restart: "500M",
error_file: "./logs/strapi-error.log",
out_file: "./logs/strapi-out.log",
merge_logs: true,
log_date_format: "YYYY-MM-DD HH:mm:ss",
autorestart: true,
watch: false,
max_restarts: 10,
restart_delay: 4000,
kill_timeout: 5000,
env_file: ".env.production",
// Health monitoring
instance_var: "INSTANCE_ID",
},
],
};
# Start with cluster mode
pm2 start ecosystem.config.js
# Check cluster status
pm2 status
# Output:
# βββββββ¬βββββββββ¬βββββββββ¬ββββββ¬βββββββββ¬βββββββββββ
# β id β name β mode β βΊ β status β cpu β
# βββββββΌβββββββββΌβββββββββΌββββββΌβββββββββΌβββββββββββ€
# β 0 β strapi β clusterβ 0 β online β 35% β
# β 1 β strapi β clusterβ 0 β online β 42% β
# β 2 β strapi β clusterβ 0 β online β 28% β
# β 3 β strapi β clusterβ 0 β online β 31% β
# βββββββ΄βββββββββ΄βββββββββ΄ββββββ΄βββββββββ΄βββββββββββ
Cluster mode runs one Strapi instance per CPU core. Each instance handles requests independently. With 4 CPU cores, you can handle approximately 4x the traffic.
Response Compression
Enable gzip compression to reduce API response sizes:
// config/middlewares.js β Enable compression
module.exports = [
// ... other middlewares
{
name: "compression",
config: {
enabled: true,
threshold: 1024, // Only compress responses larger than 1KB
gzip: true,
br: false, // Brotli (disable if nginx handles it)
},
},
];
Compression results:
Without compression: 150KB JSON response
With gzip: 22KB (85% reduction)
With brotli: 18KB (88% reduction)
Frontend load time (3G): 1.2s β 0.3s
Performance Monitoring
Monitor performance to identify bottlenecks:
// config/admin.js β Enable performance tracking
module.exports = ({ env }) => ({
// ... other config
api: {
responses: {
maxPageSize: 100,
},
},
admin: {
// Enable admin panel performance monitoring
watchIgnoreFiles: [],
},
});
// Custom middleware for request timing
// src/middlewares/request-timing.js
module.exports = (config, { strapi }) => {
return async (ctx, next) => {
const start = Date.now();
const method = ctx.method;
const path = ctx.path;
await next();
const duration = Date.now() - start;
strapi.log.info(`${method} ${path} - ${duration}ms`);
// Alert on slow requests
if (duration > 1000) {
strapi.log.warn(
`Slow request detected: ${method} ${path} took ${duration}ms`
);
}
// Add timing header for frontend debugging
ctx.set("X-Response-Time", `${duration}ms`);
};
};
Common Mistakes
Caching everything without invalidation. Caching all API responses without clearing the cache when content changes serves stale data to users. Always implement cache invalidation in lifecycle hooks.
Not indexing database columns used in filters. A query filtering by
filters[category]=5without an index oncategory_idscans the entire table. Add indexes for all columns used in filter and sort parameters.Running Strapi on a single Process in production. Without cluster mode, your application can only use one CPU core. Enable PM2 cluster mode to utilize all cores and handle concurrent requests.
Serving media files directly from Strapi. Media files served from the Strapi server consume application memory and CPU. Use an external upload provider (S3, Cloudinary) with a CDN for media delivery.
Using populate= in production.* Populating all relations on every request loads unnecessary data and slows responses. Be specific about which relations to populate and which fields to return.
Practice Questions
What is the purpose of cache invalidation in lifecycle hooks? Answer: Cache invalidation clears cached responses when content changes. Without it, users see stale data until the cache expires. Lifecycle hooks (afterCreate, afterUpdate, afterDelete) trigger invalidation immediately.
How does PM2 cluster mode improve Strapi performance? Answer: Cluster mode runs one Strapi instance per CPU core, handling requests in parallel. A 4-core server handles approximately 4x the traffic of a single-instance setup. It also provides automatic restarts on failure.
Why should you serve media from a CDN instead of the Strapi server? Answer: A CDN serves files from edge locations near users, reducing latency. It offloads bandwidth from the Strapi server, freeing resources for API requests. CDNs also handle traffic spikes better than a single server.
Challenge: Optimize a Strapi application for production: (1) Set up Redis cache with 5-minute TTL for all GET /api/* endpoints, (2) Implement cache invalidation in lifecycle hooks for the Article content type, (3) Create database indexes for the most common query patterns, (4) Configure PM2 cluster mode with instances equal to CPU cores, (5) Enable gzip compression for API responses, (6) Configure a CDN (Cloudflare or similar) for the API domain, (7) Benchmark the application before and after optimization, documenting the improvements in response time and throughput.
FAQ
Mini Project
Your task: Optimize a Strapi application and measure the performance improvements.
- Benchmark the current API response times for at least 5 endpoints.
- Install and configure Redis, then implement API response caching.
- Set up cache invalidation in lifecycle hooks for at least one content type.
- Create database indexes for three common query patterns.
- Configure PM2 cluster mode with instances equal to CPU cores.
- Enable gzip compression and measure the payload size reduction.
- Configure a CDN (Cloudflare free tier) for the API domain.
- Benchmark all endpoints again and compare results: response time, throughput (requests per second), and payload size.
- Document the performance improvements with before/after numbers.
What's Next
Now that you have optimized performance, complete the series with Security & Monitoring to learn about hardening Strapi, logging errors, tracking application health, and setting up backups for production systems.
Related lessons:
- Node.js β Node.js performance best practices
- PostgreSQL β Database performance tuning
- REST API β API optimization techniques
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro