Skip to main content

Performance and Caching

Strapi is fast out of the box for small datasets. At scale -- thousands of entries, deep relations, high traffic -- you need explicit caching, query optimization, and infrastructure tuning.

Performance bottleneck overview

The three most common bottlenecks:

  1. Deep population -- every populate adds joins
  2. N+1 queries -- fetching relations one-by-one instead of batched
  3. No response caching -- identical requests hit the database every time

Response caching

In-memory cache (simple, single-instance)

Good for small projects, no external dependencies:

// src/api/article/middlewares/cache.js
const cache = new Map();

module.exports = (config, { strapi }) => {
const ttl = config.ttl || 60000; // Default 60 seconds
const maxSize = config.maxSize || 1000; // Max cached entries

return async (ctx, next) => {
// Only cache GET requests
if (ctx.method !== 'GET') {
await next();
// Invalidate cache on writes
cache.clear();
return;
}

const key = `${ctx.url}|${ctx.get('Authorization') || 'anon'}`;
const cached = cache.get(key);

if (cached && Date.now() - cached.timestamp < ttl) {
ctx.body = cached.body;
ctx.status = cached.status;
ctx.set('X-Cache', 'HIT');
return;
}

await next();

if (ctx.status === 200 && ctx.body) {
// Evict oldest entry if cache is full
if (cache.size >= maxSize) {
const oldest = cache.keys().next().value;
cache.delete(oldest);
}

cache.set(key, {
body: ctx.body,
status: ctx.status,
timestamp: Date.now(),
});
ctx.set('X-Cache', 'MISS');
}
};
};

Redis cache (production, multi-instance)

npm install ioredis
// src/api/article/middlewares/redis-cache.js
const Redis = require('ioredis');

let redis;

module.exports = (config, { strapi }) => {
const ttl = config.ttl || 60; // seconds
const prefix = config.prefix || 'strapi:cache:';

if (!redis) {
redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
}

return async (ctx, next) => {
if (ctx.method !== 'GET') {
await next();
// Invalidate related cache on writes
const pattern = `${prefix}${ctx.url.split('?')[0]}*`;
const keys = await redis.keys(pattern);
if (keys.length) await redis.del(...keys);
return;
}

const key = `${prefix}${ctx.url}`;

try {
const cached = await redis.get(key);
if (cached) {
ctx.body = JSON.parse(cached);
ctx.set('X-Cache', 'HIT');
ctx.set('X-Cache-TTL', ttl);
return;
}
} catch (err) {
strapi.log.warn('[redis-cache] Read error:', err.message);
}

await next();

if (ctx.status === 200 && ctx.body) {
try {
await redis.setex(key, ttl, JSON.stringify(ctx.body));
} catch (err) {
strapi.log.warn('[redis-cache] Write error:', err.message);
}
ctx.set('X-Cache', 'MISS');
}
};
};

Applying the cache middleware

// src/api/article/routes/article.js
const { createCoreRouter } = require('@strapi/strapi').factories;

module.exports = createCoreRouter('api::article.article', {
config: {
find: {
middlewares: [
{ name: 'api::article.redis-cache', config: { ttl: 300 } }, // 5 min
],
},
findOne: {
middlewares: [
{ name: 'api::article.redis-cache', config: { ttl: 600 } }, // 10 min
],
},
},
});

Cache invalidation via Document Service middleware

// src/index.js
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
const result = await next();

if (['create', 'update', 'delete', 'publish', 'unpublish'].includes(context.action)) {
const prefix = 'strapi:cache:';
const apiName = context.uid.split('.').pop();
const pattern = `${prefix}/api/${apiName}*`;

const keys = await redis.keys(pattern);
if (keys.length) {
await redis.del(...keys);
strapi.log.debug(`[cache] Invalidated ${keys.length} keys for ${context.uid}`);
}
}

return result;
});
},
};

Database optimization

Adding database indexes

Strapi doesn't add indexes automatically (beyond primary keys). For frequently filtered fields, add them manually:

// database/migrations/2025.01.15T00.00.00.add-indexes.js
module.exports = {
async up(knex) {
// Index on slug for lookups
await knex.schema.alterTable('articles', (table) => {
table.index('slug');
});

// Index on published_at for publication queries
await knex.schema.alterTable('articles', (table) => {
table.index('published_at');
});

// Composite index for locale + slug queries
await knex.schema.alterTable('articles', (table) => {
table.index(['locale', 'slug']);
});

// Index on foreign keys (Strapi doesn't always add these)
await knex.schema.alterTable('articles_author_lnk', (table) => {
table.index('article_id');
table.index('author_id');
});
},

async down(knex) {
await knex.schema.alterTable('articles', (table) => {
table.dropIndex('slug');
table.dropIndex('published_at');
table.dropIndex(['locale', 'slug']);
});
},
};

Connection pooling

// config/env/production/database.js
module.exports = ({ env }) => ({
connection: {
client: 'postgres',
connection: {
host: env('DATABASE_HOST'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME'),
user: env('DATABASE_USERNAME'),
password: env('DATABASE_PASSWORD'),
},
pool: {
min: env.int('DATABASE_POOL_MIN', 2),
max: env.int('DATABASE_POOL_MAX', 10),
acquireTimeoutMillis: 30000,
idleTimeoutMillis: 30000,
},
},
});

Query optimization

Select only needed fields

// BAD: fetches all 30 fields for each article
const articles = await strapi.documents('api::article.article').findMany({
populate: '*',
});

// GOOD: only fetch what the frontend needs
const articles = await strapi.documents('api::article.article').findMany({
fields: ['title', 'slug', 'publishedAt'],
populate: {
author: { fields: ['name'] },
cover: { fields: ['url', 'alternativeText', 'width', 'height'] },
},
});

Avoid deep population

// BAD: 4 levels of nesting
const articles = await strapi.documents('api::article.article').findMany({
populate: {
author: {
populate: {
company: {
populate: {
employees: {
populate: ['avatar'],
},
},
},
},
},
},
});

// GOOD: fetch only what's needed, make separate queries if needed
const articles = await strapi.documents('api::article.article').findMany({
fields: ['title', 'slug'],
populate: {
author: { fields: ['name', 'avatar'] },
},
});

Pagination: always paginate

// BAD: loads all articles into memory
const all = await strapi.documents('api::article.article').findMany({});

// GOOD: paginate
const page1 = await strapi.documents('api::article.article').findMany({
page: 1,
pageSize: 25,
});

CDN integration

Serving media through a CDN

// config/middlewares.js
module.exports = [
// ...
{
name: 'strapi::security',
config: {
contentSecurityPolicy: {
directives: {
'img-src': ["'self'", 'data:', 'cdn.example.com', '*.cloudinary.com'],
},
},
},
},
// ...
];

Setting cache headers for API responses

// src/middlewares/cache-headers.js
module.exports = (config, { strapi }) => {
const maxAge = config.maxAge || 60;

return async (ctx, next) => {
await next();

if (ctx.method === 'GET' && ctx.status === 200) {
// Public endpoints can be cached by CDN
if (!ctx.state.user) {
ctx.set('Cache-Control', `public, max-age=${maxAge}, s-maxage=${maxAge * 2}`);
ctx.set('Vary', 'Accept, Accept-Encoding');
} else {
ctx.set('Cache-Control', 'private, no-cache');
}
}
};
};

Stale-while-revalidate pattern

ctx.set(
'Cache-Control',
`public, max-age=60, s-maxage=300, stale-while-revalidate=600`
);
// CDN serves stale content for up to 10 minutes while revalidating in the background

Monitoring and profiling

Request timing middleware

// src/middlewares/request-timing.js
module.exports = (config, { strapi }) => {
const slowThreshold = config.slowThreshold || 1000;

return async (ctx, next) => {
const start = Date.now();
await next();
const duration = Date.now() - start;

ctx.set('X-Response-Time', `${duration}ms`);

if (duration > slowThreshold) {
strapi.log.warn({
msg: 'Slow request',
method: ctx.method,
url: ctx.url,
status: ctx.status,
duration: `${duration}ms`,
query: ctx.query,
});
}
};
};

Database query logging

// config/database.js
module.exports = ({ env }) => ({
connection: {
client: 'postgres',
connection: { /* ... */ },
debug: env.bool('DATABASE_DEBUG', false), // Logs all SQL queries
},
});

Enable temporarily with DATABASE_DEBUG=true to find slow queries.


Performance checklist

ActionImpactEffort
Add fields to every queryHigh -- reduces payload 50-80%Low
Add database indexes on filtered columnsHigh -- 10x faster lookupsLow
Implement response caching (Redis)Very high -- eliminates DB hitsMedium
Set Cache-Control headersHigh -- enables CDN cachingLow
Paginate all list endpointsHigh -- prevents memory spikesLow
Limit population depthMedium -- fewer joinsLow
Use connection poolingMedium -- prevents connection exhaustionLow
Profile slow requestsVaries -- identifies the real bottleneckMedium

Common pitfalls

PitfallProblemFix
populate=* in productionFetches entire relational graphExplicitly list fields and relations
No cache invalidationStale data served foreverInvalidate on write operations
Cache key ignores authUser A sees user B's dataInclude auth state in cache key
SQLite in productionLocks under concurrent writesUse PostgreSQL or MySQL
No database indexesFull table scans on every queryAdd indexes on slug, publishedAt, foreign keys
Logging every query in productionI/O overhead from debug loggingOnly enable debug temporarily

See also