I maintain Fulmine.js, a drop-in replacement for Express 5 that runs on µWebSockets.js instead of node:http. One line changes, your middleware keeps working:
const express = require("fulmine.js"); // instead of require("express")
This post is about what happens when somebody else measures it.
The numbers are not mine
Benchmarks published by a project about itself deserve suspicion, so here are somebody else's. HttpArena runs every framework on the same 64-core machine, in containers, under the same rules, and publishes every round. Express, Fastify, hyper-express, ultimate-express and Elysia are all on the board, and every entry below runs in the same standard mode.
Requests per second, from the current published round:
| Profile | Fulmine.js | Express | Fastify | hyper-express | ultimate-express |
|---|---|---|---|---|---|
| Baseline, 4096 connections | 1,654,758 | 525,879 | 737,611 | 1,167,706 | 1,169,575 |
| 32,000 connections | 2,092,326 | 487,281 | 862,256 | 1,440,503 | 1,500,856 |
| Limited connections | 1,474,449 | 273,772 | 291,264 | 1,082,245 | 1,085,526 |
| JSON over TLS | 1,068,482 | 394,866 | 571,431 | 801,048 | 843,414 |
| Compressed JSON | 500,652 | 53,352 | 49,716 | 436,029 | 375,419 |
| Static files over TLS | 598,441 | 22,685 | - | 410,499 | 345,081 |
| 500k target on 8 CPUs | 325,693 | 96,488 | 137,608 | 221,044 | 229,993 |
| Pipelined | 33,710,054 | 942,872 | 1,671,518 | 3,516,134 | 4,451,574 |
| Async Postgres | 231,411 | 173,099 | 179,169 | 199,327 | 184,685 |
| Fortunes (Postgres + template) | 68,724 | 58,855 | - | 83,872 | 81,600 |
Against Express that is 3.1x on the baseline, 4.3x at 32,000 connections, 5.4x with limited connections, 2.7x on JSON over TLS, 3.4x on 8 CPUs, and 35x pipelined. Against Fastify, same board, same day: 2.2x on the baseline, 2.4x at 32,000 connections, 1.9x on JSON over TLS.
The pipelined row is the compiled response: a handler that always answers the same bytes is turned into a static uWS response at startup and never enters JavaScript. Compressed JSON and static files are the built-in express.compression() and express.static() doing in one call what the compression and serve-static modules do through a stream.
Now the honest parts, which matter as much as the table.
Fortunes is lost. hyper-express and ultimate-express both beat Fulmine there, 83k and 81k against 68k. That profile is a Postgres query plus an HTML template per request, and the difference is in how the entries are written, not in the framework. It is on the board, so it is here.
Async Postgres is 1.3x, not 3x. When the request waits on a database, the framework is a small share of the time. Elysia is ahead of Fulmine on that row.
Three rows are identical for everybody and are not in the table: the 8 Gbit bandwidth profile and the two latency profiles with a fixed target rate, where every entry hits the ceiling. A 20 MB upload is the same story: memory bandwidth and syscalls, not framework code.
The rule that comes out of all of it: speed comes from the framework only where the framework is doing the work. Routing, dispatch, headers, the response: that is every request. A database, JSON.parse on a big body, zlib, OpenSSL: no framework moves those.
Where the speed comes from
Not from one trick. Mostly from doing less per request:
- Routes go to µWS's own router. A literal path, or one whose parameters 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: a thousand routes measure 7x to 20x against Express in the project's own CI.
- 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 pass decides what each route needs. At registration the whole middleware chain of a route is read, and if it provably never touches the request headers or the query, the request skips copying them. The default answer is "it uses everything", which is what makes the analysis safe.
- Bodies are collected by µWS, with the size limit enforced in C++, and parsed without copying them out.
- No closures per request in the hot path, shared listeners, cached status lines, declared class fields so V8 keeps one object shape.
You do not have to guess which of your routes got there:
$ npx fulmine.js profile
3 route(s), 2 answered by µWS itself, 1 of them without running any javascript
GET /health µWS /health (compiled to a response, reads no query)
GET /api/items/:id µWS /api/items/:x (copies no request headers)
GET /flights/:from-:to router: µWS cannot match this path on its own
Still Express, proven
Being fast is not the hard part. Being fast and still being Express is the whole problem.
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. When they disagree, one of the two is wrong, and usually it is mine.
Express's own test suite is a CI gate: 1130 passing, 0 failing at the pinned version.
Frameworks have their own suite: NestJS, Next.js, Astro, SvelteKit, React Router, Apollo Server, tRPC, tsoa and an MCP server, each served once on Express and once on Fulmine, compared byte for byte.
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.
Getting started
npx fulmine.js create my-app # a new project, with a Dockerfile that works
npx fulmine.js verify # an existing one: can this machine and this image run it
npx fulmine.js migrate --dry-run # what would change, changing nothing
npx fulmine.js migrate # do it
Node 22, 24 or 26, glibc 2.38 or newer: µWS ships prebuilt binaries, so no Alpine and no Bun. Docs at fulmine.sndesign.it, code at 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)