Remote Procedure Calls (RPCs)
Calling a remote function as if it were local. The 'function-call' abstraction, its leaks, and where it's still the right call.
What it is#
A Remote Procedure Call (RPC) is a programming model where calling a function on a remote service looks, in your source code, like calling a local function. You write account.Debit(userID, 500); somewhere underneath, the RPC framework marshals the arguments, sends them over the network, waits for a response, unmarshals it, and returns it to you. The remote call has the same shape as a local one.
The idea is older than the modern web. Birrell and Nelson’s 1984 paper Implementing Remote Procedure Calls defined the canonical model: client stub, server stub, marshalling, unmarshalling. Every RPC framework since — Sun RPC (1985), CORBA (1991), DCOM (1996), Java RMI (1997), XML-RPC (1998), SOAP (1999), Thrift (2007), Protocol Buffers + gRPC (2008/2015), Cap’n Proto (2013), Twirp (2018) — is a variation on that template, optimising for a different point in the (typed-vs-dynamic, binary-vs-text, sync-vs-stream) trade-off space.
The appeal is obvious: developers know how to call functions. The cost is also well-understood: a remote call is not a local call, and pretending it is leaks every time. The Eight Fallacies of Distributed Computing (Deutsch, Gosling, et al., 1994) — “the network is reliable”, “latency is zero”, “bandwidth is infinite”, “the network is secure”, “topology doesn’t change”, “there is one administrator”, “transport cost is zero”, “the network is homogeneous” — were written exactly because the RPC abstraction encouraged developers to ignore each one.
Modern RPC frameworks (gRPC, Twirp, JSON-RPC 2.0) make the function-call abstraction without lying. The function returns; if the network failed, the function returns an error; the caller decides whether to retry. The abstraction is honest about its leaks.
When to use it#
Reach for RPC when:
- The consumer is another service in the same organisation. Internal microservice meshes are the canonical RPC ground. The latency is low, the parties know each other, the contracts can iterate.
- You want code-generated, strongly-typed clients in every language.
.protoor.thriftfiles generate Python, Go, Java, Rust, Swift, and C++ clients that all agree on the wire shape. This is what most teams actually want when they say “we need an internal API”. - The operations are fundamentally verb-shaped, not resource-shaped.
ComputeShippingQuote(items, address),RebalanceShards(table),TranscodeVideo(blobID, profile)— naming these as REST resources is a stretch. RPC names them directly. - You need bidirectional or server-streaming semantics. gRPC’s four streaming patterns (unary, client-stream, server-stream, bidi-stream) are first-class. Doing the same over REST requires WebSockets or SSE workarounds.
Avoid RPC when:
- The consumer is a browser. Browsers can speak gRPC-Web, JSON-RPC, or anything over HTTP, but the developer experience of REST + OpenAPI for browser-facing APIs is hard to beat.
- Cacheability matters. REST’s
GET+ETag+ CDN story is load-bearing for read-heavy public APIs. RPC tunnels everything throughPOST(or a binary frame) and bypasses HTTP caches entirely. - The audience is a third-party developer ecosystem. REST + OpenAPI is the lingua franca. RPC requires shipping client libraries; not every integrator wants one.
- The fan-out is across organisational boundaries with no shared deploy. Schema evolution becomes painful — every breaking change is a coordination problem with parties you don’t control.
How it works#
The classic RPC sequence diagram has four boxes and seven steps:
Caller Client Network Server Callee (app) stub stub (impl) │ │ │ │ │ 1. Result = Add(2, 3) │ │ │─────────────────►│ │ │ │ │ 2. marshal(method="Add", │ │ │ │ args=[2, 3]) │ │ │ │ 3. send bytes ────────────────►│ │ │ │ │ 4. unmarshal │ │ │ │─────────────►│ │ │ │ │ 5. return 5 │ │ │ 6. marshal │ │ │ 7. receive ◄───────────────────│ │ │ │ unmarshal │ │ │◄─────────────────│ Result = 5 │ │ │The two stubs are where every RPC framework spends its complexity:
- The client stub has the same signature as the local function. It marshals arguments, picks a server, sends the request, awaits the response, unmarshals, and returns. It is generated from an interface description (
.proto,.thrift, JSON Schema). - The server stub receives bytes, picks a method by name (or method ID), unmarshals arguments, calls the actual implementation, marshals the return value, sends bytes back.
Wire formats — the axis everyone argues about#
| Format | Shape | Encoding | Frameworks |
|---|---|---|---|
| XML-RPC | Verbose XML doc per call | Text | XML-RPC (1998); historical |
| SOAP | XML envelope + WSDL schema | Text | SOAP (1999); still in enterprise |
| JSON-RPC 2.0 | Tiny JSON envelope: method, params, id | Text | JSON-RPC; ubiquitous in Ethereum, Bitcoin, IDE tooling |
| Thrift | Apache Thrift IDL → binary frames | Binary | Thrift (Facebook 2007, then Apache) |
| Protocol Buffers + gRPC | .proto IDL → protobuf frames over HTTP/2 | Binary | gRPC (Google 2015); most common today |
| MessagePack-RPC | Same as JSON-RPC but MessagePack body | Binary | msgpack-rpc; niche |
| Cap’n Proto | Schema-evolved binary, zero-copy parse | Binary | Cap’n Proto (Kenton Varda 2013); rare but elegant |
The two that matter in 2026:
- JSON-RPC 2.0 — the lightweight default. The protocol spec is one page. The envelope is
{"jsonrpc": "2.0", "method": "...", "params": {...}, "id": 1}. Tooling is universal — every language can serialise JSON. Used in Ethereum (eth_getBlockByNumber), Bitcoin Core, the Language Server Protocol, and many internal admin APIs. Covered in this writeup. - gRPC — the polyglot, code-generated, streaming-capable default for serious internal RPC at scale. Has its own writeup in gRPC — Protobuf over HTTP/2.
A representative JSON-RPC exchange#
The wire-level look. id lets the caller match a response to a request when many calls share a connection. params can be positional ([2, 3]) or named ({"a": 2, "b": 3}).
POST /rpc HTTP/1.1Host: math.example.comContent-Type: application/jsonAuthorization: Bearer eyJhbGciOi...
{"jsonrpc": "2.0", "method": "Add", "params": [2, 3], "id": 1}HTTP/1.1 200 OKContent-Type: application/json
{"jsonrpc": "2.0", "result": 5, "id": 1}HTTP/1.1 200 OKContent-Type: application/json
{"jsonrpc": "2.0", "error": {"code": -32602, "message": "Invalid params"}, "id": 1}JSON-RPC has its own error-code vocabulary (-32700 Parse error, -32600 Invalid Request, -32601 Method not found, -32602 Invalid params, -32603 Internal error, plus a custom range for application errors). The transport is typically HTTP 200 OK on every call — the error lives in the JSON envelope, not the HTTP status. This is one of the historical leak points (the 200-with-error pattern that REST design rightly avoids); it’s the price of treating the transport as a dumb pipe.
A tiny JSON-RPC client and server in three languages#
A two-method service — Add(a, b) and Echo(message) — implemented as a JSON-RPC 2.0 service over HTTP. Same wire format across all three; one shows the server, one shows the client, one shows both.
from flask import Flask, request, jsonify
app = Flask(__name__)
METHODS = { "Add": lambda params: params[0] + params[1], "Echo": lambda params: params["message"],}
@app.post("/rpc")def rpc(): req = request.get_json() rid = req.get("id") try: fn = METHODS[req["method"]] result = fn(req.get("params")) return jsonify({"jsonrpc": "2.0", "result": result, "id": rid}) except KeyError: return jsonify({"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": rid}) except Exception as exc: return jsonify({"jsonrpc": "2.0", "error": {"code": -32603, "message": str(exc)}, "id": rid})package main
import ( "bytes" "encoding/json" "errors" "fmt" "net/http")
type rpcReq struct { JSONRPC string `json:"jsonrpc"` Method string `json:"method"` Params any `json:"params,omitempty"` ID int `json:"id"`}
type rpcResp struct { Result json.RawMessage `json:"result,omitempty"` Error *struct { Code int `json:"code"` Message string `json:"message"` } `json:"error,omitempty"` ID int `json:"id"`}
func Call(method string, params any, out any) error { body, _ := json.Marshal(rpcReq{JSONRPC: "2.0", Method: method, Params: params, ID: 1})
resp, err := http.Post("https://math.example.com/rpc", "application/json", bytes.NewReader(body)) if err != nil { return err } defer resp.Body.Close()
var r rpcResp if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { return err } if r.Error != nil { return errors.New(r.Error.Message) } return json.Unmarshal(r.Result, out)}
func main() { var sum int if err := Call("Add", []int{2, 3}, &sum); err != nil { panic(err) } fmt.Println("sum:", sum) // 5}// server.js — expressconst express = require("express");const app = express();app.use(express.json());
const methods = { Add: (params) => params[0] + params[1], Echo: (params) => params.message,};
app.post("/rpc", (req, res) => { const { method, params, id } = req.body; if (!methods[method]) { return res.json({ jsonrpc: "2.0", id, error: { code: -32601, message: "Method not found" }, }); } try { const result = methods[method](params); res.json({ jsonrpc: "2.0", id, result }); } catch (err) { res.json({ jsonrpc: "2.0", id, error: { code: -32603, message: err.message }, }); }});
app.listen(8080);
// client.jsasync function call(method, params) { const resp = await fetch("http://localhost:8080/rpc", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", method, params, id: 1 }), }); const body = await resp.json(); if (body.error) throw new Error(body.error.message); return body.result;}// await call("Add", [2, 3]) -> 5A real-world JSON-RPC service would add: batching (an array of requests in one POST), notifications (a request with no id means “fire and forget, don’t send a response”), and request-ID tracking on the client to correlate batched responses.
Synchronous vs asynchronous RPC#
Most RPC calls are synchronous: the caller’s thread (or async task) blocks until the response or a timeout. This is what makes RPC look like a local function call.
Asynchronous RPC decouples request and response. Two common flavours:
- Fire-and-forget: the caller sends the request and does not wait for a response. JSON-RPC notifications (no
id) and Thrift’sonewaykeyword are the wire-level forms. Useful for telemetry, log shipping, eventually-consistent commands. - Future / promise: the caller gets a token immediately and polls (or subscribes) for the result later. Used for long-running operations —
StartTranscode → jobID; PollJob(jobID)is the canonical shape.
Streaming RPC is the third axis: the request, response, or both are streams of messages rather than a single message. gRPC’s four streaming patterns are:
| Pattern | Request | Response | Use case |
|---|---|---|---|
| Unary | single | single | Classic call (Add(2, 3) → 5) |
| Server-streaming | single | stream | Server pushes updates (live prices, log tail) |
| Client-streaming | stream | single | Client uploads chunks, server returns summary |
| Bidirectional | stream | stream | Voice / chat / collaborative editing |
JSON-RPC does not natively support streaming; if you need it, you either layer WebSockets underneath (the LSP approach) or switch to gRPC.
Variants#
| Variant | Shape | Where it fits |
|---|---|---|
| JSON-RPC 2.0 | Text-JSON over HTTP, optionally WebSocket | Lightweight internal APIs; blockchain RPC; LSP-style protocols |
| gRPC | Protobuf over HTTP/2 with streaming | High-throughput polyglot microservices; the modern default |
| Twirp | Protobuf or JSON over HTTP/1.1 | A lighter-weight gRPC alternative; no streaming, no HTTP/2 dependency |
| Apache Thrift | Binary IDL with multiple transports | Facebook-era polyglot services; still common in older stacks |
| SOAP | XML envelope + WSDL | Enterprise integration (banking, telecom, ERP); not new-design material |
| Cap’n Proto | Zero-copy binary; promise-pipelining | Niche; latency-sensitive intra-cluster |
Trade-offs#
What RPC gives you:
- A function-call API that maps to the developer’s mental model. Less boilerplate than handcrafting a REST client.
- Code-generated clients in every language. One
.protoor.thriftfile becomes correct, type-safe clients in Python, Go, Java, Rust. - Compact wire formats. Protobuf and Thrift are 30-60% smaller than equivalent JSON on the wire.
- Strongly-typed contracts. The compiler catches field renames, removed fields, wrong types at build time.
- Streaming as a first-class concept. Bidirectional and server-streaming RPC are not bolted-on.
What RPC costs you:
- Cacheability is gone. Every RPC is a
POST(in JSON-RPC) or a binary frame; HTTP caches see one URL with opaque payloads. You build caching at the application layer or you don’t have it. - Browser support is weaker. gRPC needs a gRPC-Web proxy; JSON-RPC works but lacks the tooling ecosystem of REST.
- Discoverability is worse.
curl https://api/v1/users/42returns a readable JSON document. A gRPC endpoint returns nothing useful without the right tooling. - The function-call abstraction lies. Local calls do not fail with
DEADLINE_EXCEEDED, do not retry, do not need circuit breakers. Every RPC needs you to reason about partial failure, retries, idempotency. - Schema evolution is a discipline. Add fields by tag number, never reuse a tag, never change a type — the same rules every Protobuf user learns the hard way.
RPC. The right call for internal polyglot microservice meshes, latency-sensitive verb-shaped operations, and any service that needs bidirectional streaming. Strong types, code-gen, compact wire. Caches and intermediaries do less for you; you build correctness at the app layer.
REST. The right call for public APIs, browser-facing surfaces, resource-shaped domains, and anywhere cacheability matters. The biggest tooling ecosystem on Earth. Verbose for tiny operations; the function-call mental model is harder to map onto.
Common pitfalls#
- Designing RPC as if the network were local. No timeouts, no retries, no circuit breakers, no idempotency. Then a single slow downstream cascades into a service-wide outage. See The Circuit Breaker Pattern and Managing Retries.
- Reusing Protobuf field tag numbers. Reading old data with a new schema silently misinterprets fields. Never reuse a tag; never repurpose one.
200 OKwith a JSON-RPC error body in monitoring. Your dashboards will show 100% success while half the calls are erroring. Add JSON-RPC error-rate metrics explicitly.- No request IDs on the wire. Every RPC framework should propagate a trace ID; debugging without one is archaeology.
- Treating RPC like a local function in tests. Mocking out the RPC client with a synchronous in-process call hides every distributed-system bug. Integration tests against a real or fake server are mandatory.
- Streaming RPCs that never end. Long-lived streams need keepalive, idle-timeout, and a graceful-close path. A stuck stream eats a server-side goroutine or thread per client.
- Ignoring
Idempotency-Keyfor write-shaped RPCs. Retries are the rule, not the exception. See The Role of Idempotency in API Design.
Related building blocks#
- gRPC — Protobuf over HTTP/2 — the gRPC-specific story: Protobuf, HTTP/2 streams, code-gen, deadlines, interceptors.
- REST — The Architectural Style — the REST counterpart; resources and verbs vs methods.
- REST vs GraphQL vs gRPC — Comparison — REST vs RPC vs GraphQL, honestly.
- Binary Data Formats — Protobuf, MessagePack, Avro — Protobuf, Thrift, Cap’n Proto, MessagePack; what their wire formats actually look like.
- HTTP — The Foundational Protocol for APIs — the substrate RPC frequently rides on.