Skip to content
All writing

NOTES & IDEAS

Why My Blog Doesn't Crash When Its Cache Dies

A cache is supposed to make your app faster. Done wrong, it quietly becomes a new way for your app to go down. Here's the simple rule I used to keep mine harmless, explained with a notepad and a filing cabinet.

NestJSCachingReliabilityRedisSystem Design

While building Writer's Heaven (my blog platform), I added a cache to make pages load faster. A week later I realised I'd accidentally given my app a brand new way to break. The cache was supposed to help, and instead it had quietly become something my app couldn't live without.

That's the trap I want to walk you through, and the one simple rule that fixes it. Plain English first.

The whole idea: a notepad and a filing cabinet

Imagine you're a clerk answering people's questions. All the real answers live in a giant filing cabinet down in the basement. It's complete and always correct, but walking down there for every question is slow.

So you keep a notepad on your desk. Whenever you look something up, you jot the answer on the pad. Next time someone asks the same thing, you read it off the pad in a second instead of walking to the basement.

  • The filing cabinet is your database - the slow but reliable source of truth.
  • The notepad is your cache (I use Redis) - fast, but just a convenience copy.

The routine is: check the notepad first. If the answer's there, great - that's a hit. If it's not, walk to the basement, find it, and jot it on the pad for next time - that's a miss. This routine has a name, cache-aside, but the notepad picture is all you need.

async getPost(slug: string) {
  const key = `post:${slug}`;
  const cached = await this.cache.get<Post>(key);   // 1. check the notepad
  if (cached) return cached;                         //    on the pad? done.

  const post = await this.repo.findBySlug(slug);     // 2. not there → basement
  await this.cache.set(key, post, 300);              // 3. jot it down (5 min)
  return post;
}

The trap nobody warns you about

Here's the mistake. What happens if the notepad goes missing - someone spills coffee on it, it falls behind the desk?

If your rule is "only answer from the notepad," you're now stuck. You freeze. You can't answer anyone - even though the filing cabinet in the basement is perfectly fine and has every answer.

That's exactly what happens in code if checking the cache throws an error when the cache is unreachable. The whole request blows up with a "500" (server error), even though the database was right there, healthy, ready to answer. You added a cache to be faster and accidentally made your app more fragile.

A cache should be something you're allowed to lose. Losing it should make you slower, never stuck.

The proper behaviour has a name - failing open - and it's just the obvious rule written down: notepad missing? Just walk to the basement.

The fix: treat "cache is down" as "not on the notepad"

The trick is to put all the fragile parts in one small place - the wrapper around the cache - so the rest of the app never has to think about whether the cache is up. Every cache call is wrapped so that a failure quietly turns into "it wasn't on the pad" instead of an error:

@Injectable()
export class CacheService {
  private readonly logger = new Logger(CacheService.name);
  private client: Redis;

  async get<T>(key: string): Promise<T | null> {
    try {
      const raw = await this.client.get(key);
      if (!raw) return null;
      return JSON.parse(raw) as T;
    } catch (err) {
      // cache is unreachable → pretend the note simply wasn't there
      this.logger.warn(`cache get failed for ${key}: ${(err as Error).message}`);
      return null;
    }
  }

  async set(key: string, value: unknown, ttlSeconds: number): Promise<void> {
    try {
      await this.client.set(key, JSON.stringify(value), 'EX', ttlSeconds);
    } catch (err) {
      // couldn't jot it down? no big deal - next read just walks to the basement
      this.logger.warn(`cache set failed for ${key}: ${(err as Error).message}`);
    }
  }

  async del(...keys: string[]): Promise<void> {
    if (keys.length === 0) return;
    try {
      await this.client.del(...keys);
    } catch (err) {
      this.logger.warn(`cache del failed: ${(err as Error).message}`);
    }
  }
}

The whole idea sits in that one line: when reading fails, return null - the same thing you'd return if the answer simply wasn't cached yet. The caller already knows what to do with "not on the pad": go to the database. So when the cache dies, every read becomes a miss, every miss walks to the basement, and the app keeps serving correct answers - just a little slower.

Two small things keep this honest:

  • Writing to the cache is even safer to skip than reading. If jotting the note down fails, who cares - the next person just looks it up again. So those failures get swallowed too.
  • Quiet, not silent. Every swallowed failure is still logged. Failing open should never mean failing invisibly - you still want the alert that the cache is sick. You just don't want your users to see it as a crash.

The catch you're accepting

This isn't free. When the notepad is gone, everyone walks to the basement, so the filing cabinet suddenly does a lot more work than usual. If your app is very busy, that's a real load spike on the database, and the honest next step is to guard against it (so a thousand people don't all walk down for the same answer at once).

But think about the two failure modes. "The basement is busier than normal for a while" is a slowdown you recover from. "Every single request crashes because the notepad is gone" is an outage. Failing open trades the scary one for the survivable one.

Don't forget to cross out old notes

There's a second half to this. When an answer changes - say I edit a blog post - the old note on the pad is now wrong. So on every write, I cross out (delete) the notes that just went stale, and the next reader re-fetches the fresh answer:

async updatePost(id: string, data: UpdatePostDto) {
  const post = await this.repo.update(id, data);
  await this.cache.del(`post:${post.slug}`, 'posts:list', `summary:${id}`);
  return post;
}

And notice - crossing out also fails open. If the cache is down during an edit, the edit still goes through; worst case a stale note lingers a few minutes. That's why every note has a use-by time (a TTL): even a note I forget to cross out expires on its own. The use-by time is the safety net under my own mistakes.

So what if the cache just dies?

This is the payoff. If my cache server falls over completely at 3am, nothing pages me out of bed. Every page just quietly starts reading from the database instead. The site gets a bit slower, a log line warns me the cache is unhealthy, and users keep reading posts the whole time. A cache outage became a performance footnote instead of a front-page outage.

Takeaway

A cache should be a thing you can lose. The discipline is to shove all the fragility into one small wrapper - turn read failures into "not found," shrug off write failures, and log everything - so the rest of your code can enjoy the speed-up without ever depending on it. Get that right and a cache can only ever make your app faster, never more breakable.