DEV Community

Timevolt
Timevolt

Posted on

Caching Like a Wizard: Unlocking the Secrets of In-Memory Stores

The Quest Begins (The "Why")

I still remember the first time our API started to sputter under load. It was a Tuesday morning, coffee in hand, and the monitoring dashboard lit up like a Christmas tree—latency spiking, error rates climbing, and the ops team pinging me with the dreaded “We need to scale, yesterday!” vibe.

We had a decent microservice that talked to a Postgres database for every request. Simple, reliable, but each hit meant a round‑trip to disk, a query plan, and a lot of waiting. As traffic grew, the database became the bottleneck, and adding more app servers only helped up to a point—like trying to bail out a sinking ship with a teaspoon.

That’s when I realized we weren’t just missing a scalability knob; we were missing a cache. Not just any cache, but a thoughtful, layered approach that could turn our read‑heavy workload into something snappy and resilient. The quest for the perfect caching strategy began, and spoiler: it turned out to be less about picking the fanciest tech and more about understanding the critical insight behind hit ratios, staleness, and cost.

The Revelation (The Insight)

The big “aha!” moment came when I drew a simple picture of our request flow and asked: What if we could serve the majority of reads straight from memory, only falling back to the DB when we truly needed fresh data?

+-----------+       +-----------+       +-----------+
|  Client   | ---> |   API     | ---> |   Cache   |
+-----------+       +-----------+       +-----------+
          ^                     |
          |                     v
          |             +-----------+
          +------------ |   DB      |
                        +-----------+
Enter fullscreen mode Exit fullscreen mode

The insight? Cache‑aside (aka lazy loading) combined with a short‑lived, time‑to‑live (TTL) entry gives you the best of both worlds: low latency for hot data and automatic eviction for stale data.

Why does this beat other patterns?

  • Write‑through forces every write to hit the cache and the DB, adding latency to mutations and complicating failure handling.
  • Write‑behind (write‑back) can lose data if the cache crashes before persisting—too risky for financial or auth data.
  • Read‑through hides the DB behind the cache but still couples cache miss handling to the DB layer, making it harder to tune TTL independently.

Cache‑aside keeps the API in charge: on a miss, we fetch from the DB, store the result with a TTL, and return it. On a hit, we skip the DB entirely. The TTL acts as a safety valve—if data changes elsewhere, we’ll eventually refresh it without complex invalidation logic.

The trade‑off is a tiny window of stale data (up to the TTL). For most read‑heavy endpoints—user profiles, product catalogs, configuration—this window is acceptable and often preferable to the complexity of strong consistency.

Wielding the Power (Code & Examples)

Let’s look at a before/after example in Node.js using Redis as our cache layer.

The Struggle: No Cache

// GET /users/:id
app.get('/users/:id', async (req, res) => {
  const userId = req.params.id;
  try {
    const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
    if (user.rows.length === 0) {
      return res.status(404).send({ error: 'Not found' });
    }
    res.json(user.rows[0]);
  } catch (err) {
    console.error(err);
    res.status(500).send({ error: 'DB error' });
  }
});
Enter fullscreen mode Exit fullscreen mode

Every request hits Postgres. Under 500 RPS, the DB CPU hitches start to show—latency creeps from 15 ms to 120 ms, and we see occasional connection pool exhaustion.

The Victory: Cache‑aside with TTL

First, we set up a Redis client (using ioredis for brevity).

const Redis = require('ioredis');
const redis = new Redis({ host: 'redis-cache', port: 6379 });
const TTL_SECONDS = 60; // 1‑minute freshness window
Enter fullscreen mode Exit fullscreen mode

Now the handler:

app.get('/users/:id', async (req, res) => {
  const userId = req.params.id;
  const cacheKey = `user:${userId}`;

  // 1️⃣ Try cache first
  const cached = await redis.get(cacheKey);
  if (cached) {
    // Cache hit – return instantly
    return res.json(JSON.parse(cached));
  }

  // 2️⃣ Cache miss – fetch from DB
  try {
    const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
    if (user.rows.length === 0) {
      return res.status(404).send({ error: 'Not found' });
    }

    const userData = user.rows[0];
    // 3️⃣ Store in Redis with TTL for future requests
    await redis.setex(cacheKey, TTL_SECONDS, JSON.stringify(userData));

    res.json(userData);
  } catch (err) {
    console.error(err);
    res.status(500).send({ error: 'DB error' });
  }
});
Enter fullscreen mode Exit fullscreen mode

What changed?

  • Latency: A cache hit is a single Redis GET (~0.5 ms) vs. a DB round‑trip (~10‑15 ms).
  • Load: At 80 % hit rate, DB traffic drops by roughly four‑fold, giving us headroom for writes or complex queries.
  • Simplicity: No external invalidation hooks; the TTL automatically purges stale entries.

Common Traps to Avoid

  1. Forgetting to serialize – Storing raw objects in Redis leads to [object Object] strings. Always JSON.stringify/JSON.parse.
  2. Setting TTL too low – A 5‑second TTL on a rarely‑changing resource causes thrash; monitor hit ratios and tune.
  3. Cache stampede – If many requests miss simultaneously on a hot key, they all hammer the DB. Mitigate with a mutex or request coalescing (e.g., let one request populate the cache while others wait for the promise).

A quick mutex example:

let loadingPromises = {};

app.get('/users/:id', async (req, res) => {
  const userId = req.params.id;
  const cacheKey = `user:${userId}`;

  const cached = await redis.get(cacheKey);
  if (cached) return res.json(JSON.parse(cached));

  // If another request is already loading, wait for it
  if (loadingPromises[cacheKey]) {
    const data = await loadingPromises[cacheKey];
    return res.json(data);
  }

  // Mark this key as being loaded
  const loadPromise = (async () => {
    const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
    if (user.rows.length === 0) throw new Error('Not found');
    const data = user.rows[0];
    await redis.setex(cacheKey, TTL_SECONDS, JSON.stringify(data));
    return data;
  })();

  loadingPromises[cacheKey] = loadPromise;
  try {
    const result = await loadPromise;
    res.json(result);
  } finally {
    delete loadingPromises[cacheKey];
  }
});
Enter fullscreen mode Exit fullscreen mode

Now we avoid the thundering herd while still keeping the code readable.

Why This New Power Matters

Adopting cache‑aside with a sensible TTL transformed our service from a brittle, DB‑bound endpoint into a resilient, high‑throughput API.

  • Cost savings: Fewer DB read cycles meant we could downsize our read replicas, saving on instance hours.
  • User experience: 95th‑percentile latency dropped from ~120 ms to ~8 ms for cached requests—users noticed the snappier UI instantly.
  • Operational simplicity: No complex invalidation pipelines; we just monitor Redis hit ratio and adjust TTLs as data volatility changes.

The real win, though, is the mindset shift: caching isn’t a mysterious black box; it’s a predictable tool you wield by understanding what you’re caching, for how long, and what the cost of staleness is. Once you internalize that, you can apply the same pattern to rate limiting, leaderboards, or even session stores—each time trading a tiny consistency window for massive performance gains.

Your Turn

Grab a service that’s still hitting the database on every read. Sketch the flow, pick a TTL that matches your data’s change frequency, and throw in a Redis (or Memcached) layer with cache‑aside logic. Measure the hit ratio, watch the latency dip, and celebrate when your p99 latency looks like a superhero landing.

What’s the first endpoint you’ll cache, and what TTL will you start with? Drop your thoughts in the comments—I’m excited to hear about your own caching quests!


Happy coding, and may your cache hit ratios be ever in your favor!

Top comments (1)

Collapse
 
marcusykim profile image
Marcus Kim

Dropping p95 from roughly 120 ms to 8 ms with a 60-second cache-aside TTL makes the payoff concrete, especially alongside the 80% hit rate and four-fold reduction in Postgres traffic. One production wrinkle: the loadingPromises mutex only coalesces requests within a single Node.js process, so multiple replicas can still stampede the same hot key; a distributed lock, stale-while-revalidate, or TTL jitter may be needed at scale. I'd also treat Redis failures as cache misses where possible, because a cache should reduce database pressure without becoming a new reason the endpoint returns 500s.