# RPC over Redis in Node.js: patterns and pitfalls

Source: https://imqueue.org/blog/rpc-over-redis-nodejs/
Published: 2026-07-23
Author: Serhiy Morenko — Maintainer, @imqueue (https://github.com/SerhiyGreench)

**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`](https://imqueue.org/get-started/) 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`](https://imqueue.org/get-started/) is a maintained implementation of this exact pattern,
built for TypeScript. Two pieces do the work:

- [`@imqueue/core`](https://imqueue.org/api/core/latest/) 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`](https://imqueue.org/api/core/latest/core.imqoptions.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`](https://imqueue.org/api/rpc/latest/) is the RPC layer on top. You write a service as
  a class and mark the callable methods with `@expose()`:

~~~typescript
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:

~~~bash
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:

~~~typescript
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](https://imqueue.org/blog/backpressure-nodejs-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](https://imqueue.org/get-started/) has a working
two-service example running in a couple of minutes, and the
[throughput benchmark](https://imqueue.org/blog/benchmarking-imqueue-throughput/) 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`](https://imqueue.org/blog/imqueue-vs-bullmq/)
has retries and delays built in.

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

Decorate the method with [`@lock()`](https://imqueue.org/api/rpc/latest/rpc.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.

