Fulmine.js is a drop in replacement for Express 5 that runs on
uWebSockets.js instead of node:http. You change
one line, your middleware keeps working, and the routing gets between 2x and 20x faster. This post
explains where that number comes from, and where it does not apply.
const express = require("fulmine.js"); // instead of require("express")
Why the Express router is the part that costs
I write Express applications and I like Express. What I do not like is that its router walks a list.
Every request goes down the layers in order until one matches, so the more routes you register, the more work every request does.
On a small application nobody notices. On an application with a thousand routes, which is what a generated API layer or a multi tenant backend looks like, it is the main cost of the request. And it is a cost you pay on every single request, before your own code runs at all.
Fulmine.js registers the routes on uWebSockets.js's own router, which matches the path in C++ and hands over a chain that was worked out at startup. Nothing is scanned per request.
Benchmarks: Fulmine.js vs Express 5
These are spreads over the last nine CI runs, which landed on three different runner shapes, all on Node 26. The spread is there on purpose: a single figure from a single run would not be honest.
| scenario | vs Express 5 |
|---|---|
| a thousand routes | 9.7x to 17.4x |
| a thousand routes with a parameter each | 10x to 21.2x |
| a parameterised route in a mounted router | 6.8x to 8.8x |
| an API endpoint with params and a query | 3.1x to 4.9x |
| a urlencoded body | 3.3x to 4.6x |
| five route shapes in one process | 2.4x to 4.0x |
| nested routers | 2.0x to 3.4x |
| a thousand concurrent connections | 2.6x to 3.7x |
| hello world | 1.8x to 2.9x |
| static assets, measured on Angular SSR | 3.29x |
Read the first three rows, then read hello world. The gap is smallest on hello world and largest on the big route tables, because those routes go to the C++ router instead of being scanned. So the advantage grows with the size of your application instead of shrinking.
That is the opposite of most optimisations, and it is the only reason I think this is worth anyone's time. A benchmark that only wins on hello world wins on nothing you actually ship.
Public benchmarks
Numbers produced by a project about itself deserve suspicion, so Fulmine also stands in public arenas, run by their own rigs under their own rules,
see HttpArena.
Routes answered without entering JavaScript
This is the part I have not seen in any other Node server. A handler simple enough to be read at registration time is compiled into a uWS response written once at startup, and answered without entering JavaScript at all. Not a faster path through the framework. No path through the framework.
It needs the route to have nothing in front of it, and a single handler that only calls res.status, res.set, res.type, res.send, res.json, res.sendStatus or res.end with literal arguments.
That sounds narrow until you look at what a real service has: health and readiness probes, config endpoints, feature flags, robots.txt, the .well-known files, static manifests. In a Kubernetes cluster the probes alone are a large share of the requests, and they are pure framework overhead.
You do not have to guess which routes got there. The server tells you:
$ npx fulmine.js profile
3 route(s), 2 answered by uWS itself, 1 of them without running any javascript
GET /health uWS /health (compiled to a response, copies no request headers, reads no query)
GET /api/items/:id uWS /api/items/:x (copies no request headers, reads no query)
GET /flights/:from-:to router: uWS cannot match this path on its own
There is also npx fulmine.js explain /api/items for a single route, a Server-Timing middleware that puts the routing verdict in your browser's network panel, and assertions you can put in a test so a route that falls off the fast path turns your build red.
Express compatibility, tested byte for byte
This is where most of the work went. Every test in the suite runs against real Express first and then against Fulmine.js, and the two outputs have to match byte for byte. Express 5's own test suite runs against it too, and passes whole: 1130 passing, 0 failing at the pinned Express version.
So helmet, cors, passport, morgan, multer, express-session, compression, express-rate-limit and the rest are not ported. They just run.
NestJS, Next.js, Astro, SvelteKit, React Router and Angular SSR
The frameworks that build on Express have a suite of their own, same rule: the same application served twice, once on Express and once on Fulmine.js, compared byte for byte. NestJS, Next.js as a custom server, Astro, SvelteKit, React Router v7, Apollo Server, tRPC and Angular SSR are all in it.
NestJS has an adapter in the package, so there is nothing to write:
import { NestFactory } from "@nestjs/core";
import { FulmineExpressAdapter } from "fulmine.js/nest";
const app = await NestFactory.create(AppModule, new FulmineExpressAdapter());
await app.listen(3000);
That suite earns its keep for a reason I did not expect. It found two real bugs on its first two runs, both in code the ordinary tests cover well, because a framework uses far more of the Express surface than an application ever does.
Where Fulmine.js is not faster than Express
I would rather say this than have you find it out. Any request whose cost is work both servers hand to the same library is a wash: a large JSON body is JSON.parse, a gzipped response is zlib, a hashed upload is OpenSSL, a five megabyte stream is memory bandwidth.
Writing bytes to a socket is a wash too, and I measured that one: server sent events and WebSocket messages both land between 1.0x and 1.2x, whether the WebSocket traffic is an echo or a broadcast.
The benchmark labels those rows instead of publishing them as if they were wins.
The rule that came out of all the measuring is simple. This is faster where the framework is doing the work, and level where the kernel or a shared library is doing it. Routing is the framework's work, and it is the one part you cannot avoid paying on every request.
Migrating from Express
npx fulmine.js verify # can this machine and this image run it
npx fulmine.js migrate --dry-run # say what it would change, change nothing
npx fulmine.js migrate # do it
npx fulmine.js differences # what to check by hand, since no rewrite can find these
If a framework requires Express in its own code rather than in yours, npx fulmine.js override writes the package manager substitution for you.
Two things to know before you compare numbers with anyone. Node 24 made Express roughly 3x faster on the routing benchmarks while a uWS based server barely moved, because the gain came from node:http, so any comparison published before mid 2026 overstates the gap. And ratios are not portable across runs: the same row measures 15k or 28k requests per second on the same code depending on the machine, so only compare figures produced in the same run.
The code is on GitHub and the package is
fulmine.js on npm.
Top comments (0)