Skip to content

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.