# An SSR Performance Showdown

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text"><strong>Note</strong>: In the first iteration of this benchmark, we made a few mistakes, summarized in <a target="_blank" rel="noopener noreferrer nofollow" href="https://x.com/t3dotgg/status/1828641333894095358" style="pointer-events: none">this tweet by Theo Browne </a>. We'd like to first apologize for these errors, and to sincerely thank <a target="_blank" rel="noopener noreferrer nofollow" href="https://github.com/Rich-Harris" style="pointer-events: none">Rich Harris</a>, <a target="_blank" rel="noopener noreferrer nofollow" href="https://github.com/ryansolid" style="pointer-events: none">Ryan Carniato</a>, <a target="_blank" rel="noopener noreferrer nofollow" href="https://github.com/gaearon" style="pointer-events: none">Dan Abramov</a>, <a target="_blank" rel="noopener noreferrer nofollow" href="https://github.com/zsilbi" style="pointer-events: none">Balázs Németh</a> and <a target="_blank" rel="noopener noreferrer nofollow" href="https://x.com/trueadm" style="pointer-events: none">Dominic Gannaway</a> for their corrections, as well as <a target="_blank" rel="noopener noreferrer nofollow" href="https://github.com/JoviDeCroock" style="pointer-events: none">Jovi De Croock</a> for contributing a Preact version.</div>
</div>

Server-Side Rendering (SSR) is an often overlooked aspect when building high-performance web applications with Node.js.

During my time consulting, many engagements centered around debugging Node.js performance issues. In these scenarios, the culprit is almost always SSR. SSR is a CPU bound activity that can easily be the main cause of blocking Node.js' event loop. It's crucial to consider this when choosing your frontend stack.

We set out to find out the state of SSR performance across today's most popular libraries, especially those that can be cleanly integrated with Fastify. 

For this, we needed to generate a non-trivial sample document that includes a large number of elements, to have a very large page for the test, and consequently have more running time to capture each libraries' performance.

So, we asked an LLM to write some code to draw a spiral into a container using divs as 10x10px tiles:

```javascript
<script>
const wrapper = document.getElementById('wrapper')
const width = 960
const height = 720
const cellSize = 5

function drawSpiral() {
  let centerX = width / 2
  let centerY = height / 2
  let angle = 0
  let radius = 0
  const step = cellSize

  while (radius < Math.min(width, height) / 2) {
    let x = centerX + Math.cos(angle) * radius
    let y = centerY + Math.sin(angle) * radius
  
    if (x >= 0 && x <= width - cellSize && y >= 0 && y <= height - cellSize)
    {
      const tile = document.createElement('div')
      tile.className = 'tile'
      tile.style.left = `${x}px`
      tile.style.top = `${y}px`
      wrapper.appendChild(tile)
    }

    angle += 0.2
    radius += step * 0.015
  }
}
drawSpiral()
</script>
```

Subsequently, we asked it to create versions of it using all libraries we intended to test, with the implementation adapted to use each libraries' rendering engine rather than relying on the DOM methods of the original example.

This is what our sample document looks like with all its **2398** `<div>` elements:

![](https://lh7-rt.googleusercontent.com/docsz/AD_4nXdRAqSdwtlfB287aNpY5J95ux2jFM0hLDRtgRuRG6xmfttT8scoiyCDuJt7Y8PaWQyR4n0wmcmbYu6X-985LGHq4Qo9UTT5DqoNzQccgd4h8XTIBWvn7EQY5C2FSTR91o9_L5mmuQJO9mrpAGokNrMwqtQQ?key=g4NTNKlEeRkqdIGj6BIusg align="left")

[Fastify's Vite integration setup](https://github.com/fastify/fastify-vite) makes for the perfect testbed for investigating where SSR performance is at for various frameworks. 

In this article, we'll look at the minimum required boilerplate for performing SSR, and compare the performance of five major frontend libraries: [React](https://react.dev/), [Vue](https://vuejs.org/), [Solid](https://www.solidjs.com/), [Svelte](https://svelte.dev/) and [Preact](https://preactjs.com/). We also looked at [fastify-html](https://github.com/mcollina/fastify-html) (a Fastify wrapper for [ghtml](https://github.com/gurgunday/ghtml)) and [ejs](https://npmjs.com/package/ejs) via [@fastify/view](https://www.npmjs.com/package/@fastify/view) for simpler alternatives.

We chose not to consider tools like [Next.js](http://next.js), [Astro](https://astro.build/) and [Qwik](https://qwik.dev/), as well as other full fledged frameworks, as they do not offer isolated rendering methods.

For [@fastify/vite](https://fastify-vite.dev/)\-based tests, we used a boilerplate as follows:

```javascript
import Fastify from 'fastify'
import FastifyVite from '@fastify/vite'

const server = Fastify()
await server.register(FastifyVite, /* options */)

await server.vite.ready()
await server.listen({ port: 3000 })
```

All tests were run against the production build, that is, after running `vite build`.

The only exceptions are the **fastify-html** and **ejs** tests, which don't require Vite. 

Check out the [repository containing all examples](https://github.com/platformatic/ssr-performance-showdown).

## **Ensuring consistency**

We've made sure all examples share the same characteristics: 

* No client-side reactivity features used.
    
* All style bindings are done using template literals unless where inappropriate for the framework in question, as is the case for React and Solid.
    
* The **x** and **y** values are created with **toFixed(2)**.
    
* No **&lt;style&gt;** tags other than the one in the document shell.
    

Tests were run on a 2020 MacBook Air M1 with 8GB RAM, Node v22 on macOS Ventura.

## **fastify-html**

We begin with the outlier: [fastify-html](https://github.com/mcollina/fastify-html), a Fastify plugin wrapping [ghtml](https://github.com/gurgunday/ghtml), delivering 1088 requests per second. As mentioned earlier, this setup differs from all the others in that it doesn't require Vite, since no special syntax or transformations are required.

![](https://lh7-rt.googleusercontent.com/docsz/AD_4nXcQtUNJ0TKtnlYD9_tvx9lwPvRH2mSCeIrzlMvLf7KYQBNiFFHmv7_TliZ8lncINjD0ys-iaHXdJoLEylQiuK6M6DB0-t8OjSP8lNFQxaL0o-JRkbEBUxhkBt6E-EELwpgHPRV4I_PqYM5KbA3i6zKuS0Uh?key=g4NTNKlEeRkqdIGj6BIusg align="left")

fastify-html was added to this test as a baseline. It doesn't really compare well with all others because it's just a wrapper for a simple HTML templating library, with none of the advanced features of the other libraries. Due to its simpler nature we already expected it to be the better performing library, and we wanted to see just how far behind the other fully featured libraries would be. 

The boilerplate used can be seen below — notice that `createHtmlFunction` (mimicking @fastify/vite) is used to register a layout function that renders the document shell:

```javascript
import Fastify from 'fastify'
import fastifyHtml from 'fastify-html'
import { createHtmlFunction } from './client/index.js'

const server = Fastify()
await server.register(fastifyHtml)

server.addLayout(createHtmlFunction(server))
```

For reference, we've also added a test using the old school [EJS](https://npmjs.com/package/ejs) (based on [@fastify/view](https://www.npmjs.com/package/@fastify/view)). It was able to handle **443 requests per second.**

## **Vue**

In second place, delivering **1028 requests per second**, Vue is probably the best deal if you want great SSR performance and want a truly comprehensive library ecosystem. 

![](https://lh7-rt.googleusercontent.com/docsz/AD_4nXeDMHgex7OJw4GxM4Wmr5VBq6hpjqti9UbCJYMIbFi7renAXfVkWKwjXoECHReTXDDC3AiIUXMbTWR216EbJNtRT5cU0s2S3yf1tSfm7pIQpsLDBZ18cnSRQXD88uyN5gPNr_2h8UNXcJJNVQryx-a6_LJP?key=g4NTNKlEeRkqdIGj6BIusg align="left")

The Vue API for synchronous server-side rendering used is [`renderToString()`](https://vuejs.org/api/ssr#rendertostring):

```javascript
import { renderToString } from 'vue/server-renderer'

// ...

await server.register(FastifyVite, { 
  async createRenderFunction ({ createApp }) {
    return async () => ({
      element: await renderToString(createApp())
    })
  }
})
```

## **Svelte**

In third place, **Svelte 5** (still a pre-release) delivered a whopping **968 requests per second**, which is rather impressive considering its rich feature set**.** 

Svelte has its own non-JSX [templating syntax](https://svelte.dev/docs/logic-blocks) and its engine appears to be remarkably efficient, making it an excellent choice if you need a framework with a mature library ecosystem and don't want to compromise on SSR performance.

![](https://lh7-rt.googleusercontent.com/docsz/AD_4nXd5oqC0Znm3guGJVCtjafaphxG4BglQ8KnHqhJaCYCVZTNHD8iRJYDVIXYP6SAuTD-gu0OhDfEuoNPAcMS_z7QW5132gqH_es5lr1hI-pZpIp-H2RAG74iTGD8k-wgmSvor4eY4bW7LV4vX3nfCdbYq4D6P?key=g4NTNKlEeRkqdIGj6BIusg align="left")

The Svelte API for server-side rendering used is [`render()`](https://svelte-5-preview.vercel.app/docs/imports#svelte-server-render), from Svelte 5.

```javascript
  await server.register(FastifyVite, {
    root: import.meta.url,
    createRenderFunction ({ Page }) {
      return () => {
        const { body: element } = render(Page)
        return { element }
      }
    }
  })
```

Note that the `render()` function will also return head and body properties.

## **Solid**

In fourth place comes [SolidJS](https://www.solidjs.com/), delivering **907 requests per second**. It falls behind Svelte by a very low margin. Solid is a [very promising alternative to React](https://www.youtube.com/watch?v=hw3Bx5vxKl0), but still has a maturing ecosystem.

One thing we noticed is that SolidJS actually suffers from using IDs as part of its hydration process. Compare the markup generated by Vue and Solid, respectively:

```plaintext
<div class="tile" style="left: 196.42px; top: 581.77px"> 
<div data-hk=1c2397 class="tile" style="left: 196.42px; top: 581.77px">
```

This means a lot of the performance toll is coming from this extra fragment that needs to be sent over the wire. Still, we wanted to verify exactly that: how frameworks would behave under normal, real-world circumstances which meant having client facilities such as hydration enabled.

![](https://lh7-rt.googleusercontent.com/docsz/AD_4nXcGrbZgC3OuY9VpPd200RL_gmST_ypQAU1ET99b3abMHP_bKSmpanne1tEe-N_CLr7Xmf-rNHqpxM2UpyAOKipTg74dgIGiWiZDLYkG1D41wuyrjKtR0UcXm0R8GoWyBSJlxRtupl-2I581zQv6nGY-icc?key=g4NTNKlEeRkqdIGj6BIusg align="left")

For the boilerplate, we used @fastify/vite's `createRenderFunction` hook to capture the Solid component function (`createApp`):

```javascript
import { renderToString } from 'solid-js/web'

// ...

await server.register(FastifyVite, {
  root: import.meta.url,
  createRenderFunction ({ createApp }) {
    return () => {
      return {
        element: await renderToString(createApp)
      }
    }
  }
})
```

# **Preact**

React's popular younger brother comes in fifth place, delivering **717 requests per second**. Even though Preact is very similar to React, there are [many differences](https://preactjs.com/guide/v10/differences-to-react/) that make it faster and more lightweight.

![](https://lh7-rt.googleusercontent.com/docsz/AD_4nXcZB4es91CVzNqvVBG2ttoLzUKqObTkLD1gZnvwdKUSOLc8HKrjUOk94T1hmkPdI9zdsueEk5cNC4e_VE5ttVpes688z71ZUCEPSuH_oiLiZ3_0Ypl7gmXQ0C2NrVxLb0709LILZnKJVxaKv4mM3ElsmHIX?key=g4NTNKlEeRkqdIGj6BIusg align="left")

The Preact API used for synchronous server-side rendering is [`renderToString()`](https://www.npmjs.com/package/preact-render-to-string):

```javascript
import { renderToString } from 'preact-render-to-string'


// ...

await server.register(FastifyVite, {
  root: import.meta.url,
  createRenderFunction ({ createApp }) {
    return () => {
      return {
        element: renderToString(createApp())
      }
    }
  }
})
```

## **React**

React 19 RC comes in sixth place, delivering **572 requests per second**.

![](https://lh7-rt.googleusercontent.com/docsz/AD_4nXfMyI8YYVugN-hwbjQ4yE9vaFx8EXI4ckkfoLvKyb76ktjUy-IuzbCPmSGxQTmNoLU0iRuXi9YMq5tA0e_JGuz5TVsKfQEicIQgUulH5R90o52ysnFf6gHi9WGlvPFTs2v3vxHz6iV5z4Mb9QujHImi9-fn?key=g4NTNKlEeRkqdIGj6BIusg align="left")

The React API used for synchronous server-side rendering is [`renderToString()`](https://react.dev/reference/react-dom/server/renderToString):

```javascript
import { renderToString } from 'react-dom/server'

// ...

await server.register(FastifyVite, {
  root: import.meta.url,
  createRenderFunction ({ createApp }) {
    return () => {
      return {
        element: renderToString(createApp())
      }
    }
  }
})
```

## **Wrapping up**

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1725004972524/ffd15f71-0e3a-48b4-957c-2f08999c902b.png align="center")

💡 So what do these results mean?

At the top we see fastify-html and Vue, followed by Svelte and Solid close behind. Vue and Svelte probably offer the best trade-off between SSR performance and ecosystem maturity. 

As mentioned before, the fastify-html test was added as a baseline, and to demonstrate what can be gained in performance by doing away with full fledged frontend frameworks and sticking to minimal templating.
