Strapi is designed to scale and perform well in production environments. Teams running well-designed Strapi applications routinely serve high traffic volumes with stable response times and predictable resource usage.
When Strapi performance issues do arise, they are almost always the result of implementation choices rather than limitations of Strapi itself — most commonly around content modeling, query design, or the use of unsupported plugins.
This article highlights common pitfalls that lead to poor Strapi performance and outlines Strapi best practices for building fast, reliable Strapi applications, with a specific focus on why populate=deep plugins should be avoided in production.
In practice, Strapi performance problems tend to surface when:
These patterns can turn an otherwise performant system into one that struggles under light load.
Strapi gives developers fine-grained control over how content is queried and returned. When these controls are used deliberately, Strapi performs extremely well.
Problems arise when APIs are designed to:
The most important performance principle is simple: only fetch what you actually need.
Explicit population is the supported and recommended way to retrieve related content in Strapi.
It allows you to:
See Strapi documentation on population.
As a general guideline, population depths beyond two or three levels should be treated as a signal to revisit the content model.
For endpoints that always require the same population (such as a homepage, navigation, or global settings), Strapi supports defining population logic at the routing layer.
This approach:
Visit the Strapi blog post on route-based population.
A common performance issue in Strapi apps is inconsistent population logic across the frontend.
1// Different parts of the app
2
3GET /api/articles?populate=*
4
5GET /api/articles?populate[cover]=true
6
7GET /api/articles?populate[blocks][populate]=*This leads to:
populate=*) Instead of letting the frontend control population, centralise it in the backend.
1// ./src/api/article/middlewares/article-populate.js
2
3export default () => {
4 return async (ctx, next) => {
5 ctx.query = {
6 ...ctx.query,
7
8 fields: ["title", "slug"],
9
10 populate: {
11 cover: { fields: ["url"] },
12
13 author: { fields: ["name"] },
14 },
15 };
16
17 await next();
18 };
19};Attach it to the route:
1config: {
2 middlewares: ["api::article.article-populate"];
3}GET /api/articlesAs highlighted in the Strapi blog, this approach keeps requests “lean and organized” by handling population in the backend rather than the frontend.
Strapi is flexible by design, but flexibility requires discipline as projects grow.
Common modeling issues that affect performance include:
When these patterns appear, it’s often worth reconsidering the structure rather than compensating at query time.
For Strapi best practices, not all structured data needs to be relational.
For complex data that:
Custom fields that store structured JSON are often a better fit. This reduces database joins and keeps queries fast and predictable.
Opening times are a perfect example of where teams often over-model data in Strapi.
At first glance, opening hours look relational:
This often leads to a schema like the one below.
1Location (collection type)
2
3└── Opening Day (component, repeatable)
4
5 ├── day (enum)
6
7 ├── enabled (boolean)
8
9 └── timeframes (component, repeatable)
10
11 ├── startTime (string)
12
13 ├── endTime (string)
14
15 └── staffing (enum)Or worse, fully relational:
1Location
2
3└── hasMany OpeningDay
4
5 └── hasMany TimeframeThis structure introduces several performance and maintainability issues:
populate[days][populate][timeframes]) This is exactly the kind of structure that tempts teams into using populate=deep, which then amplifies the performance cost.
Instead of modeling opening times as relations or nested components, treat them as a single unit of structured data. This allows you greater control over the way the data is stored without the overhead of multiple joins in your end query, with this control comes the ability to build a unique UI to give your content editors better functionality when adding this content.
This is what your custom field is doing.
1{
2 "days": [
3 {
4 "day": "monday",
5 "enabled": true,
6 "timeframes": [
7 {
8 "id": "1",
9 "startTime": "09:00",
10 "endTime": "17:00",
11 "staffing": "staffed"
12 }
13 ]
14 },
15 {
16 "day": "thursday",
17 "enabled": true,
18 "timeframes": [
19 {
20 "id": "1",
21 "startTime": "09:00",
22 "endTime": "12:00",
23 "staffing": "staffed"
24 },
25 {
26 "id": "2",
27 "startTime": "15:30",
28 "endTime": "17:00",
29 "staffing": "volunteer"
30 }
31 ]
32 }
33 ]
34}This aligns exactly with the UI logic shown in your custom field component:
1. No Population Required
The entire structure is retrieved as a single field:
1const location = await strapi.documents("api::location.location").findOne({
2 documentId,
3
4 fields: ["name", "openingTimes"],
5});No populate, no joins, no depth concerns.
This aligns with Strapi’s recommended approach to selecting only required fields.
2. Zero Database Joins
Instead of:
opening_days timeframesYou’re reading a single column.
This keeps queries:
3. Matches Real Usage Patterns
Opening times are:
That makes them a poor fit for relational modeling, but a perfect fit for structured JSON.
4. Moves Complexity to the Right Layer
Your custom field handles:
sortTimeframes) findOverlappingTimeframeIndexes) This is application logic, not database logic.
The goal is not to model data in the most “normalized” way.
It’s to model data in the way it is actually used.
If a piece of data:
→ it should not be relational.
If your query looks like this:
1populate: {
2 days: {
3 populate: {
4 timeframes: true;
5 }
6 }
7}You should strongly consider whether this should instead be: fields: ['openingTimes'].
populate=deep Is a Common Footgunpopulate=deep Doespopulate=deep plugins attempt to automatically fetch all relations, components, and dynamic zones to an arbitrary depth.
While convenient during early development, this approach removes essential safeguards.
For these reasons, populate=deep is not recommended for production use and is intentionally not listed on the Strapi Marketplace.
The issue here is not Strapi’s query engine, but the removal of intentional query design.
populate=deepThese approaches preserve Strapi’s performance characteristics while keeping APIs maintainable.
Strapi does not provide automatic application-level query caching, by design.
For public, read-heavy endpoints, caching can significantly reduce load when applied after queries are properly optimised. Our REST caching plugin is commonly used for this purpose.
We have another blog post regarding this plugin that is worth reading if you’re implementing caching in your Strapi application.
Caching should reinforce good architecture, not compensate for inefficient queries.
Understanding what your application is doing at the database level is essential.
Enabling query logging makes it much easier to:
Strapi also covers performance best practices in the talk below:
Strapi is capable of excellent performance at scale when used as intended.
The majority of performance issues we see are the result of:
populate=deepBy being intentional about content modeling, population depth, and query design, teams can build Strapi applications that are both flexible and highly performant — without additional infrastructure or complexity.
Implementation manager at Strapi.