Skip to content

perf: dispatch AuthMiddleware and middlewares synchronously when possible#1812

Open
pkeuter wants to merge 6 commits into
MichalLytek:masterfrom
pkeuter:perf/sync-auth-middleware
Open

perf: dispatch AuthMiddleware and middlewares synchronously when possible#1812
pkeuter wants to merge 6 commits into
MichalLytek:masterfrom
pkeuter:perf/sync-auth-middleware

Conversation

@pkeuter

@pkeuter pkeuter commented Jun 8, 2026

Copy link
Copy Markdown

AuthMiddleware was always async, so awaiting a synchronous authChecker scheduled a microtask and allocated a promise for every @Authorized field. On queries that resolve many authorized fields this dominates CPU time (profiled at ~40k authorized field resolutions costing several seconds of event-loop time, almost all promise/microtask churn).

  • AuthMiddleware: run the authChecker synchronously, only awaiting when the checker (or container instance) is actually promise-like.
  • applyMiddlewares: synchronous-capable dispatch that only awaits when a middleware, container instance or resolver returns a promise. A chain of synchronous middlewares now allocates zero promises instead of one each.
  • Widen MiddlewareFn/NextFn to MaybePromise so synchronous middlewares are first-class.

Behavior is preserved: same grant/deny logic, the same result-or-nextResult resolution and the multiple-next() guard. Auth denial now throws synchronously, which GraphQL execution handles identically to a rejected promise.

…ible

AuthMiddleware was always async, so awaiting a synchronous authChecker
scheduled a microtask and allocated a promise for every @Authorized field.
On queries that resolve many authorized fields this dominates CPU time
(profiled at ~40k authorized field resolutions costing several seconds of
event-loop time, almost all promise/microtask churn).

- AuthMiddleware: run the authChecker synchronously, only awaiting when the
  checker (or container instance) is actually promise-like.
- applyMiddlewares: synchronous-capable dispatch that only awaits when a
  middleware, container instance or resolver returns a promise. A chain of
  synchronous middlewares now allocates zero promises instead of one each.
- Widen MiddlewareFn/NextFn to MaybePromise<any> so synchronous middlewares
  are first-class.

Behavior is preserved: same grant/deny logic, the same result-or-nextResult
resolution and the multiple-next() guard. Auth denial now throws synchronously,
which GraphQL execution handles identically to a rejected promise.
@pkeuter pkeuter requested a review from MichalLytek as a code owner June 8, 2026 09:09

@MichalLytek MichalLytek left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you also write some tests that somehow verifies that the new approach really works?

Widening NextFn to MaybePromise<any> made `return await next()` violate
@typescript-eslint/return-await (next() was no longer provably a promise),
failing CI on the error-logger example -- and the rule's autofix would have
silently stopped that middleware from catching async errors. Only the
middleware *return* type needs to allow synchronous values, so NextFn is
restored to Promise<any> while MiddlewareFn keeps MaybePromise<any>.

Also add tests for the two branches the synchronous-dispatch change
introduced, neither covered before:
- a fully synchronous middleware chain (ordering, result interception
  without next(), and the multiple-next() guard) in applyMiddlewares;
- an asynchronous (promise-returning) authChecker in AuthMiddleware, which
  the existing sync-only auth tests never exercised.
@pkeuter

pkeuter commented Jun 8, 2026

Copy link
Copy Markdown
Author

@MichalLytek quick reply! Of course, (A)I've added the tests. I noticed that this change, on large queries (thousands of authorized calls) breaks the query time in half. As you can probably see, the change has been authored by AI, but I went through it and I saw no strange things. Additionally I've tested in our own codebase.

pkeuter added 2 commits June 8, 2026 17:06
The common case is an `@Authorized`-only field whose sole middleware is the
(now synchronous) AuthMiddleware. The general dispatcher allocates a recursive
dispatchHandler plus next/finalize/invoke closures for every such field --
pure overhead when there is one function middleware. Invoke it directly with a
terminal `next` instead, preserving the same `result ?? nextResult` resolution
and the multiple-`next()` guard.
@codecov

codecov Bot commented Jun 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.60%. Comparing base (be33fd4) to head (857c1d0).
⚠️ Report is 103 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1812      +/-   ##
==========================================
+ Coverage   95.50%   95.60%   +0.09%     
==========================================
  Files         113       90      -23     
  Lines        1847     1934      +87     
  Branches      364      413      +49     
==========================================
+ Hits         1764     1849      +85     
- Misses         83       85       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

pkeuter added 2 commits June 9, 2026 09:57
Add tests for the previously-unhit branches in the sync/async-capable
AuthMiddleware dispatch: an async class-checker `check`, an async
container instance resolution, and denial under an unrecognized auth
mode (the defensive fall-through).

Brings src/helpers/auth-middleware.ts to 100% branch coverage.
src/typings/middleware.ts compiles to only the CommonJS `__esModule`
marker and is never loaded at runtime (every consumer imports its
types), so it can never be covered by tests. Use top-level `import type`
so the imports fully elide, and drop src/typings/** from
collectCoverageFrom since every declaration module there is the same.
@pkeuter

pkeuter commented Jun 10, 2026

Copy link
Copy Markdown
Author

@MichalLytek, I've added some more tests to increase the coverage. Would you be so kind to let the workflows run again so I can see if the coverage didn't drop this time? Thanks!

@pkeuter

pkeuter commented Jun 26, 2026

Copy link
Copy Markdown
Author

@MichalLytek is there anything else you expect from me, or is this PR good as it is?

@MichalLytek MichalLytek self-requested a review June 26, 2026 10:27
@MichalLytek MichalLytek added Enhancement 🆕 New feature or request Community 👨‍👧 Something initiated by a community labels Jun 26, 2026
@MichalLytek MichalLytek added this to the 2.0 release milestone Jun 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Community 👨‍👧 Something initiated by a community Enhancement 🆕 New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants