A Strapi permission-cleanup cache, merged upstream
TL;DR
I cached repeated field traversal during Strapi permission cleanup. In a synthetic benchmark, traversal calls fell from 90 to 1 for a single content type. This measures repeated work avoided, not a 90-fold improvement in startup time.
The change was merged upstream in strapi/strapi#27438. It is a small optimization: cache nested field traversal results while Strapi cleans field-level permissions.
The shape of the bottleneck was clear: many roles, field-level permissions, and a large nested content model caused Strapi to do the same expensive schema traversal over and over again.
Context
The application uses Strapi as the backend for a large business application. It has a much larger schema than a typical Strapi project, with many content types, components, localized fields, and custom role/permission logic layered on top of Strapi’s own permissions.
One content type in particular is large and deeply nested. It stores application-wide strings and contains hundreds of component fields. That is convenient from a content-management point of view, but it gives Strapi a large content model to inspect.
The role setup matters too. If many roles all have field-level permissions on the same content type, Strapi ends up processing many permission records that all refer to the same schema.
In the benchmark from the pull request, the test case looked like this:
- 30 roles
- 3 actions per role:
read,create, andupdate - 1 large content type
- about 4,320 distinct nested field paths
- 90 permissions pointing at the same subject
That is not every Strapi project. But it is a realistic shape for applications with many business roles and heavily nested content models.
The slow path
During startup, Strapi cleans permissions in the admin package. Part of that cleanup happens in cleanPermissionFields().
For field-level permissions, Strapi needs to know which fields are valid for the permission’s subject. To do that, it walks the content type and its components to produce nested field paths, including intermediate paths.
Conceptually, for a permission on a content type, Strapi needs to answer:
- which fields exist on this content type?
- which nested component fields exist below them?
- which intermediate paths are needed to validate the field list?
- which permission fields are invalid and should be removed?
That is sensible work. The problem was that the same work was repeated for every permission on the same subject.
With 30 roles and 3 actions on one large content type, getNestedFieldsWithIntermediate() was called 90 times with the same subject. Each call traversed the same nested schema. With about 4,320 field paths, that means roughly 388,800 repeated field-path traversals during one startup cleanup pass.
The more roles you add, the worse the repeated work gets.
The fix
The fix was deliberately small.
I added a subject-scoped Map inside the content-type service so cleanPermissionFields() can cache the result of getNestedFieldsWithIntermediate() for the duration of the cleanup operation.
Before the patch, Strapi recalculated nested fields for every permission:
30 roles x 3 actions x 1 content type = 90 traversals
After the patch, Strapi calculates the nested fields once for that subject and reuses the result for the remaining permissions:
30 roles x 3 actions x 1 content type = 1 traversal
The cache is scoped by subject, so permissions for different content types do not share results. In the PR benchmark, 30 roles across 5 content types went from 450 calls to 5 calls.
That is the whole idea: do not change permission semantics, do not skip cleanup, and do not introduce long-lived application state. Just avoid recomputing the same schema-derived field list for identical subjects within one cleanup pass.
The measured improvement
The benchmark in the pull request used a synthetic large schema:
- 6 levels of nested components
- 3 children per level
- 12 fields per component
- about 4,320 distinct field paths
- 30 roles with permissions on the same large content type
The measured reduction was in repeated traversal calls:
| Scenario | Before | After | Reduction |
|---|---|---|---|
| 30 roles, 1 content type | 90 calls | 1 call | 90x fewer calls |
| 30 roles, 5 content types | 450 calls | 5 calls | 90x fewer calls |
The PR body estimated a 5-10x faster permission cleanup path for deployments shaped like this. That estimate is not a measured end-to-end startup improvement. The table above reports call counts; this article does not supply before-and-after startup timings.
How to know if this applies to you
This optimization matters when your Strapi app has all or most of these characteristics:
- many roles
- field-level permissions
- repeated permissions across the same content types
- large or deeply nested component schemas
- startup time spent in admin permission cleanup
If your schema is small, or you do not use many field-level permissions, this probably will not move your startup time much.
The right first step is instrumentation. Add timing around your bootstrap code and check whether your own startup work is actually the slow part. If the expensive section is Strapi’s permission cleanup, then repeated nested field traversal is worth investigating.
Takeaway
The patch caches schema traversal by subject during permission cleanup. It preserves permission semantics and includes tests for cache correctness and subject isolation. The implementation and review are available in the merged pull request.