I maintain Fulmine, a drop-in replacement for Express 5 that runs on µWebSockets.js instead of node:http. One line changes:
const express = require("fulmine.js"); // instead of require("express")
Your middleware keeps working: helmet, cors, passport, morgan, multer, express-session and the rest.
The numbers are not mine
Benchmarks published by a project about itself deserve suspicion, so let me use somebody else's. HttpArena runs every framework on the same 64-core machine, in containers, under the same rules, and publishes the results. Express and Fastify are on that board too.
Requests per second, from their published runs:
| Profile | Fulmine | Express | Fastify |
|---|---|---|---|
| Baseline (query parsing) | 1,220,308 | 607,777 | 711,263 |
| JSON (dataset + serialization) | 1,111,187 | 395,361 | 522,201 |
| Short-lived connections | 1,026,789 | 278,163 | 298,779 |
| Pipelined | 7,259,814 | 1,009,543 | 1,671,338 |
| Mixed API workload, 16 CPUs | 126,282 | 67,724 | 75,633 |
| Async Postgres | 222,701 | 169,687 | 179,169 |
| Upload (20 MB body) | 2,154 | 2,104 | 1,902 |
That is 2.0x Express on the baseline, 2.8x on JSON, 3.7x on short-lived connections, 7.2x pipelined, and 1.9x on the mixed API profile. Against Fastify, on the same board, it is 1.7x on the baseline and 2.1x on JSON.
Now the honest parts, which matter as much as the table.
Look at the upload row: 1.02x. A 20 MB body is memory bandwidth and syscalls, not framework code. Everywhere the cost belongs to a library both servers call, the difference disappears: JSON.parse, zlib, OpenSSL. Speed comes from the framework only where the framework is doing the work.
My entry runs in the arena's "tuned" mode, Express's and Fastify's run in "standard". On two profiles I left out of the table, static files and compressed JSON, that difference is decisive, because tuned mode allows hand-written compression and negotiation. Those rows would show 23x and 8x, and they would be measuring my entry's tuning, not the framework. I would rather not quote them.
Where the speed comes from
Not from one trick. Mostly from doing less per request:
- Literal routes go to µWS's own router. A path with no pattern, or with parameters that are whole segments, is registered natively and matched in C++. The route table is never scanned in JavaScript, so the gap grows with the number of routes instead of shrinking.
- Static handlers are compiled at registration. If a handler always answers the same thing, an AST pass turns it into a µWS declarative response: no request object, no response object, nothing allocated per request. Anything the compiler does not fully understand falls back to normal routing.
- A second AST pass decides what each route needs. At registration I read the whole middleware chain of a route and ask whether it can possibly touch the request headers, or the query string. If it provably cannot, the request skips copying them. The default answer is "it uses everything": any shape the analysis does not understand keeps all the work, which is what makes it safe to act on.
- Bodies are collected by µWS. When the length is known, the body arrives in one native callback with the size limit enforced in C++, and the parser reads it without copying it out.
- Allocation discipline everywhere else. No closures per request in the hot path, shared listeners instead of per-request arrows, cached status lines, and declared class fields so V8 keeps one object shape.
The part I actually care about: proving compatibility
Being fast is not difficult. Being fast and still being Express is the whole problem, and this is how I keep myself honest.
Every test runs twice. Each test file runs against real Express 5 first, then against Fulmine, and the two outputs must match byte for byte: status, headers, body, everything. Not "looks equivalent". Identical. There are 416 such files today. When they disagree, one of the two is wrong, and usually it is mine.
Express's own test suite is a CI gate. I check out Express at a pinned commit and run its suite against Fulmine: 1130 passing, 0 failing. Any failure, or any file that produces no result, turns the build red.
This matters more than it sounds, because compatibility bugs are quiet. A missing Vary header, or a 404 where Express answers 403, will not crash anything. It will behave differently in production, six months later, in a way nobody connects to the framework swap.
On top of that: coverage sits at 94% with floors enforced in CI, and CodeQL scans the shipped source, because a framework handling paths and headers is exactly where a static analyser earns its keep.
Getting started
npm install fulmine.js
npx fulmine migrate --dry-run # says what it would rewrite, rewrites nothing
npx fulmine differences # the handful of things to check by hand
Requires Node 22+ and glibc: µWS ships prebuilt binaries, so no Alpine (use node:22-trixie-slim).
Repository: https://github.com/nigrosimone/fulmine.js
I am interested in the cases where it breaks. If you try it on a real application and something behaves differently from Express, that is a bug worth an issue, and I will take it seriously.
Top comments (0)