Appearance
Simple RPC
When to use
Use this pattern when you need a lightweight remote procedure call layer between a browser client and a Node.js server. The pattern works well when you want to call server methods as if they were local functions — without defining schemas, protocol buffers, or type stubs. It is especially useful for internal APIs where both client and server are controlled by the same team and the overhead of a formal IDL is not justified.
The pattern
A Proxy-based client intercepts property access to build a method path (e.g. ["accounts", "update"]), then sends that path plus arguments as JSON to the server. An Express middleware on the server receives the request, resolves the path to a real function in an exposed methods object, and invokes it.
Client: Proxy-based path builder
The RPC client turns property access into remote method calls. You chain properties like rpc.accounts.update(...) as if calling a local function — the proxy serializes the method path and arguments into a JSON-RPC request automatically:
javascript
const result = await rpc.accounts.update(1234, {
name: "Foo",
});Creating the client proxy
createService() takes a handler function that receives the accumulated method path on this and sends it to the server. You call it once to create the proxy object used throughout your client code:
javascript
const rpc = createService(async function (...args) {
const { path } = this;
return await fetch("/rpc", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, args }),
}).then(res => res.json());
});Implementing createService()
createService() wraps a Proxy with an RpcProxyHandler that records each property access into a path array. When you finally call the proxy as a function, the apply trap fires your handler with the accumulated path available on this:
javascript
function createService(callHandler) {
const handler = new RpcProxyHandler(callHandler);
return new Proxy({}, handler);
}
class RpcProxyHandler {
constructor(callHandler, path = []) {
this.callHandler = callHandler;
this.path = path;
}
get(target, prop, receiver) {
const path = [...this.path, prop];
const handler = new RpcProxyHandler(
this.callHandler, path
);
return new Proxy(async function noop() {}, handler);
}
async apply(target, thisArg, args) {
const { path, callHandler } = this;
return await callHandler.apply(this, args);
}
}Server: Express middleware
On the server, rpcService() takes a plain object whose nested properties are the callable methods and returns Express middleware. You mount it on a route, and every method in the object becomes callable from the client — add a new function to the object and it's immediately available without any registration step:
javascript
const methods = {
accounts: {
async update(id, data) {
return await db.accounts.update(id, data);
},
},
};
app.use("/rpc", rpcService(methods));Implementing rpcService()
rpcService() creates an Express sub-app that parses a { path, args } JSON body, validates both fields, resolves the path to a function in the exposed methods object, and calls it. If the path doesn't resolve to a function, the request falls through to the next middleware. Errors are caught and returned as 400 responses:
javascript
import express from "express";
function rpcService(exposedMethods) {
const service = express();
service.use(express.json());
service.use(async function rpcHandler(req, res, next) {
const { path, args } = req.body;
if (!isArrayOfStrings(path)) {
return next(
new TypeError(
"path must be an array of strings"
)
);
}
if (!Array.isArray(args)) {
return next(
new TypeError("args must be an array")
);
}
const parentPath = path.slice(0, -1);
const thisArg = resolve(parentPath, exposedMethods);
const method = resolve(path, exposedMethods);
if (typeof method !== "function") {
return next();
}
try {
res.json(await method.apply(thisArg, args));
} catch (error) {
next(error);
}
});
service.use(function errorHandler(err, req, res, next) {
res.status(400).json({ error: String(err) });
});
return service;
}
function resolve(path, obj) {
return path.reduce(
(current, key) => current?.[key],
obj,
);
}
function isArrayOfStrings(value) {
return (
Array.isArray(value) &&
value.every(item => typeof item === "string")
);
}Wiring client and server together
Below is a complete example with both sides. The server defines a greeting namespace with a greet() method and mounts it at /rpc. The client creates a service proxy pointed at the same path — calling rpc.greeting.greet("World") sends the request and returns the result as if it were a local function call:
javascript
// --- server ---
import express from "express";
const app = express();
const methods = {
greeting: {
greet(name, exclaim = false) {
const punct = exclaim ? "!" : ".";
return { message: `Hello, ${name}${punct}` };
},
},
};
app.use("/rpc", rpcService(methods));
app.listen(3000);On the client side, createService() returns a proxy pointed at the same /rpc endpoint — the method call reads like a local function invocation:
javascript
// --- client ---
const rpc = createService(async function (...args) {
const { path } = this;
return await fetch("/rpc", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, args }),
}).then(res => res.json());
});
const result = await rpc.greeting.greet("World", true);
// => { message: "Hello, World!" }Real-world example: mounting model APIs
A production app can expose many data models through a single RPC endpoint. The server passes each model module into rpcService(), and the client creates a single service proxy — every model method is then callable as rpc.expense.list() or rpc.goal.update(id, data) without any per-model wiring:
javascript
// server/rpc.js
import express from "express";
import accountModel from "./models/account.js";
import expenseModel from "./models/expense.js";
import goalModel from "./models/goal.js";
import userModel from "./models/user.js";
const service = express();
export default service;
service.use(rpcService({
account: accountModel,
expense: expenseModel,
goal: goalModel,
user: userModel,
}));The client-side proxy uses the same createService() function, pointed at the RPC endpoint via an HTTP client:
javascript
// client/rpc.js
import { createService } from "./rpc-client.js";
import { http } from "./http.js";
export const rpc = createService(async function (...args) {
const { path } = this;
const payload = { path, args };
return await http.post("/rpc", payload)
.then(res => res.data);
});Calling server methods then reads like local code:
javascript
// list expenses
const expenses = await rpc.expense.list();
// get a single goal
const goal = await rpc.goal.get(goalId);
// update a goal
await rpc.goal.update(goalId, { name: "New Name" });
// delete a goal
await rpc.goal.delete(goalId);Trade-offs
- Pros: Zero schema overhead — add a method to the server object and it is immediately callable from the client. The Proxy approach gives a natural, fluent API (
rpc.goal.update(id, data)) that reads like a local function call. Works with any HTTP transport. - Cons: No compile-time type safety. Typos in method names only surface at runtime. The server must validate inputs manually since there is no generated contract.
- Versus REST: REST exposes resources through URLs and HTTP verbs. Simple RPC exposes functions through a single POST endpoint. RPC is more natural for action-oriented APIs; REST is better when resource semantics (GET, PUT, DELETE) map cleanly to the domain.
- Versus gRPC / tRPC: Those frameworks provide type safety and code generation. Simple RPC trades that for zero configuration and instant iteration — useful for prototypes, internal tools, and small teams.
- Versus GraphQL: GraphQL lets clients specify exactly what fields to fetch. Simple RPC always returns the full method result. Use GraphQL when clients have diverse data needs; use RPC when calls are uniform.
See also
- Express.js Core Patterns: App creation, middleware pipelines, and route mounting that the RPC middleware builds on.
- Browser HTTP: Fetch wrapper and Axios client for the browser side of client-server communication.