Implementing Caching Layers with Redis and Postgres

Team 7 min read

#redis

#postgres

#caching

#architecture

#performance

Caching is one of the highest‑leverage tools for improving read performance and lowering database load. This guide shows how to add a Redis cache layer in front of Postgres using proven patterns, with code examples, invalidation strategies, and production considerations.

When to add a cache

  • Read-heavy workloads where the same data is requested repeatedly
  • Expensive queries (aggregations, joins, computed views)
  • Latency-sensitive endpoints on global apps
  • Content that can tolerate brief staleness

If your workload is write-heavy or requires strict read-after-write consistency across requests, cache scope and TTL must be chosen carefully.

  • L1: In-process cache (optional) for per-instance micro-caching (tens to hundreds of ms TTLs)
  • L2: Redis as a distributed cache for cross-instance sharing (seconds to minutes TTLs)
  • Source of truth: Postgres for durability and transactions

Common pattern: cache-aside. The app reads from Redis first; on miss, it fetches from Postgres and populates Redis. Writes go to Postgres, then invalidate or update Redis.

Key design

  • Namespace keys: entity:version:id. Example: user:v1:123
  • Keep values small, compress only if you must (JSON is fine; MessagePack/Zstandard for large payloads)
  • Add TTL jitter to prevent thundering herds: TTL + random(0..jitter)
  • For query caches, hash a normalized query and params: q:v1:sha256(SQL|params)

Patterns overview

  • Cache-aside (lazy loading): simplest, flexible. On miss, fetch DB, then set cache with TTL.
  • Read-through: library fetches data on miss (needs a read-through proxy or custom layer).
  • Write-through: writes go to DB and cache together. Lower miss rate; more complex on failure.
  • Write-behind: write to cache/queue, update DB later. Higher throughput, riskier consistency.
  • Stale-while-revalidate: serve slightly stale data while refreshing in background.

Most teams start with cache-aside plus event-driven invalidation or conservative TTLs.

Example: cache-aside in Node.js

Dependencies: pg and ioredis (or node-redis). This example adds stampede protection via a short lock.

// npm i pg ioredis
const { Pool } = require('pg');
const Redis = require('ioredis');

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const redis = new Redis(process.env.REDIS_URL);

const TTL_SECONDS = 300; // 5 minutes
const JITTER_SECONDS = 30;

function keyUser(id) {
  return `user:v1:${id}`;
}

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

async function getUserById(id) {
  const key = keyUser(id);
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  // Stampede protection: attempt a short-lived lock
  const lockKey = `${key}:lock`;
  const gotLock = await redis.set(lockKey, '1', 'NX', 'EX', 5); // 5s lock

  if (gotLock) {
    // We compute the value
    const { rows } = await pool.query(
      'SELECT id, email, name, updated_at FROM users WHERE id = $1',
      [id]
    );
    const user = rows[0] || null;

    const ttl = TTL_SECONDS + Math.floor(Math.random() * JITTER_SECONDS);
    if (user) {
      await redis.set(key, JSON.stringify(user), 'EX', ttl);
    } else {
      // Negative caching to avoid repeated misses; short TTL
      await redis.set(key, JSON.stringify(null), 'EX', 30);
    }
    await redis.del(lockKey);
    return user;
  } else {
    // Another request is recomputing; wait briefly and try cache again
    await sleep(80);
    const retry = await redis.get(key);
    if (retry) return JSON.parse(retry);

    // Fallback: go to DB to avoid user-facing error
    const { rows } = await pool.query(
      'SELECT id, email, name, updated_at FROM users WHERE id = $1',
      [id]
    );
    return rows[0] || null;
  }
}

async function updateUserName(id, name) {
  const { rows } = await pool.query(
    'UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2 RETURNING id, email, name, updated_at',
    [name, id]
  );
  const updated = rows[0];
  const key = keyUser(id);
  // Invalidate or update cache; prefer update to reduce miss window
  if (updated) {
    const ttl = TTL_SECONDS + Math.floor(Math.random() * JITTER_SECONDS);
    await redis.set(key, JSON.stringify(updated), 'EX', ttl);
  } else {
    await redis.del(key);
  }
  return updated;
}

Notes:

  • Negative caching should be short to avoid masking new records.
  • Locks should be short and not block the world. Use backoff if needed.

Event-driven invalidation from Postgres

TTL-only invalidation can leave windows of staleness. For stronger freshness, have Postgres send notifications on changes and let a worker invalidate or refresh keys.

Postgres trigger and NOTIFY

-- Channel-based notifications; payloads are small (a few KB limit), send identifiers not whole rows.
CREATE OR REPLACE FUNCTION notify_user_change() RETURNS trigger AS $$
BEGIN
  IF TG_OP = 'DELETE' THEN
    PERFORM pg_notify('user_updates', json_build_object('op','DELETE','id', OLD.id)::text);
    RETURN OLD;
  ELSE
    PERFORM pg_notify('user_updates', json_build_object('op',TG_OP,'id', NEW.id, 'updated_at', NEW.updated_at)::text);
    RETURN NEW;
  END IF;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS user_change_notify ON users;
CREATE TRIGGER user_change_notify
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION notify_user_change();

Worker to LISTEN and invalidate

async function startInvalidationWorker() {
  const client = await pool.connect();
  await client.query('LISTEN user_updates');
  client.on('notification', async (msg) => {
    try {
      const payload = JSON.parse(msg.payload);
      const key = keyUser(payload.id);

      // Option A: delete key; next read repopulates
      await redis.del(key);

      // Option B: refresh eagerly (extra DB hit but lower miss)
      // const { rows } = await pool.query('SELECT id, email, name, updated_at FROM users WHERE id = $1', [payload.id]);
      // if (rows[0]) await redis.set(key, JSON.stringify(rows[0]), 'EX', TTL_SECONDS);

    } catch (e) {
      // log and continue
    }
  });

  client.on('error', async () => {
    client.release();
    setTimeout(startInvalidationWorker, 1000);
  });
}
startInvalidationWorker();

Considerations:

  • LISTEN is session-scoped; re-LISTEN on reconnect.
  • For high-change tables or many entities, consider logical decoding and a CDC pipeline instead of NOTIFY.
  • If objects are cached in multiple shapes, maintain tag sets: add each concrete key to a Redis Set like tag:user:123 and delete all members on change.

Query-level caching

Not just rows—cache parameterized query results that are expensive to compute.

  • Normalize SQL and parameters, hash to build the key:
    • q:v2:sha256(“select … order by …|param1=value1|param2=value2”)
  • Use conservative TTLs and careful invalidation:
    • Invalidate on table changes impacting the query, or
    • Use short TTLs with jitter and optionally background refresh

Beware of cardinality explosions: ensure the number of unique queries is bounded.

Avoiding cache stampedes and hot keys

  • Jitter TTLs by a few seconds or a percentage
  • Early refresh: when TTL < threshold, refresh in background while serving stale data briefly
  • Locks with SET NX and a short expiration to serialize recomputation
  • For hot keys, consider:
    • Sharding load across replicas with client-side read replicas on Redis
    • Redis Cluster to distribute memory and CPU
    • Local L1 cache for ultrahot items

Consistency choices

  • Eventual consistency: TTL-based or event-driven invalidation; typical for profiles, catalogs, content
  • Stronger consistency: write-through updates plus notifications; trade complexity for freshness
  • Avoid caching data needed inside transactions where read-after-write must be guaranteed globally

Define SLOs for staleness (e.g., 30 seconds max) and tune TTLs accordingly.

Redis operations and tuning

  • Memory policy: use volatile-lru/lfu when all keys have TTLs; allkeys-lfu for mixed workloads
  • Persistence:
    • RDB snapshots for fast restarts and lower write amplification
    • AOF (append only) for stronger durability; use everysec for balance
  • Replication: use at least one replica; enable TLS and ACLs
  • Observability: track hit rate, misses, evictions, latency

Observability

Key metrics:

  • Hit rate = keyspace_hits / (keyspace_hits + keyspace_misses)
  • Evictions and expired keys per second
  • P50/P95 Redis latency
  • DB load before/after caching
  • Queue depth for background refresh or invalidation workers

Quick check:

redis-cli INFO stats | egrep 'keyspace_hits|keyspace_misses|evicted_keys'

Security and privacy

  • Do not cache sensitive data unless necessary; if you must, keep TTLs very short
  • Use TLS for Redis and Postgres connections
  • Restrict Redis commands with ACLs; separate cache from session or queue instances
  • Namespaces to avoid key collisions across environments

Postgres isn’t a cache, but…

  • Postgres has a buffer cache and benefits from OS page cache; adding Redis should reduce repetitive CPU and I/O
  • Use indexes and materialized views to lower base query cost; consider refreshing materialized views and caching them in Redis if needed

Rollout checklist

  • Start with cache-aside for top N endpoints with the worst P95 latency
  • Add TTL + jitter first; measure hit rate
  • Introduce stampede protection for hottest keys
  • Add event-driven invalidation where freshness matters
  • Monitor and tune maxmemory policy, TTLs, and key sizes
  • Document key naming and invalidation rules in the repo

Conclusion

By layering Redis in front of Postgres with cache-aside, thoughtful key design, and event-driven invalidation, you can drastically cut read latency and database load while controlling staleness. Start simple with TTLs, measure, and evolve toward notifications and background refresh where your product needs fresher data.