Fulmine (lightning in Italian ⚡) is an Express 5 compatible web framework for Node.js, built on
µWebSockets.js instead of node:http. Same API, same
middleware, same tests. Change one line and your Express application runs faster.
Docs: fulmine.sndesign.it
const express = require("fulmine.js"); // instead of require("express")- Faster than Express, measured. 1.2x to 4.5x on plain routing, 1.7x to 5x on a request with a body, 7x to 22x on a large route table. Routes are matched in C++ by µWS's own router, and a simple enough handler is answered without running any JavaScript at all.
- Zero rewrite.
helmet,cors,passport,morgan,multer,express-sessionand the rest of the Express ecosystem keep working. Not "mostly": every test runs against real Express first and the output must match byte for byte, and Express 5's own test suite passes whole. - Your framework works too. NestJS, Next.js, Astro, SvelteKit, React Router, Angular SSR, Apollo Server, tRPC, tsoa, MCP servers: each one is served twice in CI, on Express and on Fulmine, and compared.
- Ranked in public. See HttpArena and web-frameworks, run on their hardware with their rules. No figure is copied here, the boards are the current ones.
- More than Express, when you want it. Multi-core cluster on one port, native WebSockets, built-in compression and pre-compressed static files, Server-Timing, PROXY protocol, TLS. All optional.
- Typed, TypeScript first. ESM, CommonJS, named imports and the Express types you already use.
npx fulmine.js create my-app # a server, a package.json and a Dockerfile that works, --ts for TypeScript
cd my-app && npm install && npm run devOr in a project you already have:
npm install fulmine.jsconst express = require("fulmine.js");
const app = express();
app.use(express.json());
app.get("/users/:id", (req, res) => res.json({ id: req.params.id }));
app.listen(3000);ESM and TypeScript work the same way, named imports included:
import express, { Router, json } from "fulmine.js";
import type { Request, Response } from "fulmine.js";Requirements: Node 22, 24 or 26, on Linux, macOS or Windows, x64 or arm64. Not Alpine (glibc 2.38+
is needed) and not Bun; pnpm refuses the install (ERR_PNPM_EXOTIC_SUBDEP on uWebSockets.js) until
one command is run. npx fulmine.js verify tells
you in thirty seconds whether this machine, your package manager and your Docker image can run it,
and Deploying has the Dockerfile that works.
One command rewrites the imports across a whole project, and then tells you the handful of things that behave differently:
npx fulmine.js verify # can this machine and this image even run it
npx fulmine.js migrate --dry-run # say what it would change, change nothing
npx fulmine.js migrate # do it
npx fulmine.js override # when a framework requires express in its own code, not in yours
npx fulmine.js angular # angular.json's server build, one line of config
npx fulmine.js pnpm # the two lines a pnpm project needs before it installs this
npx fulmine.js differences # just the list of what to check by handNestJS is one import, FulmineExpressAdapter from fulmine.js/nest. Angular SSR's server.ts is
an ordinary Express application and takes the one-line change. A framework that requires Express in
its own code, not in yours, is answered with a package manager override, and override writes it.
The whole guide: Migrating.
Express finds a route by walking its stack and testing each layer against the path, on every request.
Fulmine hands every route it can to µWS's router, which matches in C++, and works out at listen()
which middlewares stand in front of each one. Arriving at a handler costs no matching at all, and the
gap grows with the route table instead of shrinking.
On top of that, most of what makes a request expensive is work that simply does not happen: the body is not read unless a handler asks, the headers are not copied out of µWS unless something reads them, the request is not turned into a stream unless something streams it. A handler simple enough to be read at registration time is compiled into a static response and answered by µWS itself.
npx fulmine.js profile prints what listen() decided about each of your routes, and
npx fulmine.js explain /api/items tells the story of one request. Ten measured tips, from
express.compression() to cluster: "auto", are in Performance.
Everything Express does, plus these. Each one is a runnable file in examples/.
| Feature | One line |
|---|---|
| One process per core, one port, no primary in the path | express({ cluster: "auto" }) |
Native WebSockets, with an upgrade(req, res) hook for auth |
app.ws("/room/:id", { open, message, close }) |
| socket.io | io.attachApp(app.uwsApp) |
Compression built in, 50% more requests per second than compression |
app.use(express.compression()) |
Serve the .br and .gz twins your build already wrote |
express.static(dir, { preCompressed: true }) |
| Server-Timing with how the request was routed | app.use(express.serverTiming()) |
| Assert in a test that a route stays on the fast path | express.testing.expectNative(app, ["/api/*"]) |
HTTPS without https.createServer |
express({ uwsOptions: { key_file_name, cert_file_name } }) |
| PROXY protocol from HAProxy, AWS NLB, nginx, Envoy | app.set("trust proxy protocol", true) |
Details in WebSockets, Performance and Deploying.
Use the Express 5 documentation as the reference: the application, request, response and router APIs are all there, settings included. The full checklist, the tested middlewares, frameworks and view engines, and the eight settings Fulmine adds, are in Compatibility.
A few things answer differently because there is no node:http underneath: app.listen() returns
the app, which also answers as an http.Server; TLS is configured through express(); the body is
read for POST, PUT, PATCH and QUERY unless told otherwise; x-powered-by is off. The complete list,
with the reason for each: Differences from Express.
ultimate-expressis what Fulmine is derived from, and is the closest relative by far. It targets Express 4, keeps the v4 API surface and its deprecations. Fulmine targets Express 5 only, which removes the compatibility layer for everything v5 dropped, and is typed. If you are on Express 4, useultimate-express.hyper-expresshas a similar API but is not a drop-in replacement. It implements much of the functionality differently, which produces quirks that make switching an existing application difficult, and most Express middleware is unsupported.uwebsockets-expressis closer to a drop-in replacement, but misses a lot of the API, depends on Express by calling its methods under the hood, and does not use the native µWS router.expresson Bun benefits from Bun using µWS for its HTTP module, but performs no µWS-specific optimizations.
- Why Fulmine: what it is, what it costs, who is behind it
- Migrating: the CLI, Angular SSR, NestJS, and when Express is somebody else's dependency
- Deploying: Docker, pnpm, a private npm registry, behind a proxy
- Performance: the numbers, the tips,
profile,explainand the testing helpers - Differences from Express: what answers differently and why
- WebSockets:
app.ws()and socket.io - Compatibility: the API checklist, tested middlewares, frameworks and view engines
- Compared with the others: ultimate-express, hyper-express, Fastify, Bun, raw µWS
- Examples: one runnable file per feature
- Attribution, Contributing, Security, Changelog
The major number tracks Express, not semver. Fulmine 5.x follows Express 5. If Express 6 arrives, Fulmine goes to 6, and that is the only reason the major ever moves. Minor is for new behaviour, patch for fixes, so a breaking change can land in a minor: it is in the changelog under its own heading, but the version number alone will not warn you. If you pin, pin the minor.
Apache-2.0, with the credits and the other licences in
Attribution. Found something exploitable? Report it privately, see
SECURITY.md.