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.

Building Block Intermediate
13 min read
rpc json-rpc grpc protocols microservices

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. .proto or .thrift files 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 through POST (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#

FormatShapeEncodingFrameworks
XML-RPCVerbose XML doc per callTextXML-RPC (1998); historical
SOAPXML envelope + WSDL schemaTextSOAP (1999); still in enterprise
JSON-RPC 2.0Tiny JSON envelope: method, params, idTextJSON-RPC; ubiquitous in Ethereum, Bitcoin, IDE tooling
ThriftApache Thrift IDL → binary framesBinaryThrift (Facebook 2007, then Apache)
Protocol Buffers + gRPC.proto IDL → protobuf frames over HTTP/2BinarygRPC (Google 2015); most common today
MessagePack-RPCSame as JSON-RPC but MessagePack bodyBinarymsgpack-rpc; niche
Cap’n ProtoSchema-evolved binary, zero-copy parseBinaryCap’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}).

Request
POST /rpc HTTP/1.1
Host: math.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOi...
{"jsonrpc": "2.0", "method": "Add", "params": [2, 3], "id": 1}
Response — success
HTTP/1.1 200 OK
Content-Type: application/json
{"jsonrpc": "2.0", "result": 5, "id": 1}
Response — error
HTTP/1.1 200 OK
Content-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.

JSON-RPC 2.0 server — Python (Flask)
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})

A 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’s oneway keyword 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:

PatternRequestResponseUse case
UnarysinglesingleClassic call (Add(2, 3) → 5)
Server-streamingsinglestreamServer pushes updates (live prices, log tail)
Client-streamingstreamsingleClient uploads chunks, server returns summary
BidirectionalstreamstreamVoice / 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#

VariantShapeWhere it fits
JSON-RPC 2.0Text-JSON over HTTP, optionally WebSocketLightweight internal APIs; blockchain RPC; LSP-style protocols
gRPCProtobuf over HTTP/2 with streamingHigh-throughput polyglot microservices; the modern default
TwirpProtobuf or JSON over HTTP/1.1A lighter-weight gRPC alternative; no streaming, no HTTP/2 dependency
Apache ThriftBinary IDL with multiple transportsFacebook-era polyglot services; still common in older stacks
SOAPXML envelope + WSDLEnterprise integration (banking, telecom, ERP); not new-design material
Cap’n ProtoZero-copy binary; promise-pipeliningNiche; 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 .proto or .thrift file 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/42 returns 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 OK with 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-Key for write-shaped RPCs. Retries are the rule, not the exception. See The Role of Idempotency in API Design.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.