Perf optimizations - #407
Merged
Merged
Conversation
kibertoad
commented
Feb 12, 2026
kibertoad
commented
Feb 12, 2026
jeffijoe
reviewed
Feb 12, 2026
jeffijoe
reviewed
Feb 12, 2026
Owner
|
Final request: could you squash your commits into however many logical commits you think makes sense? Could be a single one, but ideally want to keep the git log clean. |
Contributor
Author
|
@jeffijoe Done, but can't this also be done while merging? GitHub supports squashing all commits in the PR (one of the three merge modes), just needs to be enabled on the repo level. |
Owner
|
It does, but I prefer still having merge commits. Makes it easy to see what was part of a single PR. |
Owner
|
Thanks @kibertoad, this has been released! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Optimizations Applied
1. Cache-first resolve for singleton and scoped (container.ts)
Original: Awilix already caches singleton values on the root container and scoped values on the resolving container. However, the cache check lived inside the
switchstatement at the end ofresolve(). This meant every resolve - even for already-cached values - first ran cycle detection (.some()with closure allocation), strict mode lifetime check (.findIndex()with closure allocation), andresolutionStack.push({ name, lifetime })(object allocation) before reaching the cache lookup.After: The cache check is moved to the top of
resolve(), immediately after determining the lifetime. Cached singletons return in O(1), skipping cycle detection, lifetime check, and stack push/pop entirely. Cached scoped values also return early, but in strict mode still run the lifetime check (a singleton ancestor depending on a cached scoped value is still a lifetime leak). Transient resolves (which are never cached) pay a singlelifetime !== TRANSIENTcomparison to skip the cache-first block.2. Closure-free cycle detection and lifetime check (container.ts)
Original: Cycle detection used
resolutionStack.some(({name: n}) => n === name)- allocating a closure on everyresolve()call. Strict mode lifetime check usedresolutionStack.findIndex(({lifetime: lt}) => isLifetimeLonger(lt, lifetime))- allocating another closure on every strict-moderesolve().After: Both replaced with plain
forloops overresolutionStack.length. No closure allocation per resolve. These loops only run on the slow path (uncached resolves), since the cache-first optimization returns before reaching them for cached values.3. Injector proxy key deduplication (resolvers.ts)
Original:
uniq([...Reflect.ownKeys(container.cradle), ...Reflect.ownKeys(locals)])-Reflect.ownKeyson the cradle triggers the proxy'sownKeystrap which callsrollUpRegistrations(), thenuniq()creates an intermediate array + Set + spread.After:
new Set(Object.keys(container.registrations))+ iterateObject.keys(locals)- reads the registration hash directly (bypasses proxy chain), builds the Set in-place with no intermediate array.Benchmark Results
Simple Resolve
Singleton and scoped cache hits improved significantly from the cache-first optimization (skip cycle detection + stack push/pop).
Deep Chain Resolve (all transient)
All-transient chains see a regression. This is a tradeoff of the cache-first optimization: every transient resolve now runs through a
lifetime !== TRANSIENTcheck that always fails before reaching cycle detection. The changed code structure (more branches inresolve()) may also affect V8's JIT optimization of the function. This cost is inherent to the cache-first approach - the same branching that enables O(1) returns for cached singletons/scoped values adds a small penalty to transient resolves that never hit the cache. In practice, this tradeoff is favorable: real apps have a mix of lifetimes where the cache-first wins far outweigh the transient penalty.Strict Mode Lifetime Check
The "no violation" case (the common path) shows a small improvement at typical depths. The depth=10 transient benchmarks regress for the same reason as deep chain resolve: the cache-first branching adds overhead to all-transient chains that never benefit from caching.
Scope Lookup
First calls are flat (no cache to hit). Cached calls show clear improvement from the cache-first fast path, with depth=10 cached showing a +24.7% gain. hasRegistration at depth=1 also improved +10.4%.
Injector Proxy
The largest win. Bypassing the cradle proxy and avoiding
Reflect.ownKeys+uniq()intermediate allocations.Realistic App (50 registrations, scoped web app simulation)
Real-world workloads show consistent 3-11% improvement across the board. These gains come primarily from the cache-first optimization: in a typical request scope, most scoped services are resolved multiple times (shared across controllers), and each cache hit now skips cycle detection and stack manipulation entirely. Strict mode sees the largest gain (+11.3%) because cached singletons bypass both the lifetime check and cycle detection.