Nothing threw, and the job never ran: silent failures in a Node.js message queue
A send() that resolves with a message id nobody will ever deliver. A push() that returns cleanly onto a queue redis refused to write. Neither throws, and neither can — the caller has already moved on by the time redis answers. That silence is over: here is what each of those failures now writes to the log instead, and the one upgrade note that costs you a stack trace.
The bug report is always the same sentence. The email never went out, and there is nothing in the logs.
Not an error. Not a stack trace. Not a retry that gave up. Nothing — which is the
part that makes it expensive, because the first day goes on proving the code even
ran. And it did run. The call was made, it returned a message id, the function
that made it went on to return 200 OK to somebody, and the message it described
never existed anywhere.
That is not a bug in the queue. It falls straight out of an enqueue that does not wait for the broker, which is the reason an enqueue is cheap enough to put in a request handler in the first place. What was wrong is that it left no trace at all.
That part is fixed now. @imqueue/core 3.4.0, @imqueue/rpc 3.7.0 and
@imqueue/job 3.1.0 are one change seen from three sides: the failures that
used to be invisible report through the logger you already configured. No
verbose flag to turn on, no new API to call, no options to pass — upgrade and
they are there.
Here is what each of them looks like, and what it lets you conclude.
The output below is what these versions print, on Node 22.22.2 against Redis 7.0.15. Writes were failed on purpose by turning a destination key into a
string, so redis rejects theLPUSHwithWRONGTYPE— an easy way to reproduce any of it locally.
Why send() cannot throw
Worth pinning down first, because every failure below follows from it.
An @imqueue enqueue does not wait for redis. send() mints a UUID locally,
hands the packet to the redis client, and resolves — the write is still in
flight:
const id = await imq.send('Reports', { report: 1 });
// `id` exists. The LPUSH may not have happened yet, and may never happen.
push() in @imqueue/job goes further: it is not even async. It returns the
queue itself, for chaining.
So by the time redis says no, there is no promise left to reject and no caller still holding one. The rejection has nowhere to go:
sequenceDiagram
participant C as Caller
participant Q as imq.send()
participant R as Redis
C->>Q: send("Reports", payload)
Q-->>C: resolves with a message id
Note over C: the handler returns 200 OK
Q->>R: LPUSH imq:Reports
R--xQ: WRONGTYPE
Note over Q: nobody is waiting for this
An @imqueue enqueue resolves before the broker confirms the write, which is why
a rejected write cannot surface as a rejected promise.
That trade is deliberate and it has not changed. Waiting for the write would put
a redis round-trip in front of every fire-and-forget call, and the whole point of
send() returning immediately is that a request handler can hand off work
without paying for it. What changed is only who finds out when the write loses.
Five messages, five ids, nothing on the queue
Send five messages to a destination redis will refuse, and pass no
errorHandler — which is how the overwhelming majority of calls are written,
since errorHandler is an optional fourth positional argument of
send() that most callers never reach
for.
Every one of them comes back with an id:
send() resolved with 24e12d63-8e9c-4329-895c-4b8feced84c8
send() resolved with 981960a0-3fe5-4648-8c57-40e232cd7502
send() resolved with e174440a-7a28-45e3-b844-397ef5960032
send() resolved with d4493070-6bd7-44bf-96cf-fd0909be7ca6
send() resolved with 4ee77bf6-e046-4bd7-a56b-353f0a808585
On @imqueue/core 3.3.3, here is the entire log for that:
info | Sender: reader channel connected, host 127.0.0.1:6399, pid 21487
info | Sender: writer channel connected, host 127.0.0.1:6399, pid 21487
info | Sender: watcher channel connected, host 127.0.0.1:6399, pid 21487
Three lines, all of them about connecting. Five messages are gone and the log is not merely quiet about it — it is reassuring.
Same code, same failure, on 3.4.0:
info | Sender: reader channel connected, host 127.0.0.1:6399, pid 21102
info | Sender: writer channel connected, host 127.0.0.1:6399, pid 21102
info | Sender: watcher channel connected, host 127.0.0.1:6399, pid 21102
error | [IMQ-CORE][Sender]: write to queue Reports rejected on LPUSH, message 2ee52f3d-af44-46b7-bab1-084c892ca41b, code WRONGTYPE
info | [IMQ-CORE][Sender]: outbound writes resumed after 5 rejected writes
Two lines for five failures, and that ratio is the point. Repeating conditions are reported on entering the state, not per occurrence: the first rejected write of an episode is logged in full — operation, message id, failure code — and every rejection after it just increments a counter. The counter comes back out when a write finally succeeds, which is why the recovery line is the one that tells you how big the outage was. A broker down for ten minutes costs you two log lines, not ten minutes of them.
Notice what the error line does not carry: no payload, no arguments, no raw redis key. That is a rule rather than an accident, and it gets a section of its own below.
Why errorHandler was never enough
The obvious objection is that send() has always taken an errorHandler, so
this was observable all along. It was — programmatically, by callers who
remembered to pass one. Two reasons that is not the same thing.
Run those five failures again, this time with a handler, and count the calls:
5 rejected sends -> errorHandler calls: 10, error lines: 1
Ten. The redis client delivers this particular failure twice, through the command
callback and through the returned promise, and errorHandler fires on each
delivery — exactly as it always has. The episode counts the logical failure
once. So if you have an errorHandler incrementing a metric somewhere, that
metric has been double-counting, and the log line beside it is now how you find
out.
The second reason is simpler. An errorHandler is per call site; the logging is
per queue. One is something you have to remember at every place that enqueues
work, the other is just true of the process.
The job queue's version of the same silence
@imqueue/job has the sharper edge, because
push() takes no error handler at
all. There is no parameter to pass. The log is the only channel there is — which
is exactly why that package's own documentation calls the logger option worth
supplying.
Push onto a queue redis refuses, and 3.1.0 gives you this:
error | [IMQ-CORE][Broken]: write to queue Broken rejected on LPUSH, message a7aeb890-beac-49ae-a761-cfd005d7a959, code WRONGTYPE
error | [JobQueue] push error: queue Broken, delay none, ttl none, code WRONGTYPE
Two lines, one from each layer: core reports the write, @imqueue/job reports
the enqueue — with the queue, the requested delay and ttl, and a code. push()
returned normally in between, as it always does. The marker text
[JobQueue] push error: is unchanged from earlier versions; what 3.1.0 added is
that it now also covers a write rejected after push() returned, and reports
at error level.
no retry — the drop you used to have to infer
The failure that costs the most support time is not the one above. It is a job
that ran, threw, and was never seen again — because @imqueue/job has no
declarative attempts policy. Whether a failed job comes back is decided by the
handler's return value, plus one detail that is easy to miss.
Take the same handler, throwing ECONNREFUSED, and change nothing but the push.
Pushed with no delay:
error | [JobQueue] Error handling job: queue DropCase, message d6aa27d5-6613-4f70-98f3-682f0ecf7d8d, code ECONNREFUSED, no retry
Handler ran once. The job is gone. That is the trap, and now the queue states it instead of leaving you to work it out: when a handler throws, the delay used to re-schedule is the delay the job was pushed with — so a job pushed without one has nothing to be re-scheduled by.
Pushed with { delay: 400 }:
error | [JobQueue] Error handling job: queue RetryCase, message a6002f7c-915c-404d-a01b-090af8bf581e, code ECONNREFUSED, retry in 400 ms
error | [JobQueue] Error handling job: queue RetryCase, message 9c183c6d-3448-4cf7-b65d-e649279c50c0, code ECONNREFUSED, retry in 400 ms
Same handler, same throw, opposite outcome — because the push carried a delay.
One thing to catch here: the two lines carry different message ids. A retry
is a fresh send(), so it gets a fresh id. You cannot count one job's attempts
by grouping on the message id, and an alert that tries to will read every attempt
as a new job.
Pushed with { delay: 400, ttl: 500 }:
error | [JobQueue] Error handling job: queue TtlCase, message 06812f73-fc51-4d16-b890-8eb460d2fda3, code ECONNREFUSED, retry in 400 ms
error | [JobQueue] Error handling job: queue TtlCase, message 7c2d3326-8c2e-4555-85b8-3f40c71b930e, code ECONNREFUSED, no retry
info | [JobQueue] retry suppressed, ttl expired: queue TtlCase, message 7c2d3326-8c2e-4555-85b8-3f40c71b930e
ttl bounds how long a job stays worth re-scheduling, counted from the push — so
the second attempt runs, fails, and finds the window already closed. The decision
flips to no retry, and a second line says which of the two reasons it was.
Without that pair, a job that stopped retrying after one round looks identical to
a job that was never retried at all.
And the retry that was promised and is not coming:
error | [JobQueue] Error handling job: queue ReschedCase, message e3c3ef78-31f2-492a-9590-c19786a63c32, code ECONNREFUSED, retry in 300 ms
error | [IMQ-CORE][ReschedCase]: write to queue ReschedCase rejected on ZADD, message b8b70e90-b645-42e9-9cf1-71b3d3fbc2dc, code WRONGTYPE
error | [JobQueue] Job re-schedule failed: queue ReschedCase, message e3c3ef78-31f2-492a-9590-c19786a63c32, code WRONGTYPE
This is the sequence worth pinning to a wall. The queue said retry in 300 ms,
the re-schedule's own write to redis was then rejected, and the third line
retracts the promise the first one made. Read the ids: the core line names the
new delivery that failed to be written, the job lines name the job you were
already following. Treat a re-schedule as an enqueue that can fail, because that
is what it is.
A late reply is not a missing reply
On the RPC side, @imqueue/rpc 3.7.0 adds one line, for a condition that used to
be indistinguishable from a service that simply never answered.
Give a client callTimeout: 300 and a service that answers in 600ms:
caller got: IMQ_RPC_CALL_TIMEOUT — Call to UserService.get() timed out after 300 ms.
warn | UserService: response to request 7a9fb5cc-6979-4840-a61a-c00c6ab42fd8 has no pending call, method get
The caller's rejection tells you the call did not finish in time. The warning
tells you the service did answer — you gave up first. Those are different
incidents with different fixes: one is a callTimeout set too tight or a
consumer pool that is saturated, the other is a service that is genuinely down.
And after a restart, a burst of these lines is the backlog the previous process
left behind, addressed to a reply queue nobody is reading any more.
A publish has its own version of the same idea:
warn | [IMQ-CORE][Events]: published to channel Events on host 127.0.0.1:6399 with no subscribers
publish() resolved successfully every time. Redis pub/sub delivers to whoever
happens to be listening and reports success either way, so "the event fired" and
"somebody received it" have always been two different claims. Now the second one
is checkable.
What these lines will never print
The reason this is not simply log more is the rule underneath it: no failure code is ever taken from the error as-is.
Only an allow-listed code is printed — an IMQ_-prefixed framework code, a
system errno such as ECONNREFUSED, a small integer, a known redis reply code
(WRONGTYPE, NOSCRIPT, LOADING, MISCONF, …), or one of a few fixed
redis-client failure messages mapped to codes of the framework's own. Everything
else — the error's message, its stack, its class name — comes out as unknown.
That looks over-cautious until you see what it is protecting against. Here is
@logged() on @imqueue/rpc 3.4.4, on a method that throws an application
error:
logger.error <- Error: card 4111111111111111 declined for 42 EUR
at PaymentService.charge (…/payment-service.js:12:17)
at PaymentService.charge (…/@imqueue/rpc/src/decorators/logged.js:46:39)
… {
code: 'CARD_DECLINED',
args: [ { pan: '4111111111111111', cvv: '737' }, 42 ]
}
A card number, a CVV and the full argument list, in your log aggregator, forever.
Nobody logged them on purpose — the decorator handed the caught error to the
logger and the error carried them along. An @imqueue framework error carries
the call arguments in its own properties by design, so this is not limited to
errors somebody wrote carelessly.
The same method on 3.7.0:
logger.error <- PaymentService.charge() failed, code unknown
CARD_DECLINED is not on the allow-list, so it reports as unknown — the rule
holds even where the code was harmless. The class and the method survive, because
those are yours and are not data.
This is the one upgrade note in the wave. If you were parsing stack traces or
error messages out of @logged() records, they are gone, and the new line cannot
give them back. Everything else about the decorator is unchanged: which logger it
resolves, doNotThrow, and the value it re-throws. Where you do need the error
itself, catch it in the method and log it yourself, with whatever redaction your
own policy calls for.
A broken logger cannot break the queue
Every line added here goes through a contained writer, so a logger that throws
cannot turn into a queue failure. Give the queue a logger that throws on every
warn and error call, break the destination, then repair it:
send() resolved both times: true
messages actually on the queue after repair: 1
process still alive: true
The failure was reported to a logger that exploded, the queue carried on, and the message written after the repair arrived normally.
One honest limit: that containment covers the lines this wave added. The older
connection-lifecycle lines — the channel connected messages — are still written
directly, so a logger that throws on info can still take start() down. That
is a pathological logger rather than a realistic one, but if you are wrapping a
transport that can throw on a full disk, wrap it so it cannot.
What to alert on
The lines are per-event; your alerts should not be. A practical mapping, with the level each line is written at:
| Line | Level | What it means | Reasonable response |
|---|---|---|---|
write to queue … rejected on … |
error |
a send() never reached redis |
page if it repeats — the messages are gone, not delayed |
outbound writes resumed after N rejected writes |
info |
the episode ended | record N; it is the only count of what was lost |
[JobQueue] push error: … |
error |
a job was never enqueued | same as above, and the caller does not know |
Error handling job: … no retry |
error |
that job is finished, unsuccessfully | this is your dead-letter signal — there is no dead-letter queue |
Error handling job: … retry in N ms |
error |
it is coming back | alert on rate, not on occurrence |
retry suppressed, ttl expired |
info |
a retry was wanted and refused | usually a ttl set shorter than the retry needs |
Job re-schedule failed: … |
error |
a promised retry is not coming | treat exactly like no retry |
response to request … has no pending call |
warn |
the service answered after you gave up | look at callTimeout and consumer saturation, not at the service |
published to channel … with no subscribers |
warn |
the event reached nobody | a consumer is down, or the channel name is wrong |
| a subscription restored after a reconnect | info |
the subscription survived | the absence of this line after a reconnect is the alert |
Two of those invert the usual instinct, so they are worth repeating. The recovery
line is the one carrying the outage's size, so a rule that only matches error
throws the count away. And a lost subscription is provable only by a line that
does not appear — so if you take one thing from this list, alert on reconnects
that are not followed by a restore.
Turning them on (you already have)
There is nothing to enable. logger defaults to console, so on these versions
the lines are already on your stdout. To send them somewhere more useful, set
logger in the queue options to
anything matching ILogger — four methods,
log, info, warn and error:
import logger from '@imqueue/async-logger';
import { IMQService } from '@imqueue/rpc';
const service = new IMQService({ logger });
@imqueue/async-logger's default export satisfies
that shape and configures its transports from the environment, so a service
already using it needs no change at all. @imqueue/job takes the same option as
JobQueueOptions.logger, and it
is the one option that package actively recommends setting.
None of this is verbose. That flag
still controls the chatty per-operation tracing and still defaults to false;
the lines here are written regardless of it. They are failures, not diagnostics.
FAQ
Why does a failed send() or push() not throw in @imqueue?
Because neither waits for the broker. send() resolves with a locally generated
message id before redis confirms the write, and push() is not asynchronous at
all — it returns the queue for chaining. By the time redis rejects the write the
caller has moved on, so there is no promise left to reject. That is the design,
and it is what makes an enqueue cheap enough to put in a request handler. Since
@imqueue/core 3.4.0, @imqueue/rpc 3.7.0 and @imqueue/job 3.1.0 the failure
is reported through the configured logger instead.
How do I find out that a job was dropped instead of retried?
Look for [JobQueue] Error handling job: with no retry at the end. Since
@imqueue/job 3.1.0 every handler failure states the decision it just made —
retry in <ms> or no retry — with the message id, so a drop is provable rather
than inferred. The usual cause is that the job was pushed without a delay: when a
handler throws, the delay used for the retry is the delay the job was pushed
with, and there is nothing to re-schedule by if it had none.
Do I still need to pass an errorHandler to send()?
Only if you need to react in code — to increment a metric, fail a request, or
fall back to another path. For simply finding out that something failed, the log
line is better: it is per queue rather than per call site, so it cannot be
forgotten at one of the places that enqueue work. Note too that a redis client
may deliver the same failure twice, so errorHandler fires twice per logical
failure — ten calls for five failed sends — while the log counts it once.
Will @imqueue log my message payload or my error message?
No. No line carries a message payload, call arguments, a raw redis key or an
error text. Only an allow-listed failure code is printed: an IMQ_-prefixed
framework code, a system errno, a small integer, a known redis reply code, or one
of a few fixed redis-client messages mapped to codes of the framework's own.
Everything else, the error's message, stack and class name included, comes out as
unknown — because an application error may carry personal data and an imq error
carries the call arguments in its properties.
What breaks when I upgrade to @imqueue/rpc 3.7.0?
One thing: @logged() now writes Class.method() failed, code ... — the class,
the method and an allow-listed failure code — instead of handing the caught error
to the logger, so stack traces and error messages no longer appear in those
records. If you parse them, that is the change to plan for. Everything else about
the decorator is unchanged, including which logger is resolved, doNotThrow, and
the value re-thrown. The upside is that arguments and error text can no longer
leak into your log aggregator through it.
Can a broken logger break my queue?
Not through the lines this wave added: each of them is written through a
contained writer, so a logger that throws is swallowed and the queue carries on.
The older connection-lifecycle lines are not contained, so a logger that throws
on info can still fail start(). Wrap a transport that can genuinely throw.
What the silence was costing
The interesting thing about this whole class of failure is that none of it was a delivery bug. The queue behaved exactly as specified every time: an enqueue that does not wait cannot report, a handler that throws without a delay has nothing to re-schedule with, and a publish reaches whoever is listening. Each one is a documented property.
What it cost was that those properties were only knowable by reading the source. An enqueue that vanished and a queue that was idle looked identical from outside, and so did a job that was dropped and a job that was still coming, and a service that never answered and a service that answered too late. Every one of those pairs now differs by a line — a smaller change than it sounds like, and a much larger one than it looks like at three in the morning.
npm i --save @imqueue/core@^3.4.0 @imqueue/rpc@^3.7.0 @imqueue/job@^3.1.0
If you are pointing an AI assistant at one of these incidents, there is a
machine-readable version of the same material — the diagnostic table, the
commands to reproduce each line, and the failure modes as a lookup — in the
agent recipe on diagnosing silent failures. The
reference pages for the symbols named here are
IMQOptions.logger,
ILogger,
RedisQueue.send(),
JobQueuePublisher.push(),
JobQueuePopHandler,
PushOptions.ttl and
logged(); the API FAQ has the
one-question version. If what you are chasing is a delayed job rather than a lost
one, delayed and scheduled work
covers the timing side, and guaranteed message
delivery covers what safeDelivery
does and does not promise.
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.