RPC over Redis in Node.js: patterns and pitfalls

How request/reply RPC over Redis actually works in Node.js — correlation, timeouts and at-least-once delivery, which of those @imqueue handles for you, and what it deliberately leaves to you: retrying a failed RPC call, coalescing duplicate concurrent calls with @lock, and the circuit breaker it does not ship.

RPC over Redis means making one service call a method on another by sending the request through Redis and getting the reply back the same way — instead of opening an HTTP connection or a gRPC channel between them. Redis is already in most Node.js stacks, it's fast, and using it as the transport removes a surprising amount of moving parts: no per-service HTTP server to expose, no load balancer in front, no service registry to look anybody up. This post explains how the pattern works, the problems you have to solve to make it production-grade, and how @imqueue implements all of that with fully-typed clients.

TL;DR — RPC over Redis is request/reply messaging: the caller drops a request on the callee's Redis-backed queue and waits for a correlated response on its own. It's simpler to operate than HTTP-between-services, but you have to handle correlation, timeouts, at-least-once delivery, serialization and backpressure yourself. The maintained, typed way to get it in Node.js/TypeScript is @imqueue/rpc, which generates the client for you from the running service.

The pattern, concretely

At its core the pattern is four steps:

  1. The caller serializes a request — target method, arguments, a unique correlation ID, and the name of the queue it wants the answer on — and pushes it onto the callee's queue in Redis.
  2. The callee is blocked waiting on that queue. It pops the request, runs the method, and pushes the result onto the reply queue named in the request.
  3. The caller, blocked on its own reply queue, receives the message, matches the correlation ID to the pending call, and resolves the promise.
  4. Both sides go back to waiting.

Redis gives you the two primitives this needs: a place to put messages (lists consumed with a blocking BRPOP, or streams) and low latency. There's no direct connection between the two services at all — Redis is the rendezvous point, which is exactly why you stop needing service discovery and a load balancer. Add a second instance of the callee and it simply reads from the same queue; Redis distributes the work.

The pitfalls (what "just use Redis" leaves out)

The four-step sketch is easy to prototype and deceptively hard to make reliable. Every one of these is a problem you own the moment you hand-roll it:

  • Correlation. Many calls are in flight at once over one reply queue. Every request needs a unique ID and the caller needs a map of ID → pending promise, or responses get delivered to the wrong caller.
  • Timeouts. If the callee is down or throws before replying, the response never comes. Without a per-call timeout the caller's promise hangs forever and the pending-call map leaks memory.
  • Delivery semantics. A reliable queue gives you at-least-once delivery — a message can be redelivered after a crash. That means your handlers should be idempotent, and "exactly once" is something you engineer, not something Redis hands you.
  • Serialization. JSON.stringify silently drops Date, Map, Set, BigInt, undefined and typed arrays. Round-tripping rich objects needs a real serializer, or subtle data corruption creeps in.
  • Backpressure. If callers produce faster than callees consume, the queue grows without bound and latency climbs. You need to watch queue depth and push back.
  • Redis itself. Redis becomes shared infrastructure on the hot path. In production that means clustering/failover, not a single node — the transport is only as available as the Redis behind it.
  • Types. Nothing above says anything about types. A raw Redis message is an opaque blob; the caller has no idea what shape the arguments or the result should be. This is where hand-rolled RPC hurts most over time.

Why the existing packages stalled

Search npm for "redis rpc" and you'll find a scatter of small libraries — node-redis-rpc, redis-rpc, rpc-redis — most last published five to ten years ago, none TypeScript-first, and none solving the typing problem. They prove the pattern is sound and useful, but they were built for a callback-era Node.js and stopped being maintained. If you adopt one today you inherit the correlation and timeout machinery but still hand-write an untyped client for every service, and you're on your own for the rest of the list above.

Doing it typed: @imqueue

@imqueue is a maintained implementation of this exact pattern, built for TypeScript. Two pieces do the work:

  • @imqueue/core is the message queue over Redis. It owns delivery, blocking reads and reconnection. IMQ.create() returns a RedisQueue for a single server and a ClusteredRedisQueue when you pass cluster — that choice is about spreading one queue across several Redis instances, not about reliability, which is the safeDelivery option. It does not fix the serialization pitfall above: messages are plain JSON (JSON.stringify, gzipped when useGzip is on), so convert rich types yourself at both ends.
  • @imqueue/rpc is the RPC layer on top. You write a service as a class and mark the callable methods with @expose():
import { IMQService, expose } from '@imqueue/rpc';

class UserService extends IMQService {
    /**
     * Returns a user by id.
     *
     * @param {string} id
     * @return {Promise<User>}
     */
    @expose()
    public async get(id: string): Promise<User> {
        return this.db.users.find(id);
    }
}

The service is self-describing: it publishes its method signatures (JSDoc is the type source), so the caller doesn't need a hand-written client. You generate the real one from the running service:

imq client generate UserService ./src/clients

and call it like a local, fully-typed object — correlation and reply routing are handled for you, and per-call timeouts are available once you ask for them:

import { userService } from './clients/UserService.js';

// callTimeout is unset by default, and an unset timeout means a call to a service
// that is down waits forever. Set it.
const users = new userService.UserClient({ callTimeout: 5000 });
await users.start();

const user = await users.get('42'); // typed: User, no client boilerplate

Because the client is generated from the live service rather than hand-maintained, the types can't drift out of sync with the implementation — the failure mode that makes hand-rolled RPC rot.

Which pitfalls does @imqueue actually take off your hands?

Not all of them, and it is worth being exact about which — the ones that remain are the ones that fail silently.

Pitfall Who owns it with @imqueue
Correlation The library. Request ids and the pending-call map are handled; you never see them.
Types The library. The client is generated from the running service, so drift becomes a compile error in the caller's build.
Redis operations The library, as far as reconnection and blocking reads go. Clustering and failover are still your infrastructure.
Timeouts You, by opting in. callTimeout is unset by default, so an unconfigured client waits forever on a service that never answers.
Delivery semantics You. Delivery is at-least-once in both modes, so handlers must be idempotent. safeDelivery protects the hand-off, not the processing — a worker killed mid-handler loses that message either way.
Serialization You. Messages are plain JSON, so the Date/Map/Set/BigInt losses listed above apply unchanged. Convert rich types explicitly on both sides.
Back-pressure Shared. The queue absorbs a spike instead of turning it into a cascade, but nothing watches queue depth or pushes back for you — see back-pressure for Node.js services.

When this is the right call — and when it isn't

RPC over Redis is a good fit when your services already share a Redis, when you want internal calls without standing up and load-balancing HTTP endpoints, and when strong typing across service boundaries matters. It is not a workflow engine: if you need durable, resumable, long-running orchestration with history and compensation, a system like Temporal is a different tool. And it does add Redis to your critical path — worth it when Redis is already there, a cost to weigh when it isn't.

If that fit sounds right, the getting-started guide has a working two-service example running in a couple of minutes, and the throughput benchmark covers the numbers and a reproducible harness.

FAQ

Does @imqueue retry a failed RPC call?

No, and this is deliberate. There is no automatic retry at the RPC layer: a call that times out rejects with IMQ_RPC_CALL_TIMEOUT, and a method that throws returns its error to the caller. The only backoff in the stack is the queue reconnecting to Redis, which is a transport concern and has nothing to do with your call.

So retrying is the caller's decision, and it is a decision rather than a default because the safe retry policy depends on what the method does. Since delivery is at-least-once, a handler can already run twice for one send — which means the idempotency a retry needs is something you owe the system anyway. Make the handler idempotent, then retry in the caller with whatever backoff suits it.

If you find yourself wanting durable, retried, scheduled work rather than a request/reply call, that is a different tool: @imqueue/job has retries and delays built in.

How do I stop duplicate concurrent calls doing the same work twice?

Decorate the method with @lock(). Concurrent calls that share the same arguments are coalesced: the first one executes and the rest resolve with its result, which is what you want for an expensive read that several callers ask for at once.

Two limits worth knowing. It is in-process only — separate processes, cluster workers and service replicas each keep their own lock and will all run the guarded code, so it is not a distributed mutex. And similarity is computed from the argument values, so pass skipArgs for arguments that must not affect the key, such as a request context.

Is there a circuit breaker?

No. @imqueue ships no circuit breaker and no bulkhead. What the queue gives you instead is that a slow consumer does not reject callers the way a saturated HTTP service does — the work waits in the queue rather than failing outward, so a spike becomes latency instead of a cascade. That covers the failure mode a breaker is usually reached for, but it is not the same thing: if you need calls to fail fast once a dependency is unhealthy, that is yours to add on top, and callTimeout is the primitive to build it from.

Read this page as plain markdown — no HTML, no navigation. For pasting into an LLM, or for an agent to fetch.


Building on @imqueue? The open-source packages live on GitHub and the docs at imqueue.org. Shipping inside a closed-source product? See commercial licensing & support.