Remote Procedure Call

You write one modest line of code:

\texttt{result = add(2, 3)}

It looks exactly like calling a function you wrote ten lines up. But add does not live in your program. It lives on a machine in another building — maybe another continent. When your line runs, the numbers 2 and 3 are packed into a message, flung across the network to that faraway computer, added there, and the answer 5 is packed up and shipped all the way back — and only then does your variable result get its value. Yet in your source, none of that shows. It reads like an ordinary call.

That illusion is a Remote Procedure Call, or RPC: the idea that invoking code on another machine should look and feel like calling a function on your own. Instead of making the programmer hand-write socket code, serialise bytes, and parse replies, RPC hides the whole client–server conversation behind a plain function call. This page is about the machinery that pulls off that trick — and, just as importantly, about the places where the illusion leaks, because a call across a network can never truly be the same as a call across a stack frame.

The trick: stubs and marshalling

How can add(2, 3) possibly look local when the real add is elsewhere? The secret is a piece of stand-in code called a stub. On the caller's side there is a client stub — a fake add with the right name and signature that the programmer calls without a second thought. It looks like the function; it is really an impostor whose only job is to smuggle the call across the network.

When you call the client stub, four things happen in order:

Neither the programmer who wrote add(2, 3) nor the programmer who wrote the real add had to think about bytes, packets, or ports. The two stubs did all the packing and unpacking; the network sat invisibly in the middle. Follow one round trip below, step by step.

A toy RPC you can run

To see the mechanism with nothing hidden, here is a whole RPC squeezed into a single process — no real network, just the same shape. There is a marshal/unmarshal pair (JSON standing in for the wire format), a transport that would be the network, a client stub that serialises a call, and a server dispatcher that unmarshals it, runs the real function, and marshals the reply. Press Run ▶ and watch the bytes go across and come back.

// ---- the "wire format": values <-> a string of bytes ---- function marshal(value: unknown): string { return JSON.stringify(value); // serialise to bytes-on-the-wire } function unmarshal(bytes: string): any { return JSON.parse(bytes); // deserialise back to values } // ---- the SERVER: real functions, plus a dispatcher (skeleton) ---- const functions: Record<string, (...a: number[]) => number> = { add: (a, b) => a + b, mul: (a, b) => a * b, }; function server(requestBytes: string): string { const req = unmarshal(requestBytes); // UNMARSHAL the request console.log(" server got: " + requestBytes); const fn = functions[req.method]; // find the real function const result = fn(...req.args); // ordinary LOCAL call here const replyBytes = marshal({ result }); // MARSHAL the reply console.log(" server sends: " + replyBytes); return replyBytes; } // ---- the "network": just hands bytes from client to server ---- function transport(requestBytes: string): string { return server(requestBytes); // a real one would send packets } // ---- the CLIENT STUB: looks like a normal function ---- function stub(method: string, ...args: number[]): number { const requestBytes = marshal({ method, args }); // MARSHAL the call console.log("client sends: " + requestBytes); const replyBytes = transport(requestBytes); // over the "network" const reply = unmarshal(replyBytes); // UNMARSHAL the reply console.log("client got: " + replyBytes); return reply.result; } // ---- the programmer just writes this ---- const result = stub("add", 2, 3); console.log(""); console.log("result = add(2, 3) = " + result); // looks totally local!

The programmer's whole world is the last three lines: stub("add", 2, 3) returns 5. Everything above it — marshalling, the transport, the dispatcher — is exactly the plumbing a real RPC library generates for you from an interface definition.

The abstraction leaks

Making a remote call look local is a beautiful convenience, and also a quiet trap. A local call and a remote call are alike on the surface and profoundly different underneath. Wherever the two differ, the abstraction leaks — the network reality seeps through the pretty function call, and code written as if the call were local behaves badly.

These are not bugs in any particular RPC library; they are the unavoidable difference between "in this process" and "across a network." The danger is precisely that the syntax hides them. This tension is old and famous — the "Fallacies of Distributed Computing" begin with programmers assuming the network is reliable, instant, and free, exactly because RPC let them pretend it was.

This is the seductive misconception at the heart of RPC, and believing it will burn you. RPC makes a remote call look like a local one — same syntax — but it can never make it behave like one. A local call does not spend milliseconds on the wire, does not vanish into a network partition, cannot execute "somewhere between zero and two times." If you write remote calls as though they were free and infallible — chatty loops of tiny calls, no timeouts, no retry policy, sharing objects by reference in your head — the leak becomes a flood. Treat every RPC as what it is: a message that might not arrive, to a machine that might be gone, taking a time you cannot predict. Design for that, and RPC is a gift. Forget it, and it is a landmine.

The hard question: how many times did it run?

The nastiest leak deserves its own page-worth of care. Suppose your client sends a request and then … silence. The timeout fires. Something went wrong — but what? There are two very different possibilities, and from the client's chair they look identical:

The client cannot tell these apart. All it knows is "no answer came." So what should it do — give up, or retry? That single decision is the whole subject of delivery semantics:

Now the danger becomes concrete. Suppose the remote call is transfer($100), and you chose at-least-once (retry on timeout). The server receives the request, moves the $100, sends "done" — and the reply is lost. Your client, hearing nothing, retries. The server moves another $100. Your friend just got paid twice, and your account is short. The retry that saves a lost-request call doubles a lost-reply call.

The property that rescues you is idempotency: an operation is idempotent if doing it twice has the same effect as doing it once. "Set the balance to $500" is idempotent — running it again changes nothing. "Add $100 to the balance" is not — each run adds again. Retrying an idempotent call is harmless, so at-least-once is safe for it. Retrying a non-idempotent call is a bug waiting to happen. That is why well-built RPC systems attach a unique request ID to each call: the server remembers IDs it has already processed and quietly ignores duplicates — turning an at-least-once transport into effectively exactly-once effects.

Because not retrying doesn't make the problem go away — it just swaps a double-execution risk for a zero-execution risk. If you never retry (at-most-once) and it was the request that got lost, the transfer simply never happened and nobody told you. Neither extreme is safe on its own; the real fix is to make retries safe. Give each request a unique ID and have the server deduplicate: the first time it sees ID #1734 it does the transfer and records "done for #1734"; every retry of #1734 it recognises and returns the stored result without moving money again. Now the client can retry freely — the transport is at-least-once, but the effect is exactly-once. This is how real payment and messaging systems get correctness out of an unreliable network: not by avoiding retries, but by making the operation safe to repeat.

RPC today

The idea is decades old, but it is everywhere in modern systems, just under newer names. gRPC (Google's framework) marshals with a compact binary format called Protocol Buffers and runs over HTTP/2; you write an .proto interface and it generates your client and server stubs in a dozen languages. JSON-RPC is a lightweight cousin that marshals calls as small JSON objects — much like our toy above. Older relatives include Sun RPC, CORBA, and Java RMI. Even a plain REST/HTTP API is RPC-flavoured: a request names an operation and carries marshalled arguments, and a marshalled reply comes back. The vocabulary changes; the shape — stub, marshal, transport, unmarshal, run, reply — does not, and neither do the leaks. Whatever the badge on the box, a call that crosses the network is slow, can fail, passes by value, and might run more than once.