When Redis restarts: persistence, eviction and reconnects for a Node.js service fleet
Sooner or later the Redis under your services restarts: an upgrade, a moved node, a process killed for memory. Whether your queued messages are still there afterwards depends on three Redis settings most teams never chose, and what your services do in the meantime depends on how the framework reconnects. Here is what we measured on a real Redis, the five lines of configuration that decide it, and what to do in code for the seconds the broker is gone.
Your services can be built carefully, with guaranteed delivery, graceful shutdown and handlers that survive being run twice. Then one night the Redis underneath them restarts. Someone upgraded it, or the machine it ran on was replaced, or the kernel killed it for using too much memory.
When it comes back, are the messages still there? And what did your services do during the seconds it was gone?
The answers don't depend on your code as much as you'd expect. They depend on three Redis settings that most teams never chose: whether Redis saves its data to disk, what it does when it runs out of memory, and which events it publishes. The rest depends on how your services reconnect, and on what your code does with a write that failed.
This post goes through each one. Every behaviour below was measured, not
taken from documentation: @imqueue/core 3.5.4 and @imqueue/rpc 3.9.5 against
Redis 8.10, restarting it on purpose and counting what was left.
The short version
- Turn on
appendonly yes. With Redis's default snapshots, a clean restart kept all 1,010 queued messages and a crash lost all of them. - Keep
maxmemory-policy noeviction. Underallkeys-lru, Redis quietly deleted queues to make room, including a queue nobody was writing to, and no service saw an error. - Set
notify-keyspace-events Exin the Redis config. The services put it back themselves after a restart, but only where they may runCONFIG SET. - A message sent while Redis is down is not retried.
send()still resolves. Pass an error handler and decide what to do with the failure. - Set
callTimeouton RPC clients. A call that was in flight when Redis went down never settled without one. - Expect services to come back a little after Redis does. Reconnect attempts back off from 1 s up to 30 s. In our test, an 8.5 s outage meant 15 s without delivery.
- Delayed messages survive and stay on time. With the append-only file they are kept through a crash, and once the services reconnect they are released on time again, with no restart on your side.
Where your messages live
Everything a queue holds lives in Redis memory, under keys that start with your
prefix (imq by default):
| what | Redis key | Redis type |
|---|---|---|
| messages waiting for a worker | imq:Orders |
list |
| delayed messages | imq:Orders:delayed |
sorted set |
| a message a worker is processing (with safe delivery) | imq:Orders:worker:… |
list |
Nothing else holds a copy. Your services only pass through. So the question "what survives a restart" is a question about Redis, and Redis answers it with its configuration.
Recipe 1: decide what survives a crash
Redis can keep data on disk in two ways. Snapshots (RDB) write the whole
dataset every so often. The append-only file (AOF) records every write as it
happens. Out of the box, Redis takes snapshots on a schedule
(save 3600 1 300 100 60 10000) and has the append-only file off.
We put 1,000 messages in a queue and 10 delayed messages in its sorted set,
then restarted Redis two ways: a clean stop (docker restart, which sends
SIGTERM) and a crash (kill -9). Here is what was left each time:
| Redis persistence | clean restart | crash |
|---|---|---|
none (save "", appendonly no) |
0 of 1,010 | 0 of 1,010 |
| Redis defaults (snapshots only) | 1,010 of 1,010 | 0 of 1,010 |
appendonly yes |
1,010 of 1,010 | 1,010 of 1,010 |
The middle row is the one that catches people out. On a clean shutdown Redis writes one last snapshot, so a planned restart looks perfectly safe, and every test you run on purpose passes. A crash gets no last snapshot. Anything written since the previous one is gone, and with these schedules that can be up to an hour of messages.
The append-only file is what makes a crash survivable:
appendonly yes
appendfsync everysec
everysec writes the file to disk once a second. Redis documents that a crash
can then lose up to about one second of writes. That is the usual trade-off
between safety and speed, and it is the right default for a message queue.
appendfsync always closes even that second, at a real cost in throughput.
To check what a running Redis is doing:
redis-cli CONFIG GET appendonly
redis-cli INFO persistence | grep -E 'aof_enabled|rdb_last_save'
On a managed Redis service, these settings are usually in the provider's
console or parameter group rather than in redis.conf. The question to ask is
the same: is every write logged, or only a snapshot now and then?
Recipe 2: never let Redis evict a queue
When Redis reaches its maxmemory limit, maxmemory-policy decides what
happens. Some policies make room by deleting keys. For a cache that's the point.
For a queue, a deleted key is a deleted queue.
We gave Redis an 8 MB limit and two queues. Mail held 20 messages and 5
delayed ones. Orders had no consumer, and we sent it 6,000 messages of about
2 KB each, far more than fits:
maxmemory-policy |
what the sender saw | left in Orders |
left in Mail |
|---|---|---|---|
noeviction (Redis default) |
OOM errors, logged |
2,777 | 20 + 5 delayed |
allkeys-lru |
nothing | 451 | 0 + 0 delayed |
volatile-lru |
OOM errors, logged |
2,772 | 20 + 5 delayed |
Read the allkeys-lru row twice. Redis deleted the whole Orders list several
times over to make room (5 evicted keys), so only the last 451 messages were
left. Worse, it also deleted Mail, a queue that was not being written to at
all. The sender saw no error and the log stayed empty, because from Redis's
point of view nothing went wrong. It did what that policy says.
noeviction refuses new writes instead. That's louder, and it's what you want:
the queue keeps everything it had, other queues are untouched, and the failure
reaches your code and your logs:
[IMQ-CORE][Sender]: write to queue Orders rejected on LPUSH,
message 48bbccea-…, code OOM
The volatile-* policies only evict keys that have an expiry. Queue lists never
do, so they behaved like noeviction here. They can still evict the small
timer keys behind delayed messages; one was evicted in this test. That does not
lose the delayed messages themselves, but nothing gains from it either.
So:
maxmemory 2gb
maxmemory-policy noeviction
Size maxmemory for your worst backlog, not your average one, and alert on
memory use well before the limit. A queue that grows because a consumer is down
is exactly the moment you can least afford to run out.
If the same Redis also serves as a cache, this is a good reason to give the cache its own Redis. One instance can only have one eviction policy, and the cache wants the opposite of what the queue needs.
Recipe 3: handle a send that failed
Now the outage itself. We ran a consumer and a sender, sending 10 messages a second, and stopped Redis for about 8.5 seconds:
sent 218, received 69, errorHandler: 149 messages / 298 calls, lost 149
Every message sent while Redis was unreachable was lost: 149 of 149. Nothing retried them. Every message sent before and after arrived.
The reason is in how send() works. It
hands the write to the connection and resolves straight away, without waiting
for Redis to reply. That is what makes it fast, and it means a resolved
send() does not prove the message was stored. In the test every send()
resolved in under 50 ms, outage or not.
The failure is reported in two places. The queue's logger writes the first failure of an episode and a summary when writes work again:
[IMQ-CORE][Sender]: write to queue Orders rejected on LPUSH,
message 367fcb1d-…, code CONNECTION_CLOSED
[IMQ-CORE][Sender]: outbound writes resumed after 149
rejected writes
And send() takes a fourth argument, an error handler, which is the only way
for your code to learn that a particular message failed:
import IMQ from '@imqueue/core';
const queue = IMQ.create('Billing');
const unsent = new Map<string, object>();
const message = { orderId, amount };
await queue.send(
'Orders',
message,
undefined, // no delay
() => unsent.set(orderId, message), // did not land
);
The handler captures the message itself, so it knows exactly what to send again.
What to do with a failure depends on the message. Some can be dropped. Others belong in a retry list, or in your own database first so that a background job can send them again once Redis is back. That pattern is often called an outbox. The framework won't choose for you, and it shouldn't: only your code knows which messages matter.
One detail to plan for: the handler can be called more than once for the same
message. During the outage it was called 298 times for the 149 lost messages,
and when Redis was out of memory 6,446 times for 3,223 rejected writes: exactly
twice each, because the client reports one failure through two paths. Key what you keep by something that identifies the message, as the Map
above does, rather than counting calls.
Recipe 4: give every RPC call a deadline
With @imqueue/rpc, a call is a message to the service plus a reply back. We
made two calls around the same outage, with Redis down from about 1.5 s to
7.5 s:
- Call B was made while Redis was down. It was rejected immediately with
Connection is closed.. The client passes the failed write straight to the call, so nothing hangs. - Call A was made just before the outage, to a method that takes 4 seconds. The service received it, did the work, and tried to reply while Redis was gone. The reply was lost.
What happened to call A depended on one option:
callTimeout |
call A after the outage |
|---|---|
| unset (the default) | still pending after 45 s |
10000 |
rejected at 10 s with IMQ_RPC_CALL_TIMEOUT |
Without a timeout, the caller's promise never settles, and whatever was waiting on it waits forever. Set one on every client:
const orders = new OrdersClient({
callTimeout: 10_000,
});
callTimeout is the time to
wait for a reply, so pick it from how long the slowest method should take, plus
some margin.
Notice what a timeout does not tell you: whether the work happened. Call A ran to the end on the service. Only the reply was lost. If the caller retries after a timeout, the method runs twice. That's fine for a read and for any method written to be safe to repeat, and it's a bug for "charge the card". This is the same reason handlers should be idempotent under guaranteed delivery: the queue delivers at least once, and a restart is one of the ways "at least once" turns into "twice".
Recipe 5: expect a short tail after Redis returns
Each service reconnects on its own schedule. After a connection drops, the first attempt comes 1 s later, and every failed attempt doubles the wait, up to 30 s between attempts. The schedule is built into the queue and can't be changed through options:
| attempt | 1 | 2 | 3 | 4 | 5 | 6 and on |
|---|---|---|---|---|---|---|
| wait before it | 1 s | 2 s | 4 s | 8 s | 16 s | 30 s |
That means services rarely come back the moment Redis does. In our outage test, Redis was down for about 8.5 s. The attempts at 1, 3 and 7 s found nothing there, and the next one came at 15 s, 6–7 s after Redis was already back (two runs). So 8.5 seconds of downtime became 15 seconds without delivery.
After a long outage, that tail can reach 30 seconds. Nothing is wrong when it happens, and nothing needs fixing. It's the price of not hammering a Redis that is still starting up. Two practical consequences:
- Health checks and alerts should allow for it. A service that hasn't reconnected 10 s after Redis came back is behaving normally.
- Recipes 3 and 4 cover the tail too. Sends fail until the service has reconnected, not until Redis is up.
You can watch it happen in the log. Every failed attempt is logged as a warning, and every channel logs a line when it connects again.
Recipe 6: delayed messages and the restart
Delayed messages are stored in Redis like everything else, so with
appendonly yes they survived. We sent a message with a 4 s delay, then took
Redis down before it was due. It was delivered as soon as the service
reconnected. A delayed message that came due during the outage is not lost; it
is late.
Timing is the part to watch. On time, a delayed message is released by a Redis event: its timer key expires, Redis announces the expiry, and the one service that holds the queue's "watcher" moves the message into the queue. That needs two things: Redis publishing expiry events, and the watcher listening for them.
Both come back on their own. When the watcher's connection is replaced, the
service that owns it subscribes to expiry events again on the new connection,
and turns notify-keyspace-events back on if the restart dropped it. We sent
five messages with a 1 s delay before and after each kind of interruption:
| when | arrival after a 1 s delay |
|---|---|
| before the Redis restart | 1.01 – 1.14 s |
| after a restart, flag set by the services | 1.02 – 1.15 s |
| after a restart, flag in the Redis config | 1.03 – 1.20 s |
| after only the watcher's connection dropped | 1.07 – 1.08 s |
No service was restarted for any of these. The flag shows why the config file still matters:
before the restart: notify-keyspace-events = xE
right after the restart: notify-keyspace-events = (empty)
after the reconnect: notify-keyspace-events = xE
A restart reloads the configuration file, so a value set at runtime is gone
until a service puts it back, and a service can only do that if it is allowed to
run CONFIG SET. Managed Redis services often block CONFIG, and the
ACL post recommends not granting
config|set. In both cases the setting has to come from the configuration:
notify-keyspace-events Ex
Without it, delayed messages still arrive, released by the watcher's periodic
check every
watcherCheckDelay
(5 s by default), so up to about 5 s late.
So after a broker restart, check two things:
- Redis came back with its data:
INFO persistenceandDBSIZE. - The services came back to it: every channel logs a
connectedline, andredis-cli PUBSUB NUMPATis not zero, which means the watcher is listening again.
The configuration, in one place
# keep every write, lose at most ~1 s on a crash
appendonly yes
appendfsync everysec
# refuse writes when full instead of deleting queues
maxmemory 2gb
maxmemory-policy noeviction
# the events delayed messages depend on
notify-keyspace-events Ex
Choose your own maxmemory. The other four lines are the same for any
@imqueue fleet. If you also restrict what services may run on Redis, the
ACL post has the matching rule set, and
TLS for the broker covers the connection.
What this does not cover
- Replicas and automatic failover. Redis copies data to replicas asynchronously, so a replica promoted after a crash can be missing the latest writes. We didn't measure that here. If you run one Redis per service group and want to add and remove brokers, see the auto-scaling broker post.
- Your own process dying. A service killed while Redis is down loses anything it had not yet written. Safe delivery and a graceful shutdown cover the service side. See zero-drop deploys.
- Backups. Persistence brings Redis back after a restart. It does not protect against deleting the data yourself.
FAQ
Does Redis keep queued messages after a restart?
Only if it saves them to disk. With no persistence everything is gone. With
Redis's default snapshots, a clean restart keeps everything and a crash loses
everything written since the last snapshot. With appendonly yes and
appendfsync everysec, a crash loses at most about one second of writes. We
measured 1,010 of 1,010 messages kept through kill -9.
Should a message queue use AOF or RDB in Redis?
AOF, with appendfsync everysec. Snapshots alone only survive a clean
shutdown, and a planned restart is exactly the case that hides this. You can
keep snapshots on as well for backups.
Which maxmemory-policy should Redis use for a queue?
noeviction. It refuses new writes when memory is full, and the sender gets an
OOM error. allkeys-lru deletes whole queues to make room, including queues
nobody is writing to, and nobody is told.
What happens to send() while Redis is down?
It still resolves, because it doesn't wait for Redis to reply. The message is not stored and not retried. The queue's logger reports the failures, and the error handler you pass as the fourth argument is called for each failed message, sometimes more than once.
What happens to an RPC call during a Redis restart?
A call made while Redis is unreachable is rejected immediately. A call already
in flight can lose its reply, and without callTimeout its promise never
settles. Set callTimeout. A timeout doesn't mean the work didn't happen.
How long does @imqueue take to reconnect to Redis?
Attempts start 1 s after the connection drops and double each time, up to 30 s between attempts. Services usually come back some seconds after Redis does: in our tests, 6–7 s after an 8.5 s outage.
Are delayed messages lost when Redis restarts?
Not with appendonly yes. They are kept, and any that came due during the
outage are delivered as soon as the service reconnects. After that, delayed
messages are on time again without restarting anything.
Why are delayed messages a few seconds late after a Redis restart?
Usually because notify-keyspace-events is empty. A restart drops a value set
at runtime, and the services can only set it again where CONFIG SET is
allowed. Put notify-keyspace-events Ex in the Redis configuration. Without
it, delayed messages are released by the periodic check, up to about 5 s late.
Reference
RedisQueue.send() ·
IMQOptions.watcherCheckDelay ·
IMQOptions.safeDelivery ·
IMQOptions.verbose ·
IMQClientOptions.callTimeout ·
IMQClient.remoteCall() ·
IMQRPCError ·
Guaranteed delivery, tested with kill -9 ·
Least privilege for your Redis broker ·
Talking to your Redis broker over TLS
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.