Faster React SSR with Rapscallion
React has gained significant momentum over the four years since its initial release, and for good reason. I'll admit that I was initially a skeptic, particularly when it came to the strangeness that is JSX, but I was quickly won over by its codification of small, testable units of code, one-way flow of data, and composability.
In fact, it is these very qualities that have allowed us at Formidable to build front-end applications of considerable size and complexity. However, there has been one problem that has consistently reared its head in our client work: poor server-side rendering performance.
There has been a lot of discussion and a handful of attempts at tackling this issue, but with SSR not in use at Facebook, it is understandable that the effort has been deprioritized.
Fully addressing this issue should become simpler with the advent of React Fiber. We're excited to see what comes of that effort, but in the interim, there is a lot of value in addressing the issue of SSR performance with the React of today.
And so, I'm happy to announce the initial release of Rapscallion, a new approach for server-side rendering React applications. A quick run-down of the notable features:
- Rendering is asynchronous and non-blocking.
- Non-concurrent rendering speed is roughly 50% faster than React's
renderToString
. - Concurrent rendering speed is roughly 60%-85% faster.
- A streaming interface is provided, so that you can start sending content to the client as soon as the request arrives.
- It provides a templating feature, so that you can wrap your component's HTML in boilerplate without giving up the benefits of streaming.
- It provides a component caching API to further speed up SSR.
The first few items show a lot of promise, but it is with caching where Rapscallion really shines.
If you'd like to skip all this and give it a try, the docs are up on GitHub. Otherwise, read on.
Prior art
I would be remiss if I did not acknowledge the huge amount of work that has gone into addressing SSR performance. Here's a quick review of the packages of which I'm aware:
react-server
react-server
addresses the issue in a systematic way, and comes will all kinds of bells and whistles. It still relies on React's renderToString
, but allows you to compose your applications in a way that makes it easier to send data to the client incrementally.
In truth, react-server
and rapscallion
are getting at the same problem from different directions, with little overlap. In fact, the two might complement each other well.
react-ssr-optimization
From their docs: [react-ssr-optimization ...] is a configurable ReactJS extension for memoizing react component markup on the server. It also supports component templatization to further caching of rendered markup with more dynamic data.
Basically, this package memoizes the output of renderToString
internals so that you can avoid duplicate work on a single node. It is not, however, asynchronous, nor are the caching effects easily shared across nodes.
react-dom-stream
react-dom-stream
tries to solve the problem in a similar way to Rapscallion. Rendering occurs asynchronously, and it even includes a caching feature similar to Rapscallion's.
However, it started as a fork of ReactDOMServer
, and this brings some architectural limitations that have caused difficulties. Because it is a fork, it's still based on react-dom-server@0.14.0
rendering code. And memoization with react-dom-stream
is a synchronous affair, which precludes interoperability with external caching solutions.
Hypernova
Hypernova is perhaps the most novel of the solutions listed here. Instead of trying to address rendering speed in the context of your Node.js process, it farms out the work to a separate server. What makes this especially interesting is how this approach opens the door to SSR from a Ruby or Python back-end, whereas these other solutions are constrained to JavaScript.
If you find that Rapscallion doesn't serve your needs, you would be well-served to look at these other solutions!
From start to finish
I started this project by considering what I'd want out of a SSR solution. Obviously, I'd want it to be faster than the default implementation. But this is Node.js that we're talking about - a single-threaded environment - where synchronous solutions are not ideal. So in addition to being faster, it also should be non-blocking.
It would be nice if it were a near drop-in replacement, so long as that didn't interfere with the other goals. And it should definitely play nice with common async primitives like streams and promises. Backpressure for streams, for example, should work out of the box.
The original proof-of-concept was built on top of @briancavalier's fantastic FRP library, most.js. Unfortunately, mine was a somewhat off-label use of most.js, and that introduced problems. I needed a building-block that 1) was async, 2) could lazily evaluate its source(s), and 3) could be paused by the source if data is not ready.
The first and second of these requirements were easily met by most.js, but the third was difficult to manage without some significant hackery. Unfortunately, the third requirement was vital to supporting external caching strategies like Redis or memcached, so I needed to explore a couple of other options.
The second version was a port to using generators. It turned out beautifully, but introduced a significant performance regression. I needed something generator-like, but not a generator.
What I landed on was a custom Sequence
type that drew inspiration from AST traversal in programming language interpreters. As an interpreter steps through the AST during evaluation, the state of the application is maintained on the call stack. Roughly speaking, the stack itself is composed of frames, and each frame consists of a marker indicating where you are in the invocation of the corresponding function.
This pattern is quite similar to what I wanted: given a root VDOM node, it needed to be traversed, evaluating any Component
s that it encountered and transforming the rest into text. And since I was writing it from scratch, introducing mechanisms for pause/resume became simpler.
So what does it look like?
The simplest way to use Rapscallion is through its Promise interface:
render(<MyComponent {...props} />) .toPromise() .then(htmlString => console.log(htmlString));
This might be preferable in some cases, especially if you're not dealing with an actual server environment, but if you're using Express or Hapi, you'll probably be more interested in the stream interface:
app.get('/example', function(req, res){ render(<MyComponent {...props} />) .toStream() .pipe(res); });
There are a couple of reasons why this might be preferable to rendering to a Promise. First, it should reduce the TTFB by a fair margin - amounting to the round-trip from client to server plus change. Not only will the first byte arrive sooner, the last will as well. In the case of a Promise, you can only start sending the first byte after all rendering has concluded, and the last byte has to wait until everything else has been sent. If you're streaming as you render, that's not the case.
During previous discussions about possibilities for async SSR, it had been noted that renderToString
attaches a data-react-checksum
attribute to the root DOM node of your component. That's not an option when you're streaming, since the first bytes have already been sent by the time the checksum can be calculated. Fortunately, a simple solution for this is provided: injecting the checksum into the DOM before your application is bootstrapped. This technique is demonstrated in the template example.
Templates
In every one of our client projects that uses SSR, we end up writing a function that takes the rendered output of a component and wraps it in some boilerplate HTML.
This becomes somewhat difficult to achieve when streaming content. Node.js gurus could probably perform some impressive Stream
acrobatics to get this done. But why embrace complex when you can have simple? Rapscallion provides render templates, and they look something like this:
const wrappedComponent = template` <div> ${ render(<MyComponent />) } </div> `; const header = render(<MyHeader />); const html = template` <html> ${header} <body> ${wrappedComponent} </body> </html> `;
The full breadth of template functionality is documented in the README.
Caching
Now for the fun bit.
All of the above will only get you so far. Ultimately, the rendering performance of React applications is fundamentally constrained by CPU resources. We are still working on optimizing Rapscallion's performance characteristics under severe load, but there's only so much that can be done. We want strong performance guarantees for when our servers encounter peak traffic. That's where caching comes in.
Rapscallion's caching is completely opt-in and is performed on a per-component level. You see, we noticed that there were huge portions of our applications that either 1) always rendered in the same way, or 2) had few variations. That sounds like a perfect use-case for memoization.
To enable caching, you define a cacheKey
prop on your VDOM element - this can either be a <Component />
or a vanilla DOM node like a <div />
. This cacheKey
uniquely and globally identifies the expected content of the render - not just for the node you're labeling. The cacheKey
should also incorporate whatever props or context might influence how a component is rendered.
Here's what it might look like:
const Child = ({ val }) => ( <div> ComponentA </div> ); const Parent = ({ toVal }) => ( <div cacheKey={ `Parent:${toVal}` }> { _.range(toVal).map(val => ( <Child cacheKey={ `Child:${val}` } key={val} /> )) } </div> ); Promise.resolve() // The first render will take the expected duration. .then(() => render(<Parent toVal={5} />).toPromise()) // The second render will be much faster, due to multiple cache hits. .then(() => render(<Parent toVal={6} />).toPromise()) // The third render will be near-instantaneous, due to a top-level cache hit. .then(() => render(<Parent toVal={6} />).toPromise());
Remember, caching always costs you something - you're constructing keys and storing values. There's overhead, so make sure the extra work is worth it. In other words:
- Do cache your
<StaticHeader />
component. - Don't cache your
<IChangeForEveryRender />
component. - And of course, there's a middle-ground component that only produces a small set of possible HTML outputs. You should cache those too.
Finally, I'll add that a falsey cacheKey
value will bypass the caching mechanism. This means that you can conditionally cache a component. So <MyComponent cacheKey={ someCondition ? myCacheKey : null } />
is completely permissible.
By default, Rapscallion caches content in memory, but an API is also provided to wire up any external caching service you might prefer, like Redis, memcached or ElastiCache. If you do go with a networked solution, the computation cost for rendering a specific component becomes a one-off, and the benefit is shared across all server nodes!
A Redis example is included in the README.
Caveats
Rapscallion is in good enough shape that we're ready to share, but there's still work to do. Here are some things to keep in mind:
- If you stream content to the client and your render fails at a mid-way point, you've already sent an HTTP 200 response and a good portion of the HTML. This means you'll have to introduce some other mechanism for communicating an error state to the client.
- Rapscallion is only a couple of weeks old! We're excited about the opportunities that it affords, but please consider it beta-quality until there's a chance for its stability and performance to be vetted by a larger group.
- Since renders don't block the event loop, Rapscallion will answer every request. This means that if you encounter too many clustered requests (read: hundreds simultaneously), rendering for each one will slow to a crawl. If it gets bad enough, you might encounter out-of-memory errors. On the other hand, if your server is this overloaded, you'll have huge problems no matter which renderer you use.
- We're still working out some spots of perf strangeness. Expect to see improvements and tweaks in this area.
Try it!
Here's another link to the GitHub repo. If you encounter a bug, please file an issue.
If you need help getting started, or if you just have a question, you can find us on Gitter. You can also find me on Twitter as @divmain if you'd like to reach out directly.
Cheers!