arpc: a Go RPC library with routers, middleware and pub/sub
More effective network communication, two-way calling, notify and broadcast supported.
At a glance
- What is it?
- ARPC is a Go library for remote procedure calls over TCP with a fixed little endian frame layout, path style routers, middleware, synchronous and asynchronous calls, a notify pattern, broadcast, pub/sub and singleflight call de-duplication. It is more a small RPC framework than a client library, and the part that will take you longest to understand is its reflection based service registration.
- Who is it for?
- Adopt arpc if you are writing Go services that need path style routing, middleware, a notify pattern without a response, broadcast or pub/sub, and you want one protocol instead of hand rolling framing, routing and reconnection yourself. Do not adopt it for a small internal service where net/rpc or a hand written JSON over HTTP is enough, and do not adopt it if your team cannot live with a protocol that is not HTTP.
- 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 34 days ago.
- What is it written in?
- Mainly Go, 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
Two-way calling and notify over a fixed frame
ARPC describes itself as more effective network communication, with two-way calling, notify and broadcast supported. The feature list is the clearest statement of what it does: two-way calling, two-way notify, sync and async calling, sync and async response, singleflight call de-duplication, batch write with writev and net.Buffers, broadcast, middleware, pub/sub, and Opentracing support.
The interaction model is simple to describe. There are two patterns, call and notify, and both are two-way in the sense that the client and server can each initiate traffic on the same connection. A call is a request and a response, a notify is a request without a response. That is a small vocabulary, and everything else in the library is a variation on it.
What makes it more than a client is the set of things around it. Routers mean a service exposes named routes rather than a single method. Middleware means you can put code in front of a call. Broadcast means one message can go to many clients, and pub/sub means clients can subscribe to topics.
The project is MIT licensed, the module is github.com/lesismal/arpc, and the go.mod declares go 1.22. The last push was on 2026-09-19 and the newest tag is v1.2.18 from the same day, with v1.2.17 from 2025-10-13 and v1.2.16 from 2024-06-22 before it, so the project is on a steady patch line rather than a yearly major cycle.
The README also lists third party benchmarks, the go-rpc-framework-benchmark, the rpcx-benchmark and the kitex-benchmark, with a blunt instruction that the author suggests you run the code yourself and get the real result rather than just believing other people's documentation. That is good advice for any RPC library and worth repeating when you read the numbers.
The frame header is fixed and little endian
The protocol is described in the README as a header layout, and it is worth understanding because everything in the library depends on it.
The header is little endian, and the fields are bodyLen, reserved, cmd, flag, methodLen, sequence, method and body, with sizes of 4 bytes, 1 byte, 1 byte, 1 byte, 1 byte, 8 bytes, methodLen bytes and bodyLen minus methodLen bytes respectively.
So every message carries a four byte total length, a one byte reserved field, a one byte command, a one byte flag, a one byte method length, an eight byte sequence number, the method name and then the payload. The length field covers the method name plus the body, and the method length is a separate field so the route can be read before the body is fully known.
The eight byte sequence number is the field that earns its place in a two-way protocol. When a client and a server can both send on the same connection, every request needs a way to be matched with its response, and a monotonic sequence number is the cheapest way to do that. It is also what makes the async variants possible, because a pending call can be parked in a map keyed by the sequence until the reply arrives.
The cmd and flag bytes are the extension points, and the flag byte in particular is what the middleware and the batching features are built on. Since the layout is fixed and documented, a client in another language is possible, but it is a real protocol implementation rather than a configuration change, and the README's own list of customisation points, which includes a custom net protocol and a custom codec, suggests that changing the wire format is a supported thing to do.
Registering routes, and the reflection based service registration
There are two ways to expose a service, and the second one is where the complexity lives.
The first is the router. Handlers are registered by path on a handler object, and there are three of those: arpc.DefaultHandler for a package, server.Handler for a server, and client.Handler for a client. A registered handler runs in the connection's reader goroutine, and a third argument to Handle makes it run in a new goroutine instead:
var handler arpc.Handler
// package
handler = arpc.DefaultHandler
// server
handler = server.Handler
// client
handler = client.Handler
// message would be default handled one by one in the same conn reader goroutine
handler.Handle("/route", func(ctx *arpc.Context) { ... })
handler.Handle("/route2", func(ctx *arpc.Context) { ... })
// this make message handled by a new goroutine
async := true
handler.Handle("/asyncResponse", func(ctx *arpc.Context) { ... }, async)The second is service registration. Instead of registering each route by hand, handler.Register(m, svc) reflects over the struct value svc and registers all of its eligible methods at once, each under the route name m plus a dot plus the method name, or just the method name when m is empty. If no method is registered, Register panics.
Two method shapes are recognised. The first is an exported method with the signature func(ctx context.Context, req *Request, rsp *Response) and no return values, where req and rsp are pointers to structs. The second is an exported method of type arpc.HandlerFunc, which is func(*arpc.Context).
The README then breaks the first shape into three cases in a table with a Performance column, and the interesting cell is the one that says reflection at call time, yes, with reflect.New. The auto-generated handler news the request and response structs, binds the request, calls the method and writes the response, and it does that per call, which is the cost you pay for not writing the handler by hand. The other cases trade that differently, and the table is the place to look if the reflection overhead matters to you.
The client is built around a connection factory
The quick start shows the client and the server, and the client is the more interesting of the two.
The server is straightforward. A server is created, a route is registered, and it runs on an address:
package main
import "github.com/lesismal/arpc"
func main() {
server := arpc.NewServer()
// register router
server.Handler.Handle("/echo", func(ctx *arpc.Context) {
str := ""
if err := ctx.Bind(&str); err == nil {
ctx.Write(str)
}
})
server.Run("localhost:8888")
}The client takes a function that returns a connection, not an address:
client, err := arpc.NewClient(func() (net.Conn, error) {
return net.DialTimeout("tcp", "localhost:8888", time.Second*3)
})
if err != nil {
panic(err)
}
defer client.Stop()
req := "hello"
rsp := ""
err = client.Call("/echo", &req, &rsp, time.Second*5)That shape is the design decision that makes the rest of the feature set possible. Because the client does not own the dialing, the caller decides how a connection is made, which means the same client code works over a plain TCP socket, over a TLS connection, over a proxy, or over anything else that can produce a net.Conn. It is also what allows the library to handle reconnection, since it can simply call the factory again.
The call itself is a method, a path, a request pointer, a response pointer and a timeout, and the timeout is explicit on every call, which is the right default for a network library and quietly different from the pattern where a single client wide timeout is configured once.
Singleflight, the send queue and the connection lifecycle
The API examples section of the README lists the operational features, and they are the ones that decide whether the library survives contact with a real network.
Singleflight call de-duplication is the first. When several callers make the same call at the same time, the library issues one request and shares the result. For a service where several goroutines ask for the same expensive thing at once, that is a meaningful reduction in load, and it is implemented in a singleflight.go file with its own test file, which is a hint about how self contained it is.
The send queue has its own lifecycle sections in the README, including one for handling a client's send queue overstock, which tells you that the queue is a bounded resource rather than an unbounded buffer. A client whose peer stops reading will fill the queue, and what happens then is the difference between a slow peer and a dead connection. The README also documents the API for customising the send queue capacity, and sections for handling a new connection, handling a disconnect, and handling a client's reconnect, so reconnection behaviour is something the application is told about rather than something it has to reverse engineer.
The rest of the list is about where the library stops and your code starts: a custom net protocol, a custom codec, a custom logger, custom operations before a connection's recv and send, and a custom client reader by wrapping net.Conn. That is a coherent set of extension points, and it is the opposite of a library that hard codes its wire format.
The repository layout supports the same story at the code level, with client.go, server.go, handler.go, context.go, proto.go, singleflight.go, stream.go, error.go and allocator.go at the top level, plus codec/, log/ and util/ directories, and a test file beside each source file.
Where ARPC is the wrong tool
The limits are about weight and about the protocol itself.
The first is that ARPC is a framework, not a function. Routers, middleware, broadcast, pub/sub, singleflight, tracing hooks and a customisable wire protocol are all in the box. If all you need is a Go client calling one method on one server, net/rpc or a hand written JSON over HTTP is a fraction of the surface area, and the cost of a bespoke protocol is a cost you only pay if you need the rest of the framework.
The second is that ARPC is not HTTP. The framing is a fixed binary layout with a custom method naming scheme, and the README's own list of customisation points makes it clear that changing that is a supported activity rather than an accident. That is fine for a system you control end to end, and it is a problem the moment one of your clients is not Go, because the other side has to implement the same protocol.
The third is that the middleware, routers and pub/sub are yours to get right. There is a Router Middleware section and a Coder Middleware section, and the singleflight behaviour interacts with both, since de-duplication changes what the server sees. A library this configurable will not stop you from configuring it badly.
The fourth is operational. The repository is a single module with a flat set of files and no external dependencies in go.mod beyond the standard library, which is a virtue, and it also means there is no service framework around it. There is no HTTP server, no health endpoint, no configuration system and no deployment story, so those parts of your system you still have to build.
Editorial conclusion
Adopt arpc if you are writing Go services that need path style routing, middleware, a notify pattern without a response, broadcast or pub/sub, and you want one protocol instead of hand rolling framing, routing and reconnection yourself. Do not adopt it for a small internal service where net/rpc or a hand written JSON over HTTP is enough, and do not adopt it if your team cannot live with a protocol that is not HTTP. Verify first that the reflection based Register method covers the method shapes your service uses, that the reconnect and send queue behaviour matches what you need, and that you are comfortable with a wire format that only this library speaks.
Frequently asked questions
How do I install and import arpc?
The README gives go get -u github.com/lesismal/arpc to install it, and the import statement is import "github.com/lesismal/arpc". The module requires Go 1.22 or newer.
What is the difference between a call and a notify in arpc?
Both are two-way in the sense that the client and server can each send on the same connection, but a call is a request and a response while a notify is a request without a response. The library also supports asynchronous variants of both, so a client can receive a response later through the async response mechanism.
How does handler.Register work in arpc?
It reflects over the struct value you pass and registers all of its eligible methods at once, each under the route name m plus a dot plus the method name, or just the method name when m is empty. It recognises an exported method with the signature func(ctx context.Context, req *Request, rsp *Response) and no return values, or an exported arpc.HandlerFunc method, and it panics if no method is registered.
What is the arpc wire format?
The header is little endian and holds bodyLen in 4 bytes, reserved in 1, cmd in 1, flag in 1, methodLen in 1, sequence in 8, then the method name and the body. The README also documents custom net protocol, custom codec and custom logger extension points.
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/lesismal-arpc)Was this useful?