server-reason-react implements react-dom/server and some of React's internals in OCaml. It renders HTML markup natively on the server for a Reason React application.
This post covers the library's concepts, what it means to render React in OCaml, how we use it at ahrefs.com, a benchmark against a Node equivalent, and where all of this might go.
If you are not familiar with Reason or OCaml, that's fine. I'm not trying to convince you to learn or try those languages. I want to share something I built and care about.
I will explain most concepts for developers with React experience. Don't be scared by the niche languages or technologies; I'll introduce them as we go.
First, some context about Reason and OCaml.
Clarity about Reason and OCaml
Reason is a language built on top of OCaml. They share the compiler, type checker, and most of their tooling. Jordan Walke created Reason so JavaScript developers could enjoy OCaml. Back when TypeScript was less popular, many people in the JavaScript community were interested in Reason.
OCaml is a robust, type-safe language that supports functional programming with powerful type inference. It offers a unique balance of performance, maintainability, and reliability.
Despite being a niche programming language, OCaml has influenced modern programming languages and language tooling. The first versions of Rust were implemented in OCaml, as was Meta's Flow JavaScript type checker. OCaml also became the basis for ReScript, an offshoot language that compiles statically typed code to JavaScript. Today, OCaml is a solid general-purpose language backed by trading firms, blockchain companies, and SaaS companies.
I often use Reason and OCaml interchangeably because they are the same to me. Reason has a different syntax that should feel familiar to JavaScript developers, but its “engine” is OCaml.
ahrefs.com
At ahrefs, I'm a software engineer working on tooling. I maintain the design system and styled-ppx, help with Melange, and now work on server-reason-react.
ahrefs is a comprehensive SEO toolset that provides data-driven insights and competitive analysis for digital marketers and businesses. We have one of the best crawlers on the internet. We also recently launched a search engine called Yep.com, which aims to provide a better revenue-sharing model.
ahrefs is written mainly in OCaml and Reason, with some Rust and C++/D. Its monorepo contains more than 1M lines of code. We value type safety, maintainability, and performance.
The frontend parts of ahrefs include the dashboard (app.ahrefs.com), the public website (ahrefs.com), a tiny website called wordcount.com, and Yep.com.
The problem
One of the initial problems with our main React application (app.ahrefs.com) was client-side rendering.
The Header was the canonical example. We had to load contextual data for the user, permissions, billing, tokens, and theme. Running all those requests on the client creates a request waterfall. Our users often use more than one tool at a time, and navigating between them was not a good experience. Navigation was slow. Mounting each page flashed the Header and the entire app while repeating the same requests.
We addressed these issues by adding server-side rendering for the static parts. We rendered a “shell” app and injected the data as regular scripts containing serialized JSON. We used Tyxml, a library that generates HTML from OCaml, to create these static templates.
This improved the user experience by a lot because the served HTML contains the static part of the app, which the browser can cache locally. But we soon hit another problem: two separate implementations of the same component. The client version used React, while the server version used Tyxml.
- Sharing implementations or even styles between them was difficult
- Keeping both versions in sync with different data requirements was too much hassle. The server component had to work without data, and the client component had to mount the interaction on top
The resulting technology was difficult to maintain and deterred developers from making significant changes. The solution worked for a while, but we wanted a more scalable approach for our other frontends.
To address some of those problems, Javi (one of my coworkers) experimented with mixing Tyxml and ReasonReact and published the experiment on his blog: javierchavarri.com/react-server-side-rendering-with-ocaml
Alongside server-side rendering, which renders each request, we needed a different strategy for static pages. We wanted Server-Side Generation, which Next.js calls Incremental Static Regeneration: run a rendering step at build time and populate the state on each request.
Our goal was to use the same client components from our design system in these server templates.
First approach
The common solution is a Node-based framework such as Next.js, Gatsby, or Astro. After using Gatsby and react-snap for a few years, plus building a proof of concept with Next, we were not satisfied with any of them.
Pre-rendering (SSG) with Node
One option was to run the pre-rendering step during the build and serve the results through our OCaml backend.
This couples the build process running in CI to the runtime on the production and staging servers. It made serving static files more complex and polluted those files with data, also known as hydration.
- Templates could be unsafe to hydrate and error-prone
- We needed to generate templates for each language, currently 17, and upload them in CI
- Running pre-rendering in CI created a strange combination. It coupled our OCaml backend to HTML files and required us to manage cache purging
Running Node with SSR on production
The other option was to serve the application with Node and use OCaml as the API.
Putting Node in front is a common architecture that works wonderfully for some teams. It still has drawbacks for the ahrefs backend.
Most of our API fetches data from different storage systems, shuffles it, and serves the result. Several characteristics of Node made it less than ideal for us:
- Single-threaded nature: Node.js is inherently single-threaded, which can cause performance issues with high concurrency or CPU-intensive tasks
- Node.js is not the best at memory consumption. SSR applications can use a lot of memory and potentially constrain resources in production
- It would add a burden for DevOps. They were not happy about learning another language and framework to manage, especially because it would be the entry point for users
- Type-safety concerns: We would need to use TypeScript and maintain separate type definitions, or use Reason and compile it to JavaScript. However, Node.js bindings are not my favorite part of Reason
- It might duplicate logic such as authentication across our backend and Node
- Moving a request from the server to the client, or the other way around, can be difficult and increase complexity and maintenance work
After discussing this with my coworkers, especially Javi and his experiments with Tyxml and ReasonReact, we had an idea. We might be able to use the exact same code on the client and server if we reimplemented or stubbed some stuff here and there.
We could follow the JavaScript ecosystem's approach and run the same React components on the server, with one twist: native code on the server and compiled JavaScript on the client.
How hard could it be?
Enter server-reason-react
reason-react
The Reason parser includes the JSX transformation, so it compiles JSX expressions into function calls. Reason does not need Babel, esbuild, or Vite for this.
A Reason-React component describing a simple Counter
reason-react is a set of bindings to the JavaScript version of React. It is a thin type-system layer that gives Reason code the correct interface for hooks, createElement calls, and the rest of the React API. It is similar to .d.ts modules in TypeScript or FFI in Rust.
The repository is at https://github.com/reasonml/reason-react.
What's server-reason-react?
server-reason-react is a reimplementation of ReactDOMServer (react-dom/server) and parts of React, written in OCaml, that generates markup from a React component.
More precisely, it implements ReactDOM.renderToString and ReactDOM.renderToStaticMarkup.
ReactDOM must represent every kind of React node: components, elements, Fragments, Providers, and Consumers. This node variant type represents all of them:
type node =
| Empty
| Text(string)
| List(list(node))
| Fragment(node)
| Element(string, attributes, list(node))
| Component(unit => node)
| Provider(node)
| Consumer(node)
| Suspense({ children: node, fallback: node })It is a variant type (aka union or ADT), and it matches what React uses internally with Symbols. It is a recursive type (rec) because it references itself.
The implementation generates the string by traversing the component tree and producing the appropriate HTML for each node. For every node, it handles details such as serializing DOM attributes, processing inline styles, encoding HTML, and supporting React-specific behavior such as dangerouslySetInnerHTML and hydration hacks.
let rec render_to_string = node =>
/* ... */
switch (node) {
/* ... */
| Element(tag, attributes, _) when Html.is_self_closing_tag(tag) =>
"<" ++ tag ++ attributes_to_string(tag, attributes) ++ "/>"
| Element(tag, attributes, children) =>
"<" ++ tag ++ attributes_to_string(tag, attributes) ++ ">" ++
List.map(render_to_string, children) ++
"</" ++ tag ++ ">"
| Component(component) => render_to_string(component())
| Text(text) => Html.encode(text)
| List(list) => List.map(render_to_string, list)
| Consumer(element) | Provider(element) => render_element(element)
| Suspense({ children: _, fallback }) => render_element(fallback)
}
/* A pseudo implementation of renderToString to ilustrate the mapping between
components and HTML representation. The original implementation uses Buffer and tries hard to not allocate.
Note: `++` is a string concatenation operator */To ensure it supports full rendering and hydration in the same way as React, I migrated all the tests from ReactDOM's server test suite.
Once that worked, implementing the rest of React on the server, including hooks, portals, and the other APIs, was trivial by comparison. Most hooks do nothing. useEffect does not run. useState sets only the initial value, and all setStates are ignored. useCallback creates the function once, and it is probably never called. useMemo runs and returns the value.
My implementation makes a single pass over the React tree, while React.js makes multiple passes. But it's on them to change that: https://github.com/facebook/react/issues/25318.
We can return those strings as HTTP responses. That gives us server-side rendering.
Benchmark
The question I heard most while implementing this was about performance.
The theory was that a compiled language such as OCaml should outperform an interpreted one such as JavaScript in Node, even while v8, the engine under Node, works tirelessly to optimize it. Many benchmarks have set out to prove that theory, but does it hold here?
I was curious too, although performance was not the only reason for server-reason-react. The implementation has not been optimized or even profiled. It tries to minimize allocations and CPU cycles, but I have done no performance work so far.
Before pushing to production, I made a small microbenchmark to check for regressions and measure the gain against Node and Bun:
We compared the three stacks by latency, requests per second (req/s), and transfer rate.
| Req/s | Avg Latency | Transfer/s | |
|---|---|---|---|
| Node.js (with Express) | 7.2k | 30.98ms | 8.25MB |
| Bun | 10.3k | 24.32ms | 17.5MB |
| server-reason-react (with OCaml) | 64.8k | 6.21ms | 155MB |
The results show an approximate 10x improvement over Node and 6x over Bun.
I ran all tests locally on my MacBook Pro (13-inch, M1, 2020). The benchmark data comes from this demo repository: https://github.com/ml-in-barcelona/fullstack-reason-react-demo/tree/main/benchmark.
The benchmark is not very scientific, and microbenchmarks can mislead. Still, it shows the potential of this approach and validates the theory.
Status
It has been deployed to every user at app.ahrefs.com since February, and we plan to use it for all our frontends. It is not ready for general use, though. The lack of documentation, the shape of the libraries, and some missing APIs make it difficult to use. I do not recommend relying on it yet.
It is open source, and the repository is on GitHub. Check it out!
If you are still interested, feel free to contact me on Discord or Twitter, or open an issue on GitHub.
What it enables
I went deep into the implementation details for curious readers, but the consequences matter more:
Same code for frontend and backend
The same code compiles to native code and JavaScript, entirely thanks to Melange. As far as I know, other native languages such as Rust or Go cannot do this. Many similar solutions exist, but they do not cross-compile the same code.
This enables full-stack applications written in Reason. It is not about “one language to rule them all.” It is about simplicity.
That simplicity means shared data types, one learning experience, one toolchain, one set of rules, and more. Sharing code has detractors, with good reasons. Once you have struggled to maintain large pieces of code across stacks, though, you appreciate having a single language.
Performance is much better
Performance is critical for any SSR solution, both in the rendering engine and the underlying platform. Requests per second and memory footprint both matter, but slow startup stands out among the performance problems. A Node application's slow startup is a barrier for current solutions. Teams often address it by changing their application architecture to use Edge computing, and it can also block SSR in development.
Some OCaml-based frameworks are fast enough to boot for each request and shut down when the session ends. Doing the same with Node is much harder. This is one example of OCaml's performance advantage.
Maybe a faster approach can solve some of the problems from SSR.
Allows further exploration of effects of React and OCaml
OCaml and functional programming concepts have influenced React from the start, including immutability, purity, and eventually algebraic effects.
This work creates the base implementation for Server components, which are a deal breaker for server-reason-react. Running components only on the server and streaming their output representation avoids the client-side cost of executing the JavaScript code. That can radically change how we write our backends.
OCaml 5.0 was recently released with the highly anticipated Multicore and Effects features. They make it possible to explore writing some of React's concepts in OCaml.
Why you should not use it
I would not adopt server-reason-react today for these reasons:
- It is an entirely new ecosystem with a new language, package manager, and trade-offs
- The learning curve might be steep. OCaml makes different trade-offs from JavaScript or Node. It is probably not as big as learning to deal with a borrow checker 😛
- Not everyone needs this. It made a lot of sense at ahrefs, but it might not for you
- It is still very experimental
- It requires lifting the ecosystem to work on the server, so every client-side library must be ported to OCaml or stubbed when needed
- The community is smaller, but growing
Final thoughts
I intentionally did not explain how this is compiled because I want to keep this short and explain Melange in future blog posts. For reference: ahrefs is now built with Melange
This has been a lot of fun, and I hope I can keep pushing this stack further. If you are as excited as we are, come talk to us!