---
title: 'Remote Procedure Call and gRPC'
source: 'https://academia.sh/en/courses/api-design/remote-procedure-call-and-grpc'
course: 'Web API Design'
language: en
updated: '2026-08-19T05:19:29+00:00'
license: 'CC BY-SA 4.0'
---

# Remote Procedure Call and gRPC

The approach that writes the contract first: a validator generated from a schema file, the field number being the wire's identity, a byte comparison between a custom binary framing and the text format, and how gRPC brings this design together with stream types.

The previous lesson tested REST's constraints. What those constraints have in common is
that they lean the contract on the rules of the network: addresses, methods, status codes.
The contract's detail — a field's type, whether a field is required — stays outside the
wire, in the documentation.

There is an approach that does the opposite. **Remote procedure call** presents an
operation over the network as though it were a local function call: the client calls a
function named `GetBook`, the call goes out over the network, runs on the other side, and
returns a result. For this presentation to work, the two sides must agree on the call's
signature, and this agreement lives not in documentation but in a machine-readable
**schema**.

## The Limit of the Procedure Call Analogy

The analogy is useful but not exact, and knowing where it breaks down determines the
interface's design. A local call is too fast to measure; a remote call takes at least one
network round trip in the best case. A local call either runs or throws an exception; a
remote call can produce a third outcome — **partial failure**: it is not known whether the
request went out, or whether the work was done. In a local call, arguments are objects in
memory; in a remote call, they have to be converted into a byte sequence, and the rules of
that conversion are the contract itself.

These three differences show up in the design of interfaces that use the
remote-procedure-call style: calls are kept **coarse-grained** (a small number of full
calls instead of a large number of small ones), retry behavior is defined explicitly, and
the data format is tightly specified.

## The Contract Is Written First

The schema file is the interface's single source of truth. The following file defines a
message type and a service; every field has a **number**, a type, a name, and a
requiredness.

```
message Book {
  1 string  isbn      required
  2 string  title     required
  3 int     pages     optional
  4 bool    onLoan    required
}
service Catalog {
  GetBook(GetBookRequest) -> Book
  SearchBooks(SearchRequest) -> stream Book
}
```

Two things are generated from this file: a **validator** that checks the presence and type
of fields, and an **encoder** that converts an object into a byte sequence. The encoder's
format never puts field names on the wire at all; every field is referred to by its own
number, and numbers are written at variable length.

```js
// generate.mjs — generates a validator and a binary encoder from the schema file (the contract comes first).
import { readFileSync } from "node:fs";

export const readSchema = (file) => {
  const messages = {}, services = {};
  let open = null;
  for (const raw of readFileSync(file, "utf8").split("\n")) {
    const line = raw.trim();
    if (!line) continue;
    let e;
    if ((e = /^message (\w+) \{$/.exec(line))) { open = messages[e[1]] = []; continue; }
    if ((e = /^service (\w+) \{$/.exec(line))) { open = services[e[1]] = []; continue; }
    if (line === "}") { open = null; continue; }
    if ((e = /^(\d+) (\w+)\s+(\w+)\s+(\S+)$/.exec(line)))
      open.push({ number: +e[1], type: e[2], name: e[3], required: e[4] === "required" });
    else if ((e = /^(\w+)\((\w+)\) -> (stream )?(\w+)$/.exec(line)))
      open.push({ method: e[1], input: e[2], stream: Boolean(e[3]), output: e[4] });
  }
  return { messages, services };
};

const TYPES = { string: "string", int: "number", bool: "boolean" };

// Generated validator: checks fields for presence and type against the schema.
export const makeValidator = (fields) => (obj) => {
  const defects = [];
  for (const f of fields) {
    const v = obj[f.name];
    if (v === undefined) { if (f.required) defects.push(`${f.name}: required field missing`); continue; }
    if (typeof v !== TYPES[f.type]) defects.push(`${f.name}: expected ${f.type}, got ${typeof v}`);
  }
  for (const name of Object.keys(obj))
    if (!fields.some((f) => f.name === name)) defects.push(`${name}: not in schema`);
  return defects;
};

// Variable-length integer: small numbers take fewer bytes.
const writeVarint = (n) => {
  const b = [];
  do { let p = n & 0x7f; n >>>= 7; b.push(n ? p | 0x80 : p); } while (n);
  return Buffer.from(b);
};
const readVarint = (b, i) => {
  let n = 0, k = 0;
  while (b[i] & 0x80) { n |= (b[i++] & 0x7f) << k; k += 7; }
  return [n | (b[i++] << k), i];
};

// Binary framing: each field is written with a (number<<3 | wireType) key; the field name never reaches the wire.
export const makeEncoder = (fields) => ({
  encode(obj) {
    const parts = [];
    for (const f of fields) {
      const v = obj[f.name];
      if (v === undefined) continue;
      const wireType = f.type === "string" ? 2 : 0;
      parts.push(writeVarint((f.number << 3) | wireType));
      if (wireType === 0) parts.push(writeVarint(f.type === "bool" ? (v ? 1 : 0) : v));
      else { const buf = Buffer.from(v, "utf8"); parts.push(writeVarint(buf.length), buf); }
    }
    return Buffer.concat(parts);
  },
  decode(bytes) {
    const obj = {};
    let i = 0;
    while (i < bytes.length) {
      let key; [key, i] = readVarint(bytes, i);
      const field = fields.find((f) => f.number === key >> 3);
      if ((key & 7) === 2) {
        let length; [length, i] = readVarint(bytes, i);
        obj[field?.name ?? `unknown_${key >> 3}`] = bytes.toString("utf8", i, i + length);
        i += length;
      } else {
        let value; [value, i] = readVarint(bytes, i);
        obj[field?.name ?? `unknown_${key >> 3}`] = field?.type === "bool" ? Boolean(value) : value;
      }
    }
    return obj;
  },
});
```

## Measurement

The measurement answers four questions: what surface is generated from the schema, which
records the validator rejects, how many bytes the binary format takes, and what happens to
the bytes on the wire when a field name changes?

```js
// measure.mjs — tests the schema-first contract: validation, binary/text byte comparison, field renaming
import { writeFileSync } from "node:fs";
import { readSchema, makeValidator, makeEncoder } from "./generate.mjs";

const { messages, services } = readSchema("library.schema");
const fields = messages.Book;
const validate = makeValidator(fields);
const { encode, decode } = makeEncoder(fields);

console.log("-- 1) service surface generated from the schema --");
for (const y of services.Catalog)
  console.log(`  ${y.method}(${y.input}) -> ${y.stream ? "stream " : ""}${y.output}`);

console.log("\n-- 2) generated validator --");
const SAMPLES = [
  { isbn: "978-0262033848", title: "Introduction to Algorithms", pages: 1312, onLoan: false },
  { isbn: "978-0201896831", title: "The Art of Computer Programming", onLoan: true },   // no optional field
  { isbn: "978-0131103627", onLoan: true },                                             // required field missing
  { isbn: "978-0596007126", title: "Head First", pages: "272", onLoan: true },          // wrong type
  { isbn: "978-0000000000", title: "Test", onLoan: true, shelf: "R-12" },               // field not in schema
];
for (const o of SAMPLES) {
  const defects = validate(o);
  console.log(`  ${JSON.stringify(o).slice(0, 52).padEnd(54)} ${defects.length ? "FAIL  " + defects[0] : "PASS"}`);
}

console.log("\n-- 3) byte count of binary framing vs. text format --");
const valid = SAMPLES.filter((o) => validate(o).length === 0);
for (const o of valid) {
  const binary = encode(o), text = Buffer.from(JSON.stringify(o), "utf8");
  console.log(`  ${o.isbn}  binary=${String(binary.length).padStart(3)} B  ` +
    `text=${String(text.length).padStart(3)} B  gain=%${Math.round(100 * (1 - binary.length / text.length))}`);
}
const totalBinary = valid.reduce((t, o) => t + encode(o).length, 0);
const totalText = Buffer.byteLength(JSON.stringify(valid));
console.log(`  total           binary=${totalBinary} B  text=${totalText} B  ` +
  `gain=%${Math.round(100 * (1 - totalBinary / totalText))}`);

console.log("\n-- 4) no field name on the wire: the schema changes the field's name --");
const encoded = encode(valid[0]);
console.log(`  decoded with old schema : ${JSON.stringify(decode(encoded))}`);
writeFileSync("new.schema", `message Book {
  1 string  isbn      required
  2 string  heading   required
  3 int     pages     optional
  4 bool    onLoan    required
}
`);
const renamed = makeEncoder(readSchema("new.schema").messages.Book);
console.log(`  decoded with new schema : ${JSON.stringify(renamed.decode(encoded))}`);
console.log(`  same bytes, ${encoded.length} B; the only thing that changed is the name attached to the field number.`);
```

```
-- 1) service surface generated from the schema --
  GetBook(GetBookRequest) -> Book
  SearchBooks(SearchRequest) -> stream Book

-- 2) generated validator --
  {"isbn":"978-0262033848","title":"Introduction to Al   PASS
  {"isbn":"978-0201896831","title":"The Art of Compute   PASS
  {"isbn":"978-0131103627","onLoan":true}                FAIL  title: required field missing
  {"isbn":"978-0596007126","title":"Head First","pages   FAIL  pages: expected int, got string
  {"isbn":"978-0000000000","title":"Test","onLoan":tru   FAIL  shelf: not in schema

-- 3) byte count of binary framing vs. text format --
  978-0262033848  binary= 49 B  text= 90 B  gain=%46
  978-0201896831  binary= 51 B  text= 81 B  gain=%37
  total           binary=100 B  text=174 B  gain=%43

-- 4) no field name on the wire: the schema changes the field's name --
  decoded with old schema : {"isbn":"978-0262033848","title":"Introduction to Algorithms","pages":1312,"onLoan":false}
  decoded with new schema : {"isbn":"978-0262033848","heading":"Introduction to Algorithms","pages":1312,"onLoan":false}
  same bytes, 49 B; the only thing that changed is the name attached to the field number.
```

## What the Measurement Says

**Validation is generated from the contract, not hand-written.** Three of the five records
were rejected, and the three rejections are of three separate kinds: a missing required
field, a wrong type, a field not in the schema. None of these checks were written in the
application code; they came out of the lines in the schema file. When the schema changes,
validation changes with it, and the two sides' rules cannot drift apart.

**The binary format reduces the same record to roughly half the bytes.** The source of the
gain is not compression but the unnecessary never being written at all: field names,
quotation marks, colons, and commas never reach the wire. Numbers, too, are written not as
text but as a variable-length integer — `1312` takes two bytes instead of four characters.

**The field number is the identity, not the field name.** The fourth part repeats a change
considered breaking in the first lesson: the `title` field is renamed to `heading`. In the
text format, this change had broken the consumer. In the binary format, the same 49-byte
sequence decodes without any trouble under the new schema; only the key in the output
changes. The identity on the wire is the number `2`.

The mirror image of this is also true, and more dangerous: **changing the number breaks
silently.** If two fields' numbers are swapped, the validator says nothing, because the
bytes are valid; only the values land in the wrong fields. This is where schema-first
design's invariant rule comes from: once a field number has been assigned, it is never
given to another field, and a retired number is never reused.

## What gRPC Brings

The schema, validator, and framing in this lesson are a small example of this design.
**gRPC** offers the same structure as a ready-made whole: messages are defined with a
**Protocol Buffers** schema, both the client and server sides are generated from that
schema, and **HTTP/2** is used as the transport layer.

Choosing HTTP/2 is not a detail; it is what makes four call types possible. The `stream`
marker in the schema file shows this: a call can be single-request–single-response, but it
can also be server-side streaming (one request, many responses), client-side streaming
(many requests, one response), or bidirectional streaming. In the library service, jobs
like "send the shelf count as a stream" or "give catalog matches as they come in" fit these
types.

Its cost is just as clear. The content on the wire cannot be read by eye; inspecting a
request requires a tool that knows the schema. A browser cannot speak this protocol
directly; a translation layer has to sit in between. And using the interface requires
access to the other side's schema — where REST is content with sending a request to an
address and reading the response, here not a single byte can be understood without the
contract.

These costs also explain where gRPC is used the most: among a system's own components, in
places where both sides are managed by the same team and call volume is high.

## Summary

- Remote procedure call presents a network operation as though it were a local call; the
  analogy breaks down at latency, partial failure, and byte conversion, and these points
  shape the design.
- In schema-first design, the contract is a machine-readable file; the validator and
  encoder are generated from it, so the two sides' rules cannot drift apart.
- In the measurement, binary framing reduced the same records to roughly half the bytes of
  the text format; the gain comes from field names and delimiters never reaching the wire
  at all.
- The identity on the wire is the field number: when the field name changes, the same
  bytes decode without trouble, but when the number changes, the error shows up silently
  as a wrong value.
- gRPC combines this design with a Protocol Buffers schema and HTTP/2 transport; it makes
  four call types possible, and in return readability and direct browser access are lost.

## Next Step

Schema-first design fixes the response shape in the contract, and this returns to the
problem measured in the second lesson: when the client's requirement changes, the server
has to change. There is also an approach that does the exact opposite — one where the
client determines the shape, but does so not with a loose selection text but inside a type
system the server publishes. The next lesson builds this approach: a single endpoint, a
client-driven query bounded by a type system, per-field resolution, and the notion of a
partial success response.
