
net module helps, but not required)Refer to the previous article in this series:
Imagine you're building an e-commerce system. The Order service needs to call the Inventory service to check whether a product is in stock. Your first instinct might be: "I'll just use fetch('/api/check-stock') and call it a day."
That works. But then the problems start creeping in:
RPC was designed to solve exactly these problems. The vision: calling a function on a remote service should feel as natural as calling add(1, 2) right in your own process. No URLs, no manual serialization, and the interface definition is enforced by an IDL (Interface Definition Language).
Let's start with what we all know — a local function call:
function add(a, b) {
return a + b;
}
const result = add(3, 5); // Direct call — everything in the same process
console.log(result); // 8Everything here is "local." Function definition, argument passing, return value — all within one process. The CPU does a call/ret instruction pair. Latency is in microseconds.
But what if add runs on a different server?
RPC (Remote Procedure Call) aims to make this cross-network function call look and feel, at the code level, no different from the local version.
An RPC framework handles the "send the message, wait for the reply" plumbing so you can write getQ2Sales("south-china") and not think about crafting HTTP requests.
Now that we understand the why, let's build the what. We'll start with the most bare-bones version — a TCP-based toy RPC system.
For simplicity, we assume each TCP packet carries exactly one complete JSON request. In real-world scenarios, large messages may be split across multiple packets, which requires more sophisticated framing — something we'll handle in the improved version.
// server.js
import net from "net";
// Our "remote" methods
const methods = {
add: (a, b) => a + b,
greet: (name) => `Hello, ${name}!`,
};
const server = net.createServer((socket) => {
console.log("Client connected");
socket.on("data", (data) => {
try {
// Parse the incoming request
const request = JSON.parse(data.toString());
console.log("Received request:", request);
// Execute the target method
const result = methods[request.method](...request.params);
// Send back the result
const response = JSON.stringify({
result,
id: request.id,
});
socket.write(response);
} catch (error) {
console.error("Error processing request:", error);
}
});
});
server.listen(3000, () => {
console.log("RPC service running on port 3000");
});// client.js
import net from "net";
class SimpleRPCClient {
constructor(port = 3000, host = "localhost") {
this.port = port;
this.host = host;
this.requestId = 0;
}
call(method, ...params) {
return new Promise((resolve, reject) => {
const socket = new net.Socket();
const currentId = ++this.requestId;
const request = JSON.stringify({
method,
params,
id: currentId,
});
socket.connect(this.port, this.host, () => {
socket.write(request);
});
socket.on("data", (data) => {
const response = JSON.parse(data.toString());
if (response.id === currentId) {
resolve(response.result);
socket.end();
}
});
socket.on("error", (err) => reject(err));
});
}
}
// Usage
(async () => {
const client = new SimpleRPCClient();
try {
const sum = await client.call("add", 5, 3);
console.log("5 + 3 =", sum); // 8
const greeting = await client.call("greet", "RPC Beginner");
console.log(greeting); // Hello, RPC Beginner!
} catch (err) {
console.error("Call failed:", err);
}
})();# Start the server first
node server.js
# Then run the client
node client.jsThis tiny example may look simple, but it already contains every essential piece of RPC. Let's break it down.
For a function call to travel across a network, arguments and return values must become byte streams.
In our example:
// Serialization: in-memory object → JSON string
const request = JSON.stringify({ method: "add", params: [1, 2] });
// Deserialization: JSON string → in-memory object
const data = JSON.parse(request);JSON is human-friendly but verbose and slow to parse. Production systems typically choose more efficient serialization schemes like Protocol Buffers or MessagePack — we'll get to those shortly.
We used Node.js's net module to establish a TCP connection. In practice, RPC can ride on various protocols:
| Protocol | Characteristics | Best For |
|---|---|---|
| TCP | Performant, binary transport | Internal microservice calls |
| HTTP/2 | Universal, firewall-friendly | Cross-network, browser-compatible |
| WebSocket | Full-duplex, native browser support | Browser ↔ server real-time |
Every RPC call is an explicit request-response pair:
// Request format
{
method: "add", // Which method to call
params: [1, 2], // Arguments
id: 1 // Unique ID — used to match the response
}
// Response format
{
result: 3, // Return value
id: 1 // Matches the corresponding request ID
}The id field is crucial: a single TCP connection may carry multiple concurrent requests (pipelining). The id lets the client correlate each response with its originating request.
The first version has several issues:
Let's fix them one by one.
// better-server.js
import net from "net";
const methods = {
add: (a, b) => a + b,
greet: (name) => {
if (!name) throw new Error("Name cannot be empty");
return `Hello, ${name}!`;
},
};
const server = net.createServer((socket) => {
console.log("Client connected");
socket.on("data", (data) => {
try {
const request = JSON.parse(data.toString());
console.log("Received request:", request);
if (!methods[request.method]) {
throw new Error(`Method "${request.method}" does not exist`);
}
const result = methods[request.method](...request.params);
socket.write(
JSON.stringify({
result,
id: request.id,
error: null,
})
);
} catch (error) {
socket.write(
JSON.stringify({
result: null,
id: request?.id ?? null,
error: error.message,
})
);
}
});
});
server.listen(3000, () => {
console.log("Improved RPC service running on port 3000");
});Key changes:
error field. On success, error: null; on failure, it carries the error message. No more guesswork for the client.// better-client.js
import net from "net";
class BetterRPCClient {
constructor(port = 3000, host = "localhost") {
this.port = port;
this.host = host;
this.requestId = 0;
}
async call(method, ...params) {
const socket = new net.Socket();
const currentId = ++this.requestId;
const request = JSON.stringify({
method,
params,
id: currentId,
});
return new Promise((resolve, reject) => {
let responseData = "";
socket.connect(this.port, this.host, () => {
socket.write(request);
});
socket.on("data", (data) => {
responseData += data.toString();
try {
const response = JSON.parse(responseData);
if (response.id === currentId) {
if (response.error) {
reject(new Error(response.error));
} else {
resolve(response.result);
}
socket.end();
}
} catch {
// Incomplete data — keep buffering
}
});
socket.on("error", (err) => reject(err));
socket.on("timeout", () => {
reject(new Error("Request timed out"));
socket.end();
});
socket.setTimeout(5000); // 5-second timeout
});
}
}
// Usage
(async () => {
const client = new BetterRPCClient();
try {
console.log("1 + 2 =", await client.call("add", 1, 2));
} catch (err) {
console.error("Call error:", err.message);
}
// Call a non-existent method
try {
await client.call("nonexistent");
} catch (err) {
console.error("Expected error:", err.message);
}
// Trigger parameter validation
try {
await client.call("greet"); // No name provided
} catch (err) {
console.error("Validation error:", err.message);
}
})();Three key improvements:
error field becomes a client-side reject, so callers can use try/catch.socket.setTimeout(5000) prevents indefinite hangs.responseData += data accumulates chunks until a complete JSON object is assembled — solving the TCP stream-framing problem.Building RPC from scratch teaches the principles, but in real projects you'll use a mature framework. gRPC is Google's open-source modern RPC framework, built on HTTP/2 and Protocol Buffers.
| Aspect | Hand-rolled RPC (JSON/TCP) | gRPC |
|---|---|---|
| Serialization | JSON (text, verbose) | Protocol Buffers (binary, compact) |
| Transport | Raw TCP | HTTP/2 (multiplexing, flow control) |
| Interface definition | Verbal agreement | .proto file (strongly typed IDL) |
| Code generation | None | Auto-generated client/server stubs |
| Multi-language | Hand-coded per language | Officially supports 10+ languages |
Install dependencies:
npm install @grpc/grpc-js @grpc/proto-loaderDefine the service interface — create calculator.proto:
syntax = "proto3";
service Calculator {
rpc Add (Numbers) returns (Result);
rpc Multiply (Numbers) returns (Result);
}
message Numbers {
double a = 1;
double b = 2;
}
message Result {
double value = 1;
}This .proto file is gRPC's "contract" — server and client share a single definition. Field types and numbers are enforced by Protocol Buffers, eliminating "tribal knowledge" drift.
Implement the server:
// grpc-server.js
import grpc from "@grpc/grpc-js";
import protoLoader from "@grpc/proto-loader";
const packageDefinition = protoLoader.loadSync("calculator.proto");
const calculatorProto = grpc.loadPackageDefinition(packageDefinition);
const server = new grpc.Server();
const calculator = {
add: (call, callback) => {
const { a, b } = call.request;
callback(null, { value: a + b });
},
multiply: (call, callback) => {
const { a, b } = call.request;
callback(null, { value: a * b });
},
};
server.addService(calculatorProto.Calculator.service, calculator);
server.bindAsync(
"0.0.0.0:50051",
grpc.ServerCredentials.createInsecure(),
(err, port) => {
if (err) {
console.error(err);
return;
}
server.start();
console.log(`gRPC service running on port ${port}`);
}
);Implement the client:
// grpc-client.js
import grpc from "@grpc/grpc-js";
import protoLoader from "@grpc/proto-loader";
const packageDefinition = protoLoader.loadSync("calculator.proto");
const calculatorProto = grpc.loadPackageDefinition(packageDefinition);
const client = new calculatorProto.Calculator(
"localhost:50051",
grpc.credentials.createInsecure()
);
// Call the remote Add method
client.add({ a: 10, b: 20 }, (err, response) => {
if (err) {
console.error("Error:", err.message);
return;
}
console.log("10 + 20 =", response.value); // 30
});
// Call the remote Multiply method
client.multiply({ a: 7, b: 8 }, (err, response) => {
if (err) {
console.error("Error:", err.message);
return;
}
console.log("7 * 8 =", response.value); // 56
});Note: a gRPC client call no longer looks like client.add(10, 20) — it passes a request object and a callback (or a Promise in async-capable gRPC versions). While the syntax doesn't perfectly mimic a local call, the .proto file guarantees type safety and interface consistency — that's the real value of RPC in production.
add and greet remote methods.multiply method to the methods object and verify the client can call it.call, maintain a persistent socket with a request queue. Hint: use one long-lived socket and correlate responses by request ID.subtract RPC method in the .proto file and implement it on both sides..proto files serve as both interface documentation and type enforcement, eliminating the friction of "verbal contracts" between teams.Keywords: RPC, Remote Procedure Call, gRPC, Protocol Buffers, serialization, TCP, IDL, microservice communication
This article started with "why RPC exists," built a complete RPC system in Node.js from raw TCP, and graduated to the production-grade gRPC framework. You now understand how services call each other — the final piece of this series' application layer puzzle.
But there's one more protocol you use almost daily without truly understanding: SSH. When you ssh user@host into a remote server, what actually happens? How does SSH securely transmit passwords over an insecure network? How does public key authentication enable "passwordless login"?
The next article — the final piece of this networking series — pulls back the curtain on SSH: the security flaws of Telnet that birthed it, the three-layer encryption system (symmetric, asymmetric, MAC) working in harmony, the magic of port forwarding, and practical tips for everyday use.