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:
- 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.
- 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.
- The caller, blocked on its own reply queue, receives the message, matches the correlation ID to the pending call, and resolves the promise.
- 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.stringifysilently dropsDate,Map,Set,BigInt,undefinedand 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/coreis the message queue over Redis. It owns delivery, blocking reads and reconnection.IMQ.create()returns aRedisQueuefor a single server and aClusteredRedisQueuewhen you passcluster— that choice is about spreading one queue across several Redis instances, not about reliability, which is thesafeDeliveryoption. It does not fix the serialization pitfall above: messages are plain JSON (JSON.stringify, gzipped whenuseGzipis on), so convert rich types yourself at both ends.@imqueue/rpcis 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.