Skip to main content

Performance

RapidREST is built on uWebSockets.js rather than Express, specifically to keep the framework's own overhead low. Below is a series of load tests comparing RapidREST against equivalent Express, Fastify, and Next.js servers implementing the same Petstore-style CRUD API.

Methodology

Test Hardware:

  • CPU: AMD Ryzen 9 7950X3D
  • RAM: 64GB DDR5
  • GPU: nVidia RTX 4080
  • Disk: 2TB NVM.e Gen4
  • OS: Windows 11
  • Runtime: Docker Desktop for Windows

k6 load test, 200 virtual users for 60 seconds per endpoint, 3 runs averaged. RapidREST's results include its built-in response caching and RBAC checks enabled. These are "batteries included" numbers, not a stripped-down comparison.

Every branch (RapidREST, Express, Fastify, and Next.js) implements its Pet and User endpoints on top of the exact same ModelRoute/RepoUtils persistence, caching, and ACL code from @rapidrest/service-core. Only the HTTP layer differs: a native @Route class running under Server's uWebSockets.js transport for RapidREST, versus the same business logic wired into an Express router, a Fastify plugin, or a Next.js route handler for the other three. That's deliberate. It isolates the one variable this benchmark is actually about (HTTP transport overhead) instead of comparing four independently-written applications that might differ in a dozen unrelated ways.

Requests per second

Higher is better. RapidREST leads on every endpoint where framework overhead is the bottleneck, and is essentially tied with Express and Fastify on the two endpoints that are dominated by password-hashing cost rather than HTTP handling, which is exactly what you'd expect from an honest benchmark.

EndpointRapidRESTExpressFastifyNext.js
get_status9,6309,0259,4452,515
get_pet9,3967,5658,2632,056
find_bunnies7,3845,7546,4871,892
get_user4,9144,3544,4751,698
find_admins5,1244,2054,4911,656
find_pets2,4002,0942,2781,168
find_users1,7441,5311,639958
create_pet1,0701,0341,056705
create_user74.073.373.373.0
login74.373.374.773.7

(req/sec, higher is better. create_user and login are bottlenecked by Argon2 password hashing, not the HTTP framework, so throughput is deliberately similar across all four.)

The rest of this page goes endpoint by endpoint: what each test actually sends, what part of the stack it's exercising, and why the result looks the way it does. A dedicated section at the end breaks down how much of RapidREST's lead on the read-heavy endpoints comes from its response cache versus its RBAC engine, using the same test suite run with each turned off.

Endpoint by endpoint

get_status: the floor

GET /status

No auth header, no database, no cache. BaseStatusRoute just reads three config values and returns them. This is as close as the suite gets to measuring pure HTTP-layer overhead: routing a request in, serializing a tiny JSON body out, nothing else in between.

MetricRapidRESTExpressFastifyNext.js
Req/sec9,6309,0259,4452,515
p95 latency33 ms34 ms33 ms92 ms

RapidREST, Express and Fastify all land within about 6% of each other here. There's no business logic to differentiate them, so this endpoint mostly measures how cheap each framework's request/response path is, and uWebSockets.js's edge is real but modest when there's nothing else to do. Next.js is the outlier, since every request still goes through a full route handler resolution and a page-render-capable pipeline even for a JSON response.

get_pet: a single cached document

GET /pet/snowball

An anonymous, unauthenticated lookup of one document by its identifier. Pet is @Cache()-decorated, so after the first request the document comes from Redis rather than MongoDB for the rest of the run.

MetricRapidRESTExpressFastifyNext.js
Req/sec9,3967,5658,2632,056
p95 latency29 ms35 ms32 ms115 ms

9,396 req/sec, a 24% lead over Express and 14% over Fastify, despite all three running identical persistence and caching code underneath. Since the cache hit itself costs the same regardless of which HTTP layer is on top, the gap here is close to the cleanest read of transport overhead alone that this suite provides. See What caching and RBAC actually cost for exactly how much the cache is contributing to that 9,396 figure in absolute terms.

find_bunnies: a cached filtered query

GET /pet?category.name=bunny

Only one pet in the seeded dataset (snowball) has category.name: "bunny", so this always returns the same single-item result set. It's functionally a second cached-read test, but through the query/filter path instead of a direct ID lookup.

MetricRapidRESTExpressFastifyNext.js
Req/sec7,3845,7546,4871,892
p95 latency36 ms44 ms39 ms135 ms

7,384 req/sec vs Express's 5,754 (+28%) and Fastify's 6,487 (+14%). It's slower than get_pet across all three frameworks despite returning the same amount of data, because building and caching a query result costs more than a straight identifier lookup. The relative ordering between frameworks stays the same though, which is what you'd expect from a test whose cost is dominated by the same shared code path.

get_user: a document-level ACL check

GET /user/test_user{1-10}
Authorization: jwt <admin token>

Unlike Pet, User is protected with document-level ACLs (@Protect(..., true) in the model) rather than a single class-level record. Every request needs a JWT verified and a per-document permission check, on top of the same cache lookup get_pet does.

MetricRapidRESTExpressFastifyNext.js
Req/sec4,9144,3544,4751,698
p95 latency52 ms59 ms57 ms132 ms

4,914 req/sec, about half of get_pet's throughput. That's not a cache problem, it's an ACL problem. See the ablation numbers below, where turning RBAC off for this specific endpoint recovers nearly 18% of throughput, more than any other endpoint in the suite, because it's the only one exercising a per-document (rather than per-class) permission check on the read path. RapidREST still leads Express by 13% and Fastify by 10% here, so the extra check doesn't erase the transport-layer advantage. It just makes the endpoint slower for everyone equally.

find_admins: a filtered, authenticated query

GET /user?roles=admin
Authorization: jwt <admin token>

Same authenticated, document-protected User model as get_user, but through the query/filter path. Like find_bunnies, the seeded data means this always resolves to a single matching document (the admin account).

MetricRapidRESTExpressFastifyNext.js
Req/sec5,1244,2054,4911,656
p95 latency48 ms56 ms55 ms148 ms

5,124 req/sec vs Express's 4,205 (+22%) and Fastify's 4,491 (+14%). Interestingly this is faster than get_user's direct-ID lookup despite doing strictly more work (a filtered query plus the same ACL check). The ablation data below shows RBAC's cost on this endpoint is close to zero, which suggests the per-document check that hurts get_user doesn't apply the same way once the query planner has already narrowed the result set to documents the caller is allowed to see.

find_pets: a large, unauthenticated listing

GET /pet?page={0-9}

Unfiltered, paginated listing across the full (and growing, since create_pet is hammering the same dataset concurrently) Pet collection. This is the first endpoint in the suite where response size, not lookup cost, is doing most of the work.

MetricRapidRESTExpressFastifyNext.js
Req/sec2,4002,0942,2781,168
p95 latency96 ms115 ms101 ms205 ms
Received71.3 MB/s62.0 MB/s67.3 MB/s34.7 MB/s

RapidREST moves more JSON per second here than on any other endpoint in the suite. 2,400 req/sec vs Express's 2,094 (+15%) and Fastify's 2,278 (+5%), the smallest transport-layer gap over Fastify in the entire read-side of the suite. At this payload size, serialization and network write time dominate over routing overhead, and that cost is close to identical across all three HTTP layers.

find_users: a large, authenticated listing

GET /user?page={0-9}
Authorization: jwt <admin token>

The authenticated, paginated counterpart to find_pets, against the ~100 users the k6 setup script seeds before the run. Combines the document-level ACL check from get_user/find_admins with the large-payload cost of find_pets.

MetricRapidRESTExpressFastifyNext.js
Req/sec1,7441,5311,639958
p95 latency127 ms150 ms142 ms236 ms
Received84.0 MB/s74.0 MB/s79.0 MB/s46.0 MB/s

1,744 req/sec vs Express's 1,531 (+14%) and Fastify's 1,639 (+6%), roughly the same relative gap as find_pets. That fits: once payload size is the dominant cost, the extra per-document ACL evaluation doesn't change how much slower one HTTP layer is than another, it just lowers the ceiling for all of them equally.

create_pet: the write path

POST /pet
Authorization: jwt <admin token>
Body: {name, category, photoUrls, tags, status}

Validation, persistence, an ACL evaluation against the Pet class record, and (unlike the read endpoints) a cache invalidation rather than a cache hit.

MetricRapidRESTExpressFastifyNext.js
Req/sec1,0701,0341,056705
p95 latency250 ms245 ms249 ms355 ms

1,070 req/sec vs Express's 1,034 (+3.5%) and Fastify's 1,056 (+1.3%), by far the smallest lead in the suite. On p95 latency specifically Express actually edges ahead (245ms vs RapidREST's 250ms). Writes hit the database and invalidate the cache on every single request regardless of HTTP layer, so there's much less room for transport overhead to show up in the total. Most of the cost here is genuinely shared, not framework-specific. The ablation numbers below tell a more interesting story for this endpoint than the cross-framework comparison does: disabling RBAC alone buys back 16% of throughput, more than caching does for a write.

create_user / login: where the framework doesn't matter

POST /user Body: {name, password, firstName, lastName, email, phone}
GET /user/login Authorization: basic <name:password>

Both endpoints hash a password with Argon2: create_user on the way in, login to verify HTTP Basic credentials against the stored hash. Argon2 is deliberately expensive (that's the point of a password hashing algorithm), and it dominates the request cost so completely that it doesn't matter which HTTP framework is asking for it.

MetricRapidRESTExpressFastifyNext.js
create_user req/sec74.073.373.373.0
create_user p952,750 ms2,783 ms2,773 ms2,790 ms
login req/sec74.373.374.773.7
login p952,737 ms2,757 ms2,733 ms2,757 ms

~74 req/sec across all four frameworks, within 2% of each other on both endpoints, with p95 latencies around 2.7 seconds. That's essentially the Argon2 cost floor plus noise. These two rows exist in the suite specifically because they don't favor RapidREST: they're the honesty check on every other result on this page. If RapidREST's numbers were inflated by something other than real transport overhead, this is where it would show.

What caching and RBAC actually cost

The comparison above holds caching and RBAC constant (both on) across all four frameworks. A separate set of runs, RapidREST only, toggles each off to show how much of its own numbers come from those two features rather than from uWebSockets.js itself.

EndpointCache + RBAC (baseline)RBAC offCache + RBAC off
get_status9,6309,6019,189
get_pet9,3969,449668
find_bunnies7,3848,316751
get_user4,9145,7963,039
find_admins5,1245,0432,924
find_pets2,4002,5951,346
find_users1,7441,7831,011
create_pet1,0701,2401,228
create_user74.074.073.7
login74.373.372.7

(req/sec. "RBAC off" keeps caching enabled and only disables rbac:enabled. "Cache + RBAC off" disables both.)

Two endpoints make the pattern clearest. get_pet is almost entirely a caching story:

get_user is the opposite case: the one endpoint where RBAC itself has a real, measurable cost, because User is protected at the document level rather than the class level.

A few things fall out of the full table that aren't visible in the framework comparison:

  • Caching is doing almost all of the work on read-heavy endpoints, not RBAC. Turning off caching alone drops get_pet by 93% and find_bunnies by 91%. Turning off only RBAC on those same endpoints doesn't cost anything measurable. If anything the numbers tick up slightly, within run-to-run noise.
  • RBAC's cost is concentrated where it actually does more work: document-level checks and writes. get_user (document-level @Protect) gets 18% faster with RBAC off, the largest RBAC-attributable delta in the suite. create_pet gets 16% faster, since every write needs a fresh permission check that a cached read can otherwise skip. Class-level-protected reads (get_pet, find_bunnies, find_pets) show no meaningful RBAC cost at all.
  • get_status barely moves (9,630 to 9,189, about 5%) even with both features off. That's the expected result for an endpoint that never touches the database or the cache in the first place. The small dip is most likely fixed per-request overhead from the ACL and cache subsystems being initialized on the server, not per-call cost.
  • create_user and login don't move at all, confirming the earlier point independently: Argon2 is the entire cost of those two endpoints, and neither caching nor RBAC has anything to do with it.

Both rbac:enabled and the cache datastore are ordinary config (see Authorization and the @Cache() decorator in Models & Persistence). If you want to know what either one costs for your own workload, this is the same experiment you'd run to find out.

Why Next.js trails here

This isn't really an apples-to-apples "which is the faster HTTP framework" comparison for the Next.js numbers. A Next.js route handler in this test is doing a full server-side page render per request, which is inherently more work than returning JSON. It's included because RapidREST's SSR React pages are a first-class feature too, and this shows the cost of choosing to render server-side isn't unusually high relative to a mainstream SSR framework.

Try it yourself

The benchmark suite lives in the petstore_example repository in the k6-tests folder. There exists a separate branch for each of the frameworks tested (master for RapidREST, express for Express, fastify for Fastify, and nextjs for Next.js). If your workload looks different from the Petstore example, we'd rather you measure it yourself than take our word for it.