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:
mergeProps runs Object.getOwnPropertyDescriptors on each source and installs an Object.defineProperty accessor with a closure for every key on a fresh object. Every subsequent props.href / props.class / props.inactiveClass read walks the sources array inside that getter.
splitProps then runs Object.getOwnPropertyDescriptors again over that all-accessor object, builds two more objects via defineProperty, and deletes 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) when props.state === undefined.
- Replace
mergeProps for the two class defaults with ?? reads at use site.
- Build the class string directly instead of allocating a
classList object.
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.
Summary
<A>costs roughly 6 µs of server CPU per instance during SSR, about 20x a plain<a>, and about 40% of that ismergeProps+splitPropsbuilding accessor-backed prop objects that can never be observed on the server, sincerenderToStringresolves 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 in0.15.4; I diffedAacross the two and it is unchanged apart from a trailing-slash tweak inisActive.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.jsbench.jsxnpm install && npm run bench, Node 26.3.0, Apple silicon:AFastproduces the same markup, modulolinkserialising aslinkinstead oflink="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:mergePropsplussplitProps/splitis 40% of active CPU, and a large share of the 22% GC is the objects they allocate.Root cause
dist/components.jsx: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 doesJSON.stringify(props.state)unconditionally (usually onundefined), threecreateMemos, and aclassListobject spread that is immediately flattened to a string.None of the reactivity can ever fire during
renderToString.Suggested fix
Give
Aa server implementation that skipsmergeProps,splitProps, and the memos, reading each prop once and emitting the anchor directly, along the lines of theAFastabove. 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:
JSON.stringify(props.state)whenprops.state === undefined.mergePropsfor the two class defaults with??reads at use site.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:<A><a>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.