Performance
Fulmine is faster than Express where the framework itself is doing the work, and the same speed where it is not. Both halves of that sentence matter, so here is the honest version.
Where it is clearly faster. Routing and dispatch, request shapes with params and query strings, connection handling. The spreads below are the 10th to 90th percentile of the last 20 CI runs, 21 October 2026 to 27 October 2026, on Node 26: each run lands on the runner GitHub gives it, so a spread says as much about the runners as about the code. Plain routing lands at 1.3x to 3.0x: hello-world 1.1x to 1.4x, an API endpoint with params and a query 2.0x to 3.2x, a query string 1.4x to 1.9x. Other request shapes: five route shapes served by one process 1.6x to 2.3x, nested routers 1.3x to 1.6x, a urlencoded body 2.5x to 4.1x, a thousand concurrent connections 2.0x to 2.2x. Route tables are where the native router shows: a thousand routes 9.2x to 11.7x, with a parameter in every one of them 9.5x to 15.5x, a parameterised route in a mounted router 5.3x to 7.2x. Those routes go to µWS's own router instead of being scanned, so the gap grows with the table instead of shrinking. Even the chain of 100 middlewares, for a long time the one routing row that stayed even because its cost is calling application code a hundred times, sits at 1.7x to 2.1x after the per-request work of August 2026. The middleware that ships with Fulmine is its own code, measured against the packages Express uses: express.static 4.7x to 5.9x on a 4 KiB file, where a small file comes from memory and is checked against the disk on every request, and 1.8x to 2.4x on 250 KiB; a usual stack of helmet, cors, cookie-parser, express.json() and morgan 1.8x to 2.6x. The numbers are refreshed from CI with npm run speedups --prefix site.
Where it is a wash. Any request whose cost is dominated by work both servers hand to the same library. A 512 KiB JSON body is JSON.parse, a gzipped response is zlib, a hashed upload is OpenSSL, a 5 MiB stream is memory bandwidth. On those the ratio is capped by arithmetic somewhere between 1.0x and 1.6x, depending on how much of the request is the shared work, and no amount of effort on either server moves it: compression() on a 76 KiB response lands at 1.2x to 1.3x. The benchmark labels those rows rather than quietly publishing them as if the two were equivalent.
Two things worth knowing before comparing numbers with anyone:
- Node 24 moved the baseline. Express got roughly 3x faster on the routing benchmarks between Node 22 and Node 24, while a µWS-based server barely moved, because the gain came from
node:http. Any comparison published before mid-2026 overstates the current gap. - Ratios are not portable across runs. GitHub's runners vary enough that the same code measures 15k or 28k req/sec on the same row. Only compare figures produced in the same run.
Every scenario, so none of them is picked: not one run, which a runner can move by half, but the median of the last 20 CI runs, 21 October 2026 to 27 October 2026 and the lowest and highest of them, Fulmine against Express on the same runner each time. A scenario added lately has fewer runs behind it, the last column says how many. Each run is also posted as a comment on its commit or pull request, and benchmark/README.md says how to run it yourself.
| scenario | median | lowest to highest | runs |
|---|---|---|---|
| routing | |||
routes-1000-params | 10.7x | 7.80x to 17.6x | 20 |
routes-1000 | 10.2x | 7.44x to 13.8x | 20 |
router-mounted-params | 6.91x | 4.21x to 8.24x | 20 |
fallback-scan | 5.56x | 2.99x to 6.21x | 20 |
arena-baseline-post | 3.13x | 1.66x to 3.91x | 20 |
api-endpoint | 2.76x | 1.58x to 3.43x | 20 |
json-list | 2.44x | 1.51x to 3.02x | 20 |
api-mixed | 2.12x | 1.34x to 2.31x | 20 |
middlewares-100 | 1.84x | 1.65x to 2.38x | 20 |
router-list-and-item | 1.75x | 1.31x to 2.00x | 20 |
arena-baseline-get | 1.70x | 1.32x to 2.00x | 20 |
query-string | 1.65x | 1.23x to 1.92x | 20 |
nested-routers | 1.51x | 1.28x to 1.68x | 20 |
json-list-gzip | 1.48x | 1.24x to 1.60x | 20 |
hello-world | 1.30x | 1.05x to 1.45x | 20 |
| middlewares | |||
express-static-small | 5.65x | 3.90x to 6.05x | 20 |
body-urlencoded | 3.70x | 1.89x to 4.36x | 20 |
body-json-echo | 3.41x | 1.61x to 4.08x | 20 |
body-json-4kb | 3.28x | 1.78x to 3.64x | 20 |
json-list-identity | 2.61x | 1.84x to 2.89x | 17 |
realistic-stack | 2.36x | 1.51x to 2.90x | 20 |
express-static | 2.24x | 1.71x to 2.38x | 20 |
compression-file | 1.24x | 1.13x to 1.30x | 20 |
body-json-512kb | 1.18x | 0.91x to 1.64x | 20 |
| connections | |||
high-concurrency | 2.12x | 2.00x to 2.59x | 20 |
| engines | |||
art | 2.33x | 1.59x to 2.77x | 20 |
| streaming | |||
readable-hash-4mb | 1.03x | 0.96x to 1.14x | 20 |
writable-with-content-length | 1.01x | 0.97x to 1.05x | 20 |
writable-no-content-length | 1.01x | 0.97x to 1.07x | 20 |
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:
- HttpArena: thirty profiles on 64-core dedicated hardware, same conditions for every entry, rerun whenever one of them changes. The link lands filtered on the JavaScript entries. No figures are copied here on purpose: the board is the current one and this page would not be.
- web-frameworks: in the published round, ranked with the other sixty-odd JavaScript entries on their own hardware. Same rule as above, no figures copied here.
- Sharkbench: ranked with the JavaScript entries on Node.js and Bun. At the time of writing Fulmine is first on requests per second; memory and latency stability are on the board too, read them there.
More to come as their maintainers take the entries in.
Performance tips
Where the speed comes from, before the rules that govern it. Express finds a route by walking its stack and testing each layer against the path. Fulmine hands every route it can to µWS's own router, which matches in C++, and works out at listen() which layers stand in front of each one, so arriving at a handler costs no matching at all:
Express Fulmine
GET /users/42 GET /users/42
| |
v v
+--------------+ +------------------+
| layer 1 | path? no | µWS router | one match, in C++,
| layer 2 | path? no | /users/:id | against every path
| ... | +--------+---------+ registered
| layer 214 | path? yes -+ |
+--------------+ | v
a test per layer, | +------------------+
every request | | the chain, known | the layers in front,
| | since listen() | in order, no matching
v +--------+---------+
handler |
v
handlerThat is the whole difference on a large route table: the scan grows with the table and the match does not, which is why a thousand routes measure 10x and a handful measure 2x, in the median.
Two more things happen on the way in, and npx fulmine.js profile will tell you which of them your routes get:
a request arriving at a compiled route
µWS match ──► the chain ──────────────────────────► handler ──► response
| |
| a body parser is stepped over | the Readable is not
| when the request declared no | built unless something
| body and the verb reads none | asks the body for one
| |
| the headers are not copied out | the two internal
| of µWS when the analysis proved | listeners are written
| nothing in the chain reads one | into the event map
v v
work that does not happen work that is not prepared
and when the handler is simple enough to be read at registration time, none of the
above happens either: µWS answers from a response written once, at startup- Fulmine tries to optimize routing as much as possible, but it's only possible if:
- the path is a plain string, or its parameters are whole segments:
/users/:idand/a/:b/c/:dqualify,/flights/:from-:todoes not, and neither does a*splator a{}group. Routing is case-insensitive by default, as in Express; a request in the registered case is still served natively, any other case takes the ordinary path, and a route whose overlap with an earlier one leans on a cased literal goes the ordinary way for every request. That last one is worth knowing about:app.set("case sensitive routing", true)is Express's own setting, and with it/Users/listno longer overlaps/users/:id, so both are matched by µWS instead of one of them falling back. - inside a mounted router, nothing registered after the route in that router could match the same path.
/orders/:id,/orders/:id/itemsand/invoices/:idare all optimized together, since no request reaches two of them./users/:idfollowed by/users/meis not: Express answers/users/mewith the first of the two and the native router would answer it with the second, so both go the ordinary way.
Optimized routes are faster than normal routes, as they're using native uWS router and have pre-calculated path: how much depends on the table, see the numbers at the top of this page.
On top of that, a handler simple enough to be read at registration time is compiled into a uWS declarative response and answered natively, without entering JavaScript at all. That needs the route to have nothing in front of it, not a middleware and not a Router it was mounted under, and a single handler that only calls res.status, res.set, res.type, res.append, res.send, res.json, res.sendStatus or res.end with literal arguments. res.set takes a pair or a whole object of them, and res.type takes what it takes anywhere, since a media type is a lookup on a literal. Anything else, a variable, a call, an if, falls back to ordinary routing. return res.send(...) compiles, res.send(...) does too, and so does an object or an array of literals however deeply nested. Mounting a Router costs only this: the routes inside one are still registered on the native uWS router with their full path, and are as fast as any other optimized route.
Three things are refused whatever the handler does, and all three are the same fact: a response written at startup cannot read the request.
- one that would carry an
ETagor aLast-Modified, since it could never answer with the304 Not Modifiedthat the validator invites.etagis on by default, soapp.set("etag", false)is what puts an ordinary route on this path. - one whose route captures,
/users/:id, or whose body copies a piece of the request,res.send(req.query.q), unlessapp.set("declarative request values", true). uWS reads the request its own way, not as Express does: a parameter that cannot be decoded is a400in Express and nothing runs here to raise it, a parameter that can goes out undecoded, a query key that is repeated gives the first value where Express gives an array, and one that is missing gives nothing where Express writesundefinedinto a longer body. Turn it on where the values are plain, an id in a path, and the handler does nothing but write them back. - a
204,205or304, since the body would go out with the status and a client frames those as bodiless whatever it reads.
Two things then follow from the response being static:
- it carries a
Content-Lengthwhile its body is literal all the way through. A body with a piece taken from the request, underdeclarative request values, has no length until the request arrives, so that one is framed asTransfer-Encoding: chunked. uWS writes the framing either way, which is why neither header can be set by hand. - it answers
Connection: keep-aliveeven to a request that asked forConnection: close. The connection is still closed, since uWS decides that itself, and a client that asked to close is closing anyway.
app.set("declarative responses", false) turns the whole thing off if you would rather have Express's exact framing than the speed.
None of that is guesswork you have to do from the outside. listen() decides it all, and npx fulmine.js profile prints what it decided:
npx fulmine.js profile # the file "main" or the start script points at
npx fulmine.js profile server.js # or name it7 route(s), 4 answered by µWS itself, 1 of them without running any javascript
GET /hello µWS /hello (compiled to a response, copies no request headers, reads no query)
GET /api/health µWS /api/health (3 in front of it in its chain)
GET /users/:id µWS /users/:x (1 in front of it in its chain, copies no request headers, reads no query)
GET /orders µWS /orders (1 in front of it in its chain, copies no request headers, reads no query)
GET /:anything router: something before it in the same router overlaps its paths
GET /after-the-param router: the parameter route /:anything is written before it
SEARCH /odd router: µWS does not serve SEARCH
2 middleware in front of them. Every request walks the ones whose path it matches,
and a compiled route walks them from a list worked out at startup rather than by matching.
What this adds up to
4 of 7 route(s) matched by µWS in C++
1 answered from a response written at startup, running no javascript
3 copy no request headers, 3 read no query: the analysis proved nothing asks for them
layers in front of a compiled handler: 0 at least, 3 at most, 1.3 on average
Worth changing, if these are routes that carry traffic
GET /:anything
something registered earlier answers some of the same paths, so the chain that would reach this route
cannot be worked out ahead of time. Narrowing the earlier path, or moving this one above it, frees it.
GET /after-the-param
write it above /:anything. Express answers whichever matches first, so the order is already what decides,
and with the literal first µWS can match it in C++ as well.It loads the application with listen() replaced by the half that compiles the routes, so nothing binds a port and the listen callback does not run: profiling a running service does not start a second copy of it. There is no score, on purpose. A percentage of routes is not a percentage of traffic, and an application with a thousand cold routes and one hot one that fell back would score well and serve badly.
The same verdicts are readable from a test, which is where they belong for the routes that carry the traffic. A route stays on the fast path only while it stays eligible, and nothing complains when it stops: the answer is still correct, only slower, and the commit that did it is found weeks later.
const { expectNative, expectDeclarative, routeReport } = require("fulmine.js").testing;
expectNative(app, ["/api/*", "GET /health"]); // throws, naming the route and the reason
expectDeclarative(app, "/health"); // the step past native: no javascript at all
routeReport(app); // the whole list, to assert on however you likeA path is written as it was registered, "/users/:id" and not "/users/7", and a trailing * names everything under a prefix. A pattern that matches no route throws too, so a misspelled path fails instead of passing quietly. The application does not need to be listening. Runnable: examples/fast-routes.js.
A route can stay native and still slow down request by request, because most of what makes this fast is work that does not happen: the request is not turned into a Readable, the response is not turned into a Writable, req.headers is not folded into an object, the query is not parsed, no socket stand-in is allocated. A middleware that reads req.headers.host puts one of those back on every request, and nothing fails. expectLazy is the assertion for that half, asked from inside a handler:
const { workReport, expectLazy } = require("fulmine.js").testing;
app.post("/items", (req, res) => {
expectLazy(req, res, { allow: ["body"] }); // throws naming what else was built
workReport(req, res); // { native, declarative, headers, query, body, requestStream, ... }
res.json(req.body);
});Asking costs a property read: every field is state the framework already keeps, nothing is counted or wrapped to make it readable.
npx fulmine.js explain /api/items/:id answers the other question, the one about a single endpoint rather than about the table: how it is matched, what is copied out of the request, what runs and what each layer costs the route.
GET /api/items/:id
route native (µWS matched /api/items/:x and dispatched by method)
headers copied out of µWS (something in the chain reads them)
query parsed when something asks for it
chain 2 layer(s), 1 mounted layer(s) in front of it
logger readable at registration, reads the query
(anonymous) readable at registration
body read for POST, PUT, PATCH and QUERY, when one is declaredThe same verdict reaches the browser, per request, with express.serverTiming():
Server-Timing: route;desc="native", hdr;desc="not copied", db;dur=3.62, total;dur=4.66route;desc="native" means µWS matched the path in C++ and the chain was worked out at startup; route;desc="router" means this one was matched here, layer by layer. res.timing(name, ms, desc) and res.time(name, fn) add marks of your own, and fn may return a promise. The duration ends where the header does, since Server-Timing goes out with the head. A route compiled into a response never enters JavaScript, so nothing times it: npx fulmine.js profile is where those are counted. The same middleware writes work;desc="headers, query" when the request built something a fast one does not, the fields expectLazy checks, and writes nothing when it built none of them. serverTiming({ work: false }) turns that off. Runnable: examples/server-timing.js.
Do not use external
serve-staticmodule. Instead use built-inexpress.static()middleware, which is optimized for Fulmine. If your build already writes.brand.gzfiles next to the originals,express.static(dir, { preCompressed: true })serves those to the clients that accept them, so nothing is compressed at request time and a fraction of the bytes goes out: on a 4KB script with a brotli twin, 12 times fewer. It costs no more than serving the file itself, onestatper request, because the twin is looked for before the file and its ownstatis the only one the request needs. A type that is already compressed, a woff2 or a webp, is not looked up at all, and which twins a path has is remembered for a second:{ preCompressed: { cache: false } }asks the disk every time,{ preCompressed: { cache: "5s" } }sets the window. Only their presence is remembered, never their size or mtime, so nothing is ever described by a stale number.Vary: Accept-Encodingis sent whether or not a twin is found, the content type stays the one the requested name implies, and each variant carries its own ETag. Runnable:examples/static-precompressed.js.Do not use
body-parsermodule. Instead use built-inexpress.text(),express.json()etc.Do not use the
compressionmodule.express.compression()takes the same options and decides the same way, and it served about 50% more requests per second on an 8KB JSON body here, gzip and brotli alike. A response that arrives whole, which is everyres.send()andres.json(), is compressed in one call rather than through a transform stream and goes out with aContent-Lengthinstead of chunked; a response written in pieces still streams. The bytes are the same bytes either way.
// the compression module's options, unchanged: threshold, filter, level, brotli, enforceEncoding
app.use(express.compression({ threshold: 1024 }));One option is Fulmine's own, encodings: the list of what the middleware may answer with, out of "br", "gzip" and "deflate". What is not named is never used, however the client ranks it, and an uncompressed answer is always on offer. It exists because the preferred encoding is a cost decision, not only a size one: brotli compresses smaller but what it costs per response depends on the machine, and on a CPU where it runs expensive encodings: ["gzip"] buys the cheaper call for every client that accepts both.
// answer gzip even to a client that also accepts br
app.use(express.compression({ level: 1, encodings: ["gzip"] }));Runnable: examples/compression.js.
If a route answers with a JSON shape you know in advance, express-fast-json-stringify compiles that shape into a serializer and
res.fastJson()replacesres.json().JSON.stringify()has to walk an object it knows nothing about; a compiled serializer does not. It is worth reaching for, and a CPU profile says why: on a route answering 3.6KB of JSON, serialising it is about 25% of the time that is not spent waiting, ahead of the ETag at 19% and of everything the framework does to route the request and build its request and response objects.Do not set
body methodsto read body of requests with GET method or other methods that don't need a body. Reading body makes endpoint about 15% slower.app.set("etag", false)is worth about 8% on small responses, measured on both Fulmine and Express, which pay it almost identically. It is the single biggest thing an ordinary route does: in a CPU profile of one, hashing the body and building the tag are about 21% of the time that is not spent waiting, more than writing the headers and more than building the request and the response together. Know what you are trading: without an ETag a client cannot make a conditional request, so there are no304 Not Modifiedreplies and every response is downloaded in full. On anything cacheable the bandwidth a 304 saves is usually worth far more than the 8%. It is left on by default for that reason. Turn it off for an API whose responses are never revalidated, and note that it is the same setting that decides whether a simple route is compiled into a native response, above.By default, Fulmine creates 1 (or 0 if your CPU has only 1 core) child thread to improve performance of reading files, started on the first small file the app reads. You can change this number by setting
threadsto a different number inexpress(), or set to 0 to disable thread pool (express({ threads: 0 })). Threads come from one pool shared between all express() instances: each app uses its ownthreadscount, and the pool grows to the largest. Using more threads will not necessarily improve performance. Sometimes not using threads at all is faster, so measure both.One node process uses one core, and this is the setting that changes it.
express({ cluster: "auto" })forks one process per core and each of them binds the same port with µWS's shared flag, which isSO_REUSEPORT: every process has its own listening socket and the kernel decides which one gets each connection. Node's ownclustercannot do that with anhttp.Server, so the primary holds the socket and passes each accepted connection to a worker over IPC; here the primary is not in the path at all. On a 16-core machine that is close to 16 times the throughput, and no other setting comes near it. On one core"auto"forks nothing and the one process serves, since a primary that only supervises one worker costs memory and a container restarts a dead process anyway;cluster: 1still forks one worker under a primary.
// "auto" is one worker per usable core: the cgroup quota is read first, so a 2-core container
// on a 64-core host forks 2 and not 64. A number instead of "auto" says how many.
const app = express({ cluster: "auto" });
app.get("/", (req, res) => res.send("hello"));
// The whole file runs again in every worker, which is how cluster works: the code above this
// line runs once per process. The primary only forks, so the callback runs once per worker too,
// and a worker that dies is replaced.
app.listen(3000, () => console.log(`worker ${process.pid} listening`));Each worker opens its own database pool, so a pool sized for one process opens that many connections per core. app.cluster.share(40) is this worker's part of 40, the shares adding up to 40, and app.cluster also says how many workers serve, which one this is, and serves, false in the primary, where no pool should be opened: a pool per worker. Anything held per process is now held per worker: an in-memory cache, a rate-limit counter, a session store or a Map of connected sockets is not shared, and needs Redis or something like it to be. app.close() in the primary stops the workers, and a SIGTERM or SIGINT that reaches only the primary, which is what a container sends, is passed on to them. Runnable: examples/cluster.js.
app.set("connection headers", false)stopsConnection: keep-aliveandKeep-Alive: timeout=10going out on every response. Express sends both, so Fulmine sends both by default. An HTTP/1.1 connection stays open without being told, so to an HTTP/1.1 client the two headers say nothing it does not know already, and they cost 48 bytes and two header writes per response. A request that asked forConnection: closestill getsConnection: close, and the connection is closed. Turn it off for an API behind a proxy or serving HTTP/1.1 clients; keep the default where a client or a proxy relies on the header to keep the connection open. Worth 2% to 3.5% here on a route that is not compiled, plus the bytes.