DEV Community

Nigro Simone
Nigro Simone

Posted on Edited on

Fulmine.js vs Express vs Fastify on HttpArena: 3x to 35x, measured by somebody else

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)