July 20, 2026


Reference: ioredis ยท Redis EX/expire docs
This guide walks through two small files that do a lot of quiet, important work:
lib/redis.ts(the connection) andlib/cache.ts(the actual caching logic). Every line gets its own plain-language explanation and a simple, everyday scenario.
Caching bugs are sneaky. The code looks simple โ get a value, set a value, delete a value โ but almost every line exists because of a specific, real problem someone ran into first.
Instead of just showing what the code does, this guide explains why each line exists, using a scenario simple enough that the reasoning sticks. Once you understand the "why," the code stops looking like magic.
The mental model for both files together:
lib/redis.ts -> builds the connection to Redis, and a "flag" for when it's ready
lib/cache.ts -> uses that connection to get/set/delete cached data safelyBy the end of this post, you'll understand exactly what happens in these two files:
lib/
redis.ts # the Redis connection itself
cache.ts # get / set / invalidate helpers built on top of itAnd why each individual line is there โ not just "what it does," but "what breaks if you remove it."
Imagine your app keeps its important information in a big filing cabinet in the basement โ that's your Postgres database. It's reliable, but walking down there every time someone asks a question is slow.
So you keep a small notepad on your desk โ that's Redis. If the answer is already written on the notepad, you glance at it and answer instantly. If it's not there, you walk to the basement, get the answer, write it on the notepad for next time, and then answer.
lib/redis.ts is about getting a working pen to write on that notepad. lib/cache.ts is about actually using the notepad correctly โ reading from it, writing to it, and crossing things out when they're no longer true.
lib/redis.ts โ Building the Connectionimport Redis from "ioredis";Scenario: Redis doesn't speak JavaScript, and your app doesn't speak "raw Redis protocol." ioredis is the translator between the two โ a toolkit that knows exactly how to open a connection to Redis and send it commands in a language it understands.
const globalForRedis = globalThis as unknown as { redis: Redis };
export const redis =
globalForRedis.redis ??
new Redis(process.env.REDIS_URL!, {
maxRetriesPerRequest: 3,
enableOfflineQueue: false,
retryStrategy(times) {
return Math.min(times * 200, 2000);
},
});
if (process.env.NODE_ENV !== "production") globalForRedis.redis = redis;This is actually five separate decisions stacked together. Let's take them one at a time.
const globalForRedis = globalThis as unknown as { redis: Redis };
Scenario: Imagine you're renovating a house, and every time you make a small change, the whole crew leaves and a brand new crew arrives โ but they don't know a phone line to the office was already installed, so they install a new phone line every single time. Eventually you have twenty unused phone lines running out the window.
That's what happens in Next.js development: every time you save a file, part of your app code reloads ("hot reload"). Without this line, new Redis(...) would run again on every save, creating a fresh connection and abandoning the old one โ a slow leak of unused connections.
globalThis is a special object that survives across those reloads, like a permanent bulletin board nailed to the wall that the new crew always checks first. This line just says "let's use that board to remember things," and gives it a name (globalForRedis) so TypeScript knows it's allowed to hold a redis property.
export const redis = globalForRedis.redis ?? new Redis(...)
Scenario: This is the actual "check the board first" logic. ?? means "use the left side if it exists, otherwise use the right side." So in plain English: "Is there already a phone line noted on the board? If yes, use that one. If no, install a new one."
new Redis(process.env.REDIS_URL!, { ... })
Scenario: This is the "install the phone line" step โ actually dialing out to Redis using the address and password stored in your .env file (REDIS_URL). The ! after it tells TypeScript "trust me, this value will definitely exist," since environment variables are technically allowed to be missing as far as TypeScript's concerned.
maxRetriesPerRequest: 3
Scenario: Imagine calling a friend and the line drops mid-sentence. Do you hang up forever, or try calling back a few times before giving up? This says: "If a command to Redis fails, retry it up to 3 times before finally giving up and reporting failure."
enableOfflineQueue: false
Scenario: Imagine you show up at someone's office to hand them a form, but the door is still being unlocked. Do you (a) stand there patiently holding the form until the door opens, or (b) say "the door's not open yet" and walk away immediately? This setting picks option (b) โ if the connection to Redis isn't ready yet, don't quietly wait around holding commands in memory; fail immediately and let the calling code decide what to do.
This is exactly the setting that caused the "Stream isn't writeable" error you may have seen โ commands were showing up at a door that hadn't opened yet.
retryStrategy(times) { return Math.min(times * 200, 2000); }
Scenario: If your friend's call keeps dropping, you probably don't call back instantly over and over โ you wait a little longer each time, in case there's a real problem on their end. times is "which retry attempt is this?" (1st, 2nd, 3rd...). Each attempt waits times * 200 milliseconds โ so 200ms, then 400ms, then 600ms โ but Math.min(..., 2000) puts a ceiling on it: never wait more than 2 full seconds, no matter how many retries happen.
if (process.env.NODE_ENV !== "production") globalForRedis.redis = redis;
Scenario: This is "write the phone line onto the bulletin board" โ but only during development. In production, your app doesn't hot-reload the way it does in next dev, so there's no risk of accidentally creating duplicate connections โ this step simply isn't needed there.
export const redisReady = new Promise<void>((resolve) => {
if (redis.status === "ready") resolve();
else redis.once("ready", () => resolve());
});Scenario: Picture a "Now Open" sign outside a shop. The sign starts flipped to "Closed" the moment the shop starts setting up. The instant everything inside is ready, someone flips it to "Open."
redis.status === "ready" โ checks: is the sign already flipped to "Open" by the time we look? (This happens if the connection was already alive from a previous hot reload.)resolve() immediately โ the promise is instantly considered "done," like walking past a shop that's already open.redis.once("ready", () => resolve()) โ this hangs a little bell on the door that ioredis rings the moment it becomes ready. .once means the bell only rings one time, since a promise can only resolve once anyway.Anyone who wants to make sure Redis is truly ready before using it can now write await redisReady โ which is like saying "wait outside until the sign flips to Open" before walking in.
lib/cache.ts โ Using the Connectionimport { redis, redisReady } from "@/lib/redis";
const DEFAULT_TTL_SECONDS = 60 * 5; // 5 minutes โ categories don't change oftenScenario: This brings in both the phone line (redis) and the "Now Open" sign (redisReady) from the other file. DEFAULT_TTL_SECONDS is like a sticky note that says "throw this out after 5 minutes" โ TTL stands for Time To Live. Since categories rarely change, it's safe to let a cached copy sit around for 5 minutes before it's considered possibly outdated.
getCached โ reading from the notepadexport async function getCached<T>(key: string): Promise<T | null> {
try {
await redisReady;
const raw = await redis.get(key);
if (!raw) return null;
return JSON.parse(raw) as T;
} catch (error) {
console.error(`Redis getCached failed for key "${key}":`, error);
return null;
}
}Scenario: You walk up to your desk to check the notepad for an answer.
<T> โ a labeled blank ("Type goes here") so this same function works whether you're checking for a category, a user, or anything else. Whoever calls it fills in what T actually is.try { ... } โ "attempt this, but if anything goes wrong, don't panic the whole app."await redisReady; โ wait for the "Now Open" sign before reaching for the notepad.const raw = await redis.get(key); โ actually look at the notepad under this label (key, like "category:all").if (!raw) return null; โ if the notepad page is blank, that's not an error โ it just means nothing's written there yet. Say so plainly.return JSON.parse(raw) as T; โ the notepad only stores plain text, so this turns that text back into a real, usable object (as T tells TypeScript what shape to expect it in).catch (error) { ... } โ if the notepad itself was stuck, jammed, or unreachable, don't crash โ just log a warning and return null, same as "nothing found," so the rest of the app can fall back to the basement filing cabinet (Postgres) instead.setCached โ writing to the notepadexport async function setCached<T>(
key: string,
value: T,
ttlSeconds: number = DEFAULT_TTL_SECONDS,
): Promise<void> {
try {
await redisReady;
await redis.set(key, JSON.stringify(value), "EX", ttlSeconds);
} catch (error) {
console.error(`Redis setCached failed for key "${key}":`, error);
}
}Scenario: You just walked back from the basement filing cabinet with a fresh answer, and now you're jotting it onto the notepad so next time is instant.
key, value, ttlSeconds = DEFAULT_TTL_SECONDS โ the label to write under, the actual answer, and how long it should stay valid before you'd want to double-check it again (defaulting to 5 minutes if nothing else is specified).await redisReady; โ same as before, wait for the "Open" sign.JSON.stringify(value) โ turn the real object into plain text, since that's all the notepad (Redis) can hold."EX", ttlSeconds โ this is you writing a tiny note in the corner: "cross this out automatically after this many seconds." Redis handles the "crossing out" for you โ you never have to remember to clean up.catch (error) { ... } โ if writing to the notepad fails, that's disappointing but not disastrous โ the answer itself was already correctly given to whoever asked; only the "remember this for later" step failed. So we log it and move on instead of failing the whole request.invalidateCache โ crossing something out earlyexport async function invalidateCache(...keys: string[]): Promise<void> {
if (keys.length === 0) return;
try {
await redisReady;
await redis.del(...keys);
} catch (error) {
console.error(`Redis invalidateCache failed for keys [${keys.join(", ")}]:`, error);
}
}Scenario: Someone just updated a category in the basement filing cabinet. The notepad on your desk still has the old answer written down โ and you don't want to wait the full 5 minutes for it to expire naturally. So you cross it out right now.
...keys: string[] โ the three dots mean "accept any number of labels," so you can cross out one thing or several at once: invalidateCache("category:all") or invalidateCache("category:all", "category:slug:buttons").if (keys.length === 0) return; โ if nobody gave you anything to cross out, there's nothing to do โ leave immediately rather than bothering Redis with an empty request.await redisReady; โ same wait-for-"Open"-sign pattern as the other two functions.await redis.del(...keys); โ actually erase those entries from the notepad. The next time someone asks, getCached will find nothing there, walk to the basement for the fresh answer, and re-write the notepad with setCached.catch (error) { ... } โ if the erasing fails, again, don't crash โ just log it. Worst case, the notepad has a slightly stale answer for a little while longer, until the 5-minute TTL cleans it up anyway.export const categoryCacheKeys = {
all: () => "category:all",
bySlug: (slug: string) => `category:slug:${slug}`,
byId: (id: string) => `category:id:${id}`,
};
export const componentCacheKeys = {
all: () => "component:all",
bySlug: (slug: string) => `component:slug:${slug}`,
byId: (id: string) => `component:id:${id}`,
};Scenario: Imagine you had to hand-write labels on the notepad every time โ "category:all", "category:slug:buttons" โ and one day you accidentally typed "categroy:all". Now your read and write happen under two different labels, and nothing works, with no obvious error to explain why.
These two objects are label-making machines. Instead of typing the label by hand, you call a function:
categoryCacheKeys.all() // always returns "category:all"
categoryCacheKeys.bySlug("buttons") // always returns "category:slug:buttons"
categoryCacheKeys.byId("abc123") // always returns "category:id:abc123"Every part of your app that needs the "all categories" label calls the exact same function and always gets the exact same string back โ no typos possible. componentCacheKeys does the identical job, just for components instead of categories, since they're a completely separate set of notepad entries.
Here's the full flow, in order, the next time someone asks "what are all the categories?":
getCached<Category[]>(categoryCacheKeys.all()).getCached, it first await redisReady โ makes sure the notepad's "Open" sign is flipped.null. Your route code then goes to Postgres (the basement) to get the real answer.setCached(categoryCacheKeys.all(), freshData) to write that answer onto the notepad for next time, with a 5-minute expiry.invalidateCache(categoryCacheKeys.all()) โ crossing out the now-outdated notepad entry immediately, instead of letting stale data linger for up to 5 minutes.Stream isn't writeable and enableOfflineQueue options is falseThis happens when a command is sent to Redis before the connection has finished opening โ usually right at app startup, before the very first request. Adding await redisReady; before every redis.get/set/del call (as shown above) fixes this by waiting for the "Now Open" sign first.
If createCategoryAction (or similar) never calls invalidateCache(...) or setCached(...) after writing to Postgres, the notepad keeps serving the old answer until its TTL naturally expires. Always pair a write to the database with either an invalidation or a fresh write to the cache.
JSON.parse throws unexpectedlyThis usually means something was written to a key using a format JSON.stringify didn't produce โ for example, writing a raw string directly with redis.set(key, "plain text") instead of redis.set(key, JSON.stringify(value)). Always write and read through setCached/getCached consistently, never mix in raw redis.set/redis.get calls for the same key.
Use this checklist to make sure your cache layer is wired correctly:
lib/redis.ts reuses one connection via globalForRedis, not a fresh one every reloadredisReady is exported and awaited before every Redis commandgetCached returns null on both "not found" and "error" โ never throwssetCached always writes with an explicit TTL ("EX", ttlSeconds)invalidateCache is called after every write/update/delete to the real databasecategoryCacheKeys, componentCacheKeys), never typed by handredis.set is paired with a matching JSON.parse on the read sideThe clean mental model is:
lib/redis.ts
-> one shared connection
-> a "ready" flag so nobody talks to Redis too early
lib/cache.ts
-> getCached: check the notepad first
-> setCached: write the fresh answer down, with an expiry
-> invalidateCache: cross out old answers the moment they're wrong
-> cacheKeys objects: never mistype a labelOnce you see it as "a notepad next to a filing cabinet," none of this code is mysterious anymore โ every line is just handling one very specific, very human problem: what if the notepad isn't ready yet, what if it's blank, what if writing to it fails, or what if the answer on it is now wrong?