Redis "READONLY" Errors After Upstash Failover

Redis READONLY error after Upstash failover shows up at an unsettling moment: writes were succeeding normally, a failover event happens somewhere in Upstash's infrastructure, and suddenly the exact same connection your app was using a second ago throws READONLY You can't write against a read only replica. Nothing about your write command changed. The node it's talking to did — it just got demoted from primary to replica, and your client hasn't caught up to that fact yet.
Short answer: your Redis client's connection was established before the failover and is still pointed at what used to be the primary node, which Redis correctly refuses to accept writes against now that it's a replica — the fix is configuring ioredis's reconnectOnError to detect this specific error and automatically reconnect and resend the failed write, rather than letting it surface as an application error.

Why READONLY Errors Appear Right After a Failover
Redis returns the READONLY error for exactly one reason: a write command reached a node that's currently a replica, and replicas refuse writes to prevent their dataset from diverging from the actual primary — as Netdata's own explainer on this error confirms, this is Redis correctly protecting data consistency, not a malfunction. The confusing part is timing: your client's connection was established and working fine before the failover happened. A failover — whether triggered by Upstash's own health checks or planned maintenance — promotes a different node to primary and demotes the old one, and your already-open connection has no built-in mechanism to notice that shift on its own. It keeps sending writes to the same node it's always talked to, which is now, as far as Redis is concerned, no longer allowed to accept them.
(If you've been checking your write command for a syntax problem — it's almost certainly fine. The command didn't change. The role of the node receiving it did.)
The Fix: reconnectOnError Detecting READONLY Specifically
ioredis's reconnectOnError option accepts a function that inspects the error and decides what to do next — and returning 2 specifically tells it to both reconnect and automatically resend the command that triggered the error:
1// redis-connection.ts
2import Redis from 'ioredis';
3
4const redis = new Redis({
5 host: process.env.UPSTASH_REDIS_HOST,
6 port: 6379,
7 password: process.env.UPSTASH_REDIS_PASSWORD,
8 tls: {},
9 reconnectOnError(err) {
10 const targetError = 'READONLY';
11 if (err.message.includes(targetError)) {
12 return 2; // reconnect AND resend the failed write automatically
13 }
14 return false;
15 },
16});With this configured, a failover event turns into a brief, mostly invisible reconnect: the write that hit READONLY gets automatically retried against the connection ioredis re-establishes, which resolves to the actual current primary rather than the stale one. Your application code doesn't need its own try/catch around every write to handle this specific case — the client handles it at the connection level, which is exactly where a topology-awareness problem like this belongs. ioredis's own CommonRedisOptions reference documents every valid return value for reconnectOnError if you need behavior more specific than the reconnect-and-resend shown here.

Application-Level Retry as a Second Layer
reconnectOnError returning 2 handles the common case well, but it doesn't guarantee the retried write lands successfully if the failover is still actively completing at that exact instant. For genuinely critical writes, pairing the connection-level fix with a small, capped application-level retry covers that narrow remaining window:
1// with-write-retry.ts — a thin retry wrapper for critical writes during a failover window
2async function writeWithRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
3 for (let i = 0; i < attempts; i++) {
4 try {
5 return await fn();
6 } catch (error: any) {
7 const isReadonly = error?.message?.includes('READONLY');
8 if (!isReadonly || i === attempts - 1) throw error;
9 await new Promise((resolve) => setTimeout(resolve, 100 * (i + 1)));
10 }
11 }
12 throw new Error('Unreachable');
13}This is deliberately a thin second layer, not the primary fix — most READONLY errors resolve at the reconnectOnError layer alone, and this exists specifically for the rare case where a retry lands a moment too early, mid-failover, rather than as the main defense.

The Opinion Part
Here's the position worth stating plainly: a failover is infrastructure doing exactly what it's supposed to do — detecting a problem and promoting a healthy replica rather than leaving your data on a struggling primary. Treating the resulting READONLY error as evidence that Upstash "broke" gets the causality backwards. The actual gap is that your own client wasn't configured to notice the topology change automatically, which is a completely normal thing to not have configured until the first failover teaches you it mattered. reconnectOnError costs a few lines of connection configuration. Not having it costs a support ticket the first time a routine failover — something that's supposed to be invisible — becomes visible as a write error in production.
Conclusion
If Redis is throwing READONLY errors right after an Upstash failover, the node your client is writing to just got demoted, and your connection hasn't caught up yet. Configure reconnectOnError to detect the error specifically and return 2, letting ioredis reconnect and automatically resend the failed write, and add a thin application-level retry for the rare case where even that lands a moment too early during an active failover.
If MaxRetriesPerRequestError is also showing up on the same Upstash instance under a different circumstance, our dedicated guide on that error covers the separate BullMQ-specific connection requirement behind it, and if connection drops during your own deploys are a separate recurring issue, our reconnection guide for that scenario covers the deploy-specific variant of staying resilient through a brief disruption.
Configure the reconnect logic once, and let a failover go back to being the routine, self-healing event it was always designed to be.
Frequently Asked Questions
It means your client just sent a write command — SET, DEL, or similar — to a Redis node that is now a replica, not the primary. Redis returns this error specifically to prevent a replica from accepting writes it can't safely propagate, which would cause the dataset to diverge from the actual primary. It's Redis correctly refusing to do something unsafe, not a sign of corruption.
Because your client's connection was established before the failover happened, and ioredis (like most Redis clients) doesn't automatically re-discover which node is currently primary just because a failover occurred elsewhere. The client's view of the topology is stale the moment failover happens, and it stays stale until something explicitly tells it to reconnect and re-resolve.
Use the reconnectOnError option to detect the READONLY error specifically and return 2, which tells ioredis to both reconnect and automatically resend the command that triggered the error. This turns a failover event into a brief, mostly invisible reconnect rather than an error surfacing all the way up to your application code.
It guarantees the write is retried against a freshly re-resolved connection, which usually succeeds once the client has picked up the new primary. It doesn't guarantee success if the failover itself is still actively in progress at the exact moment of the retry — for genuinely resilient writes during failover windows, pairing this with a small application-level retry with backoff covers the rare case where even the immediate resend lands too early.
Usually not by itself — a failover is often a normal, expected event (planned maintenance, an automatic health-based promotion), and the READONLY error is just the visible symptom of a client that hasn't caught up yet. If READONLY errors persist well beyond a single failover event, or recur frequently without any corresponding failover, that's worth investigating as a genuine instability rather than a one-time, self-resolving event.
