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.
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.
| Endpoint | RapidREST | Express | Fastify | Next.js |
|---|---|---|---|---|
get_status | 9,630 | 9,025 | 9,445 | 2,515 |
get_pet | 9,396 | 7,565 | 8,263 | 2,056 |
find_bunnies | 7,384 | 5,754 | 6,487 | 1,892 |
get_user | 4,914 | 4,354 | 4,475 | 1,698 |
find_admins | 5,124 | 4,205 | 4,491 | 1,656 |
find_pets | 2,400 | 2,094 | 2,278 | 1,168 |
find_users | 1,744 | 1,531 | 1,639 | 958 |
create_pet | 1,070 | 1,034 | 1,056 | 705 |
create_user | 74.0 | 73.3 | 73.3 | 73.0 |
login | 74.3 | 73.3 | 74.7 | 73.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.
| Metric | RapidREST | Express | Fastify | Next.js |
|---|---|---|---|---|
| Req/sec | 9,630 | 9,025 | 9,445 | 2,515 |
| p95 latency | 33 ms | 34 ms | 33 ms | 92 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.
| Metric | RapidREST | Express | Fastify | Next.js |
|---|---|---|---|---|
| Req/sec | 9,396 | 7,565 | 8,263 | 2,056 |
| p95 latency | 29 ms | 35 ms | 32 ms | 115 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.
| Metric | RapidREST | Express | Fastify | Next.js |
|---|---|---|---|---|
| Req/sec | 7,384 | 5,754 | 6,487 | 1,892 |
| p95 latency | 36 ms | 44 ms | 39 ms | 135 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.
| Metric | RapidREST | Express | Fastify | Next.js |
|---|---|---|---|---|
| Req/sec | 4,914 | 4,354 | 4,475 | 1,698 |
| p95 latency | 52 ms | 59 ms | 57 ms | 132 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).
| Metric | RapidREST | Express | Fastify | Next.js |
|---|---|---|---|---|
| Req/sec | 5,124 | 4,205 | 4,491 | 1,656 |
| p95 latency | 48 ms | 56 ms | 55 ms | 148 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.
| Metric | RapidREST | Express | Fastify | Next.js |
|---|---|---|---|---|
| Req/sec | 2,400 | 2,094 | 2,278 | 1,168 |
| p95 latency | 96 ms | 115 ms | 101 ms | 205 ms |
| Received | 71.3 MB/s | 62.0 MB/s | 67.3 MB/s | 34.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.
| Metric | RapidREST | Express | Fastify | Next.js |
|---|---|---|---|---|
| Req/sec | 1,744 | 1,531 | 1,639 | 958 |
| p95 latency | 127 ms | 150 ms | 142 ms | 236 ms |
| Received | 84.0 MB/s | 74.0 MB/s | 79.0 MB/s | 46.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.
| Metric | RapidREST | Express | Fastify | Next.js |
|---|---|---|---|---|
| Req/sec | 1,070 | 1,034 | 1,056 | 705 |
| p95 latency | 250 ms | 245 ms | 249 ms | 355 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.
| Metric | RapidREST | Express | Fastify | Next.js |
|---|---|---|---|---|
create_user req/sec | 74.0 | 73.3 | 73.3 | 73.0 |
create_user p95 | 2,750 ms | 2,783 ms | 2,773 ms | 2,790 ms |
login req/sec | 74.3 | 73.3 | 74.7 | 73.7 |
login p95 | 2,737 ms | 2,757 ms | 2,733 ms | 2,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.
| Endpoint | Cache + RBAC (baseline) | RBAC off | Cache + RBAC off |
|---|---|---|---|
get_status | 9,630 | 9,601 | 9,189 |
get_pet | 9,396 | 9,449 | 668 |
find_bunnies | 7,384 | 8,316 | 751 |
get_user | 4,914 | 5,796 | 3,039 |
find_admins | 5,124 | 5,043 | 2,924 |
find_pets | 2,400 | 2,595 | 1,346 |
find_users | 1,744 | 1,783 | 1,011 |
create_pet | 1,070 | 1,240 | 1,228 |
create_user | 74.0 | 74.0 | 73.7 |
login | 74.3 | 73.3 | 72.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_petby 93% andfind_bunniesby 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_petgets 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_statusbarely 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_userandlogindon'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.