Speeding up the JavaScript ecosystem - Server Side JSX
📖 tl;dr: With a JSX transform that is optimized for rendering HTML as quickly as possible on the server, we can make rendering 7-20x faster and cut GC times in half. This JSX transform is generic and not tied to a particular framework.
In the realm of web development, the efficiency of server-side rendering HTML plays a crucial role in delivering fast and responsive user experiences. However, a notable challenge arises from the existing JSX transforms that turn JSX into valid JavaScript: They are primarily tailored for browser environments, often generating excessive memory overhead and causing frequent Garbage Collection pauses. This overhead can be nearly eliminated by rethinking how we transform JSX on the server.
Optimizing JSX for the server
When looking at the transpiled output of commonly used JSX transforms, it’s easy to see why it’s causing frequent GC pauses. They typically don’t differentiate between static and dynamic parts of a JSX "block" and allocate many short-lived objects for both an element and their attributes. The amount of objects to be allocated grows very quickly, the more JSX elements you have.
// input
<h1 id="heading">hello world</h1>;
// output: "classic" createElement transform
React.createElement("h1", { id: "heading" }, "hello world");
// output: newer "automatic runtime" transform
import { jsx as _jsx } from "react/runtime";
_jsx("h1", { id: "heading", children: "hello world" });
On the server, neither of those transformation outputs are ideal. They both create many short-lived objects that will be immediately thrown away after rendering. Since the input JSX element is completely static, the optimal solution would be to transform it directly to a plain string which bypasses the need for those temporary objects in the first place.
// input
<h1 id="heading">hello world</h1>;
// output: precompiled HTML string
const html = '<h1 id="heading">hello world</h1>';
Allocating one string is much less taxing on the Garbage Collector than creating and cleaning up a dozen objects, regardless of how big or small those objects might be.
Mixing static and dynamic parts
But not all templates are static and a huge part of rendering involves mixing static and dynamic pieces. We have something for that already built-in in JavaScript: Tagged template literals. They differentiate between static and dynamic inputs already.
// input
<h1 id="heading">My name is {name}</h1>;
// As a tagged template string
jsxTemplate`<h1 id="heading">My name is ${name}</h1>`;
From an implementation point of view, tagged templates share the following function signature, which is perfect for what we want to do:
function jsxTemplate(statics: string[], ...expressions: unknown[]) {
// ...
}
We can tailor our JSX transform to generate an array of the static parts of each JSX block and pass the dynamic parts as additional arguments.
const template = ['<h1 id="heading">My name is', "</h1>"];
jsxTemplate(template, name);
I’ve benchmarked if it would make a difference between passing all expressions as an array, or using a rest parameter, but couldn’t find any meaningful changes in performance.
Making it secure
A crucial aspect of generating HTML is to ensure that dynamic input is properly escaped and cannot lead to XSS vulnerabilities. This is something we should take care of in our JSX transform automatically. By wrapping every dynamic part with an additional function call that escapes passed content, we can remove those risks entirely.
const template = [
// ...
];
// The `name` variable is dynamic and we’ll wrap it with
// an escape function automatically
jsxTemplate(template, jsxEscape(name));
The benefit of baking this into the JSX transform is that it requires frameworks to supply an implementation for jsxEscape
which reduces the risk of forgetting to escape things properly. Whilst we could leverage the same function for dynamic attribute values as well, most frameworks handle attribute values differently based on which attribute they are dealing with. A common thing during server-side rendering is to drop all event related props like onClick
for example.
// input
<h1 class={someVariable}>hello world</h1>;
// output: precompiled
const template = ["<h1 ", ">hello world</h1>"];
jsxTemplate(template, jsxAttr("class", name));
With the jsxAttr
function framework authors can decide to drop the whole attribute if desired. Note that they still need to ensure that they’re escaping the attribute value accordingly.
What about components?
Components are a bit of a special case in that it’s entirely up to the framework how they’re implemented and what they do under the hood. If we want to keep our JSX transform usable for any framework, we cannot make assumptions here. For that reason we’ll simply fall back to the automatic runtime transform in this case.
// input
<Foo foo="bar" />;
// output: precompiled, same as automatic runtime transform
jsx(Foo, { foo: "bar" });
How much faster is it in the real world?
Synthetic benchmarks show that a precompiled approoach is around 7-20x faster, but the more interesting question is how it behaves outside of that. Real projects are often very different from synthetic benchmarks. For that reason I picked my own site for this. The code has a healthy mix of static parts and dynamic components. As usual, the numbers are measured on my MacBook M1 Air.
Transform | Time | ops/s | improvement |
---|---|---|---|
Classic | 40.92 µs/iter | 24,439.7 | 1x |
Automatic Runtime | 38.32 µs/iter | 26,098.1 | 1.06x |
Precompile | 5.12 µs/iter | 195,312.5 | 8x |
Visualized as a chart:
Conclusion
Interestingly, the core idea presented here is nothing new. In truth, this is how every templating language that outputs HTML has worked since at least the early 2000s (likely even older). However, with the rise of JSX and the popularity of Single-Page-Apps, focus was shifted away from the server to the browser. Since then many framework specific transforms or even full custom file-formats have been invented. There is Svelte, there is Vue and there is Solid which works closest like the strategy described in this post. They also transpile JSX to a stringified output.
The key difference here though is, that the transform isn’t tied to a particular framework. It works like the standard "classic" and "automatic runtime" JSX transforms in that it can be used with any JSX-based framework. Whether you are using Preact, Hono’s JSX layer, or something entirely different or custom grown is not important as you can support the "precompile" transform with minimal effort. Both Preact and Hono’s JSX support it out of the box already.
Enabling the new "precompile" transform in Deno (Disclaimer: I'm employed by Deno) is as easy as changing one line in the config file.
// deno.json
{
"imports": {
"preact": "npm:preact@^10.22.0",
"preact-render-to-string": "npm:preact-render-to-string@^6.4.2"
},
"compilerOptions": {
- "jsx": "react-jsx",
+ "jsx": "precompile",
"jsxImportSource": "preact"
}
}
And with that change you can make rendering JSX on the server in your app 7-20x faster and reduce Garbage Collection times by 50%. The more static elements you have the faster the transform can render HTML. The beauty about this transform is that even in the worst case scenario where there is no static content, it will have at least the same performance characteristics as the existing JSX transforms. It will never be slower, it can only be faster.