solidjs / solidjs/solid-router
<A> costs ~6us of server CPU per instance during SSR (20x a plain <a>), mostly mergeProps/splitProps
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 1.3k
- Forks
- 180
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 19
Description
Summary
<A> costs roughly 6 µs of server CPU per instance during SSR, about 20x a plain <a>, and about 40% of that is mergeProps + splitProps building accessor-backed prop objects that can never be observed on the server, since renderToString resolves everything exactly once.
On link-dense server-rendered pages this dominates the response. A 1000-link table renders in 0.31 ms with plain <a> and 6.29 ms with <A>.
Versions: @solidjs/router@1.0.0, solid-js@1.9.14. The same hot path is present in 0.15.4; I diffed A across the two and it is unchanged apart from a trailing-slash tweak in isActive.
Reproduction
Self-contained, no framework needed.
package.json
{
"name": "solid-router-a-ssr-repro",
"private": true,
"type": "module",
"scripts": { "bench": "vite build --ssr bench.jsx --outDir dist && node dist/bench.js" },
"dependencies": { "@solidjs/router": "1.0.0", "solid-js": "1.9.14" },
"devDependencies": { "vite": "^7.0.0", "vite-plugin-solid": "^2.11.0" }
}
vite.config.js
import { defineConfig } from 'vite'
import solid from 'vite-plugin-solid'
export default defineConfig({
plugins: [solid({ ssr: true })],
build: { ssr: true, minify: false, target: 'node22' },
})
bench.jsx
import { renderToString } from 'solid-js/web'
import { StaticRouter, Route, A, useResolvedPath, useHref, useLocation } from '@solidjs/router'
import { For } from 'solid-js'
const rows = Array.from({ length: 1000 }, (_, i) => ({ id: `row-${i}`, name: `name-${i}` }))
// Same rendered output as <A>, without the per-instance prop-proxy machinery.
function AFast(props) {
const inactiveClass = props.inactiveClass ?? 'inactive'
const activeClass = props.activeClass ?? 'active'
const to = useResolvedPath(() => props.href)
const href = useHref(to)
const location = useLocation()
const to_ = to()
let active = false
let exact = false
if (to_ !== undefined) {
const path = to_.split(/[?#]/, 1)[0].replace(/\/$/, '').toLowerCase()
const loc = decodeURI(location.pathname.replace(/\/$/, '').toLowerCase())
active = props.end ? path === loc : loc.startsWith(path + '/') || loc === path
exact = path === loc
}
const classes = []
if (props.class) classes.push(props.class)
classes.push(active ? activeClass : inactiveClass)
return (
<a
href={href() || props.href}
state={JSON.stringify(props.state)}
class={classes.join(' ')}
link
aria-current={exact ? 'page' : undefined}
>
{props.children}
</a>
)
}
function Table(props) {
return (
<table>
<tbody>
<For each={rows}>
{(entry) => (
<tr>
<td>{entry.id}</td>
<td>{entry.name}</td>
<td>
{props.mode === 'A' ? (
<A href={`/item/${entry.id}`}>View</A>
) : props.mode === 'AFast' ? (
<AFast href={`/item/${entry.id}`}>View</AFast>
) : (
<a href={`/item/${entry.id}`}>View</a>
)}
</td>
</tr>
)}
</For>
</tbody>
</table>
)
}
const render = (mode) =>
renderToString(() => (
<StaticRouter url="http://localhost/">
<Route path="/" component={() => <Table mode={mode} />} />
</StaticRouter>
))
function bench(label, mode) {
for (let i = 0; i < 30; i++) render(mode)
const N = 200
const t0 = performance.now()
let out
for (let i = 0; i < N; i++) out = render(mode)
const ms = (performance.now() - t0) / N
console.log(
`${label.padEnd(24)} ${ms.toFixed(2).padStart(7)} ms/render ${(1000 / ms).toFixed(0).padStart(5)} renders/s ${out.length} bytes`,
)
return { ms, out }
}
const extract = (h) => h.slice(h.indexOf('<table'), h.indexOf('</table>'))
const norm = (h) => h.replaceAll('link="true" ', 'link')
const plain = bench('plain <a> x1000', 'a')
const routerLink = bench('<A> x1000', 'A')
const fast = bench('<A> server fast path', 'AFast')
console.log(
`\n<A> overhead vs plain <a>: ${(routerLink.ms - plain.ms).toFixed(2)} ms/render (${(routerLink.ms / plain.ms).toFixed(1)}x)`,
)
console.log(
`fast path vs <A>: ${(routerLink.ms / fast.ms).toFixed(1)}x faster, output equivalent: ${norm(extract(fast.out)) === norm(extract(routerLink.out))}`,
)
npm install && npm run bench, Node 26.3.0, Apple silicon:
plain <a> x1000 0.31 ms/render 3199 renders/s 133601 bytes
<A> x1000 6.29 ms/render 159 renders/s 164601 bytes
<A> server fast path 1.12 ms/render 890 renders/s 156601 bytes
<A> overhead vs plain <a>: 5.98 ms/render (20.1x)
fast path vs <A>: 5.6x faster, output equivalent: true
AFast produces the same markup, modulo link serialising as link instead of link="true" because there is no spread on the element. It is 5.6x faster and 8 bytes per link smaller.
Profile
node --cpu-prof dist/bench.js, self time, 1736 ms active:
415 ms 23.9% mergeProps solid-js/web/dist/server.js
377 ms 21.7% (garbage collector)
158 ms 9.1% split solid-js/web/dist/server.js
126 ms 7.2% splitProps solid-js/web/dist/server.js
114 ms 6.6% A @solidjs/router
45 ms 2.6% children
43 ms 2.5% normalizePath
35 ms 2.0% ssrElement
mergeProps plus splitProps/split is 40% of active CPU, and a large share of the 22% GC is the objects they allocate.
Root cause
dist/components.jsx:
export function A(props) {
props = mergeProps({ inactiveClass: "inactive", activeClass: "active" }, props);
const [, rest] = splitProps(props, ["href","state","class","activeClass","inactiveClass","end"]);
...
}
In Solid's server build these are not cheap:
mergePropsrunsObject.getOwnPropertyDescriptorson each source and installs anObject.definePropertyaccessor with a closure for every key on a fresh object. Every subsequentprops.href/props.class/props.inactiveClassread walks the sources array inside that getter.splitPropsthen runsObject.getOwnPropertyDescriptorsagain over that all-accessor object, builds two more objects viadefineProperty, anddeletes keys out of the descriptor map, which puts it into dictionary mode.
So each <A> allocates three accessor-backed objects and pays megamorphic property access on all of them, to emit static markup. It also does JSON.stringify(props.state) unconditionally (usually on undefined), three createMemos, and a classList object spread that is immediately flattened to a string.
None of the reactivity can ever fire during renderToString.
Suggested fix
Give A a server implementation that skips mergeProps, splitProps, and the memos, reading each prop once and emitting the anchor directly, along the lines of the AFast above. The router already ships separate server and browser builds, so this can be conditioned on the build rather than a runtime check.
Cheaper partial wins if a full split is not wanted:
- Skip
JSON.stringify(props.state)whenprops.state === undefined. - Replace
mergePropsfor the two class defaults with??reads at use site. - Build the class string directly instead of allocating a
classListobject.
Why this showed up
This surfaced while investigating why SolidStart sits near the bottom of the SSR load benchmark at https://frameworks.e18e.dev (https://github.com/e18e/framework-tracker). Its benchmark page renders 1000 rows each containing an <A>. Measured on one machine, same route, same data, autocannon:
| rps @1 conn | rps @25 conn | p99 @25 conn | |
|---|---|---|---|
SolidStart with <A> |
110 | 130 | 678 ms |
SolidStart with plain <a> |
371 | 750 | 67 ms |
| SvelteKit | 329 | 710 | 57 ms |
Replacing <A> with <a> is the only change, and it takes SolidStart from far behind SvelteKit to level with it. 1000 router links on a page is not typical, so the benchmark amplifies this well past normal usage, but the per-link cost is real and shows up on any list-heavy SSR page.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with dist/components.jsx and the server/browser build split described in the issue; inspect how A currently uses mergeProps, splitProps, memos, and class handling during SSR. Run the self-contained benchmark with npm install && npm run bench, then verify the server path preserves the shown markup/output while reducing the measured SSR overhead.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100