The Road to Isomorphic Rendering Performance
I’m the creator of the SolidJS UI library, which is frequently recognized as one of the top-performing libraries in the browser. From the outset, I aimed to provide developers with an isomorphic experience—enabling the same code to execute seamlessly on both the client and server. However, once I delved into server rendering, it became clear that this was an entirely different challenge.
In this post, I walk through my path to building Solid’s isomorphic rendering solution. I describe how I arrived at a server-side rendering approach that performs so well in benchmarks that it positions Solid as a top competitor for the fastest library on both the client and the server.

Official JS Framework Benchmark Chrome 85

Search Results SSR from the Isomorphic Benchmark
Understanding Server-Side Rendering
Having spent years optimizing DOM operations, employing clever heuristics, and leveraging pre-compilation techniques, I was convinced that there was an ideal method for Server-Side Rendering (SSR)—some compilation of hidden tricks that, when applied together, would yield the perfect result. I did discover what I sought, but it wasn’t what I had envisioned.
To begin with, there are countless approaches for rendering interactive JavaScript sites and applications—an overwhelming number, actually. There are so many variations that the first insight is that when someone says "SSR," they might be referring to one of dozens of distinct concepts.
There’s on-demand rendering versus static site generation (SSG). There are buffered versus streamed responses. There’s synchronous versus asynchronous rendering. And there are Single Page Apps (SPA) versus Multi Page Apps (MPA). Furthermore, hydration approaches range from none at all to full, partial, or progressive hydration.
While each of these can be beneficial on its own, not every combination is compatible with the others.
Note: A thorough grasp of hydration is critical for any meaningful discussion of SSR performance. I highly recommend Rendering on the Web by Jason Miller and Addy Osmani as a foundational reference for the topics covered here.
SSR Architecture Choices
At first, I feared I might need to build 16 distinct solutions to cover all scenarios. But by focusing on real use cases, I managed to narrow things down. Based on my research, two primary approaches emerged as the frontrunners.
JAMstack
Let’s start here, as it’s the most straightforward if you’re coming from a SPA mindset. JAMstack is an acronym for JavaScript + APIs + Markup. The core idea is to serve static HTML and let the client handle everything else. While not exclusively for SPAs, JAMstack is commonly associated with them: you pre-render a static shell, host it on a platform like Netlify, and rely on JSON or GraphQL APIs hosted on serverless offerings such as AWS or Cloudflare Workers for data.

Opting for static pre-rendering doesn’t fully spare you from complications. You might achieve fast First Contentful Paint (FCP), but Largest Contentful Paint (LCP) and Time to Interactive (TTI) often suffer. That’s because you still must wait for JavaScript to fetch the bulk of dynamic content, and hydration still carries a heavy overhead. This improves on a traditional SPA, but you still can’t request dynamic data until the page is running in the browser.
When your sole focus is the initial render, it’s tempting to argue that we simply need less JavaScript. However, SPA-like behaviors significantly enhance the experience after that first load. There’s a reason SPAs are so prevalent despite slower initial loads—once the JavaScript assets are cached, there’s basically no perceptible latency. Matching that is no easy feat.
There’s been some headway in delivering similar UX within MPAs via Portals and TurboLinks, which can significantly smooth out page transitions. But that addresses just one dimension of the fluid interactivity SPAs offer.
Islands
Multi-Page Apps and sites rely on server-side routing, meaning each navigation leads to a fresh HTML page. The main advantage of server-side routing relates closely to hydration dynamics.
When the top-level structure of your page is mostly static, the amount of required JavaScript shrinks dramatically, and each dynamic component can operate independently. Jason Miller, the creator of Preact, has recently written an article coining the term "Islands Architecture" for this pattern. However, it’s hardly new—MarkoJS has been built around this very premise, using partial hydration in production at eBay scale for more than half a decade.

Islands Architecture from Jason Miller
Streaming the HTML response can dramatically boost loading performance. Consider MarkoJS: it flushes the initial render synchronously with placeholders, then streams in script tags to fill in dynamic sections as they finish, all within the same response. Michael Rawlings explores this approach in detail here. I anticipate this space getting more crowded, as numerous libraries are currently working on similar techniques.
In the end, this architecture offers the best initial render performance we can achieve for content that’s largely dynamic. The volume of JavaScript and DOM needed for hydration is simply smaller. Data requests can kick off as soon as the server gets the initial request. Hard to argue against that for things like eCommerce.
JavaScript Server Rendering Performance
With those architectural considerations in mind, I turned to the next challenge: figuring out how to render on the server. It turned out to be far less straightforward than I had anticipated.
Running the DOM on the Server

Example of patching a Node environment with JSDOM
My initial instinct was to simply bring the DOM along—seems easy, right? Solid’s JSX already generates DOM nodes, which supports Web Components and creates a purely isomorphic experience. You could even use jQuery if you wanted. I started with JSDOM and then experimented with lighter alternatives like basicHTML, but I struggled to find any popular library that performed slower in benchmarks.
I came across advice on making DOM-based server rendering performant by "warming up" the environment, which led me to a related concept for the VDOM known as "blueprints." Blueprints involve pre-constructing the renderer tree, then feeding in new data and serializing the output.
But for a reactive library with a granular focus, this approach wouldn’t really work. There’s no single entry point to propagate data downward. In granular libraries, the tree is split into many small, independently updating nodes.
In essence, a DOM on the server is a form of virtual DOM—one that’s less proprietary but carries idiosyncrasies suited for browsers. On the plus side, it offers platform compatibility. Yet, all evidence pointed to a VDOM being more effective than an emulated DOM. And I quickly realized I wanted little to do with either approach.
Reactivity on the Server

From "Fundamental Principles behind MobX" by Michel Westrate
So I built new runtime methods tailored for SSR that skipped DOM APIs altogether and directly generated strings. This worked fine as long as you disabled DOM interop features like events and refs. It delivered a dramatic performance boost. The results were comparable to libraries like React or Preact, but nothing particularly groundbreaking.
That made sense. Granular reactivity shines in the browser only because it avoids unnecessary DOM operations—it’s optimized for updates. To reduce initial costs, we rely on the compiler to batch DOM node creation and minimize traversal, which still comes at a price.
Without DOM overhead, the extra machinery just gets in the way. The catch is that for asynchronous server rendering, you can’t easily discard the change management system—how else would you track and apply updates?
This gets even trickier given how freely changes propagate with granular reactivity. An update to one signal can ripple independently through the tree without any top-down constraints. So it’s not just about defining render boundaries like Suspense to achieve stability.
If we want reactive updates, we still need injection points within our string template, so a form of virtual DOM with static and dynamic segments remains. And to serialize it, we essentially have to wait until the process finishes to extract all the values.
At this point, I wasn’t pleased with where this was heading. We were just shifting the bottleneck. This was a perfectly adequate solution for SSG and JAMstack scenarios, but I had aspirations for far more.
The Architecture, Reconsidered
Reframing the Challenge

The Double Diamond diagram by Ari Tanninen
At this stage, I was still far from satisfied. We have the JAMStack on one end and the "Islands" architecture on the other. Both are perfectly reasonable answers to a wide range of problems. Yet neither sat well with me. Why should one have to pick between them? There had to be a way to stay highly dynamic, enjoy fast load times, and deliver the kind of fluid experience you'd expect from a full-blown SPA.
The nice thing about pre-rendering with the JAMStack is that it removes any responsibility when it comes to how poorly our client-side library runs on the server. But what if on-demand rendering is the goal? What if we want the ability to render inside a Cloudflare worker?
Marko's streaming approach stood out as the most promising, but at the same time it seemed to carry a lot of inherent complexity for a system that's meant to support dynamic updates.
What exactly makes JavaScript-heavy SSR so difficult?
Searching for Alternatives
My mind went back to the older days of progressively enhancing pages rendered on the server with tools like jQuery and KnockoutJS. But that was hardly a workable path for a significant category of sites and applications.
A page that is reasonably static in nature (with routing dealt with on the server) can absolutely benefit from partial hydration and components that render exclusively on the server. However, the more an application starts to resemble a modern SPA, the less value these strategies bring. That's the exact area where I believed Solid could make the strongest difference.
It's hardly a secret that I have my biases here. In fact, SSR or SSG wasn't even on my radar for Solid in the beginning. My benchmarks had shown that a small library combined with careful code splitting made it possible to compete effectively with those methods using pure client-side rendering. See Solid's Realworld Demo comparison.
With that being the case, what would the perfect approach look like for a library of this kind, where running on the server genuinely adds value?
Setting the Objectives
Let's consider the weakest points of an SPA architecture. Often, FCP will lag, depending on how heavy the library itself is. But, in my honest opinion, it's LCP, and by extension TTI, that take the bigger hit. When heavy rehydration is involved, TTI doesn't really change all that much between different approaches. Rather, the delay in loading the main content is what ends up affecting it.
Based on my experience, the data fetching on those subsequent loads takes longer than the JavaScript itself. This holds true even for dynamic imports during the initial page load. Getting that data flowing has to be a priority. The React team calls this render-as-you-fetch. The idea is to not hold off on data requests until individual JS chunks are ready. Regardless of whether I'm using SSR, this is a pattern I lean on heavily within Solid.

Solid's Realworld Demo SPA Initial Load Timeline

Svelte's Sapper SSR Realworld Demo Initial Load Timeline
If you look at the timeline, you'll see that Solid has already retrieved all of the API data before Svelte has even initiated its own request. Add to that the time needed for server-side rendering versus simply sending an almost empty static HTML page. The net result is that it takes nearly twice as long for the main content to show up in the latter case.
This is a meaningful improvement that most SPAs could adopt today, and its effect on load time is significant. But what's the next step to make it even better? The answer is streaming.
With streaming, the objective is to flush out our renders synchronously. Placeholders will be displayed until the actual content finishes loading, ensuring that something is visible in the browser as fast as possible and keeping FCP numbers low.
Modern browser UI patterns are a great ally in this effort. Suspense Components give us a way to designate what placeholders look like. And by relying on Resources—a bespoke primitive designed to handle the reading of values that might not be ready—we gain insight into both when a request for data has been made and when it resolves, as well as the exact locations where these async values are being accessed.
Deliberately choosing special primitives like this is about warding off the "coloring" effect on the developer experience. Async Functions have a way of being intrusive—once you bring them into the picture, they tend to require being passed up the entire call chain. Generators can present similar complications. This was actually a core driving force behind React's strategy of "throwing promises," and it is something that fits naturally within reactive systems, given their reliance on the independent propagation of changes.
Nonetheless, we're still left with the issue of pushing granular updates into a view that's already made its way to the browser, all without dragging along the weight of a full reactivity system. That pipeline is unavoidable; there is no alternative way to get updates through. Perhaps somewhat counterintuitively, the finer the granularity, the trickier this task becomes.
The SSR SPA?
That gave me pause for a good long while. It was time to accept that there might be certain tasks granular reactivity isn't the best fit for. The problem is a tough one because async introduces unpredictability that throws all assumptions out the window. It was then I noticed that this bore a strong resemblance to a hydration challenge I'd encountered previously.
The absence of a VDOM makes hydration harder when it comes to gathering nodes. There isn't a "template" to rely on. Our process is a single pass using JSX that executes from the inside out. Additionally, due to JSX's dynamic characteristics, it can't be statically analyzed in this situation. The workaround was to leverage the server-side rendering as the initial pass, encoding all the data we need right into the resulting HTML string.
It became clear I had to abandon thinking of the client and server as two separate problems. The necessary pipeline is already there. All that's needed is for that reactive graph to be rendering the app, but it just hasn't made its way to the server yet.
Here's the strategy: render everything synchronously on the server. Upon reaching a Suspense boundary, keep executing to trigger any fetches but immediately render a placeholder to stream down to the client.
When a Resource finishes loading on the server, a script tag is written directly into the page. The matching Resource on the client side then reads from it. This effectively creates a Promise that starts on the server but resolves in the client. The server acts as a distributed source supplying the client's reactive graph.
The benefit here is twofold. Data fetch kicks off at the earliest possible moment on the server, and the client isn't left waiting to display anything. Since we avoid sending the same data twice—once as data and again as markup—non-static data only makes a single trip to the client.
More than anything, this meant I could direct all of Solid's SSR energy towards purely synchronous rendering performance on the server, all while achieving this async isomorphic model.
Constructing the Solution
Looking at Server Performance Again
The reactive system on the server isn't actually needed to keep a consistent model between the client and server. For native elements, instructing the compiler to produce a different output on the server is simple enough. But user code contains primitives that complicate things.
The way to handle that involved authoring a completely separate version of the runtime and rewriting the import statements accordingly. That way, the same source code can render isomorphically. Reactive signals turn into plain value getters, while computations become simple IIFEs.
As I dug deeper, I realized that the key to unlocking server performance was painfully unremarkable. It all comes down to the speed at which you can concatenate strings. That's it.
This exploration also reminded me of some obvious truths. For instance, while normal Template Literals are quick, Tagged Template Literals are considerably slower. Therefore, if you're escaping holes within the template, you're better off inlining the calls. Or if you're going through a function to do the merging, it's wise to stay clear of Tagged Templates.
When handling lists, using a for loop and appending to a string runs much quicker than a map operation, given that map requires cloning the array. And when it comes to regex, the performance cost of a replace operation is far greater than doing a quick test with a regex match and then manually iterating through the string for replacements.
Fortunately, the custom compiler and runtime combination allowed for just these kinds of adjustments. In fact, my only real constraint came from JSX's ability to allow any JS expression. Because of that, templates had to be specially wrapped to prevent duplicate escaping as they were merged together. This does introduce a notable overhead, but it wasn't enough to stop Solid from ranking at the top of server-side JavaScript benchmarks (refer back to the article's opening).
const doINeedToBeEscaped = "Yes";
// Needs to be escaped
const view = <div>{doINeedToBeEscaped}</div>
const doIStillNeedToBeEscaped = <span>Static Text</span>;
// Doesn't need escaping as would be handled by child template
// Don't want to encode the <span> tag itself
const view2 = <div>{doIStillNeedToBeEscaped}</div>;
Dynamic Nature of JSX
The key takeaway here is that server-side JS rendering still has room to grow. Yet, given that the DOM doesn't place the same heavy constraints on us, the bottleneck is more likely to reside in user code itself.
In a browser environment, rendering is so costly that we, as framework authors, go to great lengths to prevent unnecessary work. Strangely enough, those same preventative steps carry a measurable overhead on the server. If the framework's role is reduced to thousands of string concatenations, then the major chunk of the cost is shifted squarely onto the application's own logic.
Examining the Performance Timelines
Let's put this into practice. I constructed a straightforward cascading load example built with Solid to observe how various SSR and hydration strategies play out in a SPA context. All variants rely on identical component source code, follow recommended patterns like render-as-you-fetch, and leverage Solid's Suspense and Resource APIs to handle automatic data serialization between client and server.
The demonstration uses a basic tab navigation interface. We'll refresh the browser on the Profile page, which pulls in the core Solid JavaScript bundle plus a separate chunk dedicated to that page. Two data requests are simulated: one for general profile details (completing in 400ms) that drives the initial render, and another for supplementary user information (completing in 800ms) that populates additional sections.
The complete source is available here: https://github.com/ryansolid/solid/tree/master/packages/solid-ssr/examples.
The repository is organized with a shared folder containing all the application logic. The remaining directories each implement a distinct SSR strategy.
- Async SSR (/async) – Brings the reactive system to the server, resolves all data dependencies before sending the rendered view along with the data, and then fully hydrates in the browser.
- Hybrid SSR (/ssr) – Delivers the initial view synchronously without waiting for data, then delegates all subsequent data fetching and rendering to the client.
- Streaming (/stream) – Identical to the SSR approach, except data loading happens on the server and results are streamed to the client as each request completes.
Since these examples serve an educational purpose, I haven't applied minification or gzip compression. However, the settings are consistent across all three, so the comparison remains valid. Lighthouse produced nearly identical TTI scores for each, so my analysis focuses on the Chrome Timeline instead.
Async SSR

Async Performance Timeline
FCP – 882.3ms
LCP – 882.3ms
Last Event – 907ms
This represents the most straightforward SSR implementation. The scenario here is somewhat exaggerated—in a real application, you'd likely defer that secondary request using lazy loading, and server-side data access might be faster. Still, this is what happens when you attempt to render everything on the server without streaming.
While this example employs reactivity for asynchronous rendering, it closely mirrors the standard practice for isomorphic SSR: gather the data first, then synchronously render the complete view. Even for a page this size, the costs are fully incurred.
Hybrid SSR

SSR Performance Timeline
FCP – 102.9ms
LCP – 502.8ms
Last Event – 901ms
Here, the server renders synchronously without any reactive involvement. All asynchronous data operations are left entirely to the client. This lets the server respond immediately without holding the connection open.
The tradeoff is evident: the bulk of data fetching can't commence until the browser downloads and executes the JavaScript. The timeline makes this clear. The LCP lands almost exactly 400ms after the FCP, matching the delay of that first data request. Another 400ms later, everything wraps up—finishing at precisely the same moment as the async approach.
This pattern mirrors what JAMStack typically looks like in production. Although that involves static generation while this uses server rendering, the crucial data loading occurs on the client only after the initial page load. In this instance, resources arrive quickly. But slower networks or larger payloads could introduce significant delays.
Streaming

FCP – 101.3ms
LCP – 434.6ms
Last Event – 825.0ms
The profile.html request now spans most of the timeline, yet it doesn't prevent other resources from loading concurrently. That's the streamed content arriving in pieces. The result is the best LCP and quickest overall completion.
One might assume Async and Streaming would tie on total time since both can start fetching early from the server. But with streaming, the JavaScript and CSS beat the data to the client, enabling immediate rendering upon arrival. In a setup without code splitting, the gap might narrow, but this clearly illustrates the value of avoiding blocking.
The real win is that every SPA metric improves. Content renders on the server, so FCP is quicker. Data fetching begins server-side sooner, so LCP improves. And the entire sequence finishes earlier.
Wrapping Up
I entered this exploration with no firm expectations and harbored doubts about whether a "complex" isomorphic setup was truly necessary. I surveyed the current landscape and received considerable assistance while getting up to speed (https://github.com/ryansolid/solid/issues/109). During this process, I joined the MarkoJS core team—they're pioneers in this space—which gave me a much wider lens.
Finding something that matched the apparent goal proved difficult at first. Only by reframing the problem was I able to both simplify it and devise a solution that enhances what Solid already offers. A granular reactive library doing synchronous rendering—who would have guessed?
The outcome is something I'm genuinely pleased with: complete non-blocking streaming for both view and data, progressive hydration integrated with code splitting, exceptional raw server render performance, and consistently better Chrome timeline metrics.
It won't address every SSR requirement a website might have. But I believe it demonstrates an approach that doesn't sideline heavy JavaScript applications. It does so in a coherent manner by building on existing client-side patterns, delivering a genuinely isomorphic experience.
There's something satisfying about running identical code on server and client, watching it stream into the client's reactive system as if it were one unified entity. Even better, this happens automatically with no user effort. A modern client-side SPA can become isomorphic without touching a single component.
Moving forward, I'm eager to explore using this to take an existing client app and port it directly into a Cloudflare Worker. Partial hydration remains a possibility for Solid, though it's not currently the main focus.
On the Marko front, we're developing a novel way to express state and state compositions within the declarative template, along with a new granular client runtime. Combined with compile-time analysis, this will let us fine-tune what gets shipped to the client down to the subcomponent level.
A framework author's work is never done.
References (in order):
SolidJS – https://github.com/ryansolid/solid
MarkoJS – https://markojs.com/
JS Framework Benchmark – https://github.com/krausest/js-framework-benchmark
Isomorphic UI Benchmark – https://github.com/marko-js/isomorphic-ui-benchmarks
Rendering on the Web by Jason Miller and Addy Osmani – https://developers.google.com/web/updates/2019/02/rendering-on-the-web
The Cost of Client-Side Rehydation by Addy Osmani – https://addyosmani.com/blog/rehydration/
Islands Architecture by Jason Miller – https://jasonformat.com/islands-architecture/
Async Fragments: Rediscovering Progressive HTML Rendering with Marko by Patrick Steele-Idem – https://tech.ebayinc.com/engineering/async-fragments-rediscovering-progressive-html-rendering-with-marko/
Maybe you don't need that SPA by Michael Rawlings – https://medium.com/@mlrawlings/maybe-you-dont-need-that-spa-f2c659bc7fec
Hands-on with Portals: seamless navigation on the web by Yusuke Utsunomiya – https://web.dev/hands-on-portals/
TurboLinks – https://github.com/turbolinks/turbolinks
JSDOM – https://github.com/jsdom/jsdom
basicHTML – https://github.com/WebReflection/basicHTML
Virtual DOM SSR Performance by Boris Kaul – https://medium.com/@localvoid/virtual-dom-ssr-performance-5c292d4961a0
The Fundamental Principles Behind MobX by Michel Westrate – https://hackernoon.com/the-fundamental-principles-behind-mobx-7a725f71f3e8
The Double Diamond Process by Ari Tannenen – http://stopandfix.blogspot.com/2015/07/the-double-diamond-process.html
A Solid RealWorld Demo Comparison of JavaScript Framework Performance by Ryan Carniato – https://levelup.gitconnected.com/a-solid-realworld-demo-comparison-8c3363448fd8
Suspense for Data Fetching (Experimental) – https://reactjs.org/docs/concurrent-mode-suspense.html#approach-3-render-as-you-fetch-using-suspense
