Redis Data Structures — Memory Eviction from Missing TTL
Random auth failures from Redis memory eviction caused by String keys without TTL.
20+ years shipping high-throughput database systems. Drawn from code that ran under real load.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Strings: single values up to 512MB, atomic counters, TTL in one command
- Hashes: field-value maps for objects, granular updates, memory-efficient under 128 fields
- Lists: ordered sequences, O(1) push/pop from ends, ideal for queues with BRPOP
- Sets: unordered unique members, set algebra O(N), great for tags and deduplication
- Sorted Sets: score-ordered members, O(log N) insert/range, powers leaderboards and time-range queries
- Performance insight: server-side operations (INCR, SINTER, ZRANGE) avoid network round-trips and keep app logic thin
- Production insight: wrong type choice (e.g., String for multi-field objects) leads to serialization overhead, race conditions, and wasted memory
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.
You’ve used Redis for caching, sure. But its value lives in the data structures themselves—strings, hashes, lists, sets, sorted sets, bitmaps, and HyperLogLog. Pick the wrong one or forget TTL discipline, and you’ll paint yourself into a corner with memory bloat or O(n) bottlenecks. This article walks each structure, its real-world job, and the command complexity you need to know before your next deploy.
Why Redis Data Structures Need TTL Discipline
Redis data structures are in-memory key-value stores where each key maps to one of several native types: strings, hashes, lists, sets, sorted sets, bitmaps, hyperloglogs, streams, or geospatial indexes. The core mechanic is that every operation runs in O(1) or O(log n) time, but memory is finite. When no TTL (time-to-live) is set, keys persist indefinitely, and Redis must evict data under the configured maxmemory policy once RAM is exhausted. This eviction is not a bug—it's a deliberate trade-off between availability and data retention.
In practice, Redis uses an approximate LRU (or LFU, TTL-based, random) algorithm to select keys for eviction. The default policy is noeviction, which causes writes to fail with an OOM error. More common policies like allkeys-lru or volatile-lru evict keys without TTL or only those with TTL, respectively. The key property: eviction happens at write time, not proactively. A sudden write spike can trigger mass eviction, removing data you assumed was safe. Without TTLs, all keys are eligible under allkeys-* policies, making your cache behavior unpredictable.
Use this knowledge to design for eviction. In caching layers, always set TTLs to bound memory usage and control eviction scope. For session stores or rate limiters, use volatile-* policies so only expirable keys are evicted. In real systems, a missing TTL on a hot key can cause a cascade: the key gets evicted, the next request recomputes it (expensive), and the surge of recomputation overloads the backend. TTLs are not optional—they are the primary lever for predictable memory management.
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.
Command Complexity Reference Table
Understanding the time complexity of Redis commands is essential for writing efficient production code. Redis is single-threaded — a slow O(N) command blocks all other operations. The table below summarizes the complexity of common commands across data structures. Use it to decide whether your access pattern is safe at scale.
For Strings and Hashes, most operations are O(1). Lists have O(1) push/pop from ends but O(N) for index access. Sets and Sorted Sets have O(1) for membership, but set operations are O(N + M). Sorted Set range queries are O(log N + result set). Streams add and read are O(1) per entry, but XRANGE and XREAD with large ranges can be O(N).
Production note: Always test with your actual data size. A command that is O(log N) on a million-element sorted set (∼20 steps) may be acceptable, but O(N) on a stream with a million entries (a million steps) can block Redis for seconds.
Data Structure Decision Tree
Choosing the right Redis data structure is the most important performance decision you'll make. The decision tree below guides you step by step from your application requirement to the best Redis data type. Follow the arrows based on your access patterns — ordering, uniqueness, field-level updates — and you'll avoid the common trap of using Strings for everything.
The tree is designed for production decisions: it considers not just functionality but memory efficiency (ziplist encoding in Hashes, bit-level storage in Bitmaps) and blocking behavior (avoid O(N) commands). Use it when you're designing a new feature or refactoring a slow Redis interaction.
Below the diagram, we've included a concrete example: if you need a cache that tracks 'which users have seen this notification' and also produces a feed of recent notifications, the tree tells you to use a Set for the seen-tracking (SISMEMBER) and a List with LTRIM for the feed. If you later need to sort the feed by time, the tree would suggest a Sorted Set with timestamps as scores.
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.
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.
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).
Analytical Structures — Bitmaps and HyperLogLog
While Strings, Hashes, Lists, and Sets handle transactional data, Bitmaps and HyperLogLog are purpose-built for analytical queries at scale. They trade off some features (range queries, field updates) for extreme memory efficiency and server-side aggregation.
Bitmaps let you store boolean arrays compactly. With BITCOUNT and BITOP you can answer questions like 'how many users logged in on both Monday and Tuesday?' across millions of users in microseconds — no scanning, no joins. The memory cost is 1 bit per user per day; a year of daily tracking for 10 million users costs ~456 MB.
HyperLogLog solves the 'how many unique items?' problem with a fixed memory of ~12KB per key, regardless of how many millions of distinct elements you add. The error is typically 0.81%, which is acceptable for dashboards and trend analysis. PFMERGE lets you combine multiple days into a weekly or monthly count without storing individual elements.
Choose Bitmaps when your user base is a known integer range (e.g., user IDs from 1 to 10 million) and you need exact per-day boolean status. Choose HyperLogLog when you need unique counts of arbitrary strings (IPs, search queries, email addresses) and can tolerate small errors.
Production tip: Bitmaps are not an independent type — they are Strings. If you need to shard across multiple keys (for >2^32 bits), use consistent hashing on the user ID to select the key.
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.
Memory Overcommit — Why Your Sorted Set Will Eat RAM for Breakfast
You picked Redis because it's fast. Speed costs memory. Every data structure has a memory footprint that scales non-linearly. Sorted Sets look innocent — score, member, done. But internally Redis uses a skip list plus a hash table. For 10 million members, that's roughly 1.2 GB of overhead before you store a single byte of actual data. Strings are worse when used as counters — each key carries ~90 bytes of metadata. If you store 50 million session tokens as individual keys, you've burned 4.5 GB on structure alone. The competing pages never show you the bill. They show you the pretty syntax. Here's the fix: benchmark with redis-cli --bigkeys before you commit to production. Watch the memory/expiry ratio. Set a maxmemory policy that matches your data pattern — not the default noeviction. Your Sorted Set leaderboard should be backed by a capped ZREMRANGEBYRANK maintenance job. Run it every N writes. Not on a cron. On write. Production doesn't wait for cron.
Persistence Semantics — RDB vs AOF vs No Save. Pick One or Lose Data
Competitors treat persistence as an afterthought. 'Turn on RDB and you're safe.' That's a lie. RDB snapshots are point-in-time. Lose the last 60 seconds of writes and your leaderboard resets. AOF logs every write — but at fsync every second, you still lose one second per crash. The only zero-loss path is AOF with appendfsync always, which kills throughput by 10x. Nobody tells you that. Here's what they miss: your data structure choice determines your acceptable loss. Streams with consumer groups demand AOF. Cached session tokens? No persistence needed — rebuild on restart. Sorted Set leaderboards for a game? RDB every 5 minutes is fine because you can replay from game events. The real sin is mixing persistence tolerances in one Redis instance. Your analytics pipeline runs HyperLogLog with no persistence. Your payment queue runs Streams with AOF. Same box, different guarantees. Split them. Run one Redis for ephemeral, one for durable. Senior engineers ship two configs, not one.
Command Pipelining — The Single Biggest Performance Lever You're Ignoring
Every competitor shows you how to SET a key. None show you the cost of round-trips. At 100 microseconds per command on a local network, 10,000 pipelined commands take 10 seconds — one at a time. Pipeline them and it's 500 milliseconds. Twenty times faster. That's not theory. That's a production incident where your batch job for Sorted Set leaderboard updates locked the app for 12 seconds because you sent ZADD one by one. The fix: batch into chunks of 500-1000 commands. Use pipelining for all bulk writes — Hash set, List push, Stream XADD. But watch the pipeline buffer. Send too many and you'll OOM the client. Redis 7.4 improved this with client-side caching, but 90% of you are on older versions. For Streams, use XADD with MAXLEN ~1000 in batch — don't trim per entry. For Sets, SADD 500 members at once. The competitor tutorials treat Redis as a single-command store. It's not. It's a pipeline engine. Treat it like one.
Redis Modules: RedisJSON, RedisSearch, RedisTimeSeries, RedisGraph
Redis Modules extend Redis core functionality with specialized data structures and query capabilities. RedisJSON enables native JSON document storage and manipulation using JSONPath expressions. For example, to store and query a JSON document: JSON.SET user:1 $ '{"name":"Alice","age":30,"address":{"city":"NYC"}}'. You can then retrieve fields: JSON.GET user:1 $.name. RedisSearch provides full-text search and secondary indexing, allowing SQL-like queries: FT.SEARCH idx:users '@name:Alice'. RedisTimeSeries offers time-series data with downsampling and aggregation: TS.ADD temperature:room1 1609459200 22.5. RedisGraph implements graph databases using the Cypher query language: GRAPH.QUERY social "MATCH (a:User)-[:FOLLOWS]->(b:User) RETURN a.name, b.name". These modules are essential for modern applications requiring complex data models beyond simple key-value pairs. They integrate seamlessly with Redis Stack and can be loaded as dynamic libraries. When using modules, consider memory overhead and ensure compatibility with your Redis version. Modules are particularly useful for reducing application complexity by moving logic closer to data.
FT.CREATE with STOPWORDS and SCHEMA options) to balance search speed and memory. For RedisTimeSeries, set appropriate retention policies to avoid unbounded memory growth.Streams: Kafka-Like Message Queue in Redis
Redis Streams provide an append-only log data structure similar to Apache Kafka, enabling reliable message queuing, event sourcing, and real-time data streaming. Streams support consumer groups, message acknowledgment, and blocking reads. To create a stream and add messages: XADD mystream * sensor-id 1234 temperature 19.8. This auto-generates a unique ID (timestamp-sequence). To read messages: XRANGE mystream - +. Consumer groups allow multiple consumers to process messages in a distributed manner: XGROUP CREATE mystream mygroup $ creates a group starting from the latest message. Consumers within a group read messages with XREADGROUP GROUP mygroup consumer1 BLOCK 2000 COUNT 10 STREAMS mystream >. Streams support pending entries (PEL) for fault tolerance: XPENDING mystream mygroup shows unacknowledged messages. Unlike Kafka, Redis Streams are lightweight and can be used for simple queuing with minimal overhead. They are ideal for microservices communication, task queues, and event-driven architectures. However, they lack Kafka's partitioning and long-term storage capabilities. For high-throughput scenarios, consider using Streams with Redis Cluster for horizontal scaling. Streams also integrate with Redis Modules like RedisTimeSeries for time-series event processing.
MAXLEN on streams to control memory usage. Use XREADGROUP with BLOCK for efficient polling. Monitor consumer group lag using XINFO GROUPS and XPENDING to ensure timely processing. For high availability, replicate streams across nodes.Probabilistic Data Structures: Bloom Filter, Cuckoo Filter, HyperLogLog
Redis provides probabilistic data structures for memory-efficient approximate counting and membership testing. Bloom Filters test set membership with a configurable false positive rate. For example: BF.ADD bloomfilter user:1234 adds an item, and BF.EXISTS bloomfilter user:1234 checks membership. Bloom Filters never produce false negatives but may have false positives. Cuckoo Filters offer similar functionality with support for deletion: CF.ADD cuckoofilter item1, CF.EXISTS cuckoofilter item1, CF.DEL cuckoofilter item1. HyperLogLog counts unique elements with minimal memory (approx 12KB for up to 2^64 elements): PFADD hll user:1234, PFCOUNT hll. These structures are ideal for deduplication, spam filtering, cache optimization, and analytics. For instance, use a Bloom Filter to avoid caching unpopular items: check if a key exists in the filter before caching. HyperLogLog is perfect for counting daily active users or unique visitors. However, these structures are probabilistic: Bloom and Cuckoo Filters have false positives, and HyperLogLog has a standard error of ~0.81%. They trade accuracy for memory efficiency. When using them, consider the acceptable error rate. For Bloom Filters, set the error rate and capacity: BF.RESERVE bloomfilter 0.01 1000000 for 1% false positive rate and 1M items. Cuckoo Filters allow similar tuning. These structures are part of the RedisBloom module, which must be loaded.
BF.RESERVE to avoid performance degradation. For HyperLogLog, use PFMERGE to combine counts across shards. Monitor false positive rates and adjust parameters if needed. These structures are part of the RedisBloom module, ensure it's loaded.The Case of the Exploding Memory: Using Strings for User Sessions Without TTL
- 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.
redis-cli --bigkeysredis-cli --scan --pattern '*' | xargs -I {} redis-cli TTL {} | grep -E '^-1$'| File | Command / Code | Purpose |
|---|---|---|
| string_rate_limiter.redis | SET rate_limit:user:42:window:1700000000 0 EX 60 NX | Strings |
| complexity_reference.redis | LLEN mylist | Command Complexity Reference Table |
| decision_tree_example.redis | SADD seen:user:42 "notif_789" | Data Structure Decision Tree |
| hash_and_list_patterns.redis | HMSET user:99 \ | Hashes and Lists |
| sets_and_sorted_sets.redis | SADD following:alice "bob" "carol" "dave" | Sets and Sorted Sets |
| bitmaps_hyperloglog.redis | SETBIT signin:2026 42 1 | Bitmaps and HyperLogLog |
| analytical_structures_example.redis | SETBIT active:day1 1 1 | Analytical Structures |
| streams_event_log.redis | XADD order:events * event_name "order_placed" user_id "42" amount "29.99" | Redis Streams |
| MemoryAudit.sql | redis-cli --bigkeys | Memory Overcommit |
| PersistenceConfigs.sql | save "" | Persistence Semantics |
| PipelineBatch.sql | local pipeline = redis.pipeline() | Command Pipelining |
| redis_modules_examples.sql | JSON.SET user:1 $ '{"name":"Alice","age":30}' | Redis Modules |
| redis_streams_example.sql | XADD mystream * sensor-id 1234 temperature 19.8 | Streams |
| probabilistic_structures_example.sql | BF.RESERVE bloomfilter 0.01 1000000 | Probabilistic Data Structures |
Key takeaways
Interview Questions on This Topic
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?
Frequently Asked Questions
20+ years shipping high-throughput database systems. Drawn from code that ran under real load.
That's NoSQL. Mark it forged?
11 min read · try the examples if you haven't