Skip to content

Sync Hooks - #114

Open
DaddyWarbucks wants to merge 1 commit into
feathersjs:mainfrom
DaddyWarbucks:sync-hooks
Open

DaddyWarbucks wants to merge 1 commit into
feathersjs:mainfrom
DaddyWarbucks:sync-hooks

Conversation

@DaddyWarbucks

Copy link
Copy Markdown
Member

See also: feathersjs/feathers#3732

Summary

Regular hooks currently pay the cost of Promise scheduling even when they complete synchronously. This change executes consecutive synchronous hooks in a loop and pauses that loop when a hook returns a Promise or thenable. Public hook APIs continue to return Promises.

For example, a stack of ten synchronous before hooks previously created a Promise continuation for each hook's result and another continuation to advance to the next middleware. The new implementation runs that list as one middleware group, completes synchronous hooks immediately, and proceeds to the next middleware once the list finishes.

Local Node benchmarks measured approximately 2.5× faster dispatch for ten synchronous hooks, 3.7× for fifty synchronous hooks, and 1.7× for a mixed stack. The fully asynchronous case had the same measured runtime. These measurements cover hook dispatch and an inexpensive async method; they do not represent total service request latency.

Why this change

The existing implementation introduces asynchronous boundaries in three places:

  • runHook() always calls Promise.resolve(hook(...)).then(...), including for hooks that return undefined, a plain value, or an updated context.
  • runHooks() reduces the entire hook list into a Promise chain before execution.
  • collect() wraps every before and after hook as individual middleware, adding continuations to enter or leave each hook.

Synchronous context updates, argument transformations, and checks do not need those boundaries. The added Promise work becomes more noticeable as a regular-hook list grows. Removing it also reduces temporary allocations.

The implementation uses the same principle as the synchronous resolver optimization: retain the public Promise API and let internal code complete synchronously whenever the supplied function does.

Implementation

Execute one hook with runHookValue()

A new private helper returns void | Promise<void>:

  • Invoke the hook with context.self as its receiver.
  • Set the requested hook type before invoking the hook.
  • If the result is synchronous, immediately restore the previous type and merge any returned context properties.
  • If the result is a Promise or thenable, normalize it through Promise.resolve() and perform the same completion work once it resolves.

isPromiseLike() recognizes objects and functions with a callable then property. This allows Promise and thenable results to take the asynchronous path without requiring instanceof Promise.

Execute a sequence with runHooksValue()

The sequence runner holds a cursor for each invocation and advances through synchronous hooks in a loop:

const run = (): void | Promise<void> => {
  while (index < hooks.length) {
    const result = runHookValue(hooks[index++], context, type);

    if (result) {
      return result.then(run);
    }
  }
};

When a hook returns an asynchronous result, the runner waits for that result and then resumes at the next index. Hook execution remains sequential, so later hooks see the completed context changes from earlier hooks. The cursor belongs to the invocation, allowing the same collected middleware to handle overlapping calls independently.

Group regular hooks in collect()

collect() creates one middleware for its before list and one for its after list:

  • The before group completes its sequence and then calls next().
  • The after group waits for next() and then completes its sequence.
  • Each list runs in registration order. After hooks no longer need to be reversed during construction because they execute in a sequence instead of being nested individually.
  • Before and after lists are copied when the collection is created, preserving their existing snapshot behavior.
  • Each before/after hook still receives its own type transition and restoration.

Error hooks continue to use fromErrorHook(runHooks(error)). The sequence inside runHooks() benefits from the new loop while retaining the existing error wrapper's recovery and propagation behavior.

Keep Promise results at public boundaries

runHook() wraps the private helper's result in Promise.resolve(). runHooks() retains an initial Promise continuation and runs the optimized sequence inside it. An empty runHooks() list still resolves to the supplied context; a completed nonempty list resolves to undefined.

fromBeforeHook() advances immediately after a synchronous hook and chains to the hook's Promise when it is asynchronous. fromAfterHook() continues to wait for next(), then uses the private single-hook runner.

The core compose() dispatcher, the Promise contract of next(), and the original-method wrapper retain their existing implementations.

Observable scheduling change

Consecutive synchronous hooks now execute without a microtask boundary between them. An entirely synchronous before group can reach the next middleware in the same call stack.

For example:

const events: string[] = [];

await collect({
  before: [
    () => {
      events.push('first');
      queueMicrotask(() => events.push('queued'));
    },
    () => {
      events.push('second');
    },
  ],
})({});

Previously, the events were ['first', 'queued', 'second']. With this change, they are ['first', 'second', 'queued'].

Hooks that start asynchronous work needed by subsequent hooks must return that work's Promise. Returning a Promise or thenable causes the runner to wait before advancing. Wrapped functions and next() still return Promises, and middleware using await next(), next().then(), or next().catch() continues to use that contract.

Benchmark results

Node: comparison with the original implementation

Environment and methodology:

  • Node.js 22.22.0, macOS arm64.
  • Original regular.ts from commit f9e6e43 compared with the updated working-tree implementation. Other source modules were identical.
  • TypeScript sources were transpiled with TypeScript 5.5.4 and loaded into separate module caches for each implementation.
  • Each case wrapped an inexpensive async () => 1 method with a collected before-hook list. Hooks incremented a context property.
  • Mixed stacks contained eight synchronous hooks and two async hooks, with an async hook every fifth entry starting at index zero.
  • Each implementation received 5,000 warmup calls, followed by three samples of 30,000 awaited calls. The table reports the median elapsed time. Measurement order alternated between implementations.
  • Promise counts were measured separately with Node's async_hooks PROMISE initialization events. Instrumentation was disabled for elapsed-time measurements. Counts include common wrapper and measurement overhead and represent created Promise objects, not peak live objects.
Before-hook stack Original, 30,000 calls Updated, 30,000 calls Speedup Original Promises/call Updated Promises/call
10 synchronous 30.8 ms 12.4 ms 2.48× 47 7
50 synchronous 137.9 ms 37.1 ms 3.72× 207 7
10 mixed 38.1 ms 22.7 ms 1.68× 47 16
10 asynchronous 39.7 ms 39.7 ms 1.00× 47 48

The synchronous cases remove Promise work that previously grew with every hook. Mixed stacks also benefit because synchronous runs between async hooks complete in the loop. Fully asynchronous hooks still require their continuations; that case created one additional Promise in this harness and had unchanged measured runtime.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant