Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ coverage
node_modules
__tests__
*.test.js
dist
dist
benchmarks
79 changes: 79 additions & 0 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: 'Benchmarks'

# Perf numbers are noisy on shared runners, so benchmarks never gate PRs. They run on each
# published release (and on demand) to refresh the Benchmarks section of the README.
on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: 'Version label to record (defaults to package.json version)'
required: false
type: string
commit:
description: 'Commit the refreshed README back to the default branch'
required: false
default: true
type: boolean

permissions:
contents: write

jobs:
benchmark:
name: 'Run benchmarks (Node 20)'
runs-on: ubuntu-latest
steps:
- name: Checkout default branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Resolve version label
id: ver
run: |
RAW="${{ github.event.release.tag_name || inputs.version }}"
echo "value=${RAW#v}" >> "$GITHUB_OUTPUT"

- name: Install benchmark dependencies
working-directory: benchmarks
run: npm ci

- name: Run benchmarks and refresh README
working-directory: benchmarks
env:
LAMBDA_API_VERSION: ${{ steps.ver.outputs.value }}
run: node run.js --md results/RESULTS.md --json results/raw.json --update-readme

# Keep the committed README Prettier-clean so the push doesn't break main's CI.
- name: Format README
run: npx --yes prettier@2 --write README.md

- name: Upload raw results
uses: actions/upload-artifact@v4
with:
name: benchmark-results
path: benchmarks/results/

- name: Add results to job summary
run: cat benchmarks/results/RESULTS.md >> "$GITHUB_STEP_SUMMARY"

- name: Commit refreshed README
if: ${{ github.event_name == 'release' || inputs.commit }}
run: |
if git diff --quiet -- README.md; then
echo 'README.md unchanged — nothing to commit'
exit 0
fi
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
git add README.md
git commit -m 'docs: update benchmark results [skip ci]'
git push origin HEAD:${{ github.event.repository.default_branch }}
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ coverage
node_modules
__tests__
*.test.js
dist
dist
benchmarks
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1523,3 +1523,73 @@ Contributions, ideas and bug reports are welcome and greatly appreciated. Please
## Are you using Lambda API?

If you're using Lambda API and finding it useful, hit me up on [Twitter](https://twitter.com/jeremy_daly) or email me at contact[at]jeremydaly.com. I'd love to hear your stories, ideas, and even your complaints!

<!-- BENCHMARKS:START -->

## Benchmarks

In-process micro-benchmarks of lambda-api against other AWS Lambda web frameworks. The numbers measure **framework overhead only** (event → route → middleware → response, in a single Node VM) — not end-to-end Lambda timings. Absolute values vary by machine, so compare the **relative** ranking rather than the raw ops/sec. See [`benchmarks/`](./benchmarks) for the methodology and how to reproduce.

_Generated 2026-06-26 21:24:41 UTC · lambda-api v0.0.0-development · Node 20.19.5 · Apple M4 Max (16 cores) · darwin/arm64_

#### API Gateway REST (v1) — throughput (ops/sec, higher is better)

| Framework | get-json | path-param | post-json | routing-50 | not-found |
| ------------------------------ | --------- | ---------- | --------- | ---------- | --------- |
| baseline | 5,654,874 | 5,513,012 | 2,560,533 | 4,493,436 | 4,620,989 |
| middy `6.4.5` | 1,056,927 | 811,301 | 62,540 | 433,185 | 155,744 |
| lambda-api `0.0.0-development` | 217,700 | 202,224 | 194,819 | 199,565 | 91,296 |
| hono `4.12.27` | 58,370 | 57,559 | 38,469 | 60,441 | 61,732 |
| fastify `5.8.5` | 51,240 | 52,873 | 13,598 | 56,465 | 48,915 |
| serverless-express `4.22.2` | 26,732 | 28,082 | 15,997 | 26,940 | 28,643 |

<details><summary>API Gateway REST (v1) — latency (avg / p99, µs, lower is better)</summary>

| Framework | get-json | path-param | post-json | routing-50 | not-found |
| ------------------------------ | ----------- | ----------- | ----------- | ----------- | ----------- |
| baseline | 0.18 / 0.23 | 0.18 / 0.25 | 0.39 / 0.46 | 0.22 / 0.28 | 0.22 / 0.27 |
| middy `6.4.5` | 0.95 / 2.29 | 1.23 / 1.41 | 16.0 / 16.3 | 2.31 / 2.45 | 6.42 / 6.56 |
| lambda-api `0.0.0-development` | 4.59 / 4.74 | 4.95 / 5.14 | 5.13 / 5.28 | 5.01 / 5.63 | 11.0 / 11.0 |
| hono `4.12.27` | 17.1 / 69.7 | 17.4 / 18.7 | 26.0 / 30.2 | 16.5 / 17.3 | 16.2 / 18.1 |
| fastify `5.8.5` | 19.5 / 95.6 | 18.9 / 20.4 | 73.5 / 152 | 17.7 / 23.4 | 20.4 / 20.4 |
| serverless-express `4.22.2` | 37.4 / 118 | 35.6 / 36.8 | 62.5 / 195 | 37.1 / 99.0 | 34.9 / 36.7 |

</details>

#### API Gateway HTTP (v2) — throughput (ops/sec, higher is better)

| Framework | get-json | path-param | post-json | routing-50 | not-found |
| ------------------------------ | --------- | ---------- | --------- | ---------- | --------- |
| baseline | 4,830,598 | 4,424,847 | 2,237,039 | 3,946,604 | 3,994,042 |
| middy `6.4.5` | 1,032,952 | 791,846 | 55,802 | 372,581 | 148,189 |
| lambda-api `0.0.0-development` | 201,969 | 187,395 | 183,887 | 195,975 | 88,721 |
| hono `4.12.27` | 70,859 | 62,001 | 40,860 | 65,895 | 67,978 |
| fastify `5.8.5` | 45,114 | 38,232 | 12,973 | 45,687 | 49,536 |
| serverless-express `4.22.2` | 26,837 | 27,753 | 17,533 | 25,565 | 29,008 |

<details><summary>API Gateway HTTP (v2) — latency (avg / p99, µs, lower is better)</summary>

| Framework | get-json | path-param | post-json | routing-50 | not-found |
| ------------------------------ | ----------- | ----------- | ----------- | ----------- | ----------- |
| baseline | 0.21 / 0.47 | 0.23 / 0.44 | 0.45 / 0.71 | 0.25 / 0.48 | 0.25 / 0.51 |
| middy `6.4.5` | 0.97 / 1.17 | 1.26 / 1.38 | 17.9 / 21.8 | 2.68 / 3.70 | 6.75 / 7.37 |
| lambda-api `0.0.0-development` | 4.95 / 5.32 | 5.34 / 5.57 | 5.44 / 5.87 | 5.10 / 5.30 | 11.3 / 11.3 |
| hono `4.12.27` | 14.1 / 14.2 | 16.1 / 19.9 | 24.5 / 26.9 | 15.2 / 16.4 | 14.7 / 15.2 |
| fastify `5.8.5` | 22.2 / 20.0 | 26.2 / 30.9 | 77.1 / 248 | 21.9 / 30.3 | 20.2 / 25.6 |
| serverless-express `4.22.2` | 37.3 / 46.0 | 36.0 / 38.1 | 57.0 / 144 | 39.1 / 40.9 | 34.5 / 35.5 |

</details>

#### History

Throughput for the `get-json` scenario on V2 events (ops/sec), one row per release:

<!-- BENCHMARKS:HISTORY:START -->

| version | date | node | baseline | lambda-api | fastify | hono | middy | express |
| ----------------- | ---------- | ------- | --------- | ---------- | ------- | ------ | --------- | ------- |
| 0.0.0-development | 2026-06-26 | 20.19.5 | 4,830,598 | 201,969 | 45,114 | 70,859 | 1,032,952 | 26,837 |

<!-- BENCHMARKS:HISTORY:END -->

<!-- BENCHMARKS:END -->
3 changes: 3 additions & 0 deletions benchmarks/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
results/*.json
results/*.md
133 changes: 133 additions & 0 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# lambda-api benchmarks

A repeatable comparison of **lambda-api** against other popular frameworks running on AWS
Lambda.

This is an isolated package — its (heavy) comparison-framework dependencies live here and
never touch the zero-dependency root library. It is excluded from the published npm tarball
by the root `package.json` `files` whitelist.

```bash
cd benchmarks
npm install
npm run bench # print results to stdout
npm run bench:md # also write results/RESULTS.md
npm run bench:release # write md + json and refresh the README Benchmarks section
```

## What this measures

Each framework's compiled `aws-lambda` handler is invoked **in-process** — in the same Node
VM — with identical synthetic API Gateway events. We measure the **framework overhead** of a
request: parse the event → match a route → run middleware → serialize the response. That is
the work the maintainer clocked at ~0.68 ms per request, and the thing this suite resolves.

For every `(framework × scenario × event format)` cell, the handler is first run once and its
response is checked by a **correctness gate** (`lib/validate.js`) — status code, and body
fields where applicable. Only cells that pass are timed, so we never publish a number for a
framework that is silently 404-ing, throwing, or returning the wrong shape.

Measurement uses [mitata](https://github.com/evanwashere/mitata), which calibrates clock
overhead, warms up the JIT, and auto-tunes iteration counts for sub-microsecond accuracy.

## Why in-process, and not LocalStack or a real Lambda deploy

The signal we care about is sub-millisecond. LocalStack and real Lambda wrap that signal in:

- Docker / Firecracker container startup and the Node runtime bootstrap (cold start),
- the Lambda Runtime API request loop,
- network latency to the function URL / API Gateway.

That is **tens of milliseconds of noise** — 10–100× the thing we are trying to compare. Worse,
LocalStack would be benchmarking the _emulator_, not the framework, and shared CI runners make
absolute numbers non-reproducible. So the in-process harness is the right primary tool: it
isolates framework overhead, is deterministic, needs no Docker, and runs in seconds.

End-to-end Lambda timings (cold/warm `Duration`, `Init Duration`) are a **different, valid**
question — see the [real-Lambda appendix](#appendix-end-to-end-real-lambda-numbers) below — but
they answer "how fast is my whole deployment", not "how much overhead does the framework add".

## Frameworks compared

| key | package | adapter |
| -------------------- | --------------------------------------- | ---------------------- |
| `baseline` | — (hand-written handler) | none — the lower bound |
| `lambda-api` | this repo (loaded from the working tree)| native `api.run()` |
| `serverless-express` | `express` + `@vendia/serverless-express`| `serverlessExpress()` |
| `fastify` | `fastify` + `@fastify/aws-lambda` | `awsLambdaFastify()` |
| `hono` | `hono` | `hono/aws-lambda` |
| `middy` | `@middy/core` + `@middy/http-router` | `httpRouterHandler()` |

`baseline` is a raw handler with zero routing abstraction; every other framework's overhead is
read as the gap above it.

## Scenarios

Each framework registers the **same** canonical routes, configured as minimally and equally as
possible (JSON responses, no extra middleware). All scenarios run against both **API Gateway
REST (v1)** and **HTTP API (v2)** events.

| id | request | checks |
| ------------ | ------------------------------------ | ----------------- |
| `get-json` | `GET /` | 200, `{hello}` |
| `path-param` | `GET /users/42` | 200, `{id:'42'}` |
| `post-json` | `POST /users` with a JSON body | 200, echoes body |
| `routing-50` | `GET /r49/x` (50 routes registered) | 200 — routing cost|
| `not-found` | `GET /does-not-exist` | 404 |

## Fairness notes

- Frameworks are configured minimally and equivalently — the goal is to compare core overhead,
not feature sets. Defaults still differ (e.g. lambda-api computes an `ETag` and handles
serialization itself; Express needs an explicit `express.json()` and a 404 handler; Middy is a
middleware engine wired with only `http-router` + a per-route JSON body parser + an error
handler, and is otherwise a very thin layer), and those differences are part of what the numbers
reflect. Read the `frameworks/*.js` to see exactly how each is set up.
- Absolute ops/sec depend on the machine. **Compare the relative ranking**, not raw numbers.
- All frameworks run in one process by default. Pass `--framework <name>` to run a single one in
its own process — used for published numbers — to avoid cross-framework JIT interference.

## CLI

```
node run.js [options]

--framework <name> run a single framework (baseline | lambda-api | serverless-express
| fastify | hono | middy); also gives clean per-process JIT isolation
--md <path> write the markdown report to a file (relative to benchmarks/)
--json <path> dump raw per-cell stats as JSON
--update-readme refresh the Benchmarks section in ../README.md (full run only)
```

The version label recorded in the README history can be overridden with the
`LAMBDA_API_VERSION` environment variable (the release workflow sets this from the release tag).

## How the README stays up to date

`.github/workflows/benchmark.yml` runs on every published release (and via manual dispatch). It
runs the suite on Node 20, calls `--update-readme`, Prettier-formats the README, commits it back
to the default branch, and uploads `results/` as an artifact. The Benchmarks section lives
between `<!-- BENCHMARKS:START -->` / `<!-- BENCHMARKS:END -->` markers and is regenerated in
place, with one history row appended per release. Move that marker block anywhere in the README
once; future runs respect its position.

## Adding a framework

1. Create `frameworks/<name>.js` exporting `{ name, version, build }`, where `build()` returns
(or resolves to) an async `(event, context) => apiGatewayResponse` handler with the canonical
routes registered. Use `await import()` for ESM-only packages (see `frameworks/hono.js`).
2. Add `<name>` to `ALL_FRAMEWORKS` in `run.js`.
3. Add its dependency to `package.json` and re-run `npm install`.

## Appendix: end-to-end (real Lambda) numbers

To measure cold/warm start and total billed duration — which include infrastructure, not just
framework overhead — deploy each `frameworks/*.js` handler behind API Gateway with AWS SAM or the
Serverless Framework, then:

- drive warm throughput with a load tool (e.g. `autocannon` / `artillery`) against the API URL, or
- loop `aws lambda invoke` for controlled single invocations,
- and read `Duration` / `Init Duration` from the CloudWatch `REPORT` log lines.

These are deliberately **not** part of the in-process suite: they answer a different question and
are not reproducible on shared CI. Label any such results clearly as end-to-end, infra-inclusive.
64 changes: 64 additions & 0 deletions benchmarks/frameworks/baseline.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use strict';

/**
* Baseline: a hand-written Lambda handler with zero framework abstraction.
*
* Establishes the theoretical lower bound for request handling — event parse, a manual
* route match, JSON serialize. Every framework's overhead is meaningfully read as the gap
* above this floor.
*/

const { ROUTE_COUNT } = require('../lib/scenarios');

function pathOf(event) {
return event.rawPath || event.path;
}

function methodOf(event) {
return event.httpMethod || (event.requestContext && event.requestContext.http && event.requestContext.http.method);
}

// Emit the response shape lambda-api / the adapters produce for each event format.
function reply(event, statusCode, payload) {
const body = JSON.stringify(payload);
if (event.version === '2.0') {
return { statusCode, headers: { 'content-type': 'application/json' }, body, isBase64Encoded: false };
}
return {
statusCode,
multiValueHeaders: { 'content-type': ['application/json'] },
body,
isBase64Encoded: false
};
}

const USER_RE = /^\/users\/([^/]+)$/;
const ROUTE_RE = /^\/r(\d+)\/([^/]+)$/;

function build() {
return async (event) => {
const path = pathOf(event);
const method = methodOf(event);

if (method === 'GET' && path === '/') return reply(event, 200, { hello: 'world' });

if (method === 'POST' && path === '/users') {
const parsed = event.body ? JSON.parse(event.body) : {};
return reply(event, 200, { created: parsed });
}

let match;
if (method === 'GET' && (match = USER_RE.exec(path))) {
return reply(event, 200, { id: match[1] });
}

if (method === 'GET' && (match = ROUTE_RE.exec(path))) {
const i = Number(match[1]);
if (i >= 0 && i < ROUTE_COUNT) return reply(event, 200, { i });
}

return reply(event, 404, { error: 'Not Found' });
};
}

module.exports = { name: 'baseline', version: '-', build };
Loading
Loading