DEV Community

Daniel Pertu
Daniel Pertu

Posted on

What should your app do when Redis is not there?

Our quiz app leans on Redis for four separate things: rate limiting, a concurrent login cap, a cross instance presence counter, and pub/sub fan out between WebSocket servers.

Three of those four are allowed to fail. They fail in three different ways, and picking which way is one of the more consequential design decisions in the codebase, so it is worth writing down why each is what it is.

The mistake I see most often is treating this as one decision. "We fail open on Redis" is not a policy, it is an abdication. The right question is per concern, not per dependency.

Rate limiting: fail open, loudly, at both levels

function makeRedis(): Redis | null {
  const url = process.env.UPSTASH_REDIS_REST_URL
  const token = process.env.UPSTASH_REDIS_REST_TOKEN
  if (!url || !token) return null
  return new Redis({ url, token })
}

function makeNoopLimiter() {
  return { limit: async (_id: string) => ({ success: true as const }) }
}

function sliding(requests: number, windowSeconds: number) {
  if (!redis) return makeNoopLimiter()
  return new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(requests, `${windowSeconds} s`) })
}
Enter fullscreen mode Exit fullscreen mode

Two things are happening. When the credentials are absent, every limiter becomes a no op that returns success, so a fresh clone with no Upstash account runs the whole app locally. And in the middleware, an actual Redis error is swallowed:

try {
  const { success } = await ratelimit.global.limit(ip)
  if (!success) return tooManyRequests(request)
} catch {
  // Redis unavailable, fail open so the app stays up
}
Enter fullscreen mode Exit fullscreen mode

The justification is that a rate limiter is a guard, not a correctness property. If Upstash is having an outage and we fail closed, we have converted a dependency's bad afternoon into our own total outage, for a hundred people standing in a pub waiting for a quiz to start. Failing open means that during the outage an abuser is unconstrained, and an abuser was not going to be there anyway.

That trade only holds because the limiter is not load bearing. A player gets exactly one accepted answer per question because of a unique constraint in Postgres, not because of a rate limit. If your limiter is the only thing preventing double charges, you cannot fail open on it and you need a different design.

There is a third, sneakier fail open in the same area:

export async function currentRequestIp(): Promise<string> {
  try {
    return getRequestIp(await headers())
  } catch {
    return 'no-request-scope'
  }
}
Enter fullscreen mode Exit fullscreen mode

headers() throws when there is no request scope, for example a direct call from a test or a script. Rate limiting is a protection on real traffic, not part of the behaviour being protected, so losing the key degrades to a shared bucket rather than taking the action down with it.

Presence counters: fire and forget

The WebSocket servers keep a per session connection count in Redis so the health endpoint can report it across instances. Nothing about gameplay depends on it.

So nothing waits for it:

export function incrementPresence(sessionId: string): void {
  const key = `ws:presence:${sessionId}`
  redis
    .multi()
    .incr(key)
    .expire(key, PRESENCE_TTL_SECONDS)
    .exec()
    .catch((err) => {
      console.warn('[sessionStore] Failed to increment presence in Redis:', err)
    })
}
Enter fullscreen mode Exit fullscreen mode

Note the signature. It returns void, not Promise<void>. That is not laziness, it is the type system enforcing the policy: a caller physically cannot await this, so nobody can accidentally put a diagnostic counter on the critical path of a player joining a game. A .catch() that warns is the entire error handling, on purpose.

The decrement is more interesting, because "does not matter" is not the same as "may be nonsense":

redis.eval(
  `local v = redis.call('DECR', KEYS[1]); if v < 0 then redis.call('SET', KEYS[1], 0) end; return v`,
  1,
  key,
)
Enter fullscreen mode Exit fullscreen mode

A Lua script, so the decrement and the clamp are one atomic operation. Without it, two disconnects racing past zero can leave a negative count that never recovers, and a health endpoint reporting -3 connections is worse than one reporting nothing, because somebody will eventually spend an hour on it.

Optional does not mean unprincipled. If you are going to keep a counter at all, keep one that cannot be absurd.

Login caps: fall back to a middle value

The concurrent login cap is stored per user, written by the Stripe webhook when a subscription changes:

const capKey = `${KEY_PREFIX}:cap:${user.id}`
const storedCap = redis ? await redis.get<number>(capKey) : null
const maxLogins = typeof storedCap === 'number' ? storedCap : 3
Enter fullscreen mode Exit fullscreen mode

Here the fallback is neither open nor closed. It is three.

Missing key could mean several things: Redis is down, the webhook has not landed yet, the key has expired, or this account predates the feature. If a missing key meant unlimited, then anyone who wanted to share an account could simply wait for a Redis blip. If it meant zero, a webhook delay would lock a paying customer out of the dashboard on the day they subscribed.

Three is the cap for both paid plans, so the fallback is the value that is correct for most users most of the time, and generous for the rest. The key carries a thirty five day TTL and the webhook rewrites it on every subscription change, so the cache is refreshed far more often than it expires.

The enforcement itself still fails open, one level down:

async function enforceSessionCap(userId, jti, maxSessions): Promise<boolean> {
  if (!redis) return true
  try {
    // ...
  } catch {
    return true // Redis error, fail open
  }
}
Enter fullscreen mode Exit fullscreen mode

Because the thing on the other side of that boolean is redirecting a real customer to a login page with an explanation. Being wrong in that direction during an infrastructure wobble is a support ticket that starts with "your app logged me out mid quiz". Failing open costs an unenforced cap for a few minutes.

The one that is not allowed to fail

Redis pub/sub is how WebSocket instances fan out events to each other. If that is down and you are running more than one instance, half the room sees the leaderboard update and the other half does not, which is not degraded service, it is a broken game.

That one gets a real reconnect strategy and a real alert. The difference is that it is part of the behaviour, not a guard around it.

The discipline that came out of it

Before you write the catch block, answer two questions:

  1. If this fails and we continue, what is the worst thing a user experiences?
  2. If this fails and we stop, what is the worst thing a user experiences?

Fail open when the first answer is smaller. Fail closed when it is larger. And when neither answer is comfortable, the interesting option is often a third one: a conservative default that is right for most people, like a cap of three.

One more, mostly free:

analytics: false,
Enter fullscreen mode Exit fullscreen mode

The Upstash rate limiter can record analytics. That is an extra Redis write on every single limit() call, which is the hottest path in the app, for data nothing in this repo ever reads. On Vercel the middleware never awaits the returned pending promise, so the write was liable to be torn down mid flight anyway. We were paying twice the command count for a number nobody looked at.

Audit your optional writes. The cheapest failure to handle is the one you deleted.

If you want to see the parts that are not allowed to fail, the fastest route is to run a session. pub-trivia.app gives you a complete quiz night on the free tier with no card, and the leaderboard updating on every phone in the room at the same moment is the one behaviour in the product with no fallback path at all.

Top comments (0)