Skip to content

perf(claims): skip URL parsing for plain hrefs in SSR link claims - #701

Merged
ryansolid merged 1 commit into
solidjs:nextfrom
brenelz:perf/ssr-link-claims
Oct 27, 2026
Merged

ryansolid merged 1 commit into
solidjs:nextfrom
brenelz:perf/ssr-link-claims

Conversation

@brenelz

@brenelz brenelz commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Server link claims (from #668) made SSR of link-heavy pages noticeably slower. On a 1000-row table with one <a href="/server-side-rendered/:id"> per row (the app-solid port of the e18e framework-tracker benchmark app), the claims cost about 1.2 ms of CPU per request. That is the difference between 283 and 460 req/s.

Cause

For every anchor, setupServerLinkClaims resolves the href with new URL(href, page), then linkMatcher calls comparablePath, which parses the pathname again with new URL. Two WHATWG URL parses per link dominated the claim cost. linkMatcher also split the target on # and scanned for ? for every link, though the query only matters for an exact match.

Change

  • plainHref(href) in utils.ts reads the pathname, search and comparable path of a root-relative href without URL. It applies only when the path and query use characters URL keeps unchanged (ASCII word characters and -.~!$&'()*+,;=:@/, plus ? in the query but not ') and the path has no ./.. segments. Every other href (%, \, non-ASCII, whitespace, //, dot segments, absolute URLs) still goes through URL. It runs only under isServer, where a root-relative href always resolves against the request origin, so client bundles drop it.
  • comparablePath skips its URL parse on the server when the normalized path is plain in the same sense. The skip is gated on isServer too, so client bundles keep the plain URL parse.
  • linkMatcher accepts a precomputed comparable path and only parses the target's query when the paths are equal.

Rendered HTML is byte-identical for the benchmark page (row content aside).

Verification

  • pnpm test (723 client tests, 105 server tests) and pnpm test:types pass on top of next with rc.19.
  • test/server/plain-href-fast-path.spec.ts fuzzes 50,000 random paths and hrefs built from characters URL treats specially, and checks comparablePath and plainHref against URL. It runs in the server suite because the client never reaches the fast path. A new case in test/server/ssr-link-state.spec.tsx covers hrefs that URL normalizes: uppercase with a trailing slash, dot segments, an empty query, and percent-encoded vs raw non-ASCII.
  • Measured on /server-side-rendered of that app (solid-js/web rc.19, Node 24, M-series Mac), next.42 vs this branch packed into the same app. The first two runs were the same session, the last a later and slower one.
next.42 this branch claims disabled
CPU per request, 10 concurrent in-process 4.27 ms 3.46 ms 3.10 ms
HTTP req/s, autocannon 10 connections 283 385 —
HTTP p99 46 ms 33 ms —
Later run, HTTP req/s 257 360 —
  • On the client, the change adds only the isServer branch in comparablePath: +11 B brotli on a small router app, as measured in review.

Left open

The remaining ~0.4 ms per 1000 links is mostly the per-anchor attrs object that @solidjs/web builds for ssrLinkClaim, plus the matcher's result objects. Cutting that would need a change in @solidjs/web's claim hook.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ea3d8ba

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

This PR includes changesets to release 1 package
Name Type
@solidjs/router 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

@brenelz
brenelz marked this pull request as ready for review October 27, 2026 21:28

Copy link
Copy Markdown
Member

Thanks, the server win is real and we'd like to take this. One change before merging: client bytes matter a lot for the router, and the comparablePath fast path ships to the client, where it saves little.

Could you gate it on isServer, the same as plainHref?

// src/utils.ts
import { isServer } from "@solidjs/web";
// ...
return (isServer && isPlainPath(p) ? p : new URL(mockBase + p).pathname).toLowerCase().replace(/\/$/, "");

Measured on a small router app (vite 8 production build, same solid rc.19 for both), compared with next:

raw gzip brotli
this PR +210 B +139 B +85 B
with the gate +83 B +43 B +11 B

The client tests and the fuzz test still pass with the gate. A rebase onto next would also pick up the rc.19 test updates from #703.


Generated by Claude Code

@brenelz
brenelz force-pushed the perf/ssr-link-claims branch from 4024a6a to ea3d8ba Compare October 27, 2026 22:46
@brenelz

brenelz commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Done. The comparablePath fast path is now gated on isServer, and the branch is rebased onto next with the #703 rc.19 updates. I moved the fuzz test to test/server/, since the client run no longer reaches the fast path. Breaking the fast path on purpose makes it fail there. pnpm test (723 client tests, 105 server tests) and pnpm test:types pass.

@ryansolid
ryansolid merged commit 91fba49 into solidjs:next Oct 27, 2026
4 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