Skip to content

<A> costs ~6us of server CPU per instance during SSR (20x a plain <a>), mostly mergeProps/splitProps #583

Description

@birkskyum

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions