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, your framework keeps working, your tests keep passing, and the routing gets between 1.3x and 20x faster depending on how big your application is.
const express = require("fulmine.js"); // instead of require("express")
Docs: fulmine.sndesign.it. Code: github.com/nigrosimone/fulmine.js.
What you get for one line
-
Faster routing, and the gap grows with your app. Express walks its stack on every request. Fulmine hands the routes to uWS's router, which matches in C++, and works out at
listen()which middlewares stand in front of each one. A handful of routes measures 1.3x to 4.9x. A thousand routes measure 7x to 20x. -
Routes answered without running JavaScript. A handler simple enough to read at registration (
res.json({ ok: true }), a health probe,robots.txt, a feature flag) is compiled into a static uWS response. No request object, no response object, nothing allocated. No other Node server does this. -
Your middleware, unchanged.
helmet,cors,passport,morgan,multer,express-session,express-rate-limit,socket.io: not ported, they just run. -
Your framework, unchanged. NestJS (one import:
fulmine.js/nest), Next.js, Astro, SvelteKit, React Router, Angular SSR, Apollo Server, tRPC, tsoa, MCP servers. -
Things Express does not have, all optional: one process per core on one port (
cluster: "auto"), native WebSockets (app.ws()), built-in compression (50% more requests per second than thecompressionmodule), pre-compressed static files,Server-Timing, PROXY protocol, TLS withouthttps.createServer. -
Tooling:
npx fulmine.js verifysays whether this machine and your Docker image can run it.migraterewrites the imports across a project.profileprints whatlisten()decided about each route.createstarts a new project with a Dockerfile that works.
The numbers
Spreads over the last twelve CI runs against Express 5, on four different runner shapes, all on Node 26. The spread is on purpose, a single figure from a single run would not be honest.
| scenario | vs Express 5 |
|---|---|
| a thousand routes with a parameter each | 7.7x to 19.9x |
| a thousand routes | 7.5x to 16.4x |
| a parameterised route in a mounted router | 4.3x to 8.8x |
| a urlencoded body | 2.1x to 5.6x |
| an API endpoint with params and a query | 1.9x to 4.9x |
| five route shapes in one process | 1.6x to 4.1x |
| nested routers | 1.5x to 3.6x |
| a thousand concurrent connections | 2.2x to 3.6x |
| a chain of 100 middlewares | 1.8x to 2.5x |
| hello world | 1.3x to 3.2x |
| static assets, measured on Angular SSR | 3.29x |
Read the first rows, then read hello world. The gap is smallest on hello world and largest on the big route tables. The advantage grows with the size of your application instead of shrinking, which is the opposite of most optimisations, and the only reason I think this is worth your time.
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. On HttpArena, 64 cores, same container rules for everybody, the current round has Fulmine at 3.1x Express on the baseline profile, 5.4x with limited connections, 4.3x at 32,000 connections, and 35x pipelined. I wrote a separate post about that board, including the rows where it does not win.
Where it is not faster
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. Server-sent events and WebSocket messages land between 1.0x and 1.2x. The benchmark labels those rows instead of publishing them as wins.
The rule: faster where the framework is doing the work, level where the kernel or a shared library is doing it. Routing is the framework's work, and it is the one part you pay on every request.
Compatibility is a test suite, not a claim
Every test in the repository runs against real Express 5 first and then against Fulmine, and the two outputs have to match byte for byte: status, headers, body. Express 5's own test suite runs against it too: 1130 passing, 0 failing at the pinned version.
The frameworks have a suite of their own: the same application served twice, once on Express and once on Fulmine, compared byte for byte. That suite found two real bugs on its first two runs, in code the ordinary tests covered well, because a framework uses far more of the Express surface than an application does.
// NestJS: one import, nothing else changes
import { FulmineExpressAdapter } from "fulmine.js/nest";
const app = await NestFactory.create(AppModule, new FulmineExpressAdapter());
What it costs
- A native binary: Node 22, 24 or 26 on Linux, macOS or Windows, x64 or arm64, glibc 2.38 or newer. No Alpine, no Bun. Use
node:26-trixie-slim. - uWebSockets.js is not on npm, it comes from GitHub: git at install time, and one setting under pnpm.
- A few things answer differently because there is no
node:httpunderneath.npx fulmine.js differencesprints the list.
Try it in two minutes
npx fulmine.js create my-app # a new project
cd my-app && npm install && npm run dev
npx fulmine.js verify # an existing one: can this machine run it
npx fulmine.js migrate --dry-run # what would change, changing nothing
npx fulmine.js migrate # do it
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. If it works, a star on GitHub is what gets it in front of the next person.
Top comments (0)