Redis Explained: How It Works and How to Use It in a Node.js Backend

Learn how Redis works under the hood and how to use it in a Node.js backend for caching, rate limiting, leaderboards, Pub/Sub, and sessions, with practical code examples.

Rohit Bairwa

Rohit Bairwa

Author

8 min read
Redis Explained: How It Works and How to Use It in a Node.js Backend
Redis Explained: How It Works and How to Use It in a Node.js Backend

If you've ever watched an endpoint slow down because it hits the database on every request, you've probably been told to "just add Redis." This post explains what Redis is, why it's fast, and how to use it in a Node.js backend.

What is Redis?

Redis (Remote Dictionary Server) is an in-memory data store. You give it a key, it stores a value, and you get that value back later, like a giant shared JavaScript object that lives in its own server process and is accessible over the network.

It's commonly used as:

  • A cache in front of a slower database

  • A session store

  • A rate limiter

  • A message broker (pub/sub and streams)

  • A leaderboard or counter service

  • A job queue backend

How Redis works under the hood

1. Everything lives in RAM

Redis keeps its dataset in memory, so reads and writes take microseconds instead of the milliseconds a disk-based database needs. The trade-off is that RAM is more expensive and limited than disk, so Redis is best for data that is hot, small, or temporary.

2. Single-threaded command execution

Redis executes commands on a single main thread. That sounds like a limitation, but it means no locks and no race conditions between commands. Each command is atomic. Since every operation is an in-memory action taking microseconds, one thread can handle a very large number of requests per second. (Newer versions can use extra threads for network I/O, but command execution stays single-threaded.)

3. Rich data structures, not just strings

Redis is often called a "data structure server" because values can be:

Type Good for String Cached JSON, counters, flags Hash Objects with fields (user profiles) List Queues, recent activity feeds Set Unique items, tags, followers Sorted Set Leaderboards, priority queues Stream Event logs, message processing

Because operations like "increment this counter" or "add to this sorted set" happen inside Redis, you avoid the read-modify-write cycle in your app code.

4. Expiration and eviction

Any key can have a TTL (time to live), after which Redis deletes it automatically. That's what makes it a natural cache. If memory fills up, Redis can also evict keys according to a policy you configure, such as allkeys-lru (drop the least recently used keys).

5. Persistence (optional)

Even though data lives in memory, Redis can write to disk in two ways:

  • RDB snapshots: point-in-time dumps at intervals. Compact and fast to restore, but you can lose the most recent writes.

  • AOF (Append Only File): logs every write command. Safer and more durable, but larger files.

You can use either, both, or neither. For a pure cache, you often don't need persistence.

6. Scaling and availability

  • Replication: replicas copy data from a primary, giving you read scaling and failover candidates.

  • Sentinel: monitors nodes and promotes a replica if the primary dies.

  • Cluster: shards data across multiple nodes when one machine's memory isn't enough.

Setting up Redis locally

The quickest way is Docker:

Bash / Shell
docker run -d --name redis -p 6379:6379 redis:7

Then check that it works:

Bash / Shell
docker exec -it redis redis-cli
> SET greeting "hello"
OK
> GET greeting
"hello"
> EXPIRE greeting 10
(integer) 1
> TTL greeting
(integer) 10

Using Redis in Node.js

The two most popular clients are node-redis (redis on npm) and ioredis. Both are solid. This post uses node-redis.

Bash / Shell
npm install redis express

Creating a shared client

Create the connection once and reuse it across your app. Don't open a new connection per request.

JavaScript
// redis.js
import { createClient } from 'redis';

export const redis = createClient({
  url: process.env.REDIS_URL || 'redis://localhost:6379',
});

redis.on('error', (err) => console.error('Redis client error:', err));

await redis.connect();

Basic operations

JavaScript
import { redis } from './redis.js';

// Strings with an expiry (60 seconds)
await redis.set('site:motd', 'Welcome!', { EX: 60 });
const motd = await redis.get('site:motd');

// Counters
await redis.incr('page:views');

// Hashes
await redis.hSet('user:42', { name: 'Asha', plan: 'pro' });
const user = await redis.hGetAll('user:42');

// Delete
await redis.del('site:motd');

Use case 1: Caching (the cache-aside pattern)

This is the most common pattern. Check Redis first, and on a miss, load from the database and store the result.

JavaScript
import express from 'express';
import { redis } from './redis.js';
import { db } from './db.js'; // your database layer

const app = express();

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

  // 1. Try the cache
  const cached = await redis.get(key);
  if (cached) {
    return res.json({ source: 'cache', data: JSON.parse(cached) });
  }

  // 2. Cache miss: hit the database
  const user = await db.users.findById(req.params.id);
  if (!user) return res.status(404).json({ error: 'Not found' });

  // 3. Store it with a TTL
  await redis.set(key, JSON.stringify(user), { EX: 60 });

  res.json({ source: 'db', data: user });
});

When the underlying data changes, invalidate the cache:

JavaScript
app.put('/users/:id', async (req, res) => {
  const user = await db.users.update(req.params.id, req.body);
  await redis.del(`user:${req.params.id}`);
  res.json(user);
});

Cache invalidation is famously hard. Short TTLs plus explicit deletes on writes will cover most apps.

Use case 2: Rate limiting

Redis's atomic counters make rate limiting straightforward. This is a simple fixed-window limiter allowing 100 requests per minute per IP:

JavaScript
export async function rateLimit(req, res, next) {
  const key = `rate:${req.ip}`;
  const limit = 100;
  const windowSeconds = 60;

  const count = await redis.incr(key);
  if (count === 1) {
    await redis.expire(key, windowSeconds);
  }

  if (count > limit) {
    return res.status(429).json({ error: 'Too many requests' });
  }
  next();
}

app.use(rateLimit);

There's a tiny edge case here: if the process crashes between incr and expire, the key never expires. For production, wrap both in a MULTI transaction or a Lua script, or use a library like rate-limiter-flexible.

Use case 3: Leaderboards with sorted sets

Sorted sets keep members ordered by score automatically, with no sorting in your app code.

JavaScript
// Add or update scores
await redis.zAdd('leaderboard', [
  { score: 1500, value: 'alice' },
  { score: 2300, value: 'bob' },
]);
await redis.zIncrBy('leaderboard', 200, 'alice');

// Top 10, highest first
const top = await redis.zRangeWithScores('leaderboard', 0, 9, { REV: true });
// [{ value: 'bob', score: 2300 }, { value: 'alice', score: 1700 }]

Use case 4: Pub/Sub

Pub/Sub lets one part of your system broadcast messages and others react to them, which is useful for real-time notifications or syncing multiple Node instances. A client in subscribe mode can't run normal commands, so use a dedicated connection with duplicate().

JavaScript
// subscriber.js
const subscriber = redis.duplicate();
await subscriber.connect();

await subscriber.subscribe('orders:new', (message) => {
  const order = JSON.parse(message);
  console.log('New order received:', order.id);
});

// publisher (anywhere in your app)
await redis.publish('orders:new', JSON.stringify({ id: 123, total: 49.99 }));

Note that Pub/Sub is fire-and-forget: if a subscriber is offline, it misses the message. If you need durability, look at Redis Streams or a queue library like BullMQ, which is built on Redis and is the standard choice for background jobs in Node.

Use case 5: Sessions

For session storage with Express, the connect-redis package plugs into express-session. This lets multiple Node instances share sessions, which is essential once you scale beyond one server.

Best practices and common pitfalls

  • Always set a TTL on cache keys unless you have a reason not to. Otherwise your memory usage only grows.

  • Use consistent key naming, like entity:id:field (user:42:profile), so keys are easy to find and manage.

  • Never use KEYS * in production. It blocks Redis while scanning everything. Use SCAN instead.

  • Avoid huge values. Storing multi-megabyte blobs hurts latency for everyone sharing that instance.

  • Watch for cache stampedes. When a popular key expires, many requests may hit your database at once. Mitigations include jittered TTLs and short-lived locks.

  • Don't treat Redis as your only source of truth unless you've deliberately configured persistence and replication for that.

  • Handle Redis failures gracefully. If Redis is down, a cache should degrade to hitting the database, not crash your API. Wrap calls in try/catch where appropriate.

  • Shut down cleanly: call await redis.quit() on process exit.

Wrapping up

Redis is fast because it keeps data in memory, executes commands atomically on a single thread, and offers data structures that map directly to common backend problems. In a Node.js app, a few lines of code can cut database load with caching, protect your API with rate limiting, or power real-time features with Pub/Sub.

Start small by caching your slowest read endpoint with a short TTL, measure the difference, and expand from there.

Rohit Bairwa
Written by

Rohit Bairwa

Published on · 8 min read read

Want this implemented for your product?

Tell us what you're building. We'll help with the stack, timeline, and next steps.