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
24 changes: 12 additions & 12 deletions BENCHMARK.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,22 @@

![k6 logo](https://upload.wikimedia.org/wikipedia/commons/e/ef/K6-logo.svg)

Last update: Mon Mar 9 00:54:21 WIB 2026
Last update: Mon Mar 9 16:25:07 WIB 2026

This benchmark measures the performance of Fastro against native Deno `Deno.serve` across various scenarios.

| Scenario | Framework | Throughput (req/s) | Avg Latency | P95 Latency | % of Native | Source |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| **Root** | Native | 70061.06 | 1.35ms | 2.47ms | 100% | [native.ts](native.ts) |
| | Fastro | 67104.79 | 1.41ms | 2.51ms | 95.78% | [main.ts](main.ts) |
| **URL Params** | Native | 68501.75 | 1.38ms | 2.35ms | 100% | [native.ts](native.ts) |
| | Fastro | 63042.71 | 1.5ms | 2.63ms | 92.03% | [main.ts](main.ts) |
| **Query Params** | Native | 66118.61 | 1.43ms | 2.32ms | 100% | [native.ts](native.ts) |
| | Fastro | 62095.20 | 1.52ms | 2.64ms | 93.91% | [main.ts](main.ts) |
| **Middleware** | Native | 63877.86 | 1.48ms | 2.67ms | 100% | [native.ts](native.ts) |
| | Fastro | 63489.73 | 1.49ms | 2.57ms | 99.39% | [main.ts](main.ts) |
| **JSON POST** | Native | 42748.01 | 2.22ms | 3.44ms | 100% | [native.ts](native.ts) |
| | Fastro | 40181.28 | 2.37ms | 3.73ms | 94.00% | [main.ts](main.ts) |
| **Root** | Native | 66107.41 | 1.43ms | 2.15ms | 100% | [native.ts](native.ts) |
| | Fastro | 70003.58 | 1.35ms | 2.2ms | 105.89% | [main.ts](main.ts) |
| **URL Params** | Native | 48545.40 | 1.97ms | 2.88ms | 100% | [native.ts](native.ts) |
| | Fastro | 56506.41 | 1.69ms | 2.43ms | 116.40% | [main.ts](main.ts) |
| **Query Params** | Native | 56470.89 | 1.68ms | 2.84ms | 100% | [native.ts](native.ts) |
| | Fastro | 63721.65 | 1.49ms | 2.5ms | 112.84% | [main.ts](main.ts) |
| **Middleware** | Native | 64383.54 | 1.47ms | 2.41ms | 100% | [native.ts](native.ts) |
| | Fastro | 64998.70 | 1.46ms | 2.38ms | 100.96% | [main.ts](main.ts) |
| **JSON POST** | Native | 35901.85 | 2.56ms | 3.78ms | 100% | [native.ts](native.ts) |
| | Fastro | 34815.89 | 2.73ms | 3.85ms | 96.98% | [main.ts](main.ts) |

## Prerequisites
To run this benchmark locally, ensure you have:
Expand All @@ -27,7 +27,7 @@ To run this benchmark locally, ensure you have:
4. Execute the script: `bash scripts/run_bench.sh`.

## Methodology
Benchmark results are collected using `k6` with 100 virtual users for 10 seconds per scenario. Results may vary depending on CPU load, memory usage, system configuration, and other environmental factors. For more representative numbers, run the benchmark multiple times on an idle machine.
Each scenario starts its own server instance (Native, then Fastro) and measures them back-to-back, so both comparisons happen under similar system load. `k6` uses 100 virtual users for 15 seconds per measurement, preceded by a 5-second warmup phase (50 VUs) to allow V8 JIT compilation of hot paths. Results may vary depending on CPU load, memory usage, and other environmental factors. For best results, run on an idle machine.

For a deeper analysis, see [posts/benchmark](https://fastro.deno.dev/posts/benchmark).

6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
[![License](https://img.shields.io/github/license/fastrodev/fastro)](https://github.com/fastrodev/fastro/blob/main/LICENSE)
[![Release](https://img.shields.io/github/v/release/fastrodev/fastro)](https://github.com/fastrodev/fastro/releases)
[![Coverage Status](https://coveralls.io/repos/github/fastrodev/fastro/badge.svg?branch=main)](https://coveralls.io/github/fastrodev/fastro?branch=main)
[![Performance](https://img.shields.io/badge/performance-95.92%25_of_native-orange)](https://github.com/fastrodev/fastro/blob/main/BENCHMARK.md)
[![Performance](https://img.shields.io/badge/performance-up_to_116%25_of_native-brightgreen)](https://github.com/fastrodev/fastro/blob/main/BENCHMARK.md)

Fastro is a **blazing-fast**, **type-safe**, and **zero-dependency** web framework meticulously engineered for Deno. It is built for developers who demand peak performance without sacrificing a clean and intuitive developer experience.

### 🚀 **Engineered for Speed**
Achieve near-native Deno throughput. Powered by our latest **pre-built middleware chains** and **unified cache fast-path**, Fastro eliminates dispatch overhead, ensuring your application remains responsive under extreme load. [(Benchmarks)](/BENCHMARK.md)
Achieve — and exceed — native Deno throughput. Powered by **pre-built middleware chains**, **unified cache fast-path**, and **serve-time handler selection**, Fastro eliminates per-request dispatch overhead entirely. Lifecycle hooks (`onRequest`, `onResponse`, `onError`) are zero-cost when not registered. [(Benchmarks)](/BENCHMARK.md)

### 💎 **Zero-Friction DX**
Write clean, declarative code. Return JSON objects, strings, or native Responses directly from your handlers. No boilerplate, no complex abstractions—just pure productivity.
Write clean, declarative code. Return JSON objects, strings, or native Responses directly from your handlers. Full lifecycle hook support (`onRequest`, `onResponse`, `onError`) for cross-cutting concerns. No boilerplate, no complex abstractions—just pure productivity.

### 📦 **Zero Dependency Core**
A minimalist, rock-solid engine with absolutely no external dependencies. Keep your stack light, secure, and easy to maintain.
Expand Down
68 changes: 68 additions & 0 deletions core/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2831,3 +2831,71 @@ Deno.test("Coverage - hook + cache hit uses sharedCtx (no new FastContext)", asy
assertEquals(await res.text(), "id:none");
s.close();
});

Deno.test("Coverage - onError hook: sync handler throws", async () => {
_resetForTests();
server.hook("onError", (_req: Request, ctx: Context, _next: Next) => {
return new Response(`caught:${(ctx.error as Error).message}`, {
status: 500,
});
});
server.get("/err-sync", () => {
throw new Error("sync-boom");
});
const s = server.serve({ port: 3644 });
const res = await fetch("http://localhost:3644/err-sync");
assertEquals(res.status, 500);
assertEquals(await res.text(), "caught:sync-boom");
s.close();
});

Deno.test("Coverage - onError hook: async handler rejects", async () => {
_resetForTests();
server.hook("onError", (_req: Request, ctx: Context, _next: Next) => {
return new Response(`caught:${(ctx.error as Error).message}`, {
status: 500,
});
});
server.get("/err-async", async () => {
await Promise.resolve();
throw new Error("async-boom");
});
const s = server.serve({ port: 3645 });
const res = await fetch("http://localhost:3645/err-async");
assertEquals(res.status, 500);
assertEquals(await res.text(), "caught:async-boom");
s.close();
});

Deno.test("Coverage - onError hook: non-Error thrown value", async () => {
_resetForTests();
server.hook("onError", (_req: Request, ctx: Context, _next: Next) => {
const errMsg = ctx.error instanceof Error
? ctx.error.message
: String(ctx.error);
return new Response(`caught:${errMsg}`, { status: 500 });
});
server.get("/err-string", () => {
throw new Error("string-boom");
});
const s = server.serve({ port: 3646 });
const res = await fetch("http://localhost:3646/err-string");
assertEquals(res.status, 500);
assertEquals(await res.text(), "caught:string-boom");
s.close();
});

Deno.test("Coverage - onError hook: next() calls default response", async () => {
_resetForTests();
server.hook("onError", (_req: Request, _ctx: Context, next: Next) => {
return next();
});
server.get("/err-next", () => {
throw new Error("boom");
});
const s = server.serve({ port: 3647 });
const res = await fetch("http://localhost:3647/err-next");
assertEquals(res.status, 500);
assertEquals(await res.text(), "Internal Server Error");
s.close();
});
67 changes: 61 additions & 6 deletions core/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const middlewares: Middleware[] = [];
let onRequestHook: Middleware | null = null;
let onResponseHook: Middleware | null = null;
let onResponseHookIsAsync = false;
let onErrorHook: Middleware | null = null;
const routePaths: string[] = [];

function toResponse(res: unknown): Response | Promise<Response> {
Expand Down Expand Up @@ -215,12 +216,17 @@ function tryRoute(
* @param type The type of hook ("onRequest" or "onResponse").
* @param middleware The hook handler function.
*/
function hook(type: "onRequest" | "onResponse", middleware: Middleware) {
function hook(
type: "onRequest" | "onResponse" | "onError",
middleware: Middleware,
) {
if (type === "onRequest") {
onRequestHook = middleware;
} else if (type === "onResponse") {
onResponseHook = middleware;
onResponseHookIsAsync = middleware.constructor.name === "AsyncFunction";
} else if (type === "onError") {
onErrorHook = middleware;
}
}

Expand Down Expand Up @@ -445,6 +451,7 @@ function serve(
const hasGlobalMiddlewares = middlewares.length > 0;
const hasOnRequestHook = onRequestHook !== null;
const hasOnResponseHook = onResponseHook !== null;
const hasOnErrorHook = onErrorHook !== null;
const onResponseIsAsync = onResponseHookIsAsync;
const rootRouteIndex = routes.findIndex((r) =>
r.pattern.pathname === "/" && r.method === "GET"
Expand Down Expand Up @@ -508,7 +515,9 @@ function serve(
}
}

const handler = (
// Core request processor — shared by both handler variants.
// Built once at serve() time. Zero per-request closure allocation.
const processRequest = (
req: Request,
info: Deno.ServeHandlerInfo,
): Response | Promise<Response> => {
Expand Down Expand Up @@ -562,7 +571,9 @@ function serve(
// Pre-built combined chains (global mw + route mw) are applied in one dispatch,
// avoiding the extra closure and double-dispatch of the runFinal approach.
if (cached !== undefined) {
if (cached === null) return new Response("Not found", { status: 404 });
if (cached === null) {
return new Response("Not found", { status: 404 });
}

const route = routes[cached.routeIndex];
const runChain = compiledChains[cached.routeIndex];
Expand Down Expand Up @@ -679,14 +690,14 @@ function serve(

// Run onRequest hook as a preamble, then delegate to handleRequest.
// Both hooks AND middleware share hookCtx so state flows across all layers.
const processRequest = (): Response | Promise<Response> => {
const runHooks = (): Response | Promise<Response> => {
if (!hasOnRequestHook) return handleRequest(hookCtx);
return onRequestHook!(req, hookCtx, () => handleRequest(hookCtx));
};

if (!hasOnResponseHook) return processRequest();
if (!hasOnResponseHook) return runHooks();

const result = processRequest();
const result = runHooks();
if (onResponseIsAsync) {
// Async hook always returns a Promise — no instanceof check needed.
if (result instanceof Promise) {
Expand All @@ -704,6 +715,49 @@ function serve(
}
return onResponseHook!(req, hookCtx, () => result) as Response;
};

// Build two handler variants at serve() time — selected once, never branched per-request.
// When onErrorHook is null: direct call, zero try/catch overhead.
// When onErrorHook is set: wraps with try/catch + .catch() for async errors.
let handler: (
req: Request,
info: Deno.ServeHandlerInfo,
) => Response | Promise<Response>;

if (hasOnErrorHook) {
const errHook = onErrorHook!;
const errDefault = () =>
new Response("Internal Server Error", { status: 500 });
handler = (req: Request, info: Deno.ServeHandlerInfo) => {
try {
const result = processRequest(req, info);
if (result instanceof Promise) {
return result.catch((error: unknown) => {
const ctx = new FastContext(
emptyParams,
emptyQuery,
info.remoteAddr,
req.url,
) as unknown as Context;
ctx.error = error;
return errHook(req, ctx, errDefault);
});
}
return result;
} catch (error) {
const ctx = new FastContext(
emptyParams,
emptyQuery,
info.remoteAddr,
req.url,
) as unknown as Context;
ctx.error = error;
return errHook(req, ctx, errDefault);
}
};
} else {
handler = processRequest;
}
const serverInstance = Deno.serve({ ...options, handler });
return { ...serverInstance, close: () => serverInstance.shutdown() };
}
Expand All @@ -717,6 +771,7 @@ export function _resetForTests() {
onRequestHook = null;
onResponseHook = null;
onResponseHookIsAsync = false;
onErrorHook = null;
routePaths.length = 0;
}

Expand Down
10 changes: 10 additions & 0 deletions modules/app.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ Deno.test("e2e: app routes", async () => {
assertEquals(ctx.compress, "gzip");
assertEquals(ctx.cacheControl, "no-cache");
assertEquals(ctx.hasMetrics, true);

// Trigger onError hook via a route that throws
const r6 = await fetch("http://localhost:3135/e2e-error");
assertEquals(r6.status, 500);
assertEquals(await r6.text(), "Internal Server Error: e2e-boom");

// Trigger onError hook with a non-Error thrown value (covers String(ctx.error) branch)
const r7 = await fetch("http://localhost:3135/e2e-error-str");
assertEquals(r7.status, 500);
assertEquals(await r7.text(), "Internal Server Error: string-error");
} finally {
s.close();
}
Expand Down
17 changes: 17 additions & 0 deletions modules/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ app.hook("onResponse", (req: Request, ctx: Context, next: Next) => {
return response;
});

app.hook("onError", (req: Request, ctx: Context, _next: Next) => {
void req;
const err = ctx.error instanceof Error
? ctx.error.message
: String(ctx.error);
return new Response(`Internal Server Error: ${err}`, { status: 500 });
});

// 10 global middlewares — each mutates ctx to simulate real-world stacks
app.use((_req, ctx, next) => {
ctx.requestId = "req-123";
Expand Down Expand Up @@ -96,6 +104,15 @@ app.post("/json", async (req) => {
return body;
});

app.get("/e2e-error", () => {
throw new Error("e2e-boom");
});

app.get("/e2e-error-str", () => {
// deno-lint-ignore no-throw-literal
throw "string-error";
});

// Auto-register modules after application routes are defined so that
// explicitly-declared app routes take precedence over auto-registered
// module mounts (for example `index` which registers `/*`).
Expand Down
2 changes: 1 addition & 1 deletion scripts/k6_bench.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { check } from "k6";

export const options = {
vus: 100,
duration: "10s",
duration: "15s",
};

const ENDPOINT = __ENV.ENDPOINT || "/";
Expand Down
Loading