React Server Side Rendering with GraalVM for Clojure

Usecase at Nextjournal

At Nextjournal, we perform server side rendering of notebooks to provide better user experience, especially for published notebooks. A visitor instantly sees the content of a notebook while the client side app loads. However, we do use a few Javascript-only dependencies like CodeMirror and ProseMirror, which prevents us from doing this server side rendering directly in Clojure on the JVM. Until now, we used a separate nodejs version of our browser app to perform this task. This necessitated a whole lot of code to expose ClojureScript app via an HTTP interface to the Clojure app as they were running in different VMs.

However, recently we switched our production JVM to GraalVM, which opened a new possibility since it comes with GraalJS, a JavaScript engine which aims to compete with today's faster JavaScript engines like V8 and JavaScriptCore on performance. It lets us embed the ClojureScript app into our Clojure app so it can perform the rendering in-process in the JVM.

Let me walk you through how this works.

We start with Nextjournal's default Clojure environment, which already uses GraalVM.

java -version

We use this deps.edn file to bring in some minimal dependencies to build a small ClojureScript app which can be used for server side rendering.

{:deps
 {org.clojure/clojure {:mvn/version "1.10.0"}
  reagent {:mvn/version "0.8.1"}
  org.clojure/clojurescript {:git/url "https://github.com/nextjournal/clojurescript"
                             :sha "da9166015f6a28b2c18fa7706e457901d02a5d81"}}
  }
deps.edn
Clojure

The sha key points to a ClojureScript version with some small adaptations to make work better with GraalJS.

Let's create a simple ClojureScript app using reagent. This will be used for server side rendering.

(ns my.app
  (:require
    [reagent.core :as reagent]
    [reagent.dom.server :as dom-server]))

(defn hello-component [name]
  [:div (str "Hello " name "!")])

(defn html [name]
  (dom-server/render-to-string [hello-component name]))
app.cljs
ClojureScript

Compile it using ClojureScript's command line interface.

clj -m cljs.main -t graaljs -c my.app

As a first test to see if our ClojureScript app actually works in GraalJS, we can use Graal's standalone js command line operation to load our app and produce a call to our html function.

js --jvm -f out/main.js -e "console.log(my.app.html('GraalJS 👋'))"

However, we want to use this html function from within the JVM which runs our Clojure app. For this we can leverage GraalVM's polyglot abilities to load the JavaScript artifact from the ClojureScript app into the Clojure JVM and directly call the function with almost no overhead.

(import '(org.graalvm.polyglot Context Source))

(def context-builder
  (doto (Context/newBuilder (into-array String ["js"]))
     (.option "js.timer-resolution" "1")
     (.option "js.java-package-globals" "false")
     (.out System/out)
     (.err System/err)
     (.allowAllAccess true)
     (.allowNativeAccess true)))

(def context (.build context-builder))
user/context

We use the Context/newBuilder polyglot API to create a Graal execution context and specifically only allow JavaScript execution. The js.java-package-globals is needed to prevent namespace collisions between Java packages and some ClojureScript namespaces in our codebase.

(defn execute-fn [context fn & args]
  (let [fn-ref (.eval context "js" fn)
        args (into-array Object args)]
    (assert (.canExecute fn-ref) (str "cannot execute " fn))
    (.execute fn-ref args)))
user/execute-fn

The execute-fn helper makes it convenient to call our Javascript functions and provide them params directly from Clojure.

Before we can actually call our Javascript functions, we first need to load our little demo app into the context we created before.

(def app-js (clojure.java.io/file "out/main.js"))
(def app-source (.build (Source/newBuilder "js" app-js)))
(.eval context app-source)
Vector(4) [org.graalvm.polyglot.Value, "0x25b2672a", "null", Map]

We utilize Graal's Source class to load the JavaScript artifact and evaluate it in the execution context to make the our html function available.

Now everything is coming together and we can perform server side rendering with our React ClojureScript app directly from Clojure.

(def result (execute-fn context "my.app.html" "Polyglot Graal 🌈"))
(.asString result)
"<div data-reactroot="">Hello Polyglot Graal 🌈!</div>"

In Nextjournal's case, we need to pass data of a notebook to the ClojureScript app. Rather than passing a string like "Polyglot Graal 🌈" to the html function for rendering, we actually pass a transit-encoded notebook. This is the same data we pass to the browser for client side rendering.

Performance

GraalJS is currently optimized for long running processes, and for some workloads it is still slower than V8 or JavaScriptCore. Our use case should fit well as we reuse the Context over and over and execution times get quite a bit faster after the first few requests. We also have less overhead for server side rendering now, because we can invoke Javascript functions directly without needing a complete http stack. In many cases this is faster than our old nodejs rendering process.

These are 5 measurements of server-side rendering calls in milliseconds after a warm-up phase of 1000 render calls.

0 items

You can see that for small notebooks GraalVM actually performs better because we don't have to go through a whole http stack. For large notebooks, it's in the same ballpark (~30% slower) and we're hopeful this will further improve in the future.

Where to go from here

While we already happy with our current setup, there a few things are still on our todo list:

  • Make figwheel live code reloading work with the GraalJS so ClojureScript code changes are immediately reflected in the server side rendering in our development environment.
  • Finish up our ClojureScript patches to improve support for GraalJS and get them applied upstream.
  • Use code sharing for GraalJS so that the multiple render threads can share the AST to reduce the warmup time for optimal performance.

Upstream Issues

We ran into a number of issues and limitations, which we reported and were either resolved or worked around. We'll continue to work with the GraalVM and ClojureScript teams to get those issues resolved.