Eight pull requests, five of them shipped

That number is in #27140, one of eight pull requests Derrick Mehaffy opened against Strapi core in late July and early August. Five shipped, in 5.51.2 and 5.52.0. One is test-only, one is open, one was closed without merging.

Eight is a small slice of what went into those releases. The rest is features, fixes and dependency bumps, and this post ignores all of it to follow one thread. What's interesting about that thread isn't the speedup, it's that the diffs teach something you can use on your own code.

The projects that gain most have a lot of content types, wide schemas with deep population, strict permissions, or a lot of admin roles.

Where the time was going

The set was profiled before it was fixed, and the profiles are in the PR bodies.

A V8 sampling profiler put lodash at 36% of on-CPU time in a REST read. One chain inside it, getModelgetAllpickBy, was roughly 26% of total on-CPU time, "building registry copies that are thrown away immediately."

#27140 broke a request down by phase. Response sanitization, the step that walks a result and strips anything the caller isn't allowed to see, was 73% of request time. Within that, isPrivateAttribute alone was 35%.

Most of the CPU wasn't spent getting data out of Postgres. It was spent walking the result afterwards.

Building a copy of every schema to answer one lookup

Strapi.getModel(uid) returns a content type or a component. It's called once per relation, component, media field and dynamic-zone node of every sanitized response, so it sits at the bottom of the hottest loop in the application.

The old implementation used the contentTypes and components getters, which looks like two cheap property checks. Each getter calls the registry's getAll() with no namespace filter, which builds a fresh copy of every registered schema through lodash pickBy. The in check built one copy, the property access built a second. A content-type lookup built the registry twice; a component lookup built it four times, having missed on content types first. The fix reads each registry directly.

The cost scaled with your total schema count, not with the response. Two hundred content types meant a two-hundred-schema copy for every populated node in every response. Nothing about the request was large. The project was.

#27143 calls it "the single hottest path in a REST request, ahead of anything in the sanitization layer itself." 1.41x throughput, 14.5 to 20.4 req/s, lodash down from 36% to 14%. Shipped in 5.51.2.

strapi.getModel, before and after js
// Before: each getter rebuilt the whole registry through lodash pickBy.
if (uid in this.contentTypes) {
  return this.contentTypes[uid];
}

if (uid in this.components) {
  return this.components[uid];
}

// After: direct registry reads.
const contentType = this.get('content-types').get(uid);

if (contentType !== undefined) {
  return contentType;
}

return this.get('components').get(uid);

Throwing an exception to ask a yes/no question

This appears twice, in unrelated places, which tells you how easy it is to write.

The first is populate validation. To decide whether a key was one of the boolean-ish strings true, t or 1, the code called parseType in a try/catch and read the absence of an exception as a yes. Every key that isn't one of those six values, which is nearly all of them, built an Error and captured a stack trace to throw it away.

#27234 replaces it with an isBooleanLike predicate and hoists two array literals that parseBoolean had been allocating on every call. The code comment on those constants says query validation "calls it once per node of the populate tree of every request, which made those two throwaway allocations one of the largest single sources of GC pressure in a read: 4.3% of on-CPU time in a profiled LaunchPad run."

Two candidate causes in one function, the allocation and the throw. He tested them separately: "hoisting the arrays on their own moved it by only 8%, which is what pointed at the throw rather than the allocation as the real cost." The allocation was measurable and it wasn't the answer. Most people would have hoisted the arrays, watched the number move, and stopped. The review comment, from Strapi maintainer innerdvations: "This has bugged me for 4 years but never enough to fix it. Thanks!"

The second instance is better, because there the exception is the API. strapi.auth.verify throws on denial, so checking whether a token can see a relation meant calling it in a try/catch and treating the catch as a no. #27145 is precise about the cost: "capturing a stack trace is by far the most expensive part of that." And the same question was asked repeatedly: twenty-five entities with four relations each is around a hundred identical questions per page. The fix memoizes scope decisions in a WeakMap keyed on the request's auth object.

The asymmetry is the part worth keeping. A token permitted everything paid less than one denied some relations, because a denial is what builds the Error. The stricter your permissions, the more you paid.

Two questions asked with a throw js
// Populate validation: no exception means "boolean-like".
try {
  parseType({ type: 'boolean', value: key });
  return;
} catch {
  // not boolean-like, carry on
}

// Permission check: here the exception is the API.
try {
  await strapi.auth.verify(auth, { scope });
} catch {
  // denied
}

Memoizing the answer you already computed 48,605 times

isPrivateAttribute called getStoredPrivateAttributes(model), which did a strapi.config.get, a lodash getOr, and a union() allocation, then ran .includes(). Per key, per node, per entity.

The replacement is a WeakMap<object, Set<string>> with a size check in front. Two decisions worth stealing. The memo is keyed on the model object, not its uid, so a rebuilt schema is a distinct key and the entry can't go stale: keying a cache on an identifier means inventing an invalidation story, keying it on the object means the garbage collector handles it. And the memo only populates once strapi.config exists, so an early-boot lookup can't permanently record an empty set, which is the kind of bug that surfaces once, in production, unreproducibly.

The same PR unpicks a rest-spread for the same reason: (...args) allocated a fresh array on every call.

6.912ms to 3.197ms on 25 entities, 28.454ms to 12.855ms on 100. strapi.config.get calls per pass went from 3,775 to zero, and 15,100 to zero. Shipped in 5.52.0.

isPrivateAttribute, after js
const storedPrivateAttributes = getStoredPrivateAttributesSet(model);

// The set is empty for the overwhelming majority of models, so check size before hashing.
return storedPrivateAttributes.size !== 0 && storedPrivateAttributes.has(attributeName);

// Named parameters rather than a rest-spread: this runs once per key, per node, per
// entity, and `(...args)` allocated a fresh array on every call.

delete deoptimises the object you're about to read

My favourite of the set, and it didn't ship.

Sanitization removes keys, and the obvious way is delete data[key]. Deleting a property moves the object into dictionary mode in V8, and sanitization then walks every remaining key to recurse. You pay the deoptimisation on the reads that follow, and again at serialization.

#27141 stages removals instead, then rebuilds from the surviving keys. It also replaces a { ...path }-then-assign with a single object literal, because spread-then-write caused a hidden-class transition per key, and swaps lodash predicates for native ones with a shallowCopy helper that falls back to lodash clone for non-plain objects, "so exotic values keep their prototype and internal slots, which a spread would silently discard."

1.31x on 25 entities, 1.35x on 100, with the traversal loop body at 22.5% self time. Closed without merging. Some ideas resurface in #27233, though that's my inference from the diffs, not something a comment says.

Staged removal js
remove(key) {
  removedKeys.add(key);
  data[key] = undefined;
},

Allocating seven things per key

#27233 targets traverse/factory.ts, the generic query traversal used by both sanitize and validate. Per key it was costing a spread copy of the path, a fresh handler array, three closures, two lodash pick() calls with their key-list arrays, a promise per handler, and a promise per visitor call. The handler array is the one that stings: [...common, ...attributes], rebuilt inside the key loop, though the handler set is fixed before the traversal starts.

All hoisted to per-traversal. 47.64 to 63.00 req/s, 1.32x throughput, 1.34x on p95, GC self time down roughly 45% per request. Still open.

How it was measured

Runs are medians of three. On the later PRs, variants are cycled round-robin rather than in blocks, so machine drift lands on both sides. Postgres buffer hit ratio is reported at 100%, so the test is CPU-bound rather than accidentally measuring disk. Failed requests are reported as zero rather than left unmentioned. Competing explanations get isolated and measured separately, as with the arrays above, instead of shipped together and credited jointly.

The correctness checks are attached to the speed claims. #27143 and #27144 captured REST responses on both branches across nine endpoints covering populate=*, field selection, filters, sorting and locales, and confirmed they were byte-identical. The microbenchmark harness in #27140 asserts a hand-written reference implementation is deepStrictEqual to the shipped output before timing anything, and exits non-zero if not, because otherwise "the benchmark would be comparing two different behaviours and the numbers are meaningless."

That last line is the one I'd put on a poster.

The caveats matter, because these numbers are easy to misuse. All are author-reported and run locally. The baselines differ: #27143, #27144 and #27145 ran against examples/getstarted with autocannon from 14.5 req/s; #27234 and #27233 ran against LaunchPad from 47.64 req/s, on a base described in #27234 as "the combined performance branch (develop plus the open sanitization PRs and #26626)": open, not merged. The two groups aren't comparable, and none of these figures can be multiplied into a headline number.

What you get by upgrading

  • #27143: getModel reads registries directly. 5.51.2, 1.41x throughput.
  • #27144: cached Field instances, no date re-parse. 5.51.2, 1.06x.
  • #27140: memoized private attributes. 5.52.0, 2.16x to 2.21x on sanitize.
  • #27145: memoized scope decisions. 5.52.0, 1.15x.
  • #27234: isBooleanLike replaces throw-and-catch. 5.52.0, 1.09x.
  • #27142: regression tests for three sanitization advisories. Test-only.
  • #27141: staged removals instead of delete. Never merged, 1.31x on sanitize.
  • #27233: per-key allocations removed. Still open, 1.32x.

The eighth is worth a sentence. #27142 changes no behaviour, which is what you want alongside seven PRs rewriting the sanitization path.

Separately, #27438 shipped in 5.52.2 and matters if you run a lot of roles. cleanPermissionFields ran the same schema traversal once per permission instead of once per content type. The PR states it as O(n × m) → O(m), n being permissions and m unique subjects, and reports it in call counts: 30 roles and 5 content types went from 450 calls at startup to 5.

Floor versions: 5.52.0 for the sanitization work, 5.52.2 for the roles fix. No migration, no config change.

The one we didn't write

That last fix came from Merlijn van den Berg, who doesn't work at Strapi. He hit the slowdown on a project with a lot of roles, traced it to the repeated traversal, fixed it, and wrote three tests on the way. The review comment is four words: "Great catch, thanks for this!"

The usual case for open source is about cost or lock-in. This is the narrower one. The person running thirty roles and feeling the startup cost isn't on the core team and doesn't have the profiler, but they can read the source, and a fix that would otherwise have sat in a local patch now ships to everyone else with thirty roles who hadn't noticed.

I tell customers to open a PR rather than patch core, and this is why. Sometimes we merge it.

Which projects feel this most

Many content types and components. #27143's waste scaled with total schema count, so a large model library and small responses still paid for the library on every node.

Wide content types with deep population. The sanitization call count is keys × nodes × entities. populate=deep doesn't only widen your SQL, it multiplies the walk afterwards, and that walk was 73% of the request. That's the part people don't think about, because it isn't the database.

Strict permissions, per the denial asymmetry in section 04.

Many roles, for the 5.52.2 startup fix.

If none of those describe you, the gain is smaller rather than absent. Sanitization runs on every read whatever your schema looks like, so the floor moves for everyone. All of it arrives on upgrade: no migration, no config, no change on your side.

And these eight are a slice of what shipped between 5.51.2 and 5.52.2. Upgrade for the rest of it too.