paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

RPC Before tRPC and gRPC: Understanding the Fundamentals That Power Modern Remote Calls

A deep dive into the mechanics, history, and core concepts of Remote Procedure Call - the foundation every engineer should understand before reaching for tRPC, gRPC, or any modern RPC framework.

Introduction

Every time a developer writes a tRPC procedure or defines a Protobuf service for gRPC, they are standing on decades of protocol design, distributed systems research, and engineering trade-offs that most never stop to examine. The tools are good. The documentation is solid. The onboarding is fast. But without understanding what RPC actually is - not just how to use it, but why it works the way it does - engineers end up cargo-culting patterns, misdiagnosing failures, and making architectural decisions that only make sense on paper.

This article is not about tRPC or gRPC specifically. It is about the idea underneath them: Remote Procedure Call. What it means to call a function that executes somewhere else. What problems that seemingly simple abstraction introduces. What the tradeoffs look like in practice. And why every modern RPC framework - no matter how different it looks on the surface - is solving roughly the same set of problems that researchers identified in the 1970s and 1980s.

If you already use gRPC or tRPC in production, understanding this material will make you a better debugger, a better architect, and a more careful user of the abstractions these frameworks provide. If you are evaluating which framework to use, this foundation will give you a principled basis for that decision rather than a vibe-driven one.

What Is Remote Procedure Call?

At its core, a Remote Procedure Call is an abstraction that allows a program to call a function - a procedure - that executes in a different address space, typically on a different machine over a network, as if it were a local function call. The caller does not need to explicitly handle networking, serialization, or protocol framing. From the caller's perspective, the invocation looks like any other function call.

This is the fundamental promise of RPC: location transparency. The idea is that the distributed nature of the computation should be invisible, or at least not mandatory for the caller to manage explicitly. You call getUserById(42) and you get a user back. Whether that function ran in the same process, in a different process on the same machine, or on a server in another data center should ideally be an implementation detail.

The term itself traces back to RFC 707 (1976) by John White, and was further formalized by Birrell and Nelson in their seminal 1984 paper "Implementing Remote Procedure Calls" (published in ACM Transactions on Computer Systems). That paper introduced concepts - stubs, marshalling, binding - that are still the conceptual vocabulary used in every RPC system today, including gRPC and tRPC. The vocabulary changed; the problems did not.

The Mechanics: What Actually Happens in an RPC Call

Understanding what happens under the hood during an RPC call is essential, because every failure mode, every latency source, and every semantic difference from local calls flows from these mechanics.

When a client calls a remote procedure, the following sequence occurs:

  1. The client stub is invoked. The stub is a local proxy that looks like the function being called. It accepts the same arguments.
  2. Marshalling (serialization). The stub serializes the arguments into a byte representation suitable for transmission - this could be JSON, binary Protobuf, MessagePack, or any other format.
  3. Network transmission. The serialized bytes are sent over the network to the server, using some underlying transport (TCP, HTTP/1.1, HTTP/2, etc.).
  4. The server skeleton receives the request. On the server side, a corresponding stub (sometimes called a skeleton) receives the bytes and deserializes them back into language-level data structures.
  5. The actual function executes. The server runs the real implementation of the procedure.
  6. The result is marshalled and returned. The return value is serialized and sent back to the client over the same channel.
  7. The client stub unmarshals the response and returns it to the caller as if the function returned normally.

The entire interaction is hidden from the caller. From their perspective, step 1 and step 7 are all they see. Everything in between is the responsibility of the framework. This is elegant when it works and deeply confusing when it does not - because failures in steps 2 through 6 surface as exceptions or timeouts in code that looks like a simple function call.

The Fundamental Problem: A Local Call Is Not a Network Call

The abstraction of location transparency is useful, but it is also dangerous if taken too literally. Local procedure calls and remote procedure calls are fundamentally different in ways that matter enormously for correctness and reliability. Peter Deutsch and James Gosling articulated this famously in the "Fallacies of Distributed Computing" (originally attributed to L. Peter Deutsch at Sun Microsystems, circa 1994). The first fallacy is the most relevant here: the network is reliable.

A local function call will complete. It may raise an exception, but the caller will know about it immediately and deterministically. A remote call can fail in far more ways: the network can drop the packet, the server can crash mid-execution, the response can be lost on the way back, or the connection can time out without either side knowing what happened to the request.

This creates a category of failure that does not exist in local computing: partial failure. The server may have received and executed the request but the client never got the response. From the client's perspective, the call timed out. Was the operation performed? Unknown. This is why RPC systems that expose exactly-once semantics are either very careful about idempotency or honest about the fact that exactly-once is very hard to achieve across failure boundaries.

The Eight Fallacies of Distributed Computing and RPC

Deutsch's fallacies are a useful lens for understanding why RPC systems need to be designed carefully:

  1. The network is reliable.
  2. Latency is zero.
  3. Bandwidth is infinite.
  4. The network is secure.
  5. Topology doesn't change.
  6. There is one administrator.
  7. Transport cost is zero.
  8. The network is homogeneous.

Every one of these has direct implications for RPC system design. Fallacy 2 (zero latency) means you cannot use RPC to replace in-process function calls without accepting a performance penalty that can easily be 100x-1000x higher in wall-clock time. Fallacy 1 (reliable network) means you need retry logic, circuit breakers, and idempotency. Fallacy 4 (secure network) means you need mutual TLS, authentication, and authorization at the RPC layer - not just at the perimeter.

Frameworks like gRPC internalize many of these realities. They provide built-in support for deadlines, cancellation, retry policies, and TLS. tRPC, operating over HTTP, inherits HTTP's security model and relies on the ecosystem for retry and timeout behavior. The frameworks handle the mechanics, but the engineer still needs to understand why those features exist. Otherwise you end up disabling deadline propagation because it's inconvenient, not realizing you just removed your system's ability to bound its own latency under load.

Core Concepts Every Engineer Must Understand

Stubs and Code Generation

The client stub is a generated or hand-written proxy object that implements the same interface as the remote service. In early RPC systems (Sun RPC, DCE RPC), stubs were generated from an Interface Definition Language (IDL) specification. The IDL described the service: its procedures, their argument types, and return types. A compiler would then emit client stubs and server skeletons in the target language.

gRPC uses .proto files (Protocol Buffers IDL) and the protoc compiler to generate stubs. tRPC takes a different approach: it uses TypeScript's type system directly, inferring the interface from the router definition. Both approaches achieve the same goal - a type-safe contract between client and server - but they make different tradeoffs around language portability, schema evolution, and tooling.

The IDL-first approach (gRPC, Thrift, older Sun/DCE RPC) makes the schema the source of truth, which is beneficial in polyglot environments. The code-first approach (tRPC) ties the schema tightly to the implementation language, which eliminates boilerplate but restricts you to that language ecosystem.

Marshalling and Serialization

Marshalling is the process of converting in-memory data structures into a byte format that can be transmitted over the network. Unmarshalling is the reverse. This step is often glossed over in tutorials but it has significant performance and compatibility implications.

Different RPC systems make different serialization choices. Sun RPC used XDR (External Data Representation), a binary format. gRPC uses Protocol Buffers, which produces compact binary output and supports backward/forward-compatible schema evolution through field numbering. JSON-based RPC systems (JSON-RPC, tRPC's default over HTTP) trade compactness for human readability and broad tooling support.

The choice of serialization format matters when you care about payload size, CPU cost of encoding/decoding, schema evolution strategy, and debuggability. A binary format like Protobuf is faster and smaller but harder to inspect with basic tools like curl or browser DevTools. JSON is verbose but trivially debuggable. This tradeoff recurs in every RPC system design discussion.

Binding: How Does the Client Find the Server?

In a local call, the function address is resolved at compile time or link time. In a distributed system, the server's location - its IP address, port, and available procedures - must be discovered dynamically. This is the binding problem.

Early systems solved this with a portmapper or binder: a well-known service at a fixed address that the client could query to find the server's current endpoint. Modern service meshes, DNS-based service discovery, and Kubernetes service objects are all evolved answers to the same binding problem. gRPC integrates with service discovery through its name resolver and load balancer interfaces. tRPC, being HTTP-native, can use any HTTP load balancer or service registry.

The binding step is also where security becomes relevant. How does the client know it's talking to the right server? Certificate-based TLS with mutual authentication is the standard answer for production gRPC deployments. Getting this wrong means your RPC framework's type safety and reliability features are protecting you from programming errors but not from man-in-the-middle attacks.

Synchronous vs. Asynchronous RPC

The original conception of RPC was synchronous: the caller blocks until the remote function returns. This maps cleanly onto the mental model of a local function call. But blocking threads across network boundaries is expensive. If a thread blocks waiting for a response that takes 200ms, and you have 1000 concurrent requests, you need 1000 threads - each consuming memory and OS scheduling resources.

Modern RPC frameworks address this through async APIs. gRPC provides asynchronous client and server APIs in most languages, and supports streaming - both server-side streaming (one request, many responses) and bidirectional streaming (many requests, many responses over a single connection). tRPC is built on top of async JavaScript, so every procedure call returns a Promise natively. Understanding whether your RPC framework is truly async end-to-end, or merely provides an async API over a synchronous blocking thread pool, is important for capacity planning.

Historical Context: From Sun RPC to XML-RPC to What We Have Now

Sun RPC and ONC RPC

The most widely deployed early RPC system was Sun Microsystems' Remote Procedure Call, part of the Open Network Computing (ONC) suite developed in the early 1980s. Sun RPC used XDR for serialization, a portmapper for service discovery, and generated stubs from a .x file using the rpcgen tool. It was used as the foundation for NFS (Network File System), which is how most Unix engineers ended up encountering it in practice.

Sun RPC was designed for a trusted LAN environment. Security was minimal. The programming model was synchronous. Type support was limited to what XDR could express. But it worked reliably for its intended use cases - internal Sun infrastructure and enterprise NFS deployments - and it proved the concept at scale.

DCE RPC and CORBA

The Open Group's Distributed Computing Environment (DCE) RPC was a more ambitious attempt in the late 1980s and early 1990s to build a comprehensive RPC infrastructure for enterprise computing. It added stronger security (Kerberos-based authentication), UUID-based interface identification, and a more sophisticated distributed directory service. DCE RPC also influenced Microsoft's DCOM (Distributed Component Object Model) and its MSRPC variant, which underpins many Windows networking protocols to this day.

CORBA (Common Object Request Broker Architecture), standardized by the Object Management Group starting in 1991, was the era's attempt at a language-neutral, platform-neutral RPC system centered on object-oriented abstractions. CORBA's IDL was expressive, its interface repository sophisticated, and its ambitions vast. It became a cautionary tale - not because the ideas were wrong, but because the complexity of the standard and the slow, fragmented implementations created an ecosystem that was painful to work with in practice. Many engineers who lived through CORBA's era view its failure as a direct reason for the later embrace of simpler REST-based APIs over HTTP.

XML-RPC and SOAP

In the late 1990s, with the rise of the public internet and the need for interoperability across organizational boundaries, XML-RPC emerged as a simple way to call remote procedures using HTTP as the transport and XML as the serialization format. It was deliberately minimal: a few data types, a simple request/response envelope, and nothing else.

SOAP (Simple Object Access Protocol) grew from similar motivations but added WS-* specifications for security, transactions, and reliability - growing into something nearly as complex as CORBA, this time in XML. SOAP's WSDLs and WSDL-based tooling had some of the same problems CORBA's IDL did: the generated code was verbose, the debugging experience was poor, and the learning curve was steep.

Both XML-RPC and SOAP ran over HTTP, which made firewall traversal easy and allowed using standard HTTP infrastructure. This was a practical advantage over binary RPC systems that used custom ports and protocols. HTTP's ubiquity as a transport became a hard constraint that later systems - including REST and gRPC - had to reckon with.

The REST Interlude

REST (Representational State Transfer), articulated by Roy Fielding in his 2000 dissertation, was not an RPC system. It was an architectural style for distributed hypermedia systems. But in practice, "RESTful APIs" became the dominant way to structure remote service communication through the 2000s and 2010s, largely as a reaction to SOAP's complexity. REST over HTTP with JSON was simple, debuggable, broadly understood, and required no special tooling to consume.

REST's success came with tradeoffs that are now well-understood: no formal type system, no schema enforcement at the protocol level, no built-in streaming, and the impedance mismatch between REST's resource orientation and the procedure orientation of most application code. When you need to call sendEmailToUser(userId, templateId, variables), modeling that as a REST resource is awkward. These friction points are precisely what tRPC and gRPC address - not by abandoning HTTP, but by layering better abstractions on top of it.

Practical Example: Implementing a Simple RPC System from Scratch

Understanding what RPC frameworks do for you is best illustrated by building a stripped-down version manually. The following TypeScript example implements a minimal RPC system: an in-process one first (to establish the pattern), then extended to work over HTTP.

Step 1: The Local Baseline

// Local function - no RPC involved
async function getUserById(id: number): Promise<{ id: number; name: string }> {
  // Simulated database lookup
  return { id, name: `User ${id}` };
}

// Caller sees this
const user = await getUserById(42);
console.log(user.name); // "User 42"

This is the baseline. It works. There is no network, no serialization, no failure modes beyond the function throwing an error.

Step 2: Introducing the Stub Pattern

// Shared type contract (what IDL would generate)
type GetUserByIdRequest = { id: number };
type GetUserByIdResponse = { id: number; name: string };

// Server-side handler (the real implementation)
async function handleGetUserById(
  req: GetUserByIdRequest
): Promise<GetUserByIdResponse> {
  return { id: req.id, name: `User ${req.id}` };
}

// Client-side stub: looks like a normal function but does RPC
async function getUserById(id: number): Promise<GetUserByIdResponse> {
  const request: GetUserByIdRequest = { id };

  // Marshal: serialize the request
  const body = JSON.stringify(request);

  // Transport: send over HTTP
  const response = await fetch("http://localhost:3000/rpc/getUserById", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body,
  });

  if (!response.ok) {
    throw new Error(`RPC failed: ${response.status} ${response.statusText}`);
  }

  // Unmarshal: deserialize the response
  const result: GetUserByIdResponse = await response.json();
  return result;
}

Step 3: The Server Dispatcher (Skeleton)

import http from "http";

// Registry of available procedures
const procedures: Record<
  string,
  (req: unknown) => Promise<unknown>
> = {
  getUserById: handleGetUserById,
};

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url!, `http://${req.headers.host}`);
  const procedureName = url.pathname.split("/rpc/")[1];

  if (!procedureName || !procedures[procedureName]) {
    res.writeHead(404, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ error: "Unknown procedure" }));
    return;
  }

  const chunks: Buffer[] = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", async () => {
    try {
      // Unmarshal the incoming request
      const requestBody = JSON.parse(Buffer.concat(chunks).toString());

      // Dispatch to the real handler
      const result = await procedures[procedureName](requestBody);

      // Marshal the response
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify(result));
    } catch (err) {
      res.writeHead(500, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: String(err) }));
    }
  });
});

server.listen(3000, () => console.log("RPC server listening on :3000"));

This is, in essence, what every RPC framework does under the hood. The client stub serializes arguments and calls the transport. The server skeleton receives, deserializes, dispatches, and returns. What frameworks like gRPC and tRPC add is type safety, code generation, schema evolution, streaming, middleware, and production-grade error handling - but the core loop is what you see above.

Error Handling Patterns in RPC

One area where hand-rolled RPC and production frameworks diverge sharply is error semantics. In the example above, any server-side exception becomes a generic 500 response. Real RPC systems need structured error types that the client can inspect and react to. gRPC defines a set of standard status codes (OK, CANCELLED, NOT_FOUND, PERMISSION_DENIED, DEADLINE_EXCEEDED, etc.) that map cleanly onto distributed system failure modes. tRPC uses a similar set inherited from HTTP status codes, with additional error shapes that carry metadata.

// Structured error handling in a minimal RPC system
class RpcError extends Error {
  constructor(
    public readonly code:
      | "NOT_FOUND"
      | "PERMISSION_DENIED"
      | "INTERNAL"
      | "DEADLINE_EXCEEDED",
    message: string,
    public readonly details?: Record<string, unknown>
  ) {
    super(message);
    this.name = "RpcError";
  }
}

async function handleGetUserById(
  req: GetUserByIdRequest
): Promise<GetUserByIdResponse> {
  // Simulate a not-found condition
  if (req.id > 1000) {
    throw new RpcError("NOT_FOUND", `User ${req.id} does not exist`);
  }
  return { id: req.id, name: `User ${req.id}` };
}

Structured errors allow the client stub to catch specific error codes and apply appropriate logic: retry on DEADLINE_EXCEEDED, surface a 404 on NOT_FOUND, never retry on PERMISSION_DENIED.

Trade-offs and Common Pitfalls

The Transparency Illusion

The biggest risk with RPC is treating remote calls as if they were local calls. This leads to calling RPC procedures in tight loops, neglecting to set deadlines, ignoring partial failure modes, and being surprised by latency spikes. The abstraction that RPC provides is useful for code organization and type safety, but it should never lead an engineer to forget that the call crosses a network boundary.

A common symptom is the N+1 problem: a handler fetches a list of 100 IDs and then issues 100 individual RPC calls to retrieve each record. Each call might take 5ms. The total latency is 500ms. With batching - which requires thinking about the RPC interface differently - it could be one call taking 10ms. Local functions make the N+1 pattern cheap enough that you might not notice it. RPC makes it expensive enough to matter.

Schema Evolution

When the caller and the server are updated independently - which is always the case in microservices - the schema must evolve safely. Adding a required field to a Protobuf message is a breaking change. Removing a field that the client still sends causes issues if the server discards it and the client expects acknowledgment of it. Schema evolution is a governance and process problem as much as a technical one, but the serialization format determines how hard it is technically.

Protocol Buffers' approach - optional fields with explicit field numbers, never reusing a field number even after deprecation - makes backward and forward compatibility possible by convention. JSON schemas lack these guarantees unless you enforce them through tooling. tRPC's TypeScript-first approach uses Zod or similar validators to enforce runtime schema contracts, but versioning across independently deployed services still requires discipline.

Deadline and Cancellation Propagation

If service A calls service B which calls service C, and the original caller of A sets a deadline of 100ms, that deadline should propagate to B and C. If it does not, B and C will keep working after the original caller has already given up, consuming resources for a result nobody will ever use. This is deadline propagation, and it is a first-class concept in gRPC via context-bound deadlines.

In practice, deadline propagation requires instrumentation at every call site. If a single hop in the chain fails to pass the deadline context through, you lose the propagation. This is subtle enough that it regularly occurs in production systems. The result is backends that are busy servicing requests for which the upstream has already timed out - a form of self-inflicted overload.

Over-RPC-ification

Not every piece of application logic should be an RPC call. Splitting systems into too many small services connected by RPC increases operational complexity, adds latency at each boundary, and creates a network of partial failure possibilities. The decision to introduce an RPC boundary should be driven by genuine requirements: independent deployability, team autonomy, resource isolation, or technology heterogeneity. RPC for its own sake - or because a framework makes it easy - can make systems harder to understand and operate.

Best Practices

Always Set Deadlines on Outgoing Calls

Every RPC call that your code makes should have a deadline. Without one, a slow or stuck server can hold a client thread or connection indefinitely, eventually exhausting the calling service's resources. Deadlines should be set based on the SLA of the calling operation, not the expected performance of the downstream service. If your endpoint must respond in 200ms, the RPC calls it makes should have deadlines well under that - accounting for other work the handler does.

gRPC contexts carry deadlines natively. Propagate them through your call chain. In environments like Go, the context.Context passed to gRPC client calls automatically cancels downstream work when the deadline expires. This is one of the most important production-readiness features of gRPC and it requires no extra code beyond passing the context correctly.

Design for Idempotency Where Possible

Because RPC calls can fail in ways where the outcome is ambiguous - the request was received and executed, but the response was lost - retry logic is essential for resilience. But retrying non-idempotent operations (creating a resource, charging a payment, sending an email) can cause double-execution. Design your RPC procedures to be idempotent by default: the same call made multiple times with the same arguments should produce the same result and have no additional side effects beyond the first.

For operations that are inherently non-idempotent, use client-generated idempotency keys. The server stores the key and the result; subsequent calls with the same key return the cached result rather than re-executing. This pattern is used by Stripe's API and is worth implementing in any financial or high-consequence RPC interface.

Use Structured Errors, Not Strings

When an RPC call fails, the client needs to know why - not just that something went wrong. A string error message is useful for humans reading logs, but it is not useful for programmatic error handling. Use structured error types with a machine-readable code (NOT_FOUND, INVALID_ARGUMENT, UNAUTHENTICATED) and attach human-readable detail separately. This allows the client to implement retry logic, fallbacks, and user-facing error messages based on error code rather than string parsing.

Version and Evolve Schemas Carefully

Before you deploy a change to an RPC service, ask: is this change backward compatible? Can the current version of every client handle the new response shape? Can the new version of the server handle requests from clients that have not yet been updated? In Protobuf, the answer is usually yes if you follow the field numbering conventions. In JSON, it depends on your schema validation and how strictly your clients are written.

In tRPC, since the type inference is coupled to the server implementation, any breaking change on the server immediately breaks the client's TypeScript compilation. This tight coupling is a feature in monorepo setups but a constraint when the client and server are deployed independently.

Observe Your RPC Calls

RPC calls should be instrumented like any other network interaction. At minimum, emit metrics for: call count, latency distribution (p50, p95, p99), error rate by status code, and payload size. gRPC integrates with OpenTelemetry; trace context should be propagated from inbound requests through outgoing RPC calls to produce end-to-end distributed traces. Without this observability, diagnosing latency regressions or failure spikes in a multi-service system is guesswork.

Key Takeaways

Here are five concrete things you can apply immediately after reading this article:

  1. Audit your RPC calls for missing deadlines. Search your codebase for gRPC client calls or tRPC procedure invocations and verify that every one of them has a deadline or timeout configured. Missing deadlines are a latency and availability risk.

  2. Identify non-idempotent procedures and add idempotency keys. Walk through your service's RPC interface and classify each procedure as idempotent or not. For non-idempotent ones used in retry contexts, design and implement an idempotency key mechanism.

  3. Replace string errors with structured error codes. If your RPC service returns errors as plain strings or generic 500 responses, refactor them to use typed error codes. This immediately improves the quality of error handling logic in your clients.

  4. Add distributed tracing to your RPC calls. If you are not already propagating trace context through your RPC calls, add OpenTelemetry instrumentation. Even a basic trace showing end-to-end latency across services is invaluable for debugging.

  5. Review any RPC call made inside a loop. Find every place in your codebase where an RPC call is made inside a loop or inside a per-item handler. Evaluate whether those calls can be batched. Batching is consistently one of the highest-leverage performance improvements available.

80/20 Insight

If you learn only two things from this article and apply them, let them be these:

The 20% of RPC concepts that explain 80% of real-world behavior:

First: RPC is not a local function call. It looks like one, but it can fail in ways that local calls cannot, it is 100-1000x slower, and partial failure is a real possibility. Every time you write an RPC call, the cognitive model of "this might not complete, and I might not know what happened" should be present. This single shift in mental model prevents the majority of production incidents caused by RPC misuse.

Second: The stub is just marshalling and transport. Every framework - gRPC, tRPC, XML-RPC, Sun RPC - implements the same pattern. A client stub serializes arguments and sends them to the server. A server skeleton deserializes and dispatches them. The result takes the reverse path. If something goes wrong, it went wrong in one of those steps. Knowing this means you can reason about failures at the right level of abstraction instead of treating the framework as a black box.

Analogies and Mental Models

The Telephone Operator

Think of early RPC like a telephone operator-assisted call. You pick up the phone (the stub), tell the operator (the runtime) who you want to reach and what you want to say (the marshalled arguments). The operator connects you (the transport and binding layer). The other party answers (the server skeleton), executes the request, and responds. You hear the answer (unmarshal) and hang up.

The operator model breaks down in the same ways RPC does: the operator can't reach the party (network failure), the party answers but the call drops mid-conversation (connection reset), or the party is busy and doesn't pick up (server overloaded). These aren't framework bugs - they're inherent to anything that crosses a communication boundary.

The Postal Service Model of At-Most-Once and At-Least-Once

Delivery semantics in RPC can be understood through postal metaphors. At-most-once delivery is like sending a letter with no tracking and no return receipt: you send it once, and if it gets lost, it's gone. You don't resend because the action might be irreversible. At-least-once delivery is like using certified mail with return receipt: you keep resending until you get confirmation. The risk is that the recipient might receive and process it multiple times. Exactly-once delivery is like a notarized delivery with a locked box: the recipient can only open it once, and both sides have a signed record. It requires coordination and is expensive - which is why most distributed systems don't offer it by default.

Conclusion

tRPC and gRPC are good frameworks. They handle serialization, code generation, type safety, streaming, and much of the error handling that you would otherwise write yourself. But they are frameworks built on top of RPC, which is itself an abstraction built on top of networking primitives that have failure modes quite unlike the local function calls that the abstraction is designed to resemble.

The engineers who get the most out of these frameworks are the ones who understand what the frameworks are doing for them - and what they are not. Deadlines do not propagate by magic; you have to use the framework's context or metadata APIs correctly. Idempotency is not guaranteed; you have to design for it. Schema evolution does not prevent breaking changes; it only makes non-breaking changes easier to express.

The history of RPC - from Birrell and Nelson's 1984 paper through Sun RPC, DCE RPC, CORBA, XML-RPC, SOAP, and eventually gRPC and tRPC - is a history of the same core problems being solved progressively better, with each generation inheriting the lessons of the last. Knowing that history means you recognize the patterns, understand the tradeoffs, and are less likely to repeat the mistakes that led each previous generation to abandon its solution in favor of the next one.

Use gRPC. Use tRPC. But use them with the understanding of what they are built on.

References

  1. Birrell, A. D., & Nelson, B. J. (1984). Implementing Remote Procedure Calls. ACM Transactions on Computer Systems, 2(1), 39-59. https://dl.acm.org/doi/10.1145/2080.357392
  2. White, J. E. (1976). A High-Level Framework for Network-Based Resource Sharing (RFC 707). Network Working Group. https://www.rfc-editor.org/rfc/rfc707
  3. Deutsch, L. P. (1994). The Eight Fallacies of Distributed Computing. Originally circulated at Sun Microsystems. Widely reproduced; see: https://nighthacks.com/jag/res/Fallacies.html
  4. Fielding, R. T. (2000). Architectural Styles and the Design of Network-based Software Architectures (Doctoral dissertation, University of California, Irvine). https://ics.uci.edu/~fielding/pubs/dissertation/top.htm
  5. Google. Protocol Buffers Documentation. https://protobuf.dev/
  6. Google. gRPC Documentation. https://grpc.io/docs/
  7. tRPC. tRPC Documentation. https://trpc.io/docs/
  8. The Open Group. DCE 1.1: Remote Procedure Call. (1997 standard). https://publications.opengroup.org/c706
  9. Richardson, L., & Ruby, S. (2007). RESTful Web Services. O'Reilly Media.
  10. Kleppmann, M. (2017). Designing Data-Intensive Applications. O'Reilly Media. (Chapter 4 covers encoding and schema evolution; Chapter 12 covers distributed systems and RPCs.)
  11. Tanenbaum, A. S., & Van Steen, M. (2017). Distributed Systems: Principles and Paradigms (3rd ed.). https://www.distributed-systems.net/
  12. Sun Microsystems. ONC+ Developer's Guide (formerly Sun RPC / XDR documentation). Oracle Corporation. https://docs.oracle.com/cd/E19683-01/816-1435/index.html