Skip to content

Strapi Performance Optimization β€” Caching, CDN, Database Tuning, and Clustering

DodaTech Updated 2026-06-28 11 min read

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

  1. 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.

  2. Not indexing database columns used in filters. A query filtering by filters[category]=5 without an index on category_id scans the entire table. Add indexes for all columns used in filter and sort parameters.

  3. 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.

  4. 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.

  5. 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

  1. 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.

  2. 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.

  3. 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.

  4. 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

Is Redis required for Strapi caching?

No, but Redis provides the best caching performance. You can also use in-memory caching (Koa's built-in cache) for single-server deployments. Redis is necessary for multi-server deployments where instances share a cache.

How much can caching improve Strapi response times?

Cached responses are typically 10-100x faster than uncached responses. A database query taking 300ms becomes a Redis lookup taking 3-5ms. Cache hit rates above 90% are achievable for read-heavy APIs.

What is the recommended PM2 cluster instance count?

Set instances: 'max' to use all CPU cores, or set a specific number if you want to reserve CPU for other processes. Do not exceed the number of CPU cores.

How do I identify slow database queries?

Enable PostgreSQL query logging with log_min_duration_statement = 200 in postgresql.conf. Use EXPLAIN ANALYZE on slow queries to identify missing indexes or inefficient query plans.

Can I use Strapi with a read replica for database scaling?

Yes, configure Strapi to use a read replica for GET requests and the primary database for writes. This requires custom database configuration. Strapi does not support read replicas natively.

Mini Project

Your task: Optimize a Strapi application and measure the performance improvements.

  1. Benchmark the current API response times for at least 5 endpoints.
  2. Install and configure Redis, then implement API response caching.
  3. Set up cache invalidation in lifecycle hooks for at least one content type.
  4. Create database indexes for three common query patterns.
  5. Configure PM2 cluster mode with instances equal to CPU cores.
  6. Enable gzip compression and measure the payload size reduction.
  7. Configure a CDN (Cloudflare free tier) for the API domain.
  8. Benchmark all endpoints again and compare results: response time, throughput (requests per second), and payload size.
  9. 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:

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro