Prelude
When I was in 9th grade — last year before high-school — my best friend persuaded me to join, together with him, the schools programming club. At first, I hesitated, but later agreed. I am immensely thankful to him for this.
There, step by step, we learned Pascal using Turbo Pascal. Little by little, we grasped the basics of the language: variables, operators, string manipulations, data structures — until eventually, remaking Conway’s Game of Life. And then, summer break came. Other than HTML, which is not a programming language, Pascal was the first real programming language I have learned and used.
But I did not stay with Pascal for very long time.
After the summer break, in high-school, I have chosen the “Software Engineering” branch of studying, and there we learned C.
Just like Pascal, we started step by step: basic operators, string manipulations, memory allocation, data structures, and the final boss, the void *.
I fell in love with C. The precise control over memory; passing variables by reference or pointer; the need to allocate memory for every data structure that you wanted to create.
After 3 more years of studying, I have graduated high-school. In that time, I have improved my C skills, learned some PHP, picked up C++, and tried to build a variety of programs (don’t judge me too hard, these are ~20 years old): a clone of BattleCity, 3D software renderer, IRC bot, unfinished operating system kernel, unfinished game engine, TTF renderer for OpenGL.
After high-school, I have enrolled in a two-year college program that would earn me a Practical Software Engineering Degree. During the summer break between the first and the seconds years, I had a choice to make: go work in McDonald’s (I had experience working as a waiter during high school summer breaks), or find a job in software engineering. After 2 seconds of hesitation, I have crafted a CV and started to send it to every position I saw online. And despite the fact that I really wanted to get a software development position in C or C++, nobody would hire me. Eventually, I have secured a web-development position in PHP (thank you very much my first employer for giving me a chance).
And this would be the last time I’d touch C or C++. Dynamic, high-level languages such as PHP, Python, and Ruby — are more suited to the dynamic nature of web development. You rarely need to squeeze the maximum performance from your hardware, since for every second you gain by optimizing data structures allocations in C, you lose 10x more waiting for network or disk requests to resolve. And so, collectively, we all agreed that the web is better to be written in dynamic languages.
But just like your first true-love, C would hunt me. I would obsess about micro-optimizations, and would get mad that I can’t control when variables are allocated, and how to pass things by reference or pointer without making a redundant copy. And then, Rust became a thing.
Web development in Rust
Just like every software engineer, I started learning Rust by building my own game engine. Do you even call yourself a software engineer if you never tried to build your own game engine? And I fell in-love with Rust. C was aging, and failed to catch up with the moving world of hipster development: modern tooling, linters, formatters, package management. Rust offered the best of both worlds: low-level control of memory allocation, and variable life-cycle, while having modern development practices: a simple-to-use compiler, built-in linter and formatter, and of course a modern package management tool and repository.
The community was loving Rust, and the language was growing very fast. And for the first time you could write a web application using a low-level systems-programming language. 1 After picking up the basics of the language, and abandoning my game engine, I have decided to build a web application in Rust. And, in the end of 2023, I have succeeded. I have built, and shipped a fully operational web application that was making me money, in Rust.
Throughout the years, I learned from my mistakes, and shared my progress here, here, and here. Eventually, the application became a Frankensteins’ monster were the backend would be pure Rust, while the frontend was a statically generated Astro website. Until, in the end, I have reached a road-block and could no longer maintain or extend the application.
I took a painful decision to migrate everything to Node.js, and conclude this experiment with Rust. It was hard for me to make this decision. I really loved working with Rust, and I wanted to make it work. My work with Rust earned me valuable human connections, and opportunities to present my knowledge at two conferences, and one meetup. I am happy that I have took the risk with Rust, but…
Farewell Rust, at least for now…
I’m not going to go over the positive sides of Rust, since I covered them in other articles such as Building a web app in Rust and One year of Rust in production. But I do want to talk about some of the struggles I had, both as a reminder to my future self (you can take C from me, but you can’t take my love to low-level programming), and as a cautionary tale to everyone else.
Keep in mind that everything I share is based on my perspective: I’m a solo founder, building and running a web application. There is no heavy workload like video processing, nor a need to have low latency — two things at which Rust excels. From my experience, and my product, the bottleneck is always the database, disk, or network. I’m also, somewhat, experienced in Rust, so I’m not the “I tried to build a web app in Rust, but I don’t understand borrow checker, so Rust bad” kind of guy. Now, with that out of the way, let’s begin.
Templating
As I mentioned earlier, from pure Rust + server side generated templates with tera, I have switched to a Rust API server + Astro static website that calls the API.
The reason I moved away from rendering HTML in Rust, is the fact that I am too spoiled with type-safe templates.
With Astro, I can use the .astro components that are type-safe.
Since I use, and advocate for, Typescript, all my templates are type-safe, and there is very little chance to mistype a variable, or miss it.
If you have a component like this, it’s very hard to make it compile with the wrong type (unless you do stupid stuff like props as any):
interface Props {
username: string;
email: string;
}
export function UserCard(props: Props) {
return (
<div>
Hello {props.username} ({props.email})
</div>
);
}
With libraries like tera, handlebars, or mrml — your view is essentially separated from the props, and if you rename a field in the template, you must remember to rename it in the model as well.
It’s possible to solve it with tests at the cost of longer CI/CD pipeline, but I will talk about compilation time a bit later.
Functions inside templates are basically wild-west of strings.
And you need functions.
It’s hard to make rich templates without functions.
But there are libraries like maud or askama — I hear you say.
And I agree, there are.
These are essentially type-safe HTML templates that use the macro mechanism from Rust.
By building, basically, HTML DSL using Rust macro, you can create compile time, type-safe templates.
But then again, compilation is expensive, but more on that later.
Localization and Internationalization
I have been talking about localization back in 2015.
Node.js ships with full icu support, and a set of Intl.* APIs to format numbers, lists, currencies, country names, you name it.
On top of that, with libraries like i18next, one can get type-safe auto-complete for translations, something that I was not able to achieve in Rust.
Yes, Rust has support for fluent translation files developed by Mozilla, and a minimal support for number formatting.
But Node.js has everything you need in order to build and ship fully localized and translated web-applications.
It’s a know fact that i18n is lacking in Rust (see: AWWY: Internationalization), and bindings for icu4c are in progress, but they are nowhere near what Node.js can offer it that regard.
The web is dynamic by nature
Take it or leave it, but the web is dynamic by nature.
Most of the work is serializing and deserializing data between different systems, be it a database, Redis, external APIs, or template engines.
Rust has one of the best (de)serialization libraries in my opinion: serde.
And yet, due to the nature of safety in Rust, I’d find myself writing boilerplate code just to avoid calling .unwrap().
I’d get long chain calls of .ok_or followed by .map_err.
I defined a dozen of custom error enums, some taking other enums, because you want to be able to handle errors properly, and your functions can’t just return any error.
Similar thing can be said about writing SQL.
I was really happy with using sqlx, which is a crate for compile-time checked SQL queries.
By relying on macros in Rust, sqlx would execute the query against a real database instance in order to make sure that your query is valid, and the mappings are correct.
However, writing dynamic queries with sqlx is a PITA, as you can’t build a dynamic string and make sure it’s checked during compilation, so you have to resort to using non-checked SQL queries.
And honestly, with kysely in Node.js, I can get a similar result, without the need to have a connection to the DB, while having ergonomic query builder to build dynamic queries, without the overhead of compilation time.
Okay, I hear you, let’s talk about compilation time.
Compilation time
Rust achieves its safety at the expense of, somewhat long, compilation time. On modern hardware, the compilation time is not that bad. It’s not good enough to have uninterrupted change-f5-preview cycle, but it’s good enough especially with incremental compilation.
However, the more crates you have, the more macros you use, the slower the compilation time becomes. So the problem becomes is how to achieve fast compilation during CI/CD. Due to the dynamic nature of the web, you often times find yourself in a loop of: there is an error in production → fix it → deploy. And you want this loop to be as fast as possible, because you have customers who can’t do things with your app.
I have my own hardware to run CI/CD workers.
A dedicated VM on an Intel Core i5-7500 with 32GB of RAM.
The VM has access to all 4 CPU cores, and would take about 14 minutes from push to master until the docker container was deployed on the server (about 12 of it is the docker stage with compilation).
That’s with multistaged docker file, and a cached builder layer; with no cache, it would probably take ~20-25 minutes.
And it does not include running tests or clippy as part of the CI/CD.
I simply gave-up trying to set-up proper caching so tests, clippy, and compilation would be able to use the same cache.
Node.js, on the other hand, takes on average 5 minutes, including linting, and tests. And since I moved to Node.js, I added another back-office service, so I deploy more code, 3 times faster, while actually relying on running tests and lint as part of the CI/CD pipeline.
Ecosystem maturity
Rust has a very mature ecosystem in most aspects, but it is lacking in the web aspect. Need an obscure third party API? It’s probably not there. So you end up implementing APIs instead of doing core business logic. Each API is additional code that you need to test and maintain.
It’s not hard to implement REST APIs, but the lack of maturity does slow you down. I had to implement, and test, third party APIs, queue mechanism on top of PostgreSQL, code to validate webhook signatures. It was fun, but it comes out of the box in other, “standard”, languages.
Node.js is good enough
And honestly, Node.js is good enough.
People like to criticize Node.js or the npm ecosystem, implying that there is something fundamentally wrong with JavaScript.
And sure, JavaScript is far from being a good language.
There are many quirks in the language, which you can learn only with experience, pain, and tears.
And I miss stuff from Rust like the Result and Option types; I miss the match statement; and I miss the enum as well.
But in all honesty, Node.js ecosystem has matured and is stable to write web applications.
You have libraries like zod to validate request/response JSONs, libraries like kysely, @kitajs/html, and my own @mjmx/core to write type-safe SQL, HTML, and MJML.
And async/await is still better in Node.js than in Rust.
It’s always weird to need to bring thirds party async-trait crate because you want to have an async method in a trait.
There are still issues with Node.js.
The never-ending deprecation of cjs in favor of esm; it’s mostly good, until you encounter a cjs package, and then it’s hell.
The cumbersome eslint and prettier setup where you need to juggle between 2 files and dozen of plugins to get basic functionality you get with clippy out-of-the-box; I wait for the day biome will close the gaps, and becomes the standard tool.
Workspaces are still not solved, although pnpm is better in this regard, but it’s nowhere near the workspaces in cargo.
And the occasional struggles with typescript where the runtime seems to be changing too often; is it ts-node? tsx? tsm? The built-in typescript runtime in node? deno? bun?
Oh, and I really wish we all would just agree that snake_case is simply the best, most aesthetically pleasing, and easy to read case for programming.
I really hate the fact that JavaScript has adopted the camelCase.
Closing notes
Building solo is hard. You wear 10 hats as software engineer, in addition to the dozen hats as an entrepreneur. I really wanted Rust to succeed, but I also need to move forward. I found myself ignoring bugs in Sentry because it meant going back to long compile times. I was postponing feature development because it meant slow iteration speed, and trying to synchronize backend REST API with frontend, or the need to visually re-test every page after a variable rename in a template. Call me spoiled, but I don’t have the luxury of development team, and someone checking my code.
Rust excels at non-visual things, i.e. things you can write and wrap with tests. But when you need to develop UI, it becomes painful. You wait for the code the recompile; worried about passing the wrong variable to a view.
From pure Rust, I had to go to Rust with a statically generated HTML + Alpine on top of it.
To make sure my email templates are safe, I was considering writing a dedicated mailing service in NodeJS, just so I could use react.email (or my own @mjmx/core), and get some type-safety.
It’s as if I was moving backwards, breaking my Rust monolith and rewriting parts in a dynamic language that is better suited for the web.
No, Node.js is not perfect.
But at least I have one stack, and funny enough, I get more type-safety than I had in Rust.
Sure, it’s a fake type-safety, JavaScript is a dynamic language, but I no longer have to jump between my view files and my model and try to keep them in sync.
I no longer misspell translation keys, resulting in blank words; emails do not come with Hello {{dearCustomer}}.
It seems like most of my issues boil down to dynamic things: templates, i18n, SQL.
If I were to write an API service today, I would probably choose Rust again, since API services do not have to deal with views or translations.
I still don’t know how I would handle SQL though.
ORMs are not my cup of tea, and other than sqlx, Rust seems to lack a good type-safe query builder.
I also miss the small footprint of the application. With Rust, the containers would use between 60 and 80 MB of RAM; but with Node.js, the lowest I have is 117 MB of RAM for the back-office, without any load. And it’s true what they say: use the right tool for the job. Rust shines in CPU-heavy tasks, and for sure I will be using it when I will have such tasks.
But until then, farewell Rust.