Production insight: wrong type choice (e.g., String for multi-field objects) leads to serialization overhead, race conditions, and wasted memory
Plain-English First
Imagine your kitchen has different storage containers: a jar for sugar (one thing, quick to grab), a recipe card box organized by category (fields and values), a stack of plates (order matters), a bag of unique coins (no duplicates allowed), and a leaderboard on your fridge with scores next to names. Redis is like that kitchen — it gives you the exact right container for what you're storing, so you're never cramming spaghetti into a sugar jar.
Every production system eventually hits a wall where a relational database is simply too slow for certain jobs. Not because SQL is bad — it's excellent — but because reading a user's session data, tallying votes in real time, or powering an autocomplete box requires sub-millisecond responses that a disk-backed database can't reliably provide. Redis lives in memory, and it was designed from day one around one idea: give developers a small set of powerful, purpose-built data structures so that the data's shape in storage matches the problem being solved.
The real problem Redis solves isn't just 'be fast.' Any cache can be fast. What makes Redis different is that its data structures let you do meaningful work on the server — increment a counter atomically, pop from a queue, compute the intersection of two sets — without pulling data into your application first. That server-side computation is what keeps your network traffic lean and your application logic clean.
By the end of this article you'll be able to look at a backend requirement — a leaderboard, a rate limiter, a social graph, a job queue — and immediately know which Redis data structure to reach for, why it's the right fit, and exactly how to implement it. You'll also know the gotchas that trip up engineers in their first month of production Redis use.
Strings — Redis's Swiss Army Knife (and Why It's More Than Just Text)
The String type is the most deceptively simple structure in Redis. It stores a single value — bytes, really — up to 512 MB. That value can be plain text, a serialized JSON blob, a binary image, or an integer. When the value is an integer, Redis lets you increment and decrement it atomically with INCR and DECR, which is the key insight most beginners miss.
Atomic means no race condition. If two servers both call INCR on the same key at the exact same millisecond, Redis executes them sequentially — you get 1, then 2, never 1 and 1. This makes Strings the go-to choice for rate limiting and counting: page views, API calls, login attempts.
The second superpower is the SET options. You can set a value with an expiry in one atomic command using SET key value EX seconds. This is how session tokens work in Redis — store the token as the key, the user ID as the value, and attach a TTL. No separate expiry-management logic needed.
When should you NOT use a String? When your data has multiple fields. Storing a JSON blob like '{"name":"Alice","age":30}' as a String forces you to deserialize the whole thing to update one field. That's exactly when you want a Hash.
string_rate_limiter.redisREDIS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- Rate limiting: allow max 5 requests per user per 60 seconds
-- The key encodes both the user ID and the current time window
-- User42 makes their first request in this window
SET rate_limit:user:42:window:17000000000EX60NX
-- NX means 'only set if key does NOT exist'
-- EX60 means expire in 60 seconds automatically
-- This gives us a self-cleaning counter
-- Now increment on each request
INCR rate_limit:user:42:window:1700000000
-- Returns: (integer) 1INCR rate_limit:user:42:window:1700000000
-- Returns: (integer) 2
-- Check value before allowing the request
GET rate_limit:user:42:window:1700000000
-- Returns: "2"
-- Simulate hitting the limit
INCR rate_limit:user:42:window:1700000000INCR rate_limit:user:42:window:1700000000INCR rate_limit:user:42:window:1700000000
-- Returns: 5 — at this point your app logic blocks the request
-- Check remaining TTL (useful to tell user when they can retry)
TTL rate_limit:user:42:window:1700000000
-- Returns: (integer) 47 (13 seconds have passed)
Output
(integer) 1
(integer) 2
"2"
(integer) 3
(integer) 4
(integer) 5
(integer) 47
Pro Tip: Use SET ... NX Instead of SETNX
The old SETNX command sets a key only if it doesn't exist, but it can't set a TTL in the same operation. Always use SET key value EX seconds NX — it's atomic, safe, and keeps your rate limiter from leaking keys that never expire.
Production Insight
Storing JSON as a String for objects with independent fields causes serialization overhead on every update.
Rate limiter keys without TTL leak memory until the server runs out of RAM.
Rule: String for single values only; use Hashes for multi-field objects and always set TTL on time-bounded keys.
Key Takeaway
Strings are for single values, counters, and simple caches.
Use INCR and SET EX NX for atomic rate limiting.
Never store multi-field objects as a single String — you'll pay serialization cost on every update.
Hashes and Lists — Modeling Objects and Building Queues
A Hash stores a map of field-value pairs under a single key. Think of it as a lightweight row in a database — one Redis key holds an entire user profile, product record, or config object. The power is granular updates: HSET user:99 email 'new@example.com' updates one field without touching the rest. No deserialization, no re-serialization, no wasted bandwidth.
Hashes are also memory-efficient. When a Hash has fewer than 128 fields and each value is under 64 bytes, Redis internally uses a compact encoding called a ziplist (listpack in newer versions) that's significantly smaller than storing each field as a separate String key. This is a free optimization you get just by modelling your data correctly.
Lists are an ordered sequence of strings, implemented as a doubly-linked list. Elements are pushed and popped from both ends in O(1) time. This makes Lists the natural fit for job queues: producers LPUSH work onto the left end, consumers BRPOP block-wait on the right end. The 'B' in BRPOP is crucial — it means blocking. The consumer sits idle, consuming zero CPU, until work arrives. No polling loop needed.
Lists also work as activity feeds. Push the latest N events with LPUSH and trim the list to a fixed length with LTRIM so it never grows unbounded. Combined, LPUSH and LTRIM on every write give you a capped, real-time feed in two commands.
hash_and_list_patterns.redisREDIS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
-- ═══════════════════════════════
-- HASH: Storing a user profile
-- ═══════════════════════════════
-- Set multiple fields at once on first creation
HMSET user:99 \
username "alice_dev" \
email "alice@example.com" \
plan "pro" \
login_count 0
-- Retrieve the whole profile
HGETALL user:99
-- Returns all fields and values as a flat list
-- Update just the plan without touching other fields
HSET user:99 plan "enterprise"
-- Atomically increment login_count (same as INCR but forHash fields)
HINCRBY user:99 login_count 1
-- Returns: (integer) 1
-- Only fetch the two fields you actually need (saves bandwidth)
HMGET user:99 username plan
-- Returns: 1) "alice_dev"2) "enterprise"
-- ═══════════════════════════════
-- LIST: Capped activity feed
-- ═══════════════════════════════
-- Push a new activity event for user 99 to the front of the list
LPUSH activity_feed:user:99"logged_in"LPUSH activity_feed:user:99"viewed_dashboard"LPUSH activity_feed:user:99"exported_report"
-- Trim to the 10 most recent events — run this after every LPUSH
-- Index0 = newest, index 9 = 10th newest, -1 = last element
LTRIM activity_feed:user:9909
-- Read the 5 most recent events (newest first)
LRANGE activity_feed:user:9904
-- Job queue: producer pushes, consumer blocks until work arrives
LPUSH email_queue "send_welcome:user:99"
-- On the worker process (blocks for up to 30 seconds)
BRPOP email_queue 30
-- Returns: 1) "email_queue"2) "send_welcome:user:99"
Output
-- HMSET
OK
-- HGETALL user:99
1) "username"
2) "alice_dev"
3) "email"
4) "alice@example.com"
5) "plan"
6) "pro"
7) "login_count"
8) "0"
-- HSET (plan update)
(integer) 0
-- HINCRBY
(integer) 1
-- HMGET
1) "alice_dev"
2) "enterprise"
-- LRANGE (3 events, newest first)
1) "exported_report"
2) "viewed_dashboard"
3) "logged_in"
-- BRPOP
1) "email_queue"
2) "send_welcome:user:99"
Watch Out: LPUSH + LTRIM Must Be Atomic in High-Concurrency Systems
If two workers both LPUSH before either runs LTRIM, your list can temporarily exceed the cap. Wrap them in a Lua script or a MULTI/EXEC transaction to guarantee the push-and-trim is atomic. In most low-concurrency cases it's fine, but in production under load, one missed LTRIM can cause unbounded memory growth.
Production Insight
Hash ziplist encoding is lost when you add more than 128 fields or values >64 bytes — memory doubles.
Lists with no LTRIM grow unbounded; a forgotten producer can fill memory with stale events.
Rule: For queues, use BRPOP with a timeout; for feeds, always pair LPUSH with LTRIM in a transaction.
Key Takeaway
Hashes give you atomic field updates without serialization overhead.
Lists with BRPOP are the correct queue primitive — no polling.
Always cap lists with LTRIM and wrap push+trim in a transaction under concurrency.
Sets and Sorted Sets — Unique Collections and Real-Time Leaderboards
A Set is an unordered collection of unique strings. 'Unique' is the whole point — Redis enforces it automatically, so you never write deduplication logic yourself. The classic use cases are tagging systems, tracking which users have seen a notification, and social graph relationships like followers.
The feature that makes Sets genuinely powerful beyond simple uniqueness is set algebra: SINTER (intersection), SUNION (union), and SDIFF (difference) let you compute 'users who follow both Alice and Bob', 'all tags on either post', or 'users who bought A but not B' in a single command. Doing that computation in your application layer means pulling thousands of IDs over the network first. Doing it in Redis means only the result travels over the wire.
Sorted Sets (ZSets) are the crown jewel of Redis. Every member gets a floating-point score, and Redis keeps members ordered by that score at all times. Insertion, removal, and score updates are O(log N). Range queries — 'give me the top 10 players', 'give me everyone with a score between 1000 and 2000' — are also O(log N + result size). This is the data structure behind every real-time leaderboard you've ever used.
The score can represent anything orderable: timestamps (for time-sorted feeds), relevance scores (for search ranking), or geographic distances (for proximity search via geohashing). If you need ordered access by any numeric dimension, a Sorted Set is almost always the answer.
sets_and_sorted_sets.redisREDIS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
-- ═══════════════════════════════
-- SET: Social follow graph
-- ═══════════════════════════════
-- Store who user:alice follows
SADD following:alice "bob""carol""dave"SADD following:bob "alice""carol""eve"
-- Store alice's followers
SADD followers:alice "bob""dave"
-- Whodo alice AND bob both follow? (mutual interests)
SINTER following:alice following:bob
-- Returns: 1) "carol" (both follow carol)
-- Everyone either alice or bob follows (combined feed sources)
SUNION following:alice following:bob
-- Mark notification 789 as seen by user alice (deduplication)
SADD seen_notifications:alice "789"
-- Has alice seen it? O(1) lookup
SISMEMBER seen_notifications:alice "789"
-- Returns: (integer) 1 (yes)
SISMEMBER seen_notifications:alice "999"
-- Returns: (integer) 0 (no)
-- ═══════════════════════════════
-- SORTEDSET: Game leaderboard
-- ═══════════════════════════════
-- Add players with their scores
-- Score is the second argument, member is the third
ZADD game:leaderboard 4850"player:alice"ZADD game:leaderboard 6200"player:bob"ZADD game:leaderboard 5930"player:carol"ZADD game:leaderboard 7100"player:dave"ZADD game:leaderboard 3200"player:eve"
-- Update alice's score after she beats a level (just ZADD again)
ZADD game:leaderboard 5500"player:alice"
-- Top3 players, highest score first (WITHSCORES shows the score too)
ZREVRANGE game:leaderboard 02WITHSCORES
-- What rank is carol? (0-indexed, ZREVRANK = rank by descending score)
ZREVRANK game:leaderboard "player:carol"
-- Returns: (integer) 2 (3rd place, 0-indexed)
-- All players with scores between 5000 and 7000ZRANGEBYSCORE game:leaderboard 50007000WITHSCORES
-- How many players scored over 5000?
ZCOUNT game:leaderboard 5001 +inf
-- Returns: (integer) 3
Output
-- SINTER following:alice following:bob
1) "carol"
-- SUNION following:alice following:bob
1) "bob"
2) "carol"
3) "dave"
4) "alice"
5) "eve"
-- SISMEMBER (seen)
(integer) 1
(integer) 0
-- ZREVRANGE top 3 WITHSCORES
1) "player:dave"
2) "7100"
3) "player:bob"
4) "6200"
5) "player:carol"
6) "5930"
-- ZREVRANK carol
(integer) 2
-- ZRANGEBYSCORE 5000 to 7000 WITHSCORES
1) "player:alice"
2) "5500"
3) "player:carol"
4) "5930"
5) "player:bob"
6) "6200"
7) "player:dave"
8) "7100"
-- ZCOUNT > 5000
(integer) 3
Interview Gold: Why Use a Sorted Set for Timestamps?
Store Unix timestamps as scores and event IDs as members. Then ZRANGEBYSCORE feed:user:42 1700000000 1700086400 gives you every event in a 24-hour window with zero scanning. This is how time-series-style queries are done in Redis without a full time-series database.
Production Insight
Set intersection on large sets (millions) takes O(N * K) and can block Redis for seconds — use SSCAN or limit set sizes.
Sorted Set scores are floats; score equality comparisons can fail due to floating-point precision.
Rule: For leaderboards with ties, assign deterministic tie-breakers (e.g., inverted timestamp as fractional score).
Key Takeaway
Sets enforce uniqueness and enable server-side set algebra — avoid pulling raw data to app.
Sorted Sets provide O(log N) ordered access; use scores as timestamps for time-range queries.
Watch out for floating-point precision and large-set blocking — paginate with SSCAN and ZRANGEBYSCORE.
Bitmaps and HyperLogLog — Efficient Counting and Boolean Operations
Bitmaps are not a distinct type — they're just Strings on which you can perform bit-level operations. Use SETBIT and GETBIT to manipulate individual bits. That gives you an incredibly memory-efficient way to store boolean flags: one user's daily sign-in for a year takes just 365 bits — about 46 bytes. Compare that to storing a String per day.
Bit operations like BITCOUNT, BITOP (AND, OR, XOR, NOT) let you compute retention, cohort analysis, and feature flags across millions of users in a single command. For example, BITOP AND daily:2026-03-01 daily:2026-03-02 shows users active on both days.
HyperLogLog is a probabilistic data structure for approximate cardinality counting with fixed memory. Each key takes ~12KB regardless of how many unique items you add. PFADD adds elements, PFCOUNT returns the approximate count (typical error 0.81%). It's perfect for counting unique visitors, search queries, or IPs where precision within a few percent is acceptable.
Use cases: daily active users (HyperLogLog), feature flags (Bitmap), user behaviour tracking (Bitmap).
bitmaps_hyperloglog.redisREDIS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
-- ═══════════════════════════════
-- BITMAP: Track daily sign-ins
-- ═══════════════════════════════
-- User42 signs in on day 0 of the year (Jan1)
SETBIT signin:2026421
-- (integer) 0 (was 0 before)
-- User42 signs in again on day 100 (Apr10)
SETBIT signin:20261001
-- Has user 42 signed in at all?
GETBIT signin:202642
-- Returns: (integer) 1
-- How many users signed in on day 100?
BITCOUNT signin:20260199
-- Users active on both day 100 and day 101? (intersection)
BITOPAND active_both signin:2026 day100 day101
-- ═══════════════════════════════
-- HYPERLOGLOG: Unique visitors
-- ═══════════════════════════════
-- Add visitor IPs to the hyperloglog key for today
PFADD visitors:2026-03-25"192.168.1.1""10.0.0.2""172.16.0.3"PFADD visitors:2026-03-25"192.168.1.1" -- duplicate, ignored
-- Approximate unique count
PFCOUNT visitors:2026-03-25
-- Returns: (integer) 3
-- Merge yesterday and today for weekly unique visitors
PFMERGE visitors:week12 visitors:2026-03-24 visitors:2026-03-25PFCOUNT visitors:week12
Output
-- SETBIT returns old bit value
(integer) 0
(integer) 0
-- GETBIT
(integer) 1
-- BITCOUNT
(integer) 342
-- PFCOUNT
(integer) 3
-- PFMERGE
OK
-- PFCOUNT merged
(integer) 5
Memory Trade-off: Bitmap vs Hash for User Flags
If you have millions of users and need to store a boolean per user, a Bitmap uses 1 bit per user. A Hash storing a key per user would be hundreds of MB. Bitmaps are the right choice when the user ID can be mapped to a contiguous integer offset.
Production Insight
Bitmaps are still Strings; a key larger than 512MB is impossible, but you can shard across multiple keys.
HyperLogLog gives exact count for small sets; PFCOUNT is safe but merging many keys (PFMERGE) is O(N).
Rule: Use Bitmaps for per-user boolean flags where user ID ≤ 2^32; use HyperLogLog for high-cardinality counts where 2-3% error is acceptable.
Key Takeaway
Bitmaps store 8 boolean flags per byte — perfect for daily activity tracking and feature flags.
HyperLogLog uses ~12KB regardless of set size — ideal for unique visitor counts where absolute precision isn't required.
Choose Bitmaps when you need exact per-user state; choose HyperLogLog when you need approximate cardinality with bounded memory.
Redis Streams — The Append-Only Log for Event Sourcing
Redis Streams (introduced in Redis 5.0) is an append-only log data structure. Each stream entry has a unique auto-generated ID (timestamp-sequence) and a set of field-value pairs — essentially a persistent, ordered, and replayable event log.
The key features are: non-destructive reads (consumers can read the same entry multiple times), consumer groups (competing consumers with acknowledgments), and blocking reads (like BRPOP but for streams). This makes Streams the natural choice for event sourcing, message queues with persistence, and audit trails.
Unlike Lists, entries in a stream are never removed by reading — they stay until explicitly trimmed (XTRIM) or evicted by maxlen. This allows replay and multiple consumer groups. Use XADD to append, XREAD to read in blocking mode, XREADGROUP for consumer groups, and XACK to acknowledge processed messages.
Streams also support range queries by ID, so you can replay historical events from a given point — invaluable for debugging or rebuilding state.
streams_event_log.redisREDIS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
-- ═══════════════════════════════
-- STREAM: Order event log
-- ═══════════════════════════════
-- Add an event to the order stream (auto-generated ID using *)
XADD order:events * event_name "order_placed" user_id "42" amount "29.99"
-- Returns: "1700000000000-0" (timestamp-sequence)
XADD order:events * event_name "payment_received" user_id "42" amount "29.99"XADD order:events * event_name "shipped" user_id "42" tracking "1Z999AA10123456784"
-- Read all events from the start (blocking, latest offset $)
-- Non-blocking: read from start to latest
XREADCOUNT100STREAMS order:events 0
-- Block until new events arrive (like BRPOP)
XREADBLOCK5000STREAMS order:events $
-- Create a consumer group for competing consumers
XGROUPCREATE order:events order-processors $ MKSTREAM
-- Consumer1: read and claim
XREADGROUPGROUP order-processors consumer-1COUNT10BLOCK2000STREAMS order:events >
-- Acknowledge processing
XACK order:events order-processors 1700000000000-0
-- Trim the stream to keep only the last 1000 entries
XTRIM order:events MAXLEN ~ 1000
-- ~ means approximate trimming for efficiency
Use Streams when you need: multiple consumers with different offsets, message persistence and replay, or audit logging. Use Lists for simple FIFO queues where messages are removed on read and you don't need replay or consumer groups.
Production Insight
Streams without XTRIM grow unbounded — memory exhaustion is a real risk.
Consumer group pending entries can grow if consumers crash without XACK — use XAUTOCLAIM for auto-claiming.
Rule: Always set MAXLEN on production streams and monitor pending entries with XPENDING.
Key Takeaway
Streams are append-only logs with persistent reads — ideal for event sourcing and durable message queues.
Use consumer groups for competing consumers with acknowledgments.
Always trim streams (XTRIM MAXLEN) to prevent unbounded memory growth.
● Production incidentPOST-MORTEMseverity: high
The Case of the Exploding Memory: Using Strings for User Sessions Without TTL
Symptom
Redis memory usage climbed steadily over months; the server started evicting keys at peak traffic, causing random authentication failures and customer complaints.
Assumption
The team assumed the session data would be cleaned up when users logged out or after a reasonable time, but the application never explicitly deleted old sessions, and no TTL was set.
Root cause
Every session key was a String with no expiry. Even after the user logged out, the key persisted. Redis accumulated thousands of stale session blobs — some up to 2KB each — until the memory limit triggered eviction.
Fix
Added TTL to all session keys: SET session:abc123 user_data EX 3600. Also implemented a Lua script to bulk-set TTL on existing keys during a maintenance window.
Key lesson
Always set an expiry on any key that represents a time-bounded concept: sessions, rate limiters, temporary caches.
Audit keys with TTL -1 periodically using redis-cli --scan --pattern "*" | xargs redis-cli TTL.
Use the Redis MEMORY USAGE command to find the biggest consumers and check if they should have TTL.
Production debug guideSymptom → Action guide for the most common Redis data structure problems5 entries
Symptom · 01
High memory usage; evictions appear in INFO stats
→
Fix
Run MEMORY DOCTOR, then MEMORY STATS. Identify large keys with --bigkeys. Use TYPE to check data structure; look for Strings with no TTL or oversized Hashes/Lists.
Symptom · 02
Slow responses from KEYS * or SMEMBERS on large sets
→
Fix
Replace KEYS with SCAN 0 COUNT 100 (non-blocking). For large sets, use SSCAN instead of SMEMBERS. Add pagination to client code.
Symptom · 03
BRPOP blocking consumers never return or timeout unexpectedly
→
Fix
Check LIST length with LLEN. Ensure producers are LPUSHing. If list is empty and consumers wait, check network timeout settings (default 0 = infinite block). Use CLIENT LIST to see blocked clients.
Symptom · 04
ZREVRANGE on leaderboard returns wrong order or scores
→
Fix
Verify ZADD scores are correct. If scores are integers, Redis stores them as floats; check for floating-point corruption. Use ZSCORE to inspect individual member scores.
Symptom · 05
SINTER returns unexpected results or empty set
→
Fix
Confirm both sets exist with EXISTS. Check if values have leading/trailing spaces. Use SMEMBERS on each set to verify membership.
★ Redis Data Structures Debugging Cheat SheetQuick commands and immediate actions for the most common Redis data structure production issues.
Treating it like a List — if you don't need ordering or range queries, a Set or List is cheaper
Bitmap
N/A (bit positions)
N/A
User daily active tracking, feature flags, real-time analytics at scale
Storing non-boolean data — bit operations on large numbers >2^32 require sharding
HyperLogLog
N/A
Probabilistic (no exact count)
Unique visitor counting, search query cardinality, IP uniqueness
When you need exact counts — error of 0.81% is unacceptable for financial transactions
Stream
Append order (by entry ID)
N/A (entries unique by ID)
Event sourcing, durable message queues, audit logs, state rebuilds
Simple FIFO queue where messages are removed on read — use List instead
Key takeaways
1
Choose your data structure based on the access pattern, not the data shape
a Sorted Set with Unix timestamps as scores is a faster time-range query than any String-stored JSON.
2
Hashes use ziplist/listpack encoding under 128 fields and 64-byte values
model objects as Hashes (not one String key per field) and you get compact memory for free.
3
BRPOP is a blocking pop
it suspends the consumer with zero CPU until work arrives, making it the correct primitive for a Redis-backed job queue without polling.
4
INCR and HINCRBY are atomic by design
they are the right way to count anything in a distributed system; a GET-then-SET sequence from application code is never safe under concurrency.
5
Bitmaps store 8 boolean flags per byte
use them for per-user daily tracking and feature flags instead of storing separate keys.
6
Streams with MAXLEN prevent unbounded memory growth
always set a cap on production streams and monitor pending entries.
Common mistakes to avoid
5 patterns
×
Storing entire JSON objects as Strings and updating one field
Symptom
High CPU and bandwidth spikes because you GET the full blob, deserialize it in the app, change one field, serialize it back, then SET the whole thing again.
Fix
Model multi-field objects as Hashes and use HSET to update individual fields atomically.
×
Forgetting TTLs on keys that should expire
Symptom
Redis memory climbs steadily until the server hits maxmemory and starts evicting random keys or refusing writes.
Fix
Always set an expiry on session keys, rate limit counters, and any key tied to a time-bounded concept. Run 'redis-cli --scan --pattern "*" | xargs redis-cli TTL' periodically to audit keys with TTL -1.
×
Using KEYS * in production to find keys by pattern
Symptom
Redis blocks all other commands for seconds (or minutes on a large dataset) because KEYS is O(N) and single-threaded Redis stops the world while it runs.
Fix
Always use SCAN with a COUNT hint instead — it's cursor-based, non-blocking, and safe to run on a live server.
×
Not trimming Lists or Streams, leading to unbounded growth
Symptom
Memory usage increases linearly with every write; eventually the key becomes so large that even simple operations (LLEN, XLEN) block Redis.
Fix
Use LTRIM after every LPUSH for capped feeds, and XTRIM MAXLEN on production streams. For lists, wrap LPUSH+LTRIM in a MULTI/EXEC to avoid race conditions.
×
Using a Sorted Set when a Set or List would be cheaper
Symptom
Unnecessary O(log N) writes and higher memory overhead for simple membership checks or ordered lists that don't need score-based queries.
Fix
If you don't need range queries or score-based ordering, use a Set for uniqueness or a List for ordered sequences. Sorted Sets are powerful but memory- and CPU-expensive compared to the alternatives.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
You need to build a leaderboard that shows the top 100 players and lets ...
Q02JUNIOR
What's the difference between a Redis Set and a Sorted Set? Give me a co...
Q03SENIOR
If Redis is single-threaded, how does INCR avoid race conditions, and wh...
Q04SENIOR
What are the memory implications of using Hashes vs storing each field a...
Q01 of 04SENIOR
You need to build a leaderboard that shows the top 100 players and lets any player instantly see their own rank. Which Redis data structure would you use, and why not a regular database query with ORDER BY?
ANSWER
Use a Sorted Set (ZSET). Store each player's ID as the member and their score as the score. ZREVRANGE 0 99 WITHSCORES gives you the top 100 in O(log N + 100) time, and ZREVRANK returns any player's rank in O(log N). A relational database would require an ORDER BY score DESC LIMIT 100 query, which scans and sorts the table — even with an index it's slower and adds network overhead. Redis does the sorting in memory on the server, returning only the results. For individual rank, a sorted set's O(log N) retrieval is far faster than a subquery counting rows.
Q02 of 04JUNIOR
What's the difference between a Redis Set and a Sorted Set? Give me a concrete scenario where you'd choose one over the other.
ANSWER
A Set is an unordered collection of unique strings. It supports O(1) membership check, insertion, and deletion, plus set operations (intersection, union, difference). A Sorted Set (ZSET) also stores unique members but assigns each a floating-point score, keeping members ordered by score at all times. Key operations like ZRANGE, ZREVRANK, and ZRANGEBYSCORE are O(log N).
Choose a Set when you only need membership tracking and set algebra — for example, tracking which users have seen a notification: SADD seen:user42 "notif_123" and SISMEMBER seen:user42 "notif_123".
Choose a Sorted Set when you need ordering by a numeric dimension — for example, a real-time leaderboard: ZADD leaderboard 1500 "player:1", then ZREVRANGE leaderboard 0 9 to get top 10. If you tried to use a Set for a leaderboard, you'd have to pull all members to the app and sort there — defeating the purpose of using Redis.
Q03 of 04SENIOR
If Redis is single-threaded, how does INCR avoid race conditions, and why can't you replicate the same safety with a GET followed by a SET from your application code?
ANSWER
Redis executes all commands sequentially on a single thread. When you call INCR on a key, the read, increment, and write happen atomically — no other command can interleave between these steps. This is guaranteed by Redis's event loop.
If you try GET value, increment in your application, then SET new value, there's a window between the GET and SET where another process could read the same stale value and write an overwritten result. Even with a compare-and-swap pattern (WATCH/MULTI/EXEC), the overhead of optimistic locking and retries makes it slower and more complex. INCR is a single command, no network round-trips, and guaranteed atomicity by design. That's why it's the correct primitive for distributed counting.
Q04 of 04SENIOR
What are the memory implications of using Hashes vs storing each field as a separate String key?
ANSWER
Hashes store their field-value pairs in a compact representation (ziplist or listpack) when the hash has fewer than 128 fields and each value is under 64 bytes (configurable via hash-max-ziplist-entries and hash-max-ziplist-value). In that case, a Hash uses less memory than storing each field as a separate key because a String key carries its own key name overhead (key-value pair metadata plus the key string). With millions of objects, the difference can be gigabytes. However, once the hash exceeds those thresholds, Redis converts it to a hashtable encoding, which is more memory-efficient per field than individual keys but loses the ziplist economy. Best practice: for objects with <128 fields and <64 bytes per value, use a Hash and keep the encoding compact.
01
You need to build a leaderboard that shows the top 100 players and lets any player instantly see their own rank. Which Redis data structure would you use, and why not a regular database query with ORDER BY?
SENIOR
02
What's the difference between a Redis Set and a Sorted Set? Give me a concrete scenario where you'd choose one over the other.
JUNIOR
03
If Redis is single-threaded, how does INCR avoid race conditions, and why can't you replicate the same safety with a GET followed by a SET from your application code?
SENIOR
04
What are the memory implications of using Hashes vs storing each field as a separate String key?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
What are the five main Redis data structures?
The five core Redis data structures are Strings (single values, counters, session tokens), Hashes (field-value maps for objects), Lists (ordered sequences for queues and feeds), Sets (unique unordered collections for tags and social graphs), and Sorted Sets (unique members with scores for leaderboards and time-range queries). Redis 5+ also ships HyperLogLog, Streams, and Geospatial indexes as specialized extensions.
Was this helpful?
02
When should I use a Redis Hash instead of storing JSON in a String?
Use a Hash whenever you need to read or update individual fields independently. Storing JSON as a String forces you to deserialize the whole document just to change one field, then re-serialize and re-write it entirely. A Hash lets you call HSET to update one field and HGET to read one field — no serialization, less bandwidth, and no overwrite race condition.
Was this helpful?
03
Is Redis really single-threaded, and does that make it slow?
Redis processes commands on a single thread, which is actually what makes it fast and safe. There's no lock contention between threads, so commands like INCR are inherently atomic. The single thread avoids context-switching overhead, and since Redis lives in memory rather than on disk, I/O is not the bottleneck — the CPU can process hundreds of thousands of simple commands per second on modest hardware. Redis 6+ added I/O threading for network reads and writes while keeping command execution single-threaded.
Was this helpful?
04
What is the difference between List and Stream for message queues?
Lists are simple FIFO queues: LPUSH pushes, BRPOP pops (removing the element). Messages are consumed destructively. Streams are persistent append-only logs: XADD appends, XREAD reads without deletion. Streams support consumer groups (multiple consumers with offsets), message acknowledgment (XACK), and replay. Use List for simple, transient queues where messages are processed once and removed. Use Stream for event sourcing, durable queues, audit logs, and scenarios requiring multiple consumers or replay.
Was this helpful?
05
How do I choose between Set and Sorted Set?
Use a Set when you only need membership checks (SISMEMBER) or set algebra (SINTER, SUNION). Use a Sorted Set when you need ordering by a score — for example, a leaderboard (ZREVRANGE) or time-range queries (ZRANGEBYSCORE with timestamps as scores). Sorted Sets are more memory- and CPU-intensive, so don't use them if you don't need ordering or range queries.