Caching Strategies for Node.js APIs: From In-Memory to Redis

Technical PM + Software Engineer
APIs face pressure to respond quickly and to handle spikes in traffic. Caching is the most effective lever for reducing latencies and backend load, but it’s also where many teams introduce bugs and stale data. This article presents practical, implementable caching strategies for Node.js APIs: when to use in-memory caches versus Redis, how to apply cache-aside with sensible TTLs, and a pragmatic approach to the hard part—cache invalidation. Each section gives patterns, tradeoffs, and short Node.js examples you can adapt.
1) Start with the pattern: Cache-aside (the prudent default)
Cache-aside (a.k.a. lazy-loading) is the simplest and most widely applicable pattern: on a read, check the cache first; if there's a miss, load from the origin store, then populate the cache. On writes, update the primary store and invalidate or update the cache as appropriate. Cache-aside decouples cache logic from the datastore and works well for APIs where slightly stale reads are acceptable.
Why start here: it minimizes synchronous coupling between cache and primary data store, keeps reads fast, and is easy to reason about. It also maps cleanly to TTL strategies and distributed caches like Redis.
- Read flow: GET -> cache.get(key) -> if miss fetch from DB -> cache.set(key, value, ttl) -> return
- Write flow: POST/PUT -> update DB -> invalidate cache (delete key) or update cache
- When not to use: strongly consistent transactional reads/writes or cases requiring atomic multi-key updates
2) TTL strategy: choose values, regimes, and eviction behavior
TTL (time-to-live) balances freshness and cache hit rate. There are two common regimes to choose from: short TTLs (seconds to a few minutes) and long TTLs (hours to days). Short TTLs reduce staleness but increase origin load; long TTLs improve hit rates but risk returning stale data. Combine TTL with a refresh strategy for the best tradeoff.
Consider these practical rules of thumb: use shorter TTLs for rapidly changing data and user-sensitive fields, use longer TTLs for reference data, and make TTLs configurable per key/namespace so you can tune without redeploying. Also consider sliding expirations when access patterns benefit from keeping hot keys alive, versus absolute expirations which are simpler and more predictable.
- Short TTL: 5–60s for frequently changing user-visible data
- Medium TTL: 5–30m for session-like or moderately dynamic data
- Long TTL: hours/days for product catalogs, metadata
- Sliding TTL extends on access; absolute TTL is safer for invalidation reasoning
3) Cache invalidation: the hard part and practical strategies
Invalidation is difficult because the cache is a separate system from your authoritative data. Choose a strategy based on consistency needs, throughput, and complexity your team can maintain. Common strategies include explicit invalidation on writes, versioning/namespace bumping, event-driven invalidation, and soft/async approaches like stale-while-revalidate.
Explicit invalidation is the simplest: after updating the DB, delete or update the relevant cache keys. This requires your write path to be trusted and reliable. Event-driven invalidation (publishing change events) scales to multiple services but introduces eventual consistency and operational complexity. Versioning (including a version suffix in keys) avoids deletes and supports fast invalidation at a namespace level but requires careful key design.
- Explicit delete after successful write: simple but must be in the same code path
- Event-based: publish change events to invalidate caches across services
- Versioned keys: bump a version to logically invalidate groups of keys
- Stale-while-revalidate: serve stale content while asynchronously refreshing the cache
- Be pragmatic: prefer simple deletes for single-service writes, events for multi-service
4) In-memory (node-cache) vs Redis: tradeoffs and when to pick each
In-memory caches (e.g., node-cache, LRU caches) are cheap and fast: no network hop and minimal operational cost. They are ideal for single-process apps or ephemeral caches (e.g., feature flags, short-lived derived values). But they don’t share state across processes, making them unsuitable for horizontally scaled APIs where consistency and shared hit rates matter.
Redis is the go-to for distributed caching: it supports TTLs, eviction policies, atomic operations, pub/sub for invalidation events, and persistence options. Redis adds complexity and cost (network latency, ops overhead), but provides consistent shared caches and primitives to avoid stampedes (locks, Lua scripts). Choose Redis as traffic grows, when multiple instances must share a cache, or when you need advanced features like sorted sets or streams.
- Use node-cache when: single-node or small cluster, simple TTLs, low ops overhead
- Use Redis when: multi-node, need for shared cache, atomic operations, pub/sub
- Hybrid approach: local in-memory cache as L1 + Redis as L2 for best latency and hit rate
5) Preventing cache stampedes and ensuring robustness
A cache stampede happens when many requests miss and all fall through to the origin, overloading it. Common mitigations: mutexes/locks around population, request coalescing (single-flight), probabilistic early expiration (randomized TTL jitter), and stale-while-revalidate.
Implement a simple mutex in Redis using SET key NX PX ttl to allow one worker to refresh while others either wait or serve a stale value. Libraries like Redlock or singleflight implementations can help. Stale-while-revalidate serves the stale value with a short TTL while a background refresh updates the cache, preserving availability during origin slowness.
- Mutex refresh: one requester refreshes, others wait or serve stale
- Single-flight: coalesce duplicate in-flight loads into one origin call
- Probabilistic early expiration: start refresh before TTL expires (e.g., expire*randomFactor)
- Serve stale when origin fails, then retry background refresh
6) Implementation sketches: node-cache and Redis cache-aside in Node.js
Keep examples concise and adapt to your framework. The node-cache example suits low-scale apps; the Redis example shows a production-ready cache-aside with TTL, locking, and stale-while-revalidate.
Node-cache (simple):
- const NodeCache = require('node-cache');
- const cache = new NodeCache({ stdTTL: 60, checkperiod: 120 });
- async function getUser(id) {
- const key = `user:${id}`;
- const cached = cache.get(key);
- if (cached) return cached;
- const user = await db.getUser(id);
- cache.set(key, user, 60);
- return user;
- }
7) Redis example: cache-aside with locking and stale-while-revalidate
Use a Redis client like ioredis. The approach below outlines: check cache -> if hit and not stale return -> if miss or stale try to acquire lock to refresh -> if lock lost, serve stale or wait briefly.
A sketch of the flow (pseudo-code style):
- const redis = new Redis();
- const key = `user:${id}`;
- const cacheEntry = await redis.hgetall(key); // { value, expiresAt }
- if (cacheEntry && cacheEntry.expiresAt > now) return JSON.parse(cacheEntry.value);
- const lock = await redis.set(lockKey, instanceId, 'NX', 'PX', lockTtl);
- if (lock) {
- // we refresh
- const fresh = await db.getUser(id);
- await redis.hmset(key, { value: JSON.stringify(fresh), expiresAt: Date.now() + ttl });
- await redis.del(lockKey);
- return fresh;
- } else {
- // someone else is refreshing; optionally return stale or wait
- if (cacheEntry) return JSON.parse(cacheEntry.value);
- await sleep(50); // short backoff then retry
- }
Conclusion
Caching is not a one-size-fits-all feature. Start with cache-aside, set clear TTLs, and design invalidation strategies appropriate for your consistency needs and system scale. For small deployments, in-memory caches keep things simple. As you scale, migrate to Redis and add locking, event-driven invalidation, or versioned keys to keep caches correct and efficient. Always instrument cache hit rates, origin load, and latency, and make TTLs and invalidation behaviors configurable so you can iterate safely.
Action Checklist
- Audit your API endpoints and categorize data by volatility and consistency needs (hot data, reference data, user-specific data).
- Implement cache-aside for read-heavy endpoints with sensible default TTLs and metrics for hits/misses.
- For single-node services, prototype with node-cache; for multi-node, use Redis and add a lock-based refresh strategy.
- Add metrics and dashboards (cache hit ratio, origin QPS, latency) before changing TTLs in production.
- Plan an invalidation approach: explicit deletes for single-service writes, events for distributed systems, or key versioning for bulk invalidation.
- Test cache-failure scenarios: Redis downtime, cache stampedes, and stale-while-revalidate behavior under load.