Skip to main content

Why We Chose Rust for Version 3

· 12 min read
Dave Rapin
Dave Rapin
Founder @ Curling IO
About this post

This is a technical architecture post from the development of Curling IO v3. It is written for software engineers and goes deeper into Gleam, Rust, SQLite, client-side UI architecture, and build tooling than our usual product posts.

For Curling IO v3, we knew we wanted to move away from the Ruby on Rails stack we'd used for v2. We wanted a statically typed language with stronger compile-time guarantees and fewer footguns. We first explored Gleam because it ticked those boxes and gave us access to the BEAM. Lustre offered a typed UI framework for both server and client. An initial implementation let us see how that combination would fit our architecture. Our early foundation posts describe what we were learning and building.

That work helped settle the product requirements, the changes we wanted from Version 2, and the UI design. It also exposed tradeoffs in deployment and client interactions that led us to ultimately choose Rust and TypeScript as the best fit for our architecture before going further with Gleam and Lustre. SQLite and the product design carried forward. We’ve since ported and expanded our SQL tooling and built a small TypeScript runtime called Hypertea for client interactions.

The move improved deployment, gave us useful compile-time automation, and suited the way we investigate and fix issues. Unfortunately, it also made our builds much heavier. There are things about Gleam and Lustre that we still prefer.

What we kept from our initial exploration​

The product requirements, the planned behavioral changes from Version 2, and the UI design and CSS carried over. SQLite stayed too. Gleam and Lustre also helped us work out the architecture and design we wanted, and that exploration inspired what we went on to build with Rust and Hypertea.

We replaced all the Gleam code we'd written up to that point. None of it remains in the application. The same-language server and client arrangement, the BEAM runtime, and Lustre were part of what we gave up.

Operations, our error-tracking implementation, and the NixOS infrastructure came after the move to Rust.

Our earlier foundation articles describe the Gleam design we had at the time. Automate Club Management with AI also came out of that earlier work.

Deploying Gleam with SQLite was awkward​

SQLite was working great for us. Packaging its native dependency for the Erlang release was more frustrating.

Our SQLite integration used a NIF, Erlang's interface to native code. Building a release meant dealing with that native component and its compatibility with the production environment. We needed a Linux build environment close enough to production, and powerful enough to do the compilation and linking. Our setup also required extra components on the production host. We didn't like maintaining all of that just to get SQLite working in a release.

Rust still has native dependencies, including SQLite's C code, but we can cross-compile Linux releases locally, verify them, and upload the package. Production doesn't need the Rust toolchain or application source.

That cross-build path also lets us choose the release target explicitly.

We barely took advantage of the BEAM​

The application needed durable job state in SQLite, whatever runtime executed the work. Pending work had to survive the loss of a process or a machine. The runtime couldn't replace that persistence requirement.

The BEAM still provided concurrency and process management, but we weren't using enough of its distinctive capabilities to make it the deciding factor anymore. Rust with Tokio met our application's concurrency needs while fitting the deployment path we preferred.

Tokio doesn't reproduce OTP supervision or BEAM process isolation. We were comfortable giving those capabilities up for this workload.

Rust is excellent for tooling and debugging​

Rust and Gleam share many of the guardrails that make a codebase easier to understand: static types, explicit optional values, and exhaustive matching. Coding agents benefited from those checks in both languages. Two other things favored Rust in our experience.

At the time, agents tended to propose more idiomatic Rust fixes. They could produce working code in either language, but the Rust code more often looked like code a developer familiar with the language would write.

Rust's detailed compiler diagnostics have helped agents check their fixes, and the runtime error information in our stack has helped us trace failures too. The runtime diagnostics depend on our libraries and the error context we preserve. Together with the compiler's feedback, they help an agent trace an unfamiliar workflow and check a proposed fix. From a coding agent's perspective, this may be Rust's single greatest strength.

Derive macros have also been huge. Serde has been rock solid for us. Its derives generate serialization and deserialization implementations from Rust types without us maintaining that repetitive code by hand. Our contract tooling builds on those types to generate the TypeScript side of client-island interfaces.

Rust Marmot generates typed database calls from SQL. It started as a Gleam library. We liked it so much that we ported it to Rust, then kept expanding and improving it. Rust's derive macros made Marmot better, too. Its FromSqlRow derive generates routine mappings from query rows into application types, while business conversions stay explicit.

Rust Proute followed a similar path: it started in Gleam, and we ported it to Rust. It combines route generation with typed parameters, including derived deserialization. Route generation and derived decoding do different jobs, and both reduce the glue code we have to keep in sync.

Maintaining the generators​

Marmot and Proute generate application code we'd otherwise have to write and keep in sync ourselves. Marmot produces typed calls over rusqlite; Proute generates routing code. Keeping those jobs narrow helps us avoid a broader abstraction than the application needs. Our published SQLite benchmarks also measured better throughput for Rust Marmot than SQLx in the request shapes we tested.

The generated output is simple. The machinery behind it isn't always simple. Marmot has to inspect the database schema and turn SQL results into typed bindings. That takes substantial code, and we own its upkeep. Generation and compiler checks catch many interface mistakes quickly; they don't prove that a query implements the right business rule.

We consider that burden manageable. The generators are documented and tested, and Rust's diagnostics help both developers and coding agents work on them. The application has ADRs, ownership rules, and checked conventions to guide changes. Those supports, including agents, help when onboarding a new developer. The judgment still belongs to the maintainer.

SQLite was already fast with Gleam, but Rust nearly doubled it​

We were already happy with SQLite's performance in Gleam when we measured the two integrations.

Our published request-shape benchmarks compare the integrations directly:

Database workflowGleam MarmotRust MarmotRust/Gleam throughput
Read-heavy admin sequence8,065/sec12,886/sec1.60×
Short update transaction21,408/sec45,815/sec2.14×

These tests used synthetic data, production-derived query shapes, and a Ryzen 7 9700X server with 62 GiB of RAM. The read sequence performs about 26 small indexed queries. The write sequence performs a short transaction, using WAL and synchronous=NORMAL.

The Rust Marmot path uses rusqlite. These measurements cover the complete database integrations, including their bindings and generated code. They don't isolate the NIF's cost or measure full application throughput. The gains apply to the request shapes we tested.

Server components didn't remove the need for client state​

A server component was convenient until the state needed to change immediately under the user's hand.

A server-managed drawer or dropdown can work, but the round trip doesn't always feel good. Drag-and-drop makes the problem more obvious: the pointer and visual feedback need to stay together. Sending each position to the server and waiting for a patch wasn't the model we wanted.

We used dedicated Lustre client islands for substantial interactions. The form builder, for example, had a server host under server/src/server/admin/product/ and its browser program under client/src/client/form_builder/. The server package targeted Erlang; the client package targeted JavaScript. One feature crossed two source trees.

There were also smaller local behaviors around forms and layout. Those accumulated as JavaScript snippets. We liked the typed application structure, and we didn't like stepping outside it for all the little interactions.

The server-component model helped the admin, but it didn't provide the same benefit to our agent MCP interface or a possible future native application. That limited how much of the product could benefit from it.

Keeping TEA, including for the small stuff​

After moving to Rust, we wanted to keep The Elm Architecture on the client. We considered Elm, but also wanted the cost of adding a little island to be low enough for a drawer, a control, or a modest piece of form behavior.

We built Hypertea, a small TypeScript runtime inspired by Hyperapp and TEA. We use the same structure for small controls and larger islands: explicit state, typed messages, an update function, and managed effects and subscriptions. Even a small interaction gets that structure instead of another loose JavaScript snippet.

Strict TypeScript and linting support that model. They catch optional-value mistakes, enforce exhaustive handling where configured, and keep unmanaged side effects out of ordinary application modules. These aren't all the guarantees of Gleam or Elm, and the tooling is less elegant. We still find this a much better way to write our client behavior than maintaining loose event handlers across the interface.

The Rust build bill​

Builds have been painful. As the application grew, a small Rust change could require an expensive compilation of the large application crate. A Maud template change is Rust source. A query change can require regenerated bindings and another application compile. Cached dependencies may stay fresh, but the application compilation unit is still large.

We have development debug information disabled and development LTO off. We use incremental compilation, limit Cargo jobs, serialize competing worktree checks, and clean up stale incremental output. These measures have reduced our build times, but not significantly.

In one probe, a single compiler process for a library test used more than 10 GiB of physical memory on a 16 GiB laptop. Even single-job test-compilation probes crossed an 8 GiB stop budget. A separate snapshot found worktree build directories between 5.7 and 7.9 GiB. Those were accumulated artifacts and caches, not the size of the deployed executable.

Touching an admin template without changing its contents triggered a cargo check that took 21.38 seconds. That didn't include linking a server, restarting it, or inspecting the result. It was a favorable lower bound: the source contents hadn't changed, so the compiler could reuse more work than after a real markup edit. For repeated UI edits, the delay becomes hard to ignore.

An extraction that helped​

Our application generators originally lived in the same Cargo package as the application. Running them compiled application code they didn't need, and a contract-generation feature created another application-library identity.

We moved the generators into their own small package. In an August comparison, the timings and disk use changed as follows:

Generator workflowBefore extractionAfter extraction
Cold workflow237.47 seconds16.25 seconds
Warm workflow54.28 seconds3.53 seconds
Build output after generation4.28 GiB257 MiB

The generated output stayed the same. Generation became much cheaper, though compiling the large application itself is still expensive.

We've also investigated compiler caching through sccache. Cache eligibility and reuse differ between cold dependencies and incremental application edits, so adding a cache isn't automatically a fix for the latter. We need to measure the loop we actually spend time in.

With multiple agent worktrees, each checkout also accumulates build state. Serializing Cargo protects the laptop from overlapping compilers, but introduces a queue. A dedicated build machine may eventually be worthwhile. The build cost is already pushing us to reconsider how we divide the application and where we render the UI.

We're hoping that splitting parts of the application into separate Rust crates will reduce build costs. We haven't established that benefit yet. The generator extraction improved one specific workflow; whether further splitting makes everyday application edits faster is still undetermined.

What we miss about Gleam​

Gleam is a lovely language to work in. Explicit optional values, exhaustive case matching, and a small, consistent language give you useful guardrails without making ordinary application code unpleasant to write. We still find it significantly nicer to read and write than Rust. We've gotten used to Rust's syntax, but we still prefer Gleam's.

Lustre was a particularly good fit. It brought The Elm Architecture into Gleam: a model, messages, an update function, and a view. We could use that structure in browser applications and in server components, with Gleam types throughout. There was little handwritten JavaScript and no separate template language to learn.

We described the admin implementation in Live Admin With Gleam and Lustre. A long-lived BEAM process held the page state, handled messages, and sent DOM patches over a WebSocket. For forms, tables, and configuration pages, that was a productive way to build an interface.

Access to the BEAM was another substantial benefit. Its processes, supervision, and runtime capabilities are good reasons to choose the stack. And in our experience, the Gleam build loop was considerably lighter than the one we now have with the much larger Rust application.

In conclusion​

Gleam and Lustre probably would have worked out fine for us. We're happy we chose Rust and TypeScript: they've been a better fit for our architecture and how we work, despite the build costs and the things we miss. Hypertea's flexibility has also paid off in ways we hadn't expected, with WebMCP building directly on its existing TEA design.