Skip to content

perf(web): build direct-call carrier shells only when a value is bound - #4333

Merged
ryansolid merged 2 commits into
solidjs:nextfrom
brenelz:perf/lazy-scope-carrier
Oct 28, 2026
Merged

ryansolid merged 2 commits into
solidjs:nextfrom
brenelz:perf/lazy-scope-carrier

Conversation

@brenelz

@brenelz brenelz commented Oct 27, 2026

Copy link
Copy Markdown
Contributor

Summary

With serverFunctions enabled, every 'use server' call made during SSR (a query() loader, for example) runs its result through scopeDeferredResult, which binds deferred bodies such as generators and streams to the call's request scope. enterScopeCarrier built a shallow-rebuilt shell for every plain object and array it reached, using Object.create(prototype, descriptors) or slice(), before knowing whether anything inside needed binding. For a result with nothing deferred, which is the common case, every shell was thrown away at the end.

I found this while porting the e18e framework-tracker benchmark app to Solid 2.0 (e18e/framework-tracker#709). Its /ssr-throughput page returns 1,000 rows from a 'use server' loader. Turning serverFunctions on dropped that page from 707 to 457 req/s with 10 concurrent in-process requests.

ScopeFrame now records the container's prototype and builds its shell on first need, through frameShell(): when a child value is bound, or when a cycle reaches a frame still being walked. seen holds the in-progress frame, so a cycle builds the shell and resolves to it as before, and a completed frame records its final container. The walk itself is unchanged, including which slots it visits, the descriptors it copies, the frozen and non-plain exclusions, and the iterative driver.

What remains of the walk's cost is reading descriptors for every object, which the walk needs so it never calls a getter outside the request scope. Avoiding that would mean not walking direct-call results at all, which is a design question I left alone.

How did you test this change?

  • npx vitest run --config vite.config.server.mjs in packages/web: 207 files, 1,877 passed, 3 expected failures, 2 skipped.
  • Two new direct-road specs in server-functions-nested-deferred-scope.spec.tsx pin the cycle contract the lazy shell has to keep. A cyclic result with nothing deferred resolves its cycles inside the rebuilt graph, and a cyclic result with a bound body resolves every cycle to the rebuilt shells. Both pass on next and with this change.
  • The new test/server/direct-call-result.bench.tsx, run with npx vitest bench --run --config vite.config.server-bench.mjs (local Mac, means):
Bench next this branch
direct call, 1,000 plain rows 0.913 ms 0.558 ms
direct call, 1,000 rows and one deferred body 0.888 ms 0.534 ms
  • End to end, /ssr-throughput in the framework-tracker app (rc.19, 10 concurrent in-process requests, local) went from 2.92 to about 2.5 ms of CPU per request, and from 457 to 545–572 req/s. The rendered HTML is identical.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 635f8a8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 15 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
board-example Patch
canvas-example Patch
server-dbmon-example Patch
todos-server-example Patch
@solidjs/compiler Patch
@solidjs/signals Patch
solid-js Patch
@solidjs/universal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codspeed

codspeed Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 18.83%

2 warnings

⚠️ 2 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚠️ Base and head ran on different runner types

Some benchmarks with significant performance changes were compared across different runner types, so the changes might not come from the code. Run base and head on the same runner type, or use CodSpeed Macro Runners for more stable results.

Learn more about Macro Runners

Open the report in CodSpeed to investigate

❌ 2 regressed benchmarks
✅ 194 untouched benchmarks
🆕 2 new benchmarks
⏩ 5 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ createStore setter: delete + set one root key (#3044 overlay) 1.8 ms 2.4 ms -24.66%
❌ memo + sync render effect only (reference) 29.7 ms 34 ms -12.54%
🆕 direct call, 1,000 plain rows N/A 9.2 ms N/A
🆕 direct call, 1,000 rows and one deferred body N/A 8.8 ms N/A

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing brenelz:perf/lazy-scope-carrier (635f8a8) with next (d2323b0)

Open in CodSpeed

Footnotes

  1. 5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@ryansolid
ryansolid marked this pull request as ready for review October 28, 2026 15:18
@ryansolid
ryansolid merged commit 122797e into solidjs:next Oct 28, 2026
8 of 9 checks passed
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.

2 participants