Part 1 β Foundations of RPC (Remote Procedure Call)
(maang.io System Design Series)
What is this?
Imagine you're at your desk and you need a number crunched. You could do it yourself β or you could pick up the phone, call a colleague in another office, say "compute this for me," and a moment later they read the answer back. You wrote no network code, opened no socket, parsed no bytes. From where you sit, it felt almost exactly like calling a function. You said the name of the task, you handed over the inputs, you got a result.
That's a Remote Procedure Call. You call what looks like an ordinary local function β getUser(42) β and somewhere behind the scenes that call is packed up, shipped across the network to another machine, executed there, and the result is shipped back and handed to you as a normal return value. A piece of generated code called a stub (or proxy) sits between you and the network and performs the magic trick: it makes a call to another computer look like a call to a function in your own program.
The one-line idea: RPC lets you call a function that secretly runs on another machine, hiding the network behind a local-looking call β but the network is not actually invisible, and pretending it is will eventually bite you.
That second half of the sentence is the soul of this whole topic. The stub gives you a beautiful illusion: "the network is just a function call." But it's an illusion, and a leaky one. A local function call never times out, never half-completes, never costs 80 milliseconds, never silently vanishes because a switch rebooted. A remote one does all of those things. Learning RPC well means enjoying the convenience of the illusion while never trusting it. We'll earn that lesson across these four parts, and it ties directly to networking failure modes and the CAP theorem β when the network breaks, your "function call" is the thing standing in the breach.
1. Why RPC exists
Step back to the problem RPC was invented to solve. You've split your application into services β a user service, an order service, a payment service β and now they have to talk to each other constantly. Service-to-service chatter is the inner loop of a microservice system; a single user request might fan out into dozens of internal calls.
You could make every one of those internal calls a hand-rolled HTTP request: build a URL, serialize a JSON body, set headers, send it, parse the response, check the status code, map errors. That's fine once. It's miserable a thousand times, across a dozen teams, in five languages. Three things go wrong at scale:
- Boilerplate everywhere. Every caller re-implements the same serialize/send/parse/error-handle dance, and every implementation drifts a little.
- No shared contract. The order service thinks the user service returns a field called
email. Nobody enforces that. A rename ships, and things break at runtime, in production, far from the change. - The text tax. JSON is verbose and string-typed. Encoding numbers as decimal text, re-parsing field names on every message, shipping 2β3Γ more bytes than you need β it adds up fast when you're doing it millions of times a second between machines in the same data center.
RPC attacks all three. You write the contract once in a schema, a tool generates the client and server code so nobody hand-rolls anything, and the data travels in a compact binary form. The result is internal communication that is fast, structured, type-checked, and boring β which is exactly what you want from plumbing. This is why gRPC (Google), Thrift (Meta), Finagle (Twitter), and Dubbo (Alibaba) carry the internal traffic of some of the largest systems on earth. We dig into gRPC β the dominant modern RPC framework β in its own chapter.
2. How an RPC actually works: the full call flow
This is the mechanism you must be able to draw on a whiteboard. Let's trace a single call, getUser(42), from the client's stack frame all the way to the server and back. Six moving parts: the stub, marshalling, the transport, the skeleton, unmarshalling, and the actual function.
Walking it slowly, because each step is a place where things can leak:
- The call. Your code invokes
getUser(42). ButgetUserhere isn't the real implementation β it's the stub, a generated stand-in with the same signature. It accepts your call so the rest of your code never knows the difference. - Marshalling. The stub takes the method name and the argument
42and serializes them into a compact byte string β a process called marshalling (or serialization). "Turn live objects into a flat sequence of bytes I can put on a wire." - Transport. Those bytes go out over the network β typically TCP, and for modern frameworks, framed inside HTTP/2 (more on transports in Part 2).
- Dispatch. On the server, the skeleton (the server-side stub, sometimes called the dispatcher) receives the bytes, unmarshals them back into a method name and arguments, and figures out which real function to call.
- Execution. The skeleton invokes the actual
getUser(42)β real business logic, real database query β and gets aUserback. - The return trip. The result is marshalled, sent back across the network, unmarshalled by the client stub, and handed to your code as the return value of the call you made in step 1.
π‘ Here's the mental model to lock in: the stub and skeleton are mirror images, and marshalling/unmarshalling is a matched pair on each end. The whole apparatus exists to convert "a function call" into "bytes on a wire" and back, on both sides, automatically. You write none of it β but you should be able to picture all of it, because every failure mode lives in one of these steps.
3. The Interface Definition Language (IDL): the contract
How do the client and server agree on what getUser looks like β its arguments, its return type, what counts as a valid request? They don't negotiate it at runtime. They share a single source of truth written ahead of time in an Interface Definition Language (IDL).
An IDL is a small, language-neutral schema that describes your services and messages. Here's the flavor, in Protocol Buffers (gRPC's IDL):
message GetUserRequest { int64 user_id = 1; }
message User { int64 id = 1; string name = 2; string email = 3; }
service UserService {
rpc GetUser(GetUserRequest) returns (User);
}
That tiny file is the contract. You run it through a code generator and out come the stubs β a client library and a server interface β in whatever languages you need:
This is the part that makes RPC feel magical and safe. Because the contract is generated, not hand-written:
- Types are checked at compile time. Pass a string where a
user_idshould be anint64and your build fails β not your 2 a.m. pager. - Languages stop mattering. A Go service and a Java service that share a
.protocan call each other flawlessly; each just generates a stub in its own language. - The schema can evolve safely. Notice the
= 1,= 2,= 3β those are field numbers, not positions. The wire format identifies fields by number, so you can add new fields or reorder lines without breaking old clients. (We cover schema evolution properly in Part 2; it's one of RPC's superpowers.)
The big IDL/serialization families you'll hear named: Protocol Buffers (Google, the default for gRPC), Apache Thrift (Meta), and Apache Avro (the data/streaming world, prized for schema evolution). All three do the same core job β one contract, many generated stubs, compact binary on the wire.
4. The leaky abstraction: the network is not invisible
Now the most important section in this chapter, the one that separates people who use RPC from people who understand it. The stub's job is to make a remote call look local. The danger is believing it.
A local function call has properties you've relied on your whole career without noticing: it always returns (or throws) promptly, it never half-runs, it costs nanoseconds, and "I called it" and "it executed" are the same event. A remote call quietly breaks every one of those assumptions. This is the famous list known as the Fallacies of Distributed Computing β the things engineers assume about networks that are all false:
The two that bite RPC hardest:
- Latency is not zero. A local call is essentially free. A cross-machine call is hundreds of microseconds at best; a cross-region call is 100+ milliseconds. Code that loops calling a remote function "just like a local one" β the classic chatty interface β can be thousands of times slower than the developer imagined. The convenient illusion hides a real cost.
- The network is not reliable β which means partial failure. This is the deep one. When a local call fails, it fails completely and visibly β you get an exception, and nothing happened. When a remote call fails, you can land in a terrifying middle state: you don't know whether it ran.
Look closely at where a call can die, because the ambiguity is the whole problem:
You sent chargeCard($50) and the call timed out. Did the charge happen? You cannot know. Maybe the request never reached the server (safe to retry). Maybe the server charged the card and the response got lost on the way back (retrying double-charges the customer). A timeout is not "it failed" β it's "I don't know," and "I don't know" is the hardest state in all of distributed systems. It's the same ambiguity at the heart of the CAP theorem: a silent network looks identical to a dead peer.
β οΈ The takeaway: never write RPC code that assumes a call either fully succeeded or fully failed. It can also do the worst thing β succeed on the server while appearing to fail to the client. Designing around that ambiguity is what Part 3 calls delivery semantics, and it's where idempotency becomes your best friend.
5. Synchronous vs. asynchronous RPC
There's a second design axis: when you make the call, does your thread wait?
- Synchronous RPC is the default and the most natural: you call, your thread blocks until the answer comes back, then you continue. It reads like ordinary code, which is the point. The cost is a held resource β a thread or connection sits idle waiting. Under load, or in a deep chain of calls (A waits on B waits on C), blocked threads pile up and you risk thread-pool exhaustion and cascading timeout stacking.
- Asynchronous RPC doesn't block. The call returns a Future/Promise (a placeholder for "the answer, eventually") right away, and your thread moves on; a callback fires when the result lands. This uses resources far more efficiently at high throughput and is the natural fit for event-driven code β but it's harder to read and reason about, and error handling gets fiddlier.
The honest rule of thumb: reach for synchronous when one call's result is needed right now to continue (most user-facing request handlers); reach for asynchronous when you're firing many calls in parallel, fanning out, or can't afford to park threads. And when a call is truly fire-and-forget β you don't need the answer at all β that's a hint you might not want RPC at all, but messaging (a queue). We draw that line sharply in Part 3.
6. RPC vs. REST: the contrast in one breath
You'll be asked this constantly, so let's frame it cleanly. The deepest difference is what you're naming when you make a call.
- REST is resource-oriented. You name a thing (
/users/42) and act on it with a small, fixed set of verbs (GET, POST, PUT, DELETE). It speaks human-readable JSON over HTTP/1.1, it's cacheable, browser-friendly, and universally understood. That openness is its superpower for public APIs and third-party integrations. - RPC is action-oriented. You name a verb β
getUser,chargeCard,transcodeVideoβ and call it like a method. It speaks compact binary over (usually) HTTP/2, enforces a typed contract, and supports streaming. That tight, fast, schema-checked style is its superpower for internal service-to-service traffic.
| REST | RPC | |
|---|---|---|
| You name a... | resource (noun) | procedure (verb) |
| Payload | text JSON (verbose) | binary (compact) |
| Contract | informal / OpenAPI | enforced IDL, generated stubs |
| Typical transport | HTTP/1.1 | HTTP/2 |
| Caching | easy (HTTP semantics) | not built in |
| Best at | public APIs, browsers, third parties | internal microservices, low latency |
The senior framing isn't "RPC beats REST." It's REST at the edge, RPC on the inside. Expose a REST (or GraphQL) API to the outside world for reach and flexibility; use RPC between your own services for speed and safety. Most large systems run both, and the API gateway is where one becomes the other. We'll go deeper on this β and add messaging and WebSockets to the comparison β in Part 3.
Key takeaways
- RPC makes a remote function call look local. A generated stub marshals your call into bytes, ships them over a transport to a server skeleton that unmarshals and runs the real function β and the result travels back the same way.
- The contract lives in an IDL (Protobuf, Thrift, Avro). Write it once; generate type-safe, cross-language stubs from it. This is what makes RPC fast and safe.
- The local-call illusion is a leaky abstraction. The network is not free and not reliable β the Fallacies of Distributed Computing are all false, and partial failure means a timeout tells you "I don't know if it ran," not "it failed."
- RPC comes in synchronous (block and wait, simple) and asynchronous (Future/Promise, efficient at scale) flavors; choose by whether you need the answer to proceed.
- REST names resources for the public edge; RPC names procedures for the internal core. The grown-up architecture uses both.
Next, Part 2 cracks the box open: serialization formats and schema evolution, the transports (TCP, HTTP/2, QUIC) and why HTTP/2 multiplexing matters, plus service discovery, load balancing, and the resilience middleware that makes RPC survivable in production.