Redis #

Redis is an in-memory data store that works at incredible speed — read and write operations finish in microseconds because all data is stored in RAM. More than just a simple cache, Redis supports various data structures like strings, hashes, lists, sets, and sorted sets, each with different characteristics and use cases. In modern applications, Redis often appears in several roles at once: a cache to reduce database load, session storage, a lightweight task queue, a pub/sub broker, and even a distributed locking mechanism. In TypeScript, the ioredis library provides a mature interface with good type support, pipelines, cluster mode, and Lua scripting.

Installation #

npm install ioredis
npm install --save-dev @types/ioredis

To run Redis locally via Docker:

docker run -d --name redis -p 6379:6379 redis:7-alpine

Connecting to Redis #

ioredis handles reconnection automatically — you don’t need to write your own retry logic. What matters is managing the client instance so you don’t create a new connection for every operation.

import Redis from "ioredis";

// ANTI-PATTERN: creating a new instance in every function
async function ambilCache(key: string) {
  const redis = new Redis(); // ✗ new connection per call
  return redis.get(key);
}

// CORRECT: a singleton instance
class RedisClient {
  private static instance: Redis;

  static getInstance(): Redis {
    if (!RedisClient.instance) {
      RedisClient.instance = new Redis({
        host: process.env.REDIS_HOST ?? "localhost",
        port: Number(process.env.REDIS_PORT ?? 6379),
        password: process.env.REDIS_PASSWORD,
        db: 0,                    // database index (0–15)
        maxRetriesPerRequest: 3,
        retryStrategy(times) {
          // retry with exponential backoff, max 3 seconds
          return Math.min(times * 100, 3000);
        },
        enableOfflineQueue: true, // queue commands while disconnected
        lazyConnect: false,       // connect immediately when the instance is created
      });

      RedisClient.instance.on("connect", () => {
        console.log("Redis connected");
      });

      RedisClient.instance.on("error", (err) => {
        console.error("Redis error:", err.message);
      });

      RedisClient.instance.on("reconnecting", () => {
        console.warn("Redis is trying to reconnect...");
      });
    }

    return RedisClient.instance;
  }

  static async disconnect(): Promise<void> {
    if (RedisClient.instance) {
      await RedisClient.instance.quit();
    }
  }
}

const redis = RedisClient.getInstance();

Connecting to Redis with a URL (useful for managed Redis like Upstash, Redis Cloud, or Railway):

// connection via URL — full format
const redis = new Redis("redis://:password@host:6379/0");

// Redis with TLS (Redis Cloud, Upstash)
const redisTLS = new Redis({
  host: "my-redis.upstash.io",
  port: 6379,
  password: process.env.REDIS_PASSWORD,
  tls: {}, // enable TLS
});

Redis Data Structures #

Redis isn’t just an ordinary key-value store. Each data structure it provides has atomic commands designed for specific use cases.

flowchart TD
    A[Redis Data Structures] --> B[String\nsingle value, counter, JSON]
    A --> C[Hash\nobject / record]
    A --> D[List\nqueue, stack, log]
    A --> E[Set\ntags, unique members]
    A --> F[Sorted Set\nleaderboard, rate limit]
    A --> G[Stream\nevent log, message queue]

String — The Most Basic Data Type #

A Redis string can store text, numbers, or binary data. The maximum size is 512 MB per key.

// set and get simple values
await redis.set("app:version", "1.0.0");
const versi = await redis.get("app:version"); // "1.0.0"

// set with a TTL (Time To Live) — automatically deleted after N seconds
await redis.set("otp:081234567890", "123456", "EX", 300); // expires in 5 minutes

// setex — alternative syntax with TTL
await redis.setex("session:abc123", 3600, JSON.stringify({ userId: 1 }));

// setnx — set only if the key doesn't exist yet (atomic)
const berhasil = await redis.setnx("lock:resource-1", "1");
// berhasil = 1 if set, 0 if it already exists

// getset — get the old value while setting a new one (atomic)
const nilaiLama = await redis.getset("counter:page-view", "0");

// increment / decrement — atomic operations for counters
await redis.set("counter:like", "100");
await redis.incr("counter:like");        // 101
await redis.incrby("counter:like", 10); // 111
await redis.decr("counter:like");        // 110

// mset / mget — set or get many keys at once (more efficient than a loop)
await redis.mset(
  "user:1:nama", "Budi",
  "user:1:email", "[email protected]",
  "user:2:nama", "Sari"
);
const [namaBudi, emailBudi] = await redis.mget("user:1:nama", "user:1:email");

Hash — Storing Objects #

A hash is a key-value structure inside a key — suitable for storing objects without JSON serialization.

interface UserProfile {
  nama: string;
  email: string;
  tier: string;
  loginCount: string; // all hash values are stored as strings
}

const hashKey = "user:profile:1001";

// hset — set one or several fields
await redis.hset(hashKey, {
  nama: "Budi Santoso",
  email: "[email protected]",
  tier: "premium",
  loginCount: "0",
});

// hget — get one field
const nama = await redis.hget(hashKey, "nama"); // "Budi Santoso"

// hmget — get several fields
const [email, tier] = await redis.hmget(hashKey, "email", "tier");

// hgetall — get all fields as an object
const profil = await redis.hgetall(hashKey) as UserProfile;

// hincrby — increment a numeric field atomically
await redis.hincrby(hashKey, "loginCount", 1);

// hdel — delete specific fields
await redis.hdel(hashKey, "tier");

// hexists — check whether a field exists
const ada = await redis.hexists(hashKey, "nama"); // 1 (exists) or 0 (doesn't)

// hkeys / hvals — get only the keys or only the values
const fields = await redis.hkeys(hashKey);
const values = await redis.hvals(hashKey);

Comparing Hash vs JSON String for storing objects:

// ANTI-PATTERN: store the whole object as a JSON string
// to update one field, you must GET → parse → modify → stringify → SET
await redis.set("user:1001", JSON.stringify({ nama: "Budi", loginCount: 0 }));
const raw = await redis.get("user:1001");
const obj = JSON.parse(raw!);
obj.loginCount++;
await redis.set("user:1001", JSON.stringify(obj)); // ✗ not atomic, prone to race conditions

// CORRECT: use a Hash for objects whose fields are often updated independently
await redis.hset("user:1001", { nama: "Budi", loginCount: "0" });
await redis.hincrby("user:1001", "loginCount", 1); // ✓ atomic, no need to GET first

List — Queues and Stacks #

A list is a linked list supporting push/pop operations from both ends.

const antriKey = "antrian:email";

// rpush — add to the right end (end of list)
await redis.rpush(antriKey, JSON.stringify({ to: "[email protected]", subject: "Selamat Datang" }));
await redis.rpush(antriKey, JSON.stringify({ to: "[email protected]", subject: "Reset Password" }));

// lpop — take from the left end (FIFO queue)
const tugas = await redis.lpop(antriKey);
if (tugas) {
  const email = JSON.parse(tugas);
  console.log("Send email to:", email.to);
}

// blpop — blocking lpop — wait until an element is available (for workers)
const item = await redis.blpop(antriKey, 5); // 5 second timeout
// item = [keyName, value] or null on timeout

// lrange — get elements within an index range
const semuaTugas = await redis.lrange(antriKey, 0, -1); // 0 = first, -1 = last

// llen — list length
const jumlah = await redis.llen(antriKey);

// for a stack (LIFO), use lpush + lpop
await redis.lpush("stack:undo", JSON.stringify({ action: "delete", id: 5 }));
const aksiTerakhir = await redis.lpop("stack:undo");

Set — A Collection of Unique Values #

A set guarantees no duplicates. Suitable for storing tags, lists of processed IDs, or membership.

const tagKey = "produk:1001:tags";

// sadd — add members (duplicates are ignored)
await redis.sadd(tagKey, "laptop", "gaming", "premium");
await redis.sadd(tagKey, "laptop"); // doesn't add a duplicate

// smembers — get all members
const tags = await redis.smembers(tagKey);

// sismember — check whether a value is in the set
const adaTag = await redis.sismember(tagKey, "gaming"); // 1 or 0

// scard — number of members
const jumlahTag = await redis.scard(tagKey);

// srem — remove members
await redis.srem(tagKey, "premium");

// set operations: union, intersection, difference
const tagsA = "produk:1001:tags";
const tagsB = "produk:1002:tags";
await redis.sadd(tagsA, "laptop", "gaming", "asus");
await redis.sadd(tagsB, "laptop", "office", "asus");

const union = await redis.sunion(tagsA, tagsB);         // the union
const intersect = await redis.sinter(tagsA, tagsB);     // the intersection
const diff = await redis.sdiff(tagsA, tagsB);            // the difference (in A, not in B)

Sorted Set — A Set with Scores #

A sorted set is like a regular set, but every member has a numeric score. Members are always ordered by score automatically.

const leaderboardKey = "game:leaderboard";

// zadd — add members with scores
await redis.zadd(leaderboardKey, 9800, "player:budi");
await redis.zadd(leaderboardKey, 12500, "player:sari");
await redis.zadd(leaderboardKey, 7300, "player:andi");

// zincrby — add to a score atomically
await redis.zincrby(leaderboardKey, 500, "player:budi"); // score becomes 10300

// zrange — get members from lowest to highest score
const semuaPemain = await redis.zrange(leaderboardKey, 0, -1, "WITHSCORES");

// zrevrange — from highest to lowest score (for leaderboards)
const top10 = await redis.zrevrange(leaderboardKey, 0, 9, "WITHSCORES");

// zrank / zrevrank — a member's position (0-indexed)
const posisi = await redis.zrevrank(leaderboardKey, "player:budi");
// posisi = 1 if he's in second place

// zscore — get one member's score
const skor = await redis.zscore(leaderboardKey, "player:sari");

// zrangebyscore — filter by a score range
const pemainAktif = await redis.zrangebyscore(leaderboardKey, 5000, "+inf");

Caching Patterns #

Caching is Redis’s most common use case. Here are the two main patterns you need to understand.

Cache-Aside (Lazy Loading) #

The most common pattern — the application manages the cache manually. Data is fetched from the cache first; if it’s not there (a cache miss), it’s fetched from the database and stored in the cache.

sequenceDiagram
    participant App
    participant Redis
    participant DB

    App->>Redis: GET produk:1001
    alt Cache Hit
        Redis-->>App: product data
    else Cache Miss
        Redis-->>App: null
        App->>DB: SELECT * FROM produk WHERE id = 1001
        DB-->>App: product data
        App->>Redis: SET produk:1001 (TTL 10 minutes)
        Redis-->>App: OK
    end
async function ambilProduk(id: string): Promise<Produk> {
  const cacheKey = `produk:${id}`;

  // 1. try the cache first
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached) as Produk;
  }

  // 2. cache miss — fetch from the database
  const produk = await db.query<Produk>("SELECT * FROM produk WHERE id = $1", [id]);
  if (!produk) {
    throw new Error(`Produk ${id} tidak ditemukan`);
  }

  // 3. store in the cache with a TTL
  await redis.set(cacheKey, JSON.stringify(produk), "EX", 600); // 10 minutes

  return produk;
}

// invalidate the cache when data changes
async function updateProduk(id: string, data: Partial<Produk>): Promise<void> {
  await db.query("UPDATE produk SET ... WHERE id = $1", [id]);

  // delete the cache so the next request gets the latest data
  await redis.del(`produk:${id}`);
}

Write-Through #

Data is always written to the cache and the database simultaneously. The cache is always consistent with the database.

async function simpanProdukWriteThrough(produk: Produk): Promise<void> {
  // write to the database and cache simultaneously
  await Promise.all([
    db.query("INSERT INTO produk ... VALUES ...", [produk]),
    redis.set(`produk:${produk.id}`, JSON.stringify(produk), "EX", 600),
  ]);
}

A Generic Cache Wrapper #

To avoid duplicating cache-aside logic across the codebase, create a reusable wrapper:

async function withCache<T>(
  key: string,
  ttlDetik: number,
  fetchFn: () => Promise<T>
): Promise<T> {
  // try the cache
  const cached = await redis.get(key);
  if (cached !== null) {
    return JSON.parse(cached) as T;
  }

  // cache miss — run the original function
  const data = await fetchFn();

  // store in the cache (don't store null/undefined)
  if (data !== null && data !== undefined) {
    await redis.set(key, JSON.stringify(data), "EX", ttlDetik);
  }

  return data;
}

// usage
const produk = await withCache(
  `produk:${id}`,
  600,
  () => db.findById("produk", id)
);

const daftarKategori = await withCache(
  "kategori:semua",
  3600, // cache for 1 hour because it rarely changes
  () => db.query("SELECT * FROM kategori ORDER BY nama")
);

Session Management #

Redis is very suitable for storing sessions because it supports a per-key TTL — sessions expire automatically without a cleanup job.

import { randomUUID } from "crypto";

interface SessionData {
  userId: string;
  email: string;
  role: string;
  loginAt: string;
  lastActiveAt: string;
}

class SessionStore {
  private readonly prefix = "session:";
  private readonly ttl = 86400; // 24 hours in seconds

  private key(sessionId: string): string {
    return `${this.prefix}${sessionId}`;
  }

  async buat(data: Omit<SessionData, "loginAt" | "lastActiveAt">): Promise<string> {
    const sessionId = randomUUID();
    const sessionData: SessionData = {
      ...data,
      loginAt: new Date().toISOString(),
      lastActiveAt: new Date().toISOString(),
    };

    await redis.set(this.key(sessionId), JSON.stringify(sessionData), "EX", this.ttl);
    return sessionId;
  }

  async ambil(sessionId: string): Promise<SessionData | null> {
    const raw = await redis.get(this.key(sessionId));
    if (!raw) return null;
    return JSON.parse(raw) as SessionData;
  }

  async perbarui(sessionId: string): Promise<boolean> {
    const sesi = await this.ambil(sessionId);
    if (!sesi) return false;

    sesi.lastActiveAt = new Date().toISOString();

    // update the data and reset the TTL together
    await redis.set(this.key(sessionId), JSON.stringify(sesi), "EX", this.ttl);
    return true;
  }

  async hapus(sessionId: string): Promise<void> {
    await redis.del(this.key(sessionId));
  }

  async hapusSemuaMilikUser(userId: string): Promise<void> {
    // scan all session keys — use SCAN not KEYS in production
    const stream = redis.scanStream({
      match: `${this.prefix}*`,
      count: 100,
    });

    const keysToDelete: string[] = [];

    for await (const keys of stream) {
      for (const key of keys as string[]) {
        const raw = await redis.get(key);
        if (raw) {
          const sesi = JSON.parse(raw) as SessionData;
          if (sesi.userId === userId) {
            keysToDelete.push(key);
          }
        }
      }
    }

    if (keysToDelete.length > 0) {
      await redis.del(...keysToDelete);
    }
  }
}

const sessionStore = new SessionStore();
Don’t use the KEYS * command in production — it blocks Redis until the entire keyspace has been scanned. Use SCAN with ioredis’s scanStream(), which is non-blocking and iterative.

Rate Limiting #

Rate limiting prevents API abuse by limiting the number of requests within a certain time window. Redis is perfect for this because its operations are atomic and its TTL can be used directly.

Fixed Window Rate Limiter #

async function cekRateLimit(
  identifier: string, // can be an IP, userId, or API key
  limitPerMenit: number
): Promise<{ diizinkan: boolean; sisaRequest: number; resetDalam: number }> {
  const sekarang = Math.floor(Date.now() / 1000);
  const window = Math.floor(sekarang / 60); // per-minute window
  const key = `rate:${identifier}:${window}`;

  // increment and set TTL in one atomic pipeline
  const pipeline = redis.pipeline();
  pipeline.incr(key);
  pipeline.expire(key, 60);
  const hasil = await pipeline.exec();

  const jumlahRequest = (hasil?.[0]?.[1] as number) ?? 0;
  const diizinkan = jumlahRequest <= limitPerMenit;
  const sisaRequest = Math.max(0, limitPerMenit - jumlahRequest);
  const resetDalam = 60 - (sekarang % 60);

  return { diizinkan, sisaRequest, resetDalam };
}

// usage in an Express middleware
async function rateLimitMiddleware(
  req: any,
  res: any,
  next: any
): Promise<void> {
  const ip = req.ip;
  const { diizinkan, sisaRequest, resetDalam } = await cekRateLimit(ip, 100);

  res.setHeader("X-RateLimit-Remaining", sisaRequest);
  res.setHeader("X-RateLimit-Reset", resetDalam);

  if (!diizinkan) {
    res.status(429).json({
      error: "Terlalu banyak request",
      cobaLagiDalam: `${resetDalam} detik`,
    });
    return;
  }

  next();
}

Sliding Window Rate Limiter with a Sorted Set #

The fixed window has a weakness at window boundaries — a user could send 100 requests at second 59, then another 100 at second 61 (a new window). The sliding window is more accurate:

async function cekRateLimitSliding(
  identifier: string,
  limitPerMenit: number
): Promise<{ diizinkan: boolean; jumlahRequest: number }> {
  const key = `rate:sliding:${identifier}`;
  const sekarang = Date.now();
  const window = 60 * 1000; // 1 minute in milliseconds
  const batasWaktu = sekarang - window;

  const pipeline = redis.pipeline();

  // remove entries already outside the window
  pipeline.zremrangebyscore(key, "-inf", batasWaktu);

  // add the current request
  pipeline.zadd(key, sekarang, `${sekarang}-${Math.random()}`);

  // count the total requests in the window
  pipeline.zcard(key);

  // set a TTL so the key cleans itself up
  pipeline.expire(key, 60);

  const hasil = await pipeline.exec();
  const jumlahRequest = (hasil?.[2]?.[1] as number) ?? 0;

  return {
    diizinkan: jumlahRequest <= limitPerMenit,
    jumlahRequest,
  };
}

Pub/Sub — Inter-Process Communication #

Redis Pub/Sub lets one process publish messages and other processes receive them in real time. Useful for notifications, distributed cache invalidation, or communication between microservices.

// IMPORTANT: the subscriber needs its own Redis connection
// because a connection in subscribe mode can't be used for other commands
const publisher = new Redis({ host: "localhost", port: 6379 });
const subscriber = new Redis({ host: "localhost", port: 6379 });

// subscriber — listen to channels
await subscriber.subscribe("notifikasi:pesanan", "notifikasi:pembayaran");

subscriber.on("message", (channel: string, message: string) => {
  const data = JSON.parse(message);

  switch (channel) {
    case "notifikasi:pesanan":
      console.log("New order:", data);
      // send email, push notification, etc.
      break;
    case "notifikasi:pembayaran":
      console.log("Payment received:", data);
      break;
  }
});

// publisher — send messages to a channel
async function publikasikanPesananBaru(pesanan: {
  id: string;
  userId: string;
  total: number;
}): Promise<void> {
  await publisher.publish(
    "notifikasi:pesanan",
    JSON.stringify({ ...pesanan, timestamp: new Date().toISOString() })
  );
}

// pattern subscribe — subscribe to several channels with a wildcard
await subscriber.psubscribe("notifikasi:*");

subscriber.on("pmessage", (pattern: string, channel: string, message: string) => {
  console.log(`Message from ${channel} (pattern ${pattern}):`, message);
});
Redis Pub/Sub is fire-and-forget — messages aren’t stored. If a subscriber is offline when a message is sent, that message is lost. For higher reliability, use Redis Streams or a message broker like RabbitMQ or Kafka.

Distributed Locks #

When several application instances run at the same time, a distributed lock ensures only one instance works on a given task at a time — for example, a cron job or a payment process.

class RedisLock {
  private readonly lockPrefix = "lock:";
  private readonly defaultTTL = 30; // seconds

  async kunci(
    resource: string,
    ttlDetik: number = this.defaultTTL
  ): Promise<string | null> {
    const lockKey = `${this.lockPrefix}${resource}`;
    const lockValue = randomUUID(); // a unique value per lock owner

    // SET NX EX — atomic: set only if it doesn't exist, with a TTL
    const hasil = await redis.set(lockKey, lockValue, "EX", ttlDetik, "NX");

    return hasil === "OK" ? lockValue : null; // null = failed to get the lock
  }

  async bebaskan(resource: string, lockValue: string): Promise<boolean> {
    const lockKey = `${this.lockPrefix}${resource}`;

    // Lua script for atomicity: only delete if the value matches (ours)
    // without this, there's a risk of deleting another process's lock
    const script = `
      if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
      else
        return 0
      end
    `;

    const hasil = await redis.eval(script, 1, lockKey, lockValue);
    return hasil === 1;
  }

  async withLock<T>(
    resource: string,
    ttlDetik: number,
    fn: () => Promise<T>
  ): Promise<T> {
    const lockValue = await this.kunci(resource, ttlDetik);

    if (!lockValue) {
      throw new Error(`Gagal mendapatkan lock untuk resource: ${resource}`);
    }

    try {
      return await fn();
    } finally {
      await this.bebaskan(resource, lockValue);
    }
  }
}

const lock = new RedisLock();

// usage — only one process can run this at a time
async function prosesTagihan(tagihanId: string): Promise<void> {
  await lock.withLock(`tagihan:${tagihanId}`, 30, async () => {
    // the code here only runs on one instance
    const tagihan = await db.findById("tagihan", tagihanId);
    if (tagihan.status === "selesai") return;

    await prosesPaymentGateway(tagihan);
    await db.update("tagihan", tagihanId, { status: "selesai" });
  });
}

Pipelines — Sending Many Commands at Once #

Every Redis command has network round-trip overhead. Pipelines let you send many commands in a single request, then receive all responses at once.

// ANTI-PATTERN: commands one by one — N network round-trips
async function simpanUserDataSatuSatu(userId: string, data: any): Promise<void> {
  await redis.hset(`user:${userId}`, data);     // round-trip 1
  await redis.expire(`user:${userId}`, 3600);   // round-trip 2
  await redis.sadd("users:aktif", userId);       // round-trip 3
  await redis.incr("counter:users:total");       // round-trip 4
}

// CORRECT: pipeline — one round-trip for all commands
async function simpanUserDataPipeline(userId: string, data: any): Promise<void> {
  const pipeline = redis.pipeline();

  pipeline.hset(`user:${userId}`, data);
  pipeline.expire(`user:${userId}`, 3600);
  pipeline.sadd("users:aktif", userId);
  pipeline.incr("counter:users:total");

  // all commands are sent and executed together
  const hasil = await pipeline.exec();

  // check errors per command
  hasil?.forEach(([err, result], index) => {
    if (err) {
      console.error(`Command ${index + 1} failed:`, err);
    }
  });
}

When to Use Redis #

Use Redis for:
  ✓ Caching expensive or frequently accessed database query results
  ✓ Storing user sessions with automatic TTL
  ✓ Rate limiting requests per IP or per user
  ✓ Real-time leaderboards and rankings with sorted sets
  ✓ Lightweight task queues (email, notifications) with lists
  ✓ Distributed locks for inter-instance coordination
  ✓ Pub/sub for real-time notifications between services
  ✓ Real-time counters and statistics (page views, like counts)

Don't rely on Redis for:
  ✗ Data that must not be lost — Redis can lose data on a crash without persistence configuration
  ✗ Data larger than the server's RAM — Redis works in memory
  ✗ Complex queries like joins between "tables" — use a relational database
  ✗ Being the only session store without a backup — always have a fallback

Summary #

  • A singleton instance with retryStrategy configuration — don’t create a new Redis connection for every operation; let ioredis handle automatic reconnection.
  • Choose the right data structure — Strings for single values and counters; Hashes for objects whose fields are updated independently; Lists for FIFO queues; Sets for unique membership; Sorted Sets for rankings and sliding windows.
  • Cache-aside is the default pattern — try the cache first, fetch from the DB on a miss, store in the cache with a TTL; always invalidate the cache when data changes.
  • TTL is a feature, not an afterthought — almost every Redis key should have a TTL to prevent stale data accumulation that consumes memory.
  • Pipelines for repeated operations — use redis.pipeline() when sending several commands at once to avoid repeated network round-trip overhead.
  • Subscribers need a separate connection — a Redis connection in subscribe mode can’t be used for other commands; always create a separate Redis instance for publisher and subscriber.
  • Distributed locks with a Lua script — use an atomic script to ensure only the lock owner can release it; avoid race conditions between GET and DEL.
  • Use SCAN not KEYSKEYS * blocks all of Redis on large keyspaces; use ioredis’s scanStream(), which is iterative and non-blocking.

← Previous: Elasticsearch   Next: Memcached →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact