Improve React.js Server-Side Rendering by 150% with GraalVM
This post was written by Jiří Maršík.
GraalVM is a high performance virtual machine with support for a number of popular languages including JavaScript. If you’re running JavaScript on the deprecated Nashorn engine you should definitely take a look at GraalVM which provides excellent ECMAScript compliance along with a convenient way to migrate from Nashorn.
In this post, we will look at an existing web application that was written for Nashorn to run React.js on the server-side. We show how to port it so that it uses GraalVM instead. We will see that porting the application is straightforward without compromising correctness, and actually increases the performance of the JavaScript component.
Talkyard.io — a React.js client application with server-side features, written in Scala and TypeScript
Today, we will be looking at Talkyard. Talkyard is a discussion website with a feature set inspired by StackOverflow, Discourse, Slack and other online platforms. Website users can post questions, submit answers, upvote their favorite posts and exchange direct messages. The server-side part of the application is implemented using the Play framework and ~44K lines of Scala code. The client-side part is written using React.js and contains ~27K lines of TypeScript code that is transpiled to JavaScript.
Web applications like Talkyard, that use a rich client-side UI framework such as React.js, run the risk of slowing down page loads. In order for the user’s browser to render the page, the browser first has to download, parse and run the application code. Application developers can get around this by doing the rendering ahead of time on the server and providing the ready-to-view HTML to the client alongside with the code. This means that the client is able to render the page without having to run the application code first, but the page is still reactive as the application code can be executed to evolve the client-side view. However, for an application to be able to leverage server-side React.js rendering, it needs to be able to execute JavaScript code on the server-side. Fortunately, when working in the Java ecosystem, one has multiple choices for running JavaScript code. In this post, we will focus on two of them, Nashorn and GraalVM.
Nashorn is a JavaScript runtime that has shipped with the JDK since version 8 and it is the runtime that is currently used by Talkyard to do server-side React.js rendering. GraalVM is a language runtime built on top of the JVM that supports the efficient execution of not only JVM-based languages but other programming languages too, including JavaScript. In this post, we will look at what goes into porting Talkyard to GraalVM. There are two reasons why we will be studying this particular transition:
- GraalVM JavaScript performs better in peak performance benchmarks.
- Nashorn was deprecated in JDK 11.
Migrating the application
The interface between the Talkyard application and the Nashorn JavaScript engine is entirely encapsulated in a single Scala module called Nashorn (Nashorn.scala). We will now go through all of the changes to this module necessary to get it to use GraalVM JavaScript instead of Nashorn.
First off, we will be using the GraalVM SDK API instead of the ScriptEngine API. GraalVM JavaScript supports the ScriptEngine API and when appropriate, this is usually the easiest way to port a Nashorn application to GraalVM. However, as we will see below, this particular codebase is not agnostic w.r.t. the ScriptEngine used and assumes that the JavaScript implementation returns instances of Nashorn-specific classes. This kind of code is not compatible across different ScriptEngines as they each use different data structures under the hood. On the other hand, the GraalVM SDK API includes a generic interface which will allow us to access the JavaScript objects without knowing about the implementation details of its JavaScript runtime.
Under the GraalVM SDK API, instead of using a ScriptEngine to refer to an instance of the JavaScript runtime, we will be using a GraalVM Context.
To build an instance of a GraalVM Context, we use Context.Builder and specify the languages we want to have access to (in our case js
for JavaScript) and we also state that we want the JavaScript code to have access to the JVM, as is the case when using Nashorn.
After we create the Context, we need to set it up by loading our application code so that it is ready to process page render requests. Since a GraalVM Context can in general execute code in a variety of languages, the code below becomes slightly more verbose as we need to specify which language is used.
Now that we have a GraalVM Context all set up, we can execute functions within it like we did with Nashorn.
Finally, we have a location in the codebase which accesses JavaScript objects directly from the Scala code. In the Nashorn case, the variable result
is of the Nashorn type ScriptObjectMirror
and in the GraalVM case, it is of the GraalVM type Value
. The snippet below shows how the interface for querying the shape of the JavaScript object and accessing its members differs between the two.
This is all that needs to be done to port the application from Nashorn to GraalVM JavaScript. You can look at the full diff here: https://gist.github.com/jirkamarsik/b282fef51075468b735105ede1c25452
Apart from the changes shown above, the full patch contains the following edits:
- Replace all references to
js.ScriptEngine
andjs.Invocable
with references tograalvm.Context
- Replace usage of
Invocable.invoke
withValue.execute
- Include
graal-sdk/org.graalvm.sdk
as a Maven dependency - Replace a custom subclass of
AbstractStrictEngine
with the use ofOption
Finally, the Dockerfile which builds the image running the web application was modified to include GraalVM instead of the original JDK (OpenJDK).
Testing the port
The talkyard repository contains both unit tests and end-to-end tests using Selenium. Running these shows that switching to GraalVM and its JavaScript engine preserved the behavior observed under Nashorn.
NB: This is in part due to the fact that this application restricted itself to standard ECMAScript functionality. Nashorn extends ECMAScript with custom features, the use of which is supported by GraalVM but it has to be explicitly enabled (for more information about the compatibility of GraalVM JavaScript, see our documentation).
Benchmarking the port
In order to test the effect of the port on the performance, we will have to prepare some reproducible task for the application to process so that we can measure and compare the throughput/latency of the different implementations. Since Talkyard uses server-side JavaScript when serving pages to the user, we will be testing the throughput of the application when serving a single page. However, if for the entire duration of the benchmark, we request the same page over and over again, we run the risk of the runtimes producing overoptimized code that performs well on the benchmark but that is not representative of the performance of the application over a wider range of pages. In order to avoid this, we alternate randomly between a series of different pages during the warmup process, forcing the different runtimes to end up with code that is generic enough to handle different pages. For the set of pages that we test against, we chose the default example site to which we added a more verbose question page that we built up from paragraphs of random text and the Markdown README files of several open-source repositories (one of the responsibilities of the server-side JavaScript is to convert Markdown to HTML). You can find the site data as well as the different scripts we used to run this benchmark here.
Furthermore, we had to tweak Talkyard a little to make these benchmarks possible. First, we had to disable the included rate limiter so it didn’t block the requests from our workload generator. Talkyard is also caching the results of the server-side rendering, both in memory and in a relational database. In order for changes to the server-side rendering pipeline to be visible, we had to disable those caches. We also included some hooks that let us capture latency data for each request.
We used wrk
to send requests to the application and measured the throughput. The data shown below was captured on a workstation running OpenJDK 8u121-b03 for the OpenJDK + Nashorn part and GraalVM 19.3.0 for the GraalVM Community Edition + GraalVM JavaScript and GraalVM Enterprise Edition+ GraalVM JavaScript parts.
As we can see from the graph, there is a long warmup curve both on the vanilla JVM with Nashorn and on GraalVM. However, we reach peak performance sooner on GraalVM (9 and 12 minutes on GraalVM Enterprise Edition/Community Edition, respectively, compared to 20 minutes on Nashorn) and more importantly, we end up with a peak performance that is more than 25% higher than the one we observe on Nashorn, with GraalVM Enterprise Edition outperforming Nashorn by more than 35%.
We have also measured the time that is spent solely in the part of the application that is responsible for executing the server-side JavaScript code. This gives us a more focused look at the performance difference between GraalVM and Nashorn on a React.js workload.
NB: In the graph above, throughput was calculated as the number of cores (8) divided by the average time spent rendering a page.
In the graph, we can see that on Nashorn, we get about ~800 renders per second (10ms per one render) whereas with GraalVM, we get ~2000 renders per second (4ms per one render), a 150% increase in rendering throughput.
Warmup performance is a constant topic of our optimization efforts, so we expect to get better warmup behavior in the future. This example is also our first experience with trying to run larger React.js applications on GraalVM, so we still have yet to try and optimize our performance for this kind of workload.
Summary
We have looked at a fairly large application that executes both Scala and TypeScript/JavaScript on the server-side, within a JVM. We have seen how we can port the JavaScript interface from Nashorn to the GraalVM JavaScript implementation. In cases where the interface between the JVM code and the JavaScript code is small, as is the case when doing React.js server-side rendering, the porting itself can be done easily. In the end, we have a port that was migrated from Nashorn, passes all the tests and exceeds in performance.
Do you have an existing JavaScript application that you want to port to GraalVM JavaScript? Let us know about your experience, your feedback helps us improve GraalVM! We are happy to help if you find any obstacles or performance blockers, please open a ticket against GraalVM JavaScript.