Moving from express to fastify, pt 1

Tom MacWright on

The JavaScript ecosystem has changed a lot in the last 15 years. We’ve gone from CommonJS to ESM, from browserify to webpack to vite, from mocha to vitest, from the request module to using web-standard fetch everywhere. The constant reinvention can be exciting or annoying from one day to the next.

The success of Express

Amid all this churn, express has persevered. Express is a web framework for Node.js, introduced by TJ Holowaychuk in 2009. Like many early JavaScript modules and some of TJ’s projects, it took inspiration from the land of Ruby - in particular, Sinatra, which was established in 2007.

express-example.js
// This is a tiny example of using
// express. Notice how there's almost no boilerplate
// involved in creating a tiny web server.
const app = require('express')();
app.get('/', (req, res) => {
res.send('Hello World!');
});
app.listen(3000);

Express is a beautifully light abstraction: a ‘hello world’ example is barely over 10 lines. Compared to the boilerplate required to do the same in Ruby on Rails or a Java server at the time, it was a breath of fresh air.

Express became the default for new JavaScript projects and has stayed there. In a typical week, express is downloaded from NPM 30 million times. It’s a roaring success in every way.

A need for types

Express is an expressive framework: like a classic Ruby or JavaScript framework, it lets you color outside the lines using loosely defined objects and types. Query string parameters in express become freeform objects. The same goes for the bodies of requests, and the kinds of responses you can send: it’s all a bit ad-hoc by default.

But the vibes have shifted: we write TypeScript now, and everyone’s excited about validation modules like Zod and TypeBox. Interfaces are less ad-hoc and more declared, tested, and strictly typed.

Modern tooling is written to take full advantage of types and schemas. With tRPC, we’re writing backend endpoints and calling them from auto-generated, auto-typed functions on the frontend. Other backends are embracing OpenAPI (née Swagger) to generate clients, test suites, and more. End-to-end type-safety is in vogue, and it is good.

The promise of OpenAPI

In our case, we want our types as an OpenAPI specification. OpenAPI is a JSON/YAML document that lets you describe your entire REST API strictly: what URLs are supported, how parameters, headers, and request bodies should be formatted, what kind of responses will be produced. It lets you enrich this strict machine-readable information with human-readable descriptions and helpful examples. It’s a pretty exciting time for the OpenAPI spec.

A number of companies and open source projects, including Stainless, Speakeasy, and OpenAPI TypeScript, are writing tools that can generate functional, clean SDKs straight from OpenAPI specifications.

You can also generate great documentation from OpenAPI specifications. We’re already generating api.val.town/documentation using a library from Scalar.

And in the near future, we should be able to use our OpenAPI specification to give our AI assistant the ability to interact with our API via function calling: conveniently, OpenAPI and the OpenAI function calling system are both based on JSON Schema, so translating between the two is possible.

Issues with express

Val Town started off with Express. It has served us pretty well. But the papercuts started to accumulate.

When we started creating a public API with an OpenAPI spec, we immediately started to get a bad feeling about using express as we were doing before. The OpenAPI specification was making strong promises about the kinds of requests we accepted and responses we’d return. But the specification was just a dead document, a YAML file - it wasn’t part our codebase, or our test suite. So it wasn’t enforced or validated, and sure enough, it was slightly incorrect from day one.

The other pain point was async. We write the Val Town backend as TypeScript but try to transpile it to modern code, so we keep our async functions and ESM syntax when we run our code. Express far predates the existence of the async & await keywords in JavaScript, and it does not mix well with them.

Finding an alternative

Because express is incredibly popular, there are ways to implement OpenAPI with it, and support async handlers - plugins like express-openapi and express-async-errors. But these are a bit bolted-on: they make it technically possible to achieve the goal, but nothing about them is idiomatic.

Another option would be waiting for the next version of express, v5, which includes support for async functions. That release is taking a little while to complete, so we didn’t want to bet on it landing soon.

It is great, though, that express is still actively maintained, and I don’t envy the task of rolling a major update to such a heavily-adopted project. There are certainly codebases with tens of thousands of lines of code that rely on express and can’t easily jump to an alternative.

But Val Town’s backend is not an enormous legacy project. Switching web frameworks wasn’t going to require a deadly from-scratch rewrite. We were ready to bid adieu to express.

Technology choice is complicated. I don’t want to make a decision based on some simplistic criteria like GitHub Stars, but it’s equally fraught to rely on vibes and instinct. So I investigated whether projects had good documentation and maintainership, if they had a decent history of stability, and perhaps most importantly, whether they were trying to solve the problems that we needed to solve.

Fastify

Fastify

We landed on fastify as the technology to build the future of Val Town’s public API. Some of the strongest points for fastify were:

  • A comprehensive ecosystem, which includes a lot of high-quality, first-party plugins. We’re building a system in production, which means things like rate limiting are going to be necessary, and it’s amazing that fastify’s rate limiter is well-architected.
  • Great, thorough documentation, and an active community that has been really helpful for support.
  • Solid support for plugins and encapsulation that let us structure the application into sub-applications.
  • Support for validation and serialization with multiple validators and validation for all inputs and ouputs.
  • Good support for async/await, like you might expect from a modern framework.
  • Off-the-shelf integrations for opentelemetry and Sentry mean that we could have tracing and telemetry from day one.
  • We were already using the light-my-request module to test our express server. light-my-request is written and maintained by the fastify folks, and it is built into fastify.
  • Finally, @fastify/express let us incrementally migrate from express to fastify.

Of course, fastify is also very fast, but web framework overhead wasn’t a problem with express and most likely won’t be a problem with any framework we choose: most of our performance envelope has to do with operating the Val Town runtime and interacting with the database.

We considered a few other options, like Hono and Elysia. Both are lovely projects - we use and recommend Hono a ton in Vals. Elysia has convenient typesafety and a lot of features, but its support for Node.js is very experimental. And Hono has a first-party OpenAPI plugin, but some of the ecosystem around fastify - for tracing, incremental migration from express, and plugins - was more built-out.

For simpler projects and especially for situations where we want multi-platform compatibility so that code can run on Node.js, Deno, or Cloudflare Workers, we reach for Hono.

Incrementally porting our application, route by route

By using @fastify/express we were able to do this migration entirely incrementally: the first fastify PR just added the fastify dependency and wrapped our express server with fastify. Then each successive PR moved a few routes from express to fastify, until the application has been completely ported.

As of today, we’ve ported all but two routes in our backend server. It’s been an incredibly incremental effort, thanks to @fastify/express - such that we never had to stop work on feature development or freeze part of the codebase while switching frameworks.

And the best is yet to come – next we’ll be talking about our new TypeScript SDK, which is generated directly from the OpenAPI documents that fastify produces, as well as improved documentation and more robust API endpoints. Stay tuned!

Edit this page