What is Redis?
Redis is an in-memory data-structure server. It keeps its entire dataset in RAM and answers commands in microseconds. Every value is stored under a string key, and the value can be a string, a hash, a list, a set, a sorted set, a stream, a bitmap or a HyperLogLog.
It began in 2009 as a way to speed up a real-time analytics product and quickly became the default cache for the web. The reason is not only speed. Redis also brings a small, composable command set, useful data structures, TTLs, replication and scripting — enough that teams use it for far more than caching.
Think of Redis as a shared, in-memory data structure that every process can see. That framing explains both its strengths and its limits.
Why Redis is fast
Three things make Redis fast, and none of them is a secret.
First, the data lives in memory. There is no disk seek and no buffer pool to manage on the read path. Memory access is measured in nanoseconds; a network round trip is measured in fractions of a millisecond.
Second, there is no query planner. A command such as GET user:42 is a hash-table lookup, and ZADD is a skip-list insert. There is no parsing of a query language, no optimisation step and no joins. The operation is fixed and direct.
Third, commands run one at a time on a single thread. That sounds like a weakness, but it removes lock contention and makes every command atomic. The event loop handles thousands of connections and executes commands sequentially, which is why a single instance can serve enormous throughput.
The catch is that one slow command blocks everything. KEYS * over millions of keys, or ZRANGE over a huge sorted set, stalls every other client. Keep commands bounded and use SCAN instead of KEYS in production.
The data structures that matter
Choosing the right structure is most of the skill. Here is what each one is for.
Strings hold text, JSON, counters and binary data. SET, GET, INCR, APPEND and SETEX are the workhorses.
SET user:42:name "Ada"
INCR page:home:views
SETEX token:abc123 900 "opaque-token-value"
Hashes store an object as field-value pairs under one key. They are perfect for sessions and records you update field by field, because you avoid serialising the whole object on every write.
HSET session:9f2c userId 42 role admin
HINCRBY session:9f2c pageViews 1
HGET session:9f2c role
Lists are ordered sequences with O(1) push and pop at both ends. They make simple queues, recent-activity feeds and capped logs.
LPUSH queue:emails "welcome:42"
RPOP queue:emails
LTRIM feed:global 0 99
Sets hold unique, unordered members. Use them for tags, followers and membership tests.
SADD post:7:tags redis database
SISMEMBER post:7:tags redis
SINTER user:1:follows user:2:follows
Sorted sets are the star. Every member has a score, so the set stays ordered by score while lookups stay O(log n). Leaderboards, priority queues and rate-limit windows all use them.
ZADD leaderboard 1500 ada
ZINCRBY leaderboard 50 ada
ZREVRANGE leaderboard 0 9 WITHSCORES
Streams are an append-only log with consumer groups, closer to Kafka than to a list. Use them when you need replay, acknowledgement and multiple consumers.
XADD events * type signup userId 42
XREADGROUP GROUP workers alice COUNT 10 STREAMS events ">"
Bitmaps and HyperLogLogs handle specialised counting. Bitmaps track per-user booleans in one bit each, ideal for daily active users; HyperLogLog estimates unique counts with about 12 KB and a small error rate.
SETBIT active:2026-09-16 42 1
PFADD visitors:2026-09-16 user:42
PFCOUNT visitors:2026-09-16
If you remember one rule, make it this: pick the structure that matches the access pattern, not the one that matches the JSON you already have.
Key naming and namespacing
Redis has no tables, no schemas and no namespaces. The only structure is the key itself, so a naming convention is your schema.
A common pattern is object:id:attribute in colon-separated segments:
user:42
user:42:sessions
session:9f2c
order:1001:items
rate:203.0.113.7:2026091612
Short, predictable keys keep memory down and make scanning and debugging easier. Add a version or environment prefix when several applications share an instance: app:v2:user:42. Avoid keys derived from unbounded user input, and never use spaces or newlines.
Because keys are strings, you can list them for debugging, but do it carefully. SCAN with a cursor is safe; KEYS is not, because it blocks the server while it walks the whole keyspace.
Expiry, TTL and eviction
Expiry is what makes Redis a cache rather than a leak. Almost every key you create for caching should have a TTL.
SET user:42 '{"name":"Ada"}' EX 300 # seconds
SET user:42 '{"name":"Ada"}' PX 300000 # milliseconds
EXPIRE user:42 300
TTL user:42
PERSIST user:42
EXPIRE and its variants attach a timeout; TTL reports the remaining seconds. Expiry is lazy and active: keys are removed when accessed after their time, and a background cycle samples and clears expired keys so memory is reclaimed even if they are never read again.
TTLs alone are not enough when the dataset grows faster than it expires. Set maxmemory and a maxmemory-policy:
noeviction— reject writes when full; the safe default for durable data.allkeys-lru— evict the least recently used key of any kind; the common cache choice.allkeys-lfu— evict the least frequently used key, better when a small hot set matters.volatile-lru/volatile-ttl— evict only keys that have an expiry, so persistent keys are protected.
CONFIG SET maxmemory 2gb
CONFIG SET maxmemory-policy allkeys-lru
The choice matters. allkeys-lru will happily evict a session if it is the coldest key; volatile-ttl will not, because sessions always carry an expiry.
Caching patterns and invalidation
The most common pattern is cache-aside. The application checks Redis first, falls back to the database on a miss, and writes the result back with a TTL.
async function getUser(id) {
const key = `user:${id}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const user = await db.users.findById(id);
if (user) await redis.set(key, JSON.stringify(user), { EX: 300 });
return user;
}
Two other patterns are worth knowing. Write-through updates the cache and the database together, which keeps reads warm but doubles the write path. Write-behind writes to Redis first and flushes to the database asynchronously, which is fast but risks losing writes.
Invalidation is the hard part. A TTL guarantees eventual correctness; explicit deletes make it immediate. The reliable habit is to update the database first, then delete the cache key, and to accept a brief window where a concurrent reader repopulates stale data.
async function updateUser(id, patch) {
const user = await db.users.update(id, patch);
await redis.del(`user:${id}`);
return user;
}
Avoid the temptation to keep a cache forever because invalidation is annoying. Stale data and unbounded memory are worse problems than a slightly lower hit rate.
Sessions, rate limiting and leaderboards
Redis shines wherever state must be shared across processes and survive restarts.
Sessions are a hash or a serialised string with a TTL that slides on each request.
await redis.set(`session:${sid}`, JSON.stringify(data), { EX: 3600 });
await redis.expire(`session:${sid}`, 3600); // sliding window
Rate limiting is a counter with an expiry. A fixed window is two commands; a sliding window uses a sorted set of timestamps.
const minute = Math.floor(Date.now() / 60_000);
const key = `rate:${ip}:${minute}`;
const replies = await redis.multi().incr(key).expire(key, 60).exec();
if (replies[0] > 100) throw new Error("rate_limited");
Leaderboards are a sorted set. ZADD records a score, ZINCRBY adjusts it, and ZREVRANGE returns the top players with scores. ZRANK gives a single player’s position, which is exactly what a profile page needs.
ZADD leaderboard 1500 ada
ZINCRBY leaderboard 50 ada
ZREVRANGE leaderboard 0 9 WITHSCORES
ZRANK leaderboard ada
All three patterns rely on the same two properties: commands are atomic, and every key can expire.
Queues and pub/sub
Lists make a dependable work queue. Producers LPUSH, workers BRPOP — the blocking pop means a worker sleeps until a job arrives instead of polling.
LPUSH queue:emails "welcome:42"
BRPOP queue:emails 30
For a reliable queue, move jobs to a processing list before handling them, and remove them only on success. That way a crashed worker does not silently lose a job.
Pub/sub is fire-and-forget messaging: publishers PUBLISH, subscribers receive on channels. It is excellent for live notifications and cache-busting signals, but there is no persistence and no delivery guarantee. If a subscriber is offline, the message is gone.
PUBLISH notifications:user:42 "You have a new follower"
SUBSCRIBE notifications:user:42
When you need persistence, acknowledgement and replay, use streams with consumer groups instead. Streams are the modern answer to “I want pub/sub, but reliable”.
Pipelines, transactions and Lua
A command is a network round trip. If you need ten values, ten sequential GETs spend most of their time waiting on the network. A pipeline sends them together and reads all replies at once.
const [name, plan, logins] = await redis
.multi()
.hget("user:42", "name")
.hget("user:42", "plan")
.hget("user:42", "logins")
.exec();
A pipeline is not automatically a transaction. Wrapping commands in MULTI/EXEC makes them run as one atomic block, with no other client’s commands interleaved. Redis does not roll back on a command error the way SQL does; it simply continues, so validate inputs before you queue them.
For logic that must run atomically on the server, use a Lua script. It executes as a single command, can read and write several keys, and is the correct tool for compare-and-set operations such as releasing a lock only if you still own it.
const release = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0
`;
await redis.eval(release, { keys: ["lock:order:1001"], arguments: [token] });
Scripts should be small and deterministic. Do not run anything unbounded inside one, because the whole server waits for it.
Persistence: RDB and AOF
In-memory does not have to mean ephemeral, but durability is a trade-off you choose.
RDB writes point-in-time snapshots of the dataset to disk, either on a schedule or on demand. Snapshots are compact, restore quickly, and are ideal for backups. The cost is that a crash loses everything written since the last snapshot.
SAVE # blocking snapshot, avoid in production
BGSAVE # fork and snapshot in the background
AOF appends every write command to a log. With appendfsync everysec you lose at most about a second of writes; with always you lose almost nothing but pay a much higher write cost. AOF files can be rewritten in the background to stay compact.
CONFIG SET appendonly yes
CONFIG SET appendfsync everysec
Many deployments enable both: RDB for fast restores and AOF for a small loss window. If you use Redis purely as a cache, you can turn persistence off entirely and let a cold cache refill from the database. If you use it for queues or counters you care about, keep persistence on and understand exactly how much data a crash can cost.
Replication, Sentinel and Cluster
A replica connects to a primary and receives a stream of writes, so it holds a near-real-time copy. Replicas can serve reads, which offloads the primary, and they are the basis for failover.
Sentinel watches a primary and its replicas and promotes a replica when the primary fails. It also tells clients the new primary’s address. Sentinel gives high availability for a dataset that fits on one node.
Redis Cluster shards data across nodes using 16,384 hash slots. Each key maps to a slot by hashing its name, and each node owns a range of slots. Clients can talk to any node and are redirected to the right one. Cluster gives horizontal scale and failover, at the cost of complexity: multi-key operations must keep keys in the same slot, usually through hash tags such as user:{42}:name.
The usual path is a single instance, then a replica, then Sentinel, then Cluster only when memory no longer fits on one machine.
Common anti-patterns
- Redis as the only database. Without persistence and replication tuned for durability, a crash can lose data. Keep a durable system of record.
- Unbounded keys. Every cached key needs a TTL, or memory fills and eviction starts dropping things you needed.
- Big keys and big collections. A single hash with millions of fields, or a list with millions of entries, is slow to delete and can block the server.
KEYSin production. It blocks the event loop. UseSCAN.- One key for everything. Serialising a whole object on every change causes write amplification. Use hashes and update fields.
- Ignoring the eviction policy.
allkeys-lrucan evict sessions and locks, not just cache entries.
Connecting from Node
Two clients dominate the Node ecosystem: node-redis and ioredis. Both speak the same protocol and expose one method per command, so the choice usually comes down to API taste and clustering support.
import { createClient } from "redis";
const redis = createClient({ url: process.env.REDIS_URL });
redis.on("error", (err) => console.error("redis", err));
await redis.connect();
await redis.set("health", "ok", { EX: 60 });
const value = await redis.get("health");
Create the client once at startup and share it. A connection is a socket with a command queue, and opening one per request adds latency and file descriptors. Always attach an error handler: without one, a dropped connection can surface as an unhandled exception.
Most commands return plain values, but options are passed as an object. SET accepts { EX: 300 } for seconds, { NX: true } to write only if the key is absent, and { KEEPTTL: true } to preserve an existing expiry. NX plus a TTL is the standard recipe for a distributed lock.
const acquired = await redis.set(`lock:order:${id}`, token, {
NX: true,
EX: 30,
});
For a sharded deployment, choose a client that understands cluster redirections and hash tags. Both major clients do; the important part is to keep related keys in the same slot when a command touches more than one.
Monitoring a running instance
A few commands tell you almost everything about a Redis instance’s health.
INFO memory
INFO stats
DBSIZE
SLOWLOG GET 10
INFO memory reports used_memory, maxmemory and the current policy. INFO stats includes keyspace_hits and keyspace_misses, whose ratio is your cache hit rate — the number to watch when tuning TTLs. DBSIZE counts keys, and SLOWLOG records commands that exceeded a latency threshold.
CONFIG SET slowlog-log-slower-than 10000 # microseconds
CONFIG RESETSTAT
Two more tools are useful but dangerous. MONITOR streams every command the server processes in real time; it is invaluable for debugging and too expensive to leave on in production. SCAN walks the keyspace in cursor-sized batches and is safe, unlike KEYS.
Watch three numbers on a dashboard: memory usage against maxmemory, the eviction count, and the hit rate. A falling hit rate with rising evictions usually means TTLs are too short or the dataset no longer fits, and it is better to add memory or fix the keys than to raise the limit and let the instance swap.
Best practices
- Set a TTL on every cached key and pick an eviction policy that matches the data.
- Use a consistent
object:id:attributenaming scheme and document it. - Choose the data structure that fits the access pattern before writing code.
- Reuse one client connection and pipeline independent commands.
- Use Lua or
MULTIfor atomic multi-step operations, and keep them small. - Prefer
SCANoverKEYS, and cap the size of any single key. - Enable persistence and replication for data you cannot rebuild, and test a restore.
- Monitor memory, hit rate, evictions and slow commands before they become incidents.
Common mistakes
- Caching without a TTL and slowly exhausting memory.
- Using
KEYS *to find keys and blocking every other client. - Treating pub/sub as a reliable queue and losing messages when a subscriber is down.
- Storing a large object as one JSON string and rewriting it for every small change.
- Assuming
MULTIrolls back like a SQL transaction; it does not. - Letting one hot key or one huge sorted set become a bottleneck.
- Forgetting that
allkeys-lrucan evict sessions, locks and rate-limit counters. - Running Redis with persistence off and no replica, then losing data on restart.
Where to go next
Redis is the practical face of caching, and understanding it makes the caching patterns on the backend roadmap concrete. Pair it with a durable system of record: PostgreSQL for relational data or MongoDB for documents. Then connect it to a Node service and revisit the Node.js basics to see how the client fits into your request path.