DEV Community

Cover image for How Redis Actually Works — RAM, Single Thread, and the Expiry Behavior Nobody Explains
Piyush Kumar Singh
Piyush Kumar Singh

Posted on • Originally published at Medium

How Redis Actually Works — RAM, Single Thread, and the Expiry Behavior Nobody Explains

A RAM read takes about 100 nanoseconds. A disk read — even on a modern SSD — takes around 100,000 nanoseconds. That single gap explains most of Redis’s speed, before it does a single thing clever. Friend’s Link

But RAM alone isn’t the full story. The other half is a design decision that looks like a limitation on paper — and turns out to be one of the smartest choices in the codebase. More on that in a moment. Here’s what’s actually happening inside Redis when your app talks to it.

Why is Redis so fast?

The first reason is obvious once you hear it: Redis keeps everything in RAM. Your PostgreSQL instance, however well-tuned, writes to disk. Redis doesn’t. Every key lives in memory, which is why a GET on a Redis key can return in under a millisecond even under load. There’s no disk seek, no page cache miss, no I/O wait. But here’s where most explanations stop — and they shouldn’t.

Single-threaded — and that’s the point

Redis processes one command at a time—one thread. No parallelism, no concurrency. That sounds like a bottleneck. It’s actually a feature.

In a multi-threaded system, shared state requires locks. Locks mean threads waiting on each other. Waiting introduces latency spikes that are hard to reproduce and harder to debug. Redis avoids the entire problem by never having two threads compete for the same data.

# These three clients connect simultaneously
Client 1: SET counter 100     ← executes fully first
Client 2: INCR counter        ← executes next, sees 100, returns 101
Client 3: GET counter         ← executes last, returns 101
Enter fullscreen mode Exit fullscreen mode

The order is deterministic. Always. You can reason about it. With threads and locks, you can’t—not without careful synchronization, which adds complexity and latency.

Redis is fast, not just because of RAM, but because it never waits on itself.

The six data structures — with the tradeoffs that actually matter

Redis isn’t just strings. Each data structure solves a specific problem, and knowing when to pick one over another is more useful than knowing the commands.

String

SET user:1001:name "John"
GET user:1001:name        # "John"
SET page:views 0
INCR page:views           # atomic - safe under concurrent load
GET page:views            # "1"
Enter fullscreen mode Exit fullscreen mode

The default choice for simple values, flags, and counters. INCR is atomic — a thousand clients calling it simultaneously will never produce a wrong count.

Hash

HSET user:1001 name "John" email "j@example.com" role "admin"
HGET user:1001 name       # "John"
HGETALL user:1001         # all fields
Enter fullscreen mode Exit fullscreen mode

A Hash is better than a String when you have a structured object with multiple fields you’ll update independently. If you stored this as a JSON string, updating a single field means deserializing the whole blob, changing one value, and reserializing. A Hash lets you update one field with one command.

List

RPUSH notifications:1001 "Order shipped"
RPUSH notifications:1001 "Payment received"
LRANGE notifications:1001 0 -1   # all items in order
LPOP notifications:1001           # "Order shipped"
Enter fullscreen mode Exit fullscreen mode

Lists maintain insertion order. Use them for notification feeds, activity timelines, and simple job queues where you’re okay with at-most-once delivery. If you need guaranteed delivery, a List isn’t enough — use Kafka or RabbitMQ.

Sorted Set

ZADD leaderboard 9500 "alice"
ZADD leaderboard 11200 "John"
ZREVRANGE leaderboard 0 2 WITHSCORES
# John    11200
# alice   9500
Enter fullscreen mode Exit fullscreen mode

Every member has a score. Redis keeps them sorted automatically. Real-time leaderboards, priority queues, and rate limiting windows — sorted sets handle all three. The reason to reach for this over a List is when rank or score matters, not just insertion order.

Set

SADD online:users "user:1001" "user:1002"
SISMEMBER online:users "user:1002"   # 1 (true)
SCARD online:users                   # 2
Enter fullscreen mode Exit fullscreen mode

No duplicates, O(1) membership check. Good for tracking online users, visited pages, or anything where “is X in this group” is the question. Use a Set over a List when you need uniqueness and don’t care about order.

HyperLogLog

PFADD page:views:home "ip1" "ip2" "ip3" "ip1"
PFCOUNT page:views:home   # 3 (deduplicated)
Enter fullscreen mode Exit fullscreen mode

HyperLogLog gives you approximate unique counts using a fixed 12KB of memory — regardless of whether you have 1,000 or 100 million unique values. A plain Set would work too, but each unique member consumes memory. For a site with 50 million daily visitors, the Set version could eat gigabytes. HyperLogLog stays at 12KB with a ~0.81% error margin. That tradeoff is almost always worth it for analytics.

**

TTL and the expiry behavior nobody explains

**

SET session:abc123 "user_data" EX 3600   # expires in 1 hour
TTL session:abc123                        # 3597
# one hour later
GET session:abc123                        # (nil)
Enter fullscreen mode Exit fullscreen mode

Most developers assume Redis runs a background job that scans for expired keys and deletes them at the exact moment of expiry. It doesn’t. That would be expensive — imagine scanning millions of keys every second. Instead, Redis uses two strategies in parallel:

Lazy deletion: When you read a key, Redis checks its expiry first. If it’s expired, Redis deletes it right then and returns nil. Memory is reclaimed at access time, not expiry time.

Active sampling: Every 100ms, Redis randomly picks 20 keys that have TTLs set. If more than 25% of them are expired, it runs the loop again immediately. It keeps looping until the expired ratio drops below 25%.

The consequence: if you have 10 million keys expiring at 3 am and nothing reads them, the active sampler will gradually clean them up over the following minutes. Your memory won’t drop instantly. If you’re sizing Redis memory around key expiry, that lag is real, and you need to account for it.

Persistence — what survives a restart

Redis lives in RAM. Restart the process, lose everything — unless you’ve configured persistence.

RDB snapshots

# redis.conf
save 900 1       # snapshot if 1+ keys changed in 15 minutes
save 300 10      # snapshot if 10+ keys changed in 5 minutes
save 60 10000    # snapshot if 10000+ keys changed in 1 minute
Enter fullscreen mode Exit fullscreen mode

Redis forks a child process and writes everything to dump.rdb. Fast to recover from. The risk: if your server crashes between snapshots, you lose whatever happened in that window. Fine for cache. Not fine for anything where losing recent writes matters.

AOF — Append Only File

# redis.conf
appendonly yes
appendfsync everysec   # flush to disk every second
Enter fullscreen mode Exit fullscreen mode

Every write command gets appended to a log. On restart, Redis replays the log. With every second, you lose at most one second of data. With always, you lose nothing, but your write throughput drops noticeably.

Production setup — use both

save 900 1
appendonly yes
appendfsync everysec
Enter fullscreen mode Exit fullscreen mode

RDB handles fast restarts. AOF handles durability. Together, they cover both failure modes without adding much overhead.

Spring Boot integration — with the why behind the config

Dependencies and connection

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
# application.yml
spring:
  redis:
    host: localhost
    port: 6379
    timeout: 2000ms
Enter fullscreen mode Exit fullscreen mode

RedisTemplate — why you need a custom serializer
By default, Spring uses Java serialization for values. That works, but it stores class names alongside data, making keys unreadable and tying you to your class structure. Switch to JSON serialization so your data is readable outside Spring too:

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);
    template.setKeySerializer(new StringRedisSerializer());
    // Jackson JSON instead of Java serialization
    template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
    return template;
}
@Cacheable  the two-line cache layer
Springs caching abstraction lets you add Redis caching without touching repository logic. The first call hits the database; every subsequent call with the same ID returns from Redis:

@Cacheable(value = "users", key = "#id")
public User getUserById(Long id) {
    return userRepository.findById(id)
        .orElseThrow(() -> new RuntimeException("User not found"));
}

@CacheEvict(value = "users", key = "#user.id")
public User updateUser(User user) {
    return userRepository.save(user);   // evicts stale cache on update
}
@SpringBootApplication
@EnableCaching   // don't forget this
public class Application { ... }
Enter fullscreen mode Exit fullscreen mode

Rate limiting with sorted sets
A sliding window rate limiter is one of Redis’s cleanest use cases. The sorted set score is the timestamp — so you can count requests within a time window with a range query:

public boolean isAllowed(String userId, int maxRequests, long windowSeconds) {
    String key = "ratelimit:" + userId;
    long now = System.currentTimeMillis();
    long windowStart = now - (windowSeconds * 1000);
    ZSetOperations<String, String> ops = redisTemplate.opsForZSet();
    ops.removeRangeByScore(key, 0, windowStart);   // drop old requests
    Long count = ops.zCard(key);
    if (count != null && count >= maxRequests) return false;
    ops.add(key, String.valueOf(now), now);
    redisTemplate.expire(key, windowSeconds, TimeUnit.SECONDS);
    return true;
}
Enter fullscreen mode Exit fullscreen mode

**

When your tech lead says “add Redis,” — ask this first

**
There’s a version of this story everyone knows: the tech lead says “add Redis,” you add Redis, and something gets faster. Nobody questions it. But Redis has real constraints, and using it wrong is a common way to create problems that look like infrastructure issues.

Don’t use it as your primary database. Redis has no foreign keys, no joins, no complex queries. If your data has relationships, use a relational database. Redis is the layer on top, not the foundation.

Don’t store large values. Redis works well with small, hot data. A 5MB JSON blob in Redis is possible and wasteful — you’re burning expensive RAM, hurting the event loop for every other client, and making serialization your bottleneck.

Don’t use pub/sub for anything you can’t afford to lose. Redis pub/sub has no persistence. If a subscriber goes offline for 30 seconds, those messages are gone. Use Kafka or RabbitMQ when reliability matters.

Set a memory limit and eviction policy — always. Without it, Redis will reject writes when it runs out of memory, and that failure mode is jarring in production:

# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru   # evict least recently used keys when full
**

## ```


The one line that ties it all together
**
Redis is fast because it stays in RAM and never waits for itself. Use it for caching, sessions, leaderboards, rate limiting, and lightweight pub/sub, where dropped messages are acceptable. Don’t ask it to be your source of truth. Understand those two constraints, and Redis stops being magic — it becomes a predictable tool that does exactly what you’d expect. That’s a good thing.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)