Performance
Overview
Rune is designed for minimal overhead. The entire pipeline uses a composable middleware pattern with zero dependencies on heavy frameworks. Key design choices:
- Web-standard Request/Response — no wrapper objects, no serialization overhead
- Simple middleware pipeline — classic onion dispatch, no heavy abstractions
- WeakMap-based metadata — avoids
Reflect.*overhead - Tree-shakeable packages — only import what you use
- Bun-native — when deployed on Bun, the JIT compiles hot paths efficiently
Benchmarks
Run the benchmark suite:
bun perf/bench.tsTypical results (Bun 1.2, single core, macOS):
| Framework | Requests/sec (routing) | Requests/sec (JSON) | Latency (p50) |
|---|---|---|---|
| Rune | ~180,000 | ~150,000 | ~0.03ms |
| Hono | ~175,000 | ~145,000 | ~0.04ms |
| Express | ~25,000 | ~20,000 | ~0.3ms |
Optimization Tips
1. Use Appropiate Runtime
- Bun — Fastest startup and hot-path execution for Rune
- Deno — Good all-around, similar perf to Bun
- Node.js — Slightly slower but widest ecosystem
2. Minimize Middleware
Each middleware adds a function call per request. Keep the pipeline lean:
// Instead of 3 middlewares:app.use(cors());app.use(logger());app.use(requestId());
// Consider combining:app.use(async (ctx, next) => { const start = Date.now(); const id = crypto.randomUUID(); ctx.state.set("requestId", id); await next(); console.log(`${id} ${Date.now() - start}ms`);});3. Skip JSON Parsing
If you don’t need the body, don’t call ctx.body:
@Get("/health")health() { return { status: "ok" }; // No body parsing needed}4. Avoid Throwing for Control Flow
Throw/catch is expensive in hot paths. Use early returns instead:
// Prefer:@Get("/:id")async getUser(@Param() id: string) { const user = await db.findUser(id); if (!user) { return new Response("Not Found", { status: 404 }); } return user;}
// Over:@Get("/:id")async getUser(@Param() id: string) { const user = await db.findUser(id); if (!user) throw new NotFoundError(); return user;}5. Use Singleton Providers
Mark stateless services as @Injectable("singleton") to avoid repeated instantiation:
@Injectable("singleton")export class UserService { findAll() { /* ... */ }}6. Cache Repeated Results
import { MemoryCache } from "@rune/cache";
const cache = new MemoryCache();
@Injectable("singleton")export class ProductService { async findById(id: string) { const cached = await cache.get(`product:${id}`); if (cached) return cached; const product = await db.products.findById(id); await cache.set(`product:${id}`, product, 60); return product; }}7. Use the Fast Path for Simple Controllers
Controllers without constructor dependencies, guards, interceptors, or DTO validation automatically use a pre-instantiated fast path that skips DI resolution, guard checks, and interceptor chains. The controller instance is created once at startup and reused for every request.
// This controller is "simple" — no deps, guards, interceptors, DTOs@Controller("/api")class BenchmarkController { @Get("/hello") hello() { return { message: "hello" }; }}What the fast path eliminates per request:
Container.createScope()— no scoped container allocationContainer.resolve(Controller)— no DI resolution- Guard check loop + interceptor chain closures
Promise.allover guard results
8. Pre-Compiled JSON Serialization
When the fast path detects a controller’s response shape, it lazily compiles an optimized JSON serializer. The first request analyzes the returned object keys and generates a tight for loop that produces JSON directly, avoiding JSON.stringify()’s generic reflection overhead.
// First request: learns keys {"message", "id"}// Subsequent requests: uses compiled serializer@Get("/user/:id")@Param()getUser(id: string) { return { id }; // compiled to: '{"id":"'+o.id+'"}'}9. Router Static Cache
Routes without dynamic path segments (:param or * wildcards) are cached in a Map for O(1) hash-based lookup, bypassing the radix-tree traversal entirely.
10. Middleware Pipeline Bypass
When no middleware is registered, compose() returns a direct call to the handler without creating the dispatch closure chain, saving per-request allocations.
11. Batch Database Queries
Use Promise.all for independent queries:
@Get("/dashboard")async dashboard() { const [users, orders, metrics] = await Promise.all([ db.users.count(), db.orders.recent(), db.metrics.summary(), ]); return { users, orders, metrics };}Profiling
Use the built-in middleware pattern for ad-hoc profiling:
app.use(async (ctx, next) => { const start = performance.now(); await next(); const elapsed = performance.now() - start; if (elapsed > 100) { console.warn(`Slow request: ${ctx.request.url} (${elapsed.toFixed(2)}ms)`); }});