How was it measured?
On 2026-11-01, on an Apple M5 Max with Node.js 26.11 and Bun 1.4.3: 5 s per scenario, 64 connections, autocannon on the same machine at a load average of 17 to 19. Every column except run 1 comes from run 2.
Dates come back as Dates Generators stream to for await Uploads report their progress No framework needed on Bun
defuss-rpc exposes server-side functions and classes as a fully typed, async client API, built from the server's schema at runtime. defuss-dson encodes every argument and result, so Date, Map, Set, BigInt and typed arrays arrive intact. Calls, sequential or parallel, are plain HTTP and JSON; event streams are NDJSON you read with for await; files travel as raw bytes, uploads with progress. On Bun it needs nothing else: Bun.serve is the default and answered the most calls with the least memory. Express, bunway, Vite and Astro are optional.
bun add defuss-rpc
Part of the defuss (opens in a new tab) ecosystem by Aron Homberg (kyr0) (opens in a new tab) · Open Source (MIT (opens in a new tab))
getRpcClient<typeof RpcApi>() types every call from the server's own export. No schema language, no generated stubs that go stale.
An async * method sends each yield as an NDJSON frame the moment it happens; break on the client stops the generator on the server.
DSON carries Date, Map, Set, BigInt and every typed array, plus undefined, NaN, ±Infinity and -0, as arguments and as results.
Uploads stream to disk with SHA-256, gzip and live progress, and resume after a dropped connection; streaming handlers read them in bounded chunks.
Server guards reject calls with 403 before they run; client hooks add auth headers, metrics and logging around every call.
rpcRoute maps a Request to a Response for Bun.serve and Astro routes; ExpressRpcServer runs it on Bun.serve by default, or on Express (Node.js and Bun) or bunway (Bun) when you import their provider.
Three files. Each one below is a file of examples/, and the end-to-end test runs every one of them against the published package, on Node.js and on Bun.
Export one object of namespaces and its type. A class gets a fresh instance per call; a module is shared.
/** A module: plain functions on one shared object. */export const MathApi = { async add(a: number, b: number) { return a + b; }, async multiply(a: number, b: number) { return a * b; },};/** * A class: the server creates a fresh instance for every call, so * fields never leak between requests. */export class GreeterApi { async greet(name: string) { return `Hello, ${name}!`; }}/** * An async generator streams: the client receives every `yield` * as it happens. */export const ChatApi = { async *reply(prompt: string) { for (const word of `You said: ${prompt}`.split(" ")) { await new Promise((resolve) => setTimeout(resolve, 40)); yield word; } return "done"; },};/** * DSON keeps the types JSON loses: Date, Set, Map, BigInt and * typed arrays. */export const TypesApi = { async sample() { return { when: new Date("2026-11-01T12:00:00.000Z"), tags: new Set(["rpc", "dson"]), scores: new Map([["ada", 1815]]), big: 2n ** 64n, bytes: new Uint8Array([222, 173, 190, 239]), }; },};/** Which runtime answers: the same API runs on Node.js and Bun. */export const ServerInfo = { async runtime() { const { bun, node } = process.versions; return bun ? `Bun ${bun}` : `Node.js ${node}`; },};const RpcApi = { MathApi, GreeterApi, ChatApi, TypesApi, ServerInfo };export default RpcApi;export type RpcApi = typeof RpcApi;ExpressRpcServer adds CORS, gzip, body limits and a health route. Without a provider it runs on Bun.serve at port 63920: the default, with the most calls per second and the least memory (Performance). Import a provider only on Node.js or for a framework's routes and middleware. A Bun.serve you already run can mount rpcRoute itself.
import { createRpcServer } from "defuss-rpc/server";import { ExpressRpcServer } from "defuss-rpc/express-server";import RpcApi from "./api.ts";import "./files.ts";import "./hooks.ts";createRpcServer(RpcApi);// No provider: Bun.serve, without a framework. On Node.js, select// one (server-express.ts).const server = new ExpressRpcServer({ host: "127.0.0.1", // undefined: the default port, 63920 port: process.env.PORT ? Number(process.env.PORT) : undefined,});// Requests outside the RPC routes go to one fetch handler (default:// 404); add your own before start().server.getApp().fetch = (request) => new URL(request.url).pathname === "/version" ? Response.json({ version: 1 }) : new Response("Not Found", { status: 404 });await server.start(); // listening at http://127.0.0.1:63920import { createRpcServer } from "defuss-rpc/server";import { ExpressRpcServer } from "defuss-rpc/express-server";import { expressProvider } from "defuss-rpc/providers/express";import RpcApi from "./api.ts";import "./files.ts";import "./hooks.ts";createRpcServer(RpcApi);const server = new ExpressRpcServer({ provider: expressProvider(), // Express 5 on node:http: Node.js and Bun host: "127.0.0.1", port: Number(process.env.PORT ?? 63920),});// Your own Express routes and middleware live on the same app;// add them before start().server.getApp().get("/version", (_req, res) => void res.json({ version: 1 }));await server.start(); // listening at http://127.0.0.1:63920import { createRpcServer } from "defuss-rpc/server";import { ExpressRpcServer } from "defuss-rpc/express-server";import { bunwayProvider } from "defuss-rpc/providers/bunway";import RpcApi from "./api.ts";import "./files.ts";import "./hooks.ts";createRpcServer(RpcApi);const server = new ExpressRpcServer({ provider: bunwayProvider(), // bunway on Bun.serve: Bun only host: "127.0.0.1", port: Number(process.env.PORT ?? 63920),});await server.start(); // listening at http://127.0.0.1:63920import { createRpcServer, rpcRoute } from "defuss-rpc/server";import RpcApi from "./api.ts";import "./files.ts";import "./hooks.ts";createRpcServer(RpcApi);// rpcRoute answers requests; CORS for browser pages on other// origins is the server's job.const cors = { "Access-Control-Allow-Origin": "*", "Access-Control-Expose-Headers": "Content-Disposition, X-File-Sha256, X-Upload-Offset",};const server = Bun.serve({ hostname: "127.0.0.1", port: Number(process.env.PORT ?? 63920), // seconds: streams and upload progress keep a request open // longer than Bun's default of 10 idleTimeout: 120, async fetch(request) { if (request.method === "OPTIONS") { const allowHeaders = request.headers.get("Access-Control-Request-Headers") ?? "Content-Type"; return new Response(null, { headers: { ...cors, "Access-Control-Allow-Headers": allowHeaders }, }); } const response = await rpcRoute({ request }); for (const [name, value] of Object.entries(cors)) { response.headers.set(name, value); } return response; },});The client fetches the schema once per endpoint and builds callers for every method. The comments show what the e2e test sees printed.
import { addHook, download, getRpcClient, setHeaders, uploadComplete,} from "defuss-rpc/client";import type { RpcApi } from "./api.ts";const baseUrl = process.env.RPC_URL ?? "http://127.0.0.1:63920";const rpc = await getRpcClient<RpcApi>({ baseUrl });console.log(await rpc.MathApi.add(2, 3)); // 5console.log(await new rpc.GreeterApi().greet("Ada")); // Hello, Ada!Of every ExpressRpcServer setup, the default on Bun.serve answered the most small calls and needed the least memory, idle and under load, in two runs of make bench. Choose a provider for Node.js or for a framework's middleware, not for speed.
Small calls and streams per second, latency, and the server process's memory: idle, under small calls, and at its peak while moving files.
| Server | Runtime | calls/s | calls/s, run 1 | p50 / p99 ms | streams/s | RSS idle | RSS under calls | RSS peak (files) |
|---|---|---|---|---|---|---|---|---|
No provider: Bun.serve default | Bun | 41,930 | 35,798 | 1 / 3 | 6,748 | 29 MiB | 59 MiB | 384 MiB |
bunwayProvider() | Bun | 28,491 | 28,296 | 2 / 4 | 7,304 | 36 MiB | 65 MiB | 394 MiB |
expressProvider() | Bun | 20,027 | 20,981 | 2 / 7 | 5,134 | 45 MiB | 129 MiB | 308 MiB |
expressProvider() | Node.js | 16,776 | 15,551 | 3 / 9 | 3,991 | 99 MiB | 213 MiB | 335 MiB |
rpcRoute in a bare Bun.serve | Bun | 62,621 | 56,056 | 0 / 2 | 9,244 | 29 MiB | 66 MiB | 389 MiB |
What the benchmark measured, and where the default does not lead.
On 2026-11-01, on an Apple M5 Max with Node.js 26.11 and Bun 1.4.3: 5 s per scenario, 64 connections, autocannon on the same machine at a load average of 17 to 19. Every column except run 1 comes from run 2.
Streams and files: the default and bunway were within each other's noise. The memory peak while moving files, 300 to 400 MiB for every server, did not rank the servers the same way in both runs.
rpcRoute faster than the default?It is the baseline: rpcRoute in a bare Bun.serve. The default adds CORS, routing, body reading and the size limit, and that work is the gap.
make bench runs every scenario on your machine. The README has the full table, with uploads and downloads.
Plain HTTP with four body formats: DSON for calls, NDJSON for generators and upload answers, raw bytes for files, Server-Sent Events for upload progress. Without ExpressRpcServer (rpcRoute in your own Bun.serve or an Astro route) your server takes the place of the HTTP layer.
What each layer reads and writes, per kind of request, from your code down to your API.
Date, Map, Set, BigInt, typed arrays.POST /rpc with DSON text as application/json.jsonSizeLimit; gzip above 1 KiB.rpcRoute and your APIDSON.parse gives the arguments, DSON.stringify the answer.for await over the values.yield, then return or error.rpcRoute and your APIsending, receiving and complete.POST /rpc/upload: raw bytes, gzip, with the headersX-Upload-HandlerX-Upload-IdX-Original-SizeX-Upload-OffsetX-Original-FilenamerpcRoute and your API{ data, filename, sha256 }, or a Blob.GET /rpc/download: raw bytes with Content-Disposition (RFC 6266) and X-File-Sha256.rpcRoute and your APIfileMeta.You need neither. defuss-rpc runs on its own, on Bun.serve or a provider, as shown above, and depends on neither. Add one of these packages only when your app already runs on Vite or Astro and vite dev or astro dev should start the RPC server for you.
defuss-rpc-vite runs an ExpressRpcServer beside the dev server (on Bun without a provider, port 63920) and tells the client where it is through virtual:defuss-rpc, so getRpcClient() needs no options.
bun add defuss-rpc-vite
// registers the dev server's RPC endpoint as the defaultimport "virtual:defuss-rpc";import { getRpcClient } from "defuss-rpc/client";import type { RpcApi } from "./api.ts";// no baseUrl: the virtual module set itconst rpc = await getRpcClient<RpcApi>();const sum = await rpc.MathApi.add(2, 3);document.querySelector("#sum")!.textContent = String(sum);import { defineConfig } from "vite";import { defussRpc } from "defuss-rpc-vite";import RpcApi from "./api.ts";// No provider: the RPC server runs on Bun.serve, port 63920.// On Node.js add `provider: expressProvider()`.export default defineConfig({ plugins: [ defussRpc({ api: RpcApi, host: "127.0.0.1", watch: ["api.ts"], }), ],});To import rpcEndpoint in TypeScript, declare the virtual module once:
declare module "virtual:defuss-rpc" { /** Endpoint the client uses, e.g. "http://127.0.0.1:63920" in vite dev. */ export const rpcEndpoint: string;}defuss-rpc-astro wraps the Vite plugin and adds Astro.locals.rpcEndpoint, so server-rendered code knows the endpoint too.
bun add defuss-rpc-astro defuss-rpc-vite
import "virtual:defuss-rpc";import { getRpcClient } from "defuss-rpc/client";import type { RpcApi } from "../../api.ts";const rpc = await getRpcClient<RpcApi>();const sum = await rpc.MathApi.add(2, 3);document.querySelector("#sum")!.textContent = String(sum);import { defineConfig } from "astro/config";import { defussRpc } from "defuss-rpc-astro";import RpcApi from "./api.ts";export default defineConfig({ // No provider: the RPC server runs on Bun.serve, port 63920. // On Node.js add `provider: expressProvider()`. integrations: [ defussRpc({ api: RpcApi, host: "127.0.0.1", watch: ["api.ts"], }), ],});Server-rendered code, such as the frontmatter or an API route, reads the endpoint from Astro.locals:
// Server side: the middleware put the RPC server's endpoint into locals.const endpoint = Astro.locals.rpcEndpoint;for awaitA generator method answers with application/x-ndjson: one DSON frame per yield, then a return frame, or an error frame the client throws. The server pulls the next value only when the client reads, and a break runs the generator's finally.
const words: string[] = [];for await (const word of rpc.ChatApi.reply("hi there")) { words.push(word);}console.log(words.join(" ")); // You said: hi there/** * An async generator streams: the client receives every `yield` * as it happens. */export const ChatApi = { async *reply(prompt: string) { for (const word of `You said: ${prompt}`.split(" ")) { await new Promise((resolve) => setTimeout(resolve, 40)); yield word; } return "done"; },};Each NDJSON line is DSON's compact encoding of { type, value }, which the client decodes with DSON.parse. A method that is not a generator answers with one DSON body.
[[2,[[1,2],[3,4]]],[0,"type"],[0,"yield"],[0,"value"],[0,"You"]][[2,[[1,2],[3,4]]],[0,"type"],[0,"yield"],[0,"value"],[0,"said:"]][[2,[[1,2],[3,4]]],[0,"type"],[0,"yield"],[0,"value"],[0,"hi"]][[2,[[1,2],[3,4]]],[0,"type"],[0,"yield"],[0,"value"],[0,"there"]][[2,[[1,2],[3,4]]],[0,"type"],[0,"return"],[0,"value"],[0,"done"]]DSON encodes every argument and result, so a Date stays a Date, a Map a Map and a BigInt a BigInt, and a typed array keeps its bytes.
const { when, tags, scores, big, bytes } = await rpc.TypesApi.sample();console.log(when.toISOString()); // 2026-11-01T12:00:00.000Zconsole.log(tags.has("dson"), scores.get("ada")); // true 1815console.log(big); // 18446744073709551616nconsole.log(bytes instanceof Uint8Array); // true/** * DSON keeps the types JSON loses: Date, Set, Map, BigInt and * typed arrays. */export const TypesApi = { async sample() { return { when: new Date("2026-11-01T12:00:00.000Z"), tags: new Set(["rpc", "dson"]), scores: new Map([["ada", 1815]]), big: 2n ** 64n, bytes: new Uint8Array([222, 173, 190, 239]), }; },};An upload is written chunk by chunk to a temp file while the server computes SHA-256; the handler runs when the last byte arrived. Pass a known uploadId and the client asks the server how far it got and sends only the rest.
type StoredFile = { id: string; size: number };const data = new TextEncoder().encode("hello, file");const { result, sha256 } = await uploadComplete<StoredFile>( "file", data, { baseUrl, originalFilename: "hello.txt" },);console.log(result.size, sha256.length); // 11 64const file = await download("file", baseUrl, { downloadId: result.id,});const text = new TextDecoder().decode(file.data);console.log(text, file.filename); // hello, file hello.txtimport { addDownloadHandler, addUploadHandler,} from "defuss-rpc/server";interface StoredFile { data: Uint8Array; name: string;}/** The last 20 files: a demo must not grow without bound. */const files = new Map<string, StoredFile>();addUploadHandler("file", (data, meta) => { const { uploadId: id, originalFilename, sha256 } = meta; const name = originalFilename || `${id}.bin`; files.set(id, { data, name }); if (files.size > 20) files.delete(files.keys().next().value!); return { id, size: data.byteLength, sha256 };});addDownloadHandler("file", ({ downloadId }) => { const file = files.get(downloadId); if (!file) throw new Error(`Unknown file ${downloadId}`); return { data: file.data, fileMeta: { size: file.data.byteLength, contentType: "application/octet-stream", filename: file.name, }, };});The same calls from a page: progress for a progress bar, and the file back as a Blob for an object URL or a save link.
import { downloadAsBlob, upload } from "defuss-rpc/client";export interface StoredFile { id: string; size: number; sha256: string;}/** * Uploads a File and reports progress: "sending" from the browser, * "receiving" from the server's SSE stream. */export async function sendFile( file: File, baseUrl: string, onProgress: (phase: string, percent: number) => void,) { const options = { baseUrl, originalFilename: file.name }; for await (const event of upload<StoredFile>("file", file, options)) { if (event.type === "complete") return event; onProgress(event.type, event.percent); } throw new Error("upload ended without a result");}/** Fetches the file back as a Blob, for an object URL or a save link. */export async function fetchFile(id: string, baseUrl: string) { const { blob, result } = await downloadAsBlob("file", baseUrl, { downloadId: id, }); return { blob, filename: result.filename ?? `${id}.bin` };}upload()upload() yields these events to for await. uploadComplete() skips the progress stream and returns the fields of complete.
sendingNode.js and Bun: per chunk of the streamed body. Browsers: once, after the buffered body was sent.
bytesSenttotalBytespercentreceivingWhere EventSource exists (browsers): the bytes the server has stored, from its progress stream.
bytesReceivedtotalBytespercentcompleteOnce, at the end, with the handler's return value.
resultuploadIdbytesReceivedsha256durationMsServer guards see the namespace, method, arguments and the raw Request; an explicit false answers 403 before the method runs. Client hooks wrap every call: a guard can stop it, response and result hooks observe it.
// sent with every later call and uploadsetHeaders({ Authorization: "Bearer demo-token" });let answered = 0;// runs after each decoded resultaddHook({ phase: "result", fn: () => void answered++ });await rpc.MathApi.multiply(6, 7);console.log(answered); // 1import { addHook } from "defuss-rpc/server";const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;// Guards run before every call, upload and download; returning// false answers HTTP 403. For uploads the namespace is// "__upload__" and args[0] is the size the client announced.addHook({ phase: "guard", fn: (namespace, _method, args) => { if (namespace !== "__upload__") return true; return Number(args[0]) <= MAX_UPLOAD_BYTES; },});Every cell below is a test that runs on each change: Vitest in Node.js, Bun, Chromium and WebKit, and an end-to-end run of the packed package on Node.js and Bun.
| Part | Browsers | Node.js 22, 24, 26 | Bun default |
|---|---|---|---|
| Client: calls, streams, uploads, downloads | Chromium, WebKit | ||
rpcRoute (Fetch handler) | n/a | with Bun.serve | |
ExpressRpcServer, no provider | n/a | throws and names expressProvider() | on Bun.serve |
ExpressRpcServer on expressProvider() | n/a | ||
ExpressRpcServer on bunwayProvider() | n/a | bunway runs on Bun.serve | |
defuss-rpc-vite, defuss-rpc-astro | n/a | on Express | without a provider; the Vite plugin on every provider |
make bench compares the providers on calls, streams, uploads, downloads and memory; Performance shows the latest run.
One runtime dependency (defuss-dson), ESM and CommonJS with TypeScript types. The source, the tests and the design notes are on GitHub.
MIT licensed