GraalJS & React 18 Streaming SSR

Introduction

Back in 2023, I started working on a website aggregating multiple flight price tracker websites under a single frontend.

This project is currently hosted under https://flights.gmmz.dev and was originally built using Htmx and Ktor, but that stack eventually reached it’s limit.

Around the start of 2024 I decided to rewrite the entire project using Klite for the backend and React with Vite for the frontend, working occasionally on it in my free time.

The webapp gained new features and a better user experience, but the performance was very much still lacking, with a Lighthouse score of ≈ 30 👎, while the original app had a 99 points score.

This was mostly due to misconfigured chunk splitting, laggy dependencies and many more issues I tackled in the past few months, except for the biggest one: SSR.

The usual way

The usual way to implement SSR in a React app is to use a Node.js server with an Express server serving the SSR’ed code and putting everything behind a proxy (like Caddy or Nginx).

I did not want to add another moving part to my project, especially because the whole thing runs on a 4€/month Hetzner VPS, so I started looking for alternatives.

I decided to explore GraalJS, a JavaScript runtime built on top of the JVM by Oracle, so I could directly run the SSR logic (ReactDOMServer.renderToString) in the JVM.

GraalJS

Initially the task seemed very simple: Vite built in SSR mode gives a “server” file which exposes a “render” function which returns the HTML to inject in the root div component.

GraalJS’s Api is extremely easy to use: you define a Context and call the eval method with the code you want to run.

import org.graalvm.polyglot.Context

val ctx = Context.create()
fun renderSSR(path: String): String {
    // PS: Highly simplified
    ctx.eval(
        Source.newBuilder(
            "js",
            Path.of("./src/main/javascript/dist-server/main-server.js")
                .toAbsolutePath()
                .toUri().toURL()
        ).mimeType("application/javascript+module").build()
    )
    
    return ctx.eval("js", "render('$path')").toString()
}

Then, we insert the SSR’ed HTML into our index.html file and serve it.

get("/") {
    // PS: Highly simplified
    return File("./src/main/javascript/dist/index.html")
        .readLines()
        .joinToString("\n").replace(
            "<div id=\"root\"></div>",
            "<div id=\"root\">${renderSSR("/")}</div>"
        )
}

Then the issues began. The many, many many issues.

Main issues

The first issue I encountered was that GraalJS doesn’t support ES6 modules (there’s a PR in progress, but it doesn’t really work), so I had to rewrite the main-server.js file to simply export the render function on the window object

ctx.eval("js", "window.render('$path')").toString()

The second issue I encountered was that GraalJS doesn’t support NPM modules (yet), so I had to use esbuild to bundle the server code and the dependencies into a single file.

{
  "scripts": {
    "build:bundleserver": "esbuild dist-server/main-server.js --bundle --outfile=dist-server/bundle.js --platform=browser"
  }
}

Then the biggest issue came to light: a lot of the modules I used in my app did not support SSR or used APIs that GraalJS did not yet support.

This involved a lot of trial-and-error, debugging and polyfilling, but I eventually got everything working.

The real MVP of the situation was Vite’s import.meta.env.SSR, which let me cut out of the ssr process the most interactive parts of my app, letting React hydrate them on the client side once the main bundle loads.

Polyfills

I had to write a lot of polyfills for GraalJS to work, which was a very time-consuming task that involved hacks & flat-out stubbing of some methods.

// There's a PR in progress for these
var TextEncoder = require("text-encoding").TextEncoder
var TextDecoder = require("text-encoding").TextDecoder
// Used by many libraries as NodeJS just aliases it to the global object
var window = this;
var SecureRandom = Java.type('java.security.SecureRandom');

// Found it on StackOverflow, used by some libraries during rendering so couldn't be stubbed
var crypto = {
    getRandomValues: (buf) => {
        var bytes = SecureRandom.getSeed(buf.length);
        buf.set(bytes);
    }
}

// Could be reimplemented with Java APIs but i'm lazy :>
var process = {
    env: {
        NODE_DEBUG: false
    }
}

// Stubbed out after an afternoon of debugging, worked fine, could probably break something
function setTimeout(callback,delay) {}

To apply these polyfills you just have to call the code block using ctx.eval().

val polyfillContents = "The codeblock above..."
ctx.eval("js", polyfillContents)

SSR Streaming

React 18 added a new feature called Streaming SSR, which allows you to send an SSR’ed skeleton of the page to the client while the rest of the page is being rendered, then hydrate the Suspended components once they’re evaluated.

Implementing it with GraalJS was a bit tricky, but it followed the same principles I used for the initial SSR implementation.

This is how the final render function looks like:

async function render(_url: string, bundleName: string, javaStream: any) {
    const stream = await renderToReadableStream(
        <Index>
            <Shell>
                <StaticRouter location={_url}>
                    <AppRoutes/>
                </StaticRouter>
            </Shell>
        </Index>, {
            bootstrapModules: [bundleName]
        }
    )

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    await stream.pipeTo(new WritableStream<any>({
        write(chunk) {
            javaStream.write(chunk) // <<<<<<<<< !!!
        }
    }))
}

The javaStream object is an OutputStream object which represents the response stream, which I pass to the render function, so it can write the chunks directly to the response stream.

This is the magic part of GraalJS: you can directly call java methods from the JS code, and it handles all the pain of type conversion for you.

Performance Considerations

GraalJS will take 4-5s to start up with the JVM, then the first 5-10 requests to the webserver will take around 400-500ms to complete.

After that, the JIT compiler of the JVM will do it’s magic and requests will take 80-100ms to start sending data, which is a very good result (considering I run this on a 4€/month Hetzner ARM VPS).

I recommend setting up a little CI script that pings the server some times after a deploy so that users will get the possible best experience.

Conclusion

It took me around 3 days to get everything working, but the result was worth it: the Lighthouse score went from 30 to 100!!! and the website is now much faster and more responsive.

img.png

I’m very happy with the result and I’m looking forward to using GraalJS in more projects in the future.

If you want to check out the code, the repo is available at https://github.com/Rattlyy/GmmzFlights.

The app is also running at https://flights.gmmz.dev, so feel free to check it out.