Skip to content

Commit 31a1a54

Browse files
committed
fix(docs): correct what @imqueue actually takes off your hands, and answer three questions it never did
This post listed the pitfalls of hand-rolling RPC over Redis and then claimed the library removes all of them: "Everything on the pitfalls list (correlation, timeouts, at-least-once delivery, serialization, backpressure handling) lives in the library, not in your service code." Three of those five were wrong, and each fails silently, which is the worst way for a claim like this to be wrong. - **Serialization.** It said @imqueue/core owns "the serialization that plain JSON gets wrong" — four bullets after correctly warning that JSON.stringify drops Date, Map, Set, BigInt and undefined. RedisQueue sets `pack = useGzip ? pack : JSON.stringify` and the gzip helper is `gzipSync(JSON.stringify(data))`, so it IS plain JSON and every one of those losses applies. A reader trusted this page and shipped a Date across the queue. - **Timeouts.** It said timeouts "are handled for you". `callTimeout` is unset by default; the generated reference says an unset timeout means the caller's promise waits forever on a service that never answers. Now stated as opt-in, and the example client sets it. - **At-least-once delivery.** Not something the library removes — it is the property you design around, so handlers must be idempotent. Also corrected `ClusteredRedisQueue` being called "the reliable message queue": IMQ.create() returns RedisQueue for a single server, clustering spreads one queue across instances, and reliability is the safeDelivery option. That exact conflation was also in @imqueue/core's context7.json until today (60572e0) — the wording is near-identical, so this page is the likely source of the rule Context7 has been serving to coding agents. The prose claim is replaced by a table naming who owns each pitfall, because the interesting half is what remains yours: timeouts until you opt in, delivery semantics, serialization, and half of back-pressure. Then three questions the docs could not answer at all, found by asking the hosted 3.3.0 server what a developer would ask it: - "retry a failed RPC call" returned PgPubSubOptions.retryDelay, .retryLimit, RETRY_DELAY and RETRY_LIMIT — pg-pubsub's Postgres LISTEN reconnection knobs, offered for a question about RPC. Not silence: a wrong answer with config values attached. There is no RPC-level retry, and now the page says so, with why it is the caller's decision and where @imqueue/job fits instead. - "coalesce duplicate concurrent calls" returned nothing relevant in six slots, though @lock does exactly that. Documented, including the two limits that matter: in-process only, and skipArgs for arguments that must not affect the key. - "is there a circuit breaker" had no answer. There isn't one; what the queue gives instead is a spike becoming latency rather than a cascade, which is adjacent but not the same thing. The three are `###` questions inside a `## FAQ` section so the generator turns them into FAQPage markup: 83 answers across 15 pages -> 86 across 16. The front matter matters more than the body here, and this is worth recording: search_docs indexes `section + description + url` and never the page body (mcp/src/docs.ts:525). Adding an answer to a page does not make it findable — the llms.txt description is the index. So the summary and description now name retries, call coalescing and the circuit breaker, and the meta description is 159 of its 160 characters. Measured against a simulated post-deploy corpus: "how do I retry a failed RPC call" and "does @imqueue have a circuit breaker" now return this page #1, where both previously returned unrelated symbols. Keyword-shaped queries without an interrogative still favour symbol names, because a prose entry scores 1x per term against a symbol title's 5x — a ranker trade-off left deliberately alone.
1 parent b0fc2fb commit 31a1a54

1 file changed

Lines changed: 74 additions & 12 deletions

File tree

src/org/blog/posts/rpc-over-redis-nodejs.md

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ layout: post.html
33
permalink: /blog/rpc-over-redis-nodejs/
44
templateEngineOverride: md
55
title: "RPC over Redis in Node.js: patterns and pitfalls"
6-
summary: "How request/reply RPC over Redis actually works in Node.js — the correlation, timeout and delivery problems you have to solve yourself, why the old npm packages stalled, and how @imqueue turns it into typed, boilerplate-free calls."
7-
description: "A practical guide to RPC over Redis in Node.js: the request/reply pattern, its pitfalls — correlation, timeouts, at-least-once, backpressure — and a typed fix."
8-
keywords: "RPC over Redis, redis rpc, redis rpc node.js, typed rpc redis, node.js redis rpc, request reply redis, redis pub/sub rpc, imqueue"
6+
summary: "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."
7+
description: "RPC over Redis in Node.js: correlation, timeouts, at-least-once deliveryplus retries, @lock call coalescing, and the circuit breaker @imqueue does not ship."
8+
keywords: "RPC over Redis, redis rpc, redis rpc node.js, typed rpc redis, retry failed rpc call, imqueue retry, duplicate concurrent calls, imqueue lock decorator, circuit breaker node.js rpc, request reply redis, imqueue"
99
date: 2026-07-23
1010
author: serhiy-morenko
1111
illustration: redis-rpc
@@ -91,9 +91,14 @@ you're on your own for the rest of the list above.
9191
[`@imqueue`](/get-started/) is a maintained implementation of this exact pattern,
9292
built for TypeScript. Two pieces do the work:
9393

94-
- [`@imqueue/core`](/api/core/latest/) is the reliable message queue over Redis
95-
(`ClusteredRedisQueue`) — it owns delivery, blocking reads, reconnection and the
96-
serialization that plain JSON gets wrong.
94+
- [`@imqueue/core`](/api/core/latest/) is the message queue over Redis. It owns
95+
delivery, blocking reads and reconnection. `IMQ.create()` returns a `RedisQueue`
96+
for a single server and a `ClusteredRedisQueue` when you pass `cluster` — that
97+
choice is about spreading one queue across several Redis instances, not about
98+
reliability, which is the [`safeDelivery`](/api/core/latest/core.imqoptions.safedelivery/)
99+
option. It does **not** fix the serialization pitfall above: messages are plain
100+
JSON (`JSON.stringify`, gzipped when `useGzip` is on), so convert rich types
101+
yourself at both ends.
97102
- [`@imqueue/rpc`](/api/rpc/latest/) is the RPC layer on top. You write a service as
98103
a class and mark the callable methods with `@expose()`:
99104

@@ -122,23 +127,38 @@ real one from the running service:
122127
imq client generate UserService ./src/clients
123128
~~~
124129

125-
and call it like a local, fully-typed object — the correlation, reply routing and
126-
timeouts are handled for you:
130+
and call it like a local, fully-typed object — correlation and reply routing are
131+
handled for you, and per-call timeouts are available once you ask for them:
127132

128133
~~~typescript
129134
import { userService } from './clients/UserService.js';
130135

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

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

137144
Because the client is generated from the live service rather than hand-maintained,
138145
the types can't drift out of sync with the implementation — the failure mode that
139-
makes hand-rolled RPC rot. Everything on the pitfalls list (correlation, timeouts,
140-
at-least-once delivery, serialization, backpressure handling) lives in the library,
141-
not in your service code.
146+
makes hand-rolled RPC rot.
147+
148+
### Which pitfalls does @imqueue actually take off your hands?
149+
150+
Not all of them, and it is worth being exact about which — the ones that remain are
151+
the ones that fail silently.
152+
153+
| Pitfall | Who owns it with @imqueue |
154+
|---|---|
155+
| Correlation | **The library.** Request ids and the pending-call map are handled; you never see them. |
156+
| Types | **The library.** The client is generated from the running service, so drift becomes a compile error in the caller's build. |
157+
| Redis operations | **The library**, as far as reconnection and blocking reads go. Clustering and failover are still your infrastructure. |
158+
| Timeouts | **You, by opting in.** `callTimeout` is unset by default, so an unconfigured client waits forever on a service that never answers. |
159+
| 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. |
160+
| Serialization | **You.** Messages are plain JSON, so the `Date`/`Map`/`Set`/`BigInt` losses listed above apply unchanged. Convert rich types explicitly on both sides. |
161+
| 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](/blog/backpressure-nodejs-services/). |
142162

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

@@ -154,3 +174,45 @@ If that fit sounds right, the [getting-started guide](/get-started/) has a worki
154174
two-service example running in a couple of minutes, and the
155175
[throughput benchmark](/blog/benchmarking-imqueue-throughput/) covers the numbers
156176
and a reproducible harness.
177+
178+
## FAQ
179+
180+
### Does @imqueue retry a failed RPC call?
181+
182+
No, and this is deliberate. There is no automatic retry at the RPC layer: a call that
183+
times out rejects with `IMQ_RPC_CALL_TIMEOUT`, and a method that throws returns its
184+
error to the caller. The only backoff in the stack is the queue reconnecting to
185+
Redis, which is a transport concern and has nothing to do with your call.
186+
187+
So retrying is the caller's decision, and it is a decision rather than a default
188+
because the safe retry policy depends on what the method does. Since delivery is
189+
at-least-once, a handler can already run twice for one send — which means the
190+
idempotency a retry needs is something you owe the system anyway. Make the handler
191+
idempotent, then retry in the caller with whatever backoff suits it.
192+
193+
If you find yourself wanting durable, retried, scheduled work rather than a
194+
request/reply call, that is a different tool: [`@imqueue/job`](/blog/imqueue-vs-bullmq/)
195+
has retries and delays built in.
196+
197+
### How do I stop duplicate concurrent calls doing the same work twice?
198+
199+
Decorate the method with [`@lock()`](/api/rpc/latest/rpc.lock/). Concurrent calls
200+
that share the same arguments are coalesced: the first one executes and the rest
201+
resolve with its result, which is what you want for an expensive read that several
202+
callers ask for at once.
203+
204+
Two limits worth knowing. It is **in-process only** — separate processes, cluster
205+
workers and service replicas each keep their own lock and will all run the guarded
206+
code, so it is not a distributed mutex. And similarity is computed from the argument
207+
values, so pass `skipArgs` for arguments that must not affect the key, such as a
208+
request context.
209+
210+
### Is there a circuit breaker?
211+
212+
No. @imqueue ships no circuit breaker and no bulkhead. What the queue gives you
213+
instead is that a slow consumer does not reject callers the way a saturated HTTP
214+
service does — the work waits in the queue rather than failing outward, so a spike
215+
becomes latency instead of a cascade. That covers the failure mode a breaker is
216+
usually reached for, but it is not the same thing: if you need calls to fail fast
217+
once a dependency is unhealthy, that is yours to add on top, and `callTimeout` is
218+
the primitive to build it from.

0 commit comments

Comments
 (0)