birpc: two-way RPC over whatever message transport you already have
Message-based two-way remote procedure call.
At a glance
- What is it?
- birpc is a message-based, two-way remote procedure call library that turns a post/on pair into typed async function calls in about half a kilobyte with no dependencies, working over WebSockets, MessageChannel or a hand-rolled SSE plus POST channel. It suits a TypeScript project that needs a handful of remote functions rather than a full RPC framework, and it is not a transport and does not do streaming.
- Who is it for?
- birpc fits a TypeScript project that needs a small set of remote functions between a browser and a server, or between two workers, and wants to keep ownership of the transport, since the whole library is a post and an on callback and the serializer is yours to choose. It is the wrong choice if you need streaming responses, bidirectional subscriptions, or generated client stubs, because the design assumes one request maps to one resolved promise.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 66 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
A transport-shaped API rather than an RPC framework
birpc is described in one line as message-based two-way remote procedure call, useful for WebSockets and Workers communication. That phrasing is precise, and it is the first thing to understand about the design.
A full RPC framework owns the transport. It starts a server, defines a service interface, generates or derives a client stub, and hides the wire format. birpc does none of that. You hand it a post function and an on function, and it gives you an object whose methods return promises. The transport stays yours.
The four features in the readme are all consequences of that choice. Calling remote functions like locals, with a Promise to get the response, is the calling convention. TypeScript support for safe calls on arguments and returns is the typing layer. Protocol agnosticism is the consequence of not owning the transport. And zero dependencies at around 0.5KB is what is left once the framework parts are removed.
So the audience is a developer who already has a message channel and wants a small set of remote functions over it, not someone looking for a way to define and host a service. If you have ten functions between two workers, this fits. If you have a hundred and want stubs, a different tool fits.
The project is MIT licensed, copyright 2021 Anthony Fu, published on npm as birpc, and the readme gives no install command beyond the npm package page linked from the version badge at the top. The package manifest shows the shape of the distribution: it is an ES module package with sideEffects set to false, a single export entry point at ./dist/index.mjs and types at ./dist/index.d.mts, and only dist shipped in the files list. The build script is tsdown, the test script is vitest, and the typecheck script is tsc with noEmit. The project is developed with pnpm, with [email protected] declared as the packageManager, and the repository lives under the antfu-collective organisation. The package is version 4.2.0 in the manifest, with 4.2.0 released on 2026-08-19 and the last push to the repository on the same day.
The two callbacks that carry everything
The entire transport contract is a pair of functions, and understanding their signatures explains the library's behaviour.
post takes data and sends it. on takes a handler and registers it for incoming messages. Everything else, including how a response is matched to a request and how errors travel back, is birpc's business.
For a WebSocket the mapping is direct. The readme notes that when using WebSocket you need to pass your own serializer and deserializer, because a WebSocket carries text or binary frames and birpc does not know which encoding you want:
const rpc = createBirpc<ServerFunctions>(
clientFunctions,
{
post: data => ws.send(data),
on: fn => ws.on('message', fn),
// these are required when using WebSocket
serialize: v => JSON.stringify(v),
deserialize: v => JSON.parse(v),
},
)The type parameter is the interesting part of the API. createBirpc<ServerFunctions> takes the type of the functions the other side exposes, so the object returned has methods matching that interface, and a call with the wrong arguments is a type error rather than a runtime failure. The functions you pass locally, here clientFunctions, are the ones the remote side can call.
The MessageChannel case needs no serializer at all, because the platform handles encoding. The readme says MessageChannel will automatically serialize the message and support circular references out of the box, and the example reduces to two lines of transport:
const rpc = createBirpc<AliceFunctions>(
Bob,
{
post: data => channel.port1.postMessage(data),
on: fn => channel.port1.on('message', fn),
},
)One side binds port1 and the other port2, and both sides can call into the other. That symmetry is what makes the two-way part of the name real, and it is the reason the library is useful for worker communication where both ends are the same kind of code.
Serialization is your problem, and the readme says so
The most likely source of confusion for a new user is that birpc does not choose how your values are encoded.
The default examples use JSON.stringify and JSON.parse, which is the simplest thing that works over a WebSocket. The readme then flags the limitation directly: since JSON.stringify does not support circular references, it recommends structured-clone-es as the serializer when you expect circular references. The example swaps the two functions:
import { parse, stringify } from 'structured-clone-es'
const rpc = createBirpc<ServerFunctions>(
functions,
{
post: data => ws.send(data),
on: fn => ws.on('message', fn),
// use structured-clone-es as serializer
serialize: v => stringify(v),
deserialize: v => parse(v),
},
)That recommendation is the useful part of the design. birpc does not force a serializer on you because the right choice depends on your transport and your payload. JSON is readable in logs and cheap. structured-clone-es handles object graph shapes that JSON mangles, such as circular references. If your transport is a MessageChannel, the platform already does the encoding and you pass neither.
What birpc does not do is negotiate the format between the two ends. Both sides have to agree on the serializer, and if they disagree you get a parse error on one side rather than a clear protocol error. That is a real deployment consideration when one side is a browser with a hardcoded bundle and the other is a server you can change.
One more transport case is worth noting. birpc can run over Server-Sent Events for server to client traffic paired with HTTP POST for client to server, so a browser and an HTTP server can call each other with the same developer experience as the WebSocket case. The readme is careful about what that means in practice: because SSE is only half of a duplex channel, the post and on pairing takes more code than a WebSocket, and it is not part of the package. It is a copy-paste recipe you own, in examples/sse/channel, with shared.ts, client.ts and server.ts, dependency-free.
The SSE plus POST channel as a worked example
The examples/sse directory is the most instructive part of the repository, because it shows the shape of the transport you have to own when you go beyond a single bidirectional channel.
On the server side, an HTTP server handles two routes. A GET on /sse opens a session, and the channel it returns is wired into a birpc instance. A POST on /rpc is where incoming client messages arrive:
createServer(async (req, res) => {
if (req.method === 'GET' && req.url === '/sse') {
const { channel } = sessions.open(req, res)
const rpc = createBirpc<ClientFunctions>(serverFunctions, {
post: channel.post,
on: channel.on,
serialize: v => JSON.stringify(v),
deserialize: v => JSON.parse(v),
})
await rpc.hey('Server') // Hey Server from client
return
}
if (req.method === 'POST' && req.url === '/rpc')
await sessions.handlePost(req, res)
}).listen(3737)The server listens on port 3737. The client side mirrors it, creating a client channel pointed at http://localhost:3737 and passing its post and on into the same createBirpc call shape as the WebSocket example.
Two observations follow from the code. First, the SSE half is a long-lived HTTP response that the server pushes into, and the POST half is where the client sends. A single birpc instance cannot carry both directions on its own, so the recipe has to stitch two channels into one post and on pair. Second, the server calls back into the client, rpc.hey('Server'), as soon as the session is open, which demonstrates that the two-way capability is not limited to the client calling the server.
The readme points at examples/sse for the complete runnable demo, with a Node client and server plus a browser page, along with a writeup of how the transport works. One-to-multiple communication is a separate case, and the readme refers to test/group.test.ts as the example for it.
Where birpc is the wrong choice
The first limitation is the one inherent in the design. birpc models a request and a response. Every call returns a promise that resolves when the other side answers. There is no streaming response, no server-sent chunk of a long-running computation, no subscription model where the server pushes updates and the client registers interest. If your use case is a live feed, a progress stream or a bidirectional event channel, you need a protocol built for it, and birpc will make you encode that protocol yourself on top of the promise interface.
The second is the absence of a schema and generated stubs. The type safety comes from you passing a TypeScript interface to createBirpc, and nothing checks that the two sides agree on it. Both sides import their own types file, so the contract is enforced by discipline and by the compiler within one project, not by the library. The moment the two sides are in different repositories or written in different languages, birpc gives you no help beyond the serializer you chose.
The third is that the transport and the protocol are the same thing. birpc's message format is fixed, and it assumes the channel delivers one complete message per on callback. A transport that delivers partial frames, or a proxy that batches messages, will break the mapping. WebSocket and MessageChannel both deliver discrete messages, so both work. An arbitrary message bus with different delivery semantics will not.
The fourth is that the SSE path is explicitly not part of the package. The readme says it is a copy-paste recipe you own, which means it carries no maintenance guarantee and no fixes when the platform changes. A team that picks birpc specifically because it can run over HTTP is signing up for code they maintain themselves.
The fifth is scale and operational reality. The whole library is about half a kilobyte with no dependencies, which is the point, but it also means there is no connection management, no reconnection logic, no heartbeats, no request timeouts and no backpressure. In the examples those concerns belong to the WebSocket or to the session manager, and in production they will belong to you.
Editorial conclusion
birpc fits a TypeScript project that needs a small set of remote functions between a browser and a server, or between two workers, and wants to keep ownership of the transport, since the whole library is a post and an on callback and the serializer is yours to choose. It is the wrong choice if you need streaming responses, bidirectional subscriptions, or generated client stubs, because the design assumes one request maps to one resolved promise. Before adopting it, decide what your serializer will be, since the WebSocket example relies on JSON and the readme points at structured-clone-es for circular references, and note that the package version is 4.2.0 with the last push on 2026-08-19 under an MIT licence.
Frequently asked questions
What is birpc?
birpc is a message-based two-way remote procedure call library for TypeScript. You give it a post function and an on function, and it returns an object whose methods call the remote side and resolve with a promise. It is MIT licensed, has no dependencies and is about 0.5KB.
How do I use birpc with a WebSocket?
Pass ws.send as post and a handler registered on the WebSocket message event as on, and supply serialize and deserialize since a WebSocket needs to know the encoding. The readme's client example calls createBirpc<ServerFunctions> with those four options and then awaits rpc.hi('Client').
Does birpc support circular references?
Not with the default JSON serializer, since JSON.stringify does not support circular references. The readme recommends structured-clone-es as the serializer when you expect them, or using MessageChannel, which serializes the message and supports circular references out of the box.
Can birpc run over HTTP instead of WebSocket?
Yes, over Server-Sent Events for server to client paired with HTTP POST for client to server. The readme notes this is not part of the package but a copy-paste recipe in examples/sse/channel with shared.ts, client.ts and server.ts, because SSE is only half of a duplex channel.
What licence is birpc released under?
MIT, copyright 2021 Anthony Fu, with the licence file at the root of the repository. The package version is 4.2.0, released on 2026-08-19, and the last push to the repository was on the same day.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/antfu-collective-birpc)Was this useful?