Redis Use Cases: 10 Patterns and How to Build Them in Java with Redisson
Most introductions to Redis describe it as a cache, and then stop. That undersells it. Redis is an in-memory data platform with rich data structures and atomic operations, and that combination unlocks a surprising number of patterns: distributed locks, rate limiters, leaderboards, session stores, job queues, and real-time messaging — all backed by the same server you may already be running for caching.
There is a catch for Java developers, though. Redis speaks its own wire protocol, not Java: its primitives are commands like SET, INCR, and ZADD, plus Lua scripts when you need atomicity across several of them. Turning those into something correct and maintainable means hand-writing the distributed-systems plumbing — TTL bookkeeping, lock renewal, race-free counters, acknowledgment logic — which is exactly where bugs hide. This is the gap Redisson fills: a Valkey and Redis Java client that exposes more than 50 distributed objects through idiomatic Java APIs, many implementing familiar interfaces like ConcurrentMap and Lock. Instead of composing commands, you call methods on a distributed object and let Redisson get the protocol-level details right.
This guide walks through ten common Redis use cases. For each, we look at the problem, the trap if you build it on raw commands, and the Redisson object that solves it — so you can map almost any "we need Redis for X" requirement to a concrete Java implementation.
In this guide: Caching · Distributed locking · Rate limiting · Session storage · Leaderboards · Reliable messaging & pub/sub · Background jobs & queues · Task scheduling · Shared objects & counters · Deduplication · FAQ
A quick setup note — every example assumes a configured client:
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);
Redisson connects to any Valkey or Redis deployment — Amazon ElastiCache and MemoryDB, Azure Cache for Redis, Google Cloud Memorystore, Redis Cloud, Aiven, or self-hosted — so nothing below is tied to a particular vendor.
1. Caching Beyond a Plain Key-Value Store
The problem. Caching is the use case everyone reaches for first: keep hot data in memory so you stop hammering your database. The naive version is a string GET/SET with an expiry. That works until you need per-entry time-to-live on a structured object, want to avoid a network round trip on every read, or hit a cache stampede when a popular key expires and a thousand requests rebuild it at once.
The raw-Redis way. You serialize objects yourself, manage EXPIRE calls by hand, and reach for Lua or a lock to prevent the stampede. None of it is hard individually; together it adds up.
The Redisson way. RMapCache is a map with per-entry TTL and max-idle time, so eviction is declarative rather than something you track by hand:
RMapCache<String, Product> cache = redisson.getMapCache("products");
// value expires 60s after write, or 20s after the last access — whichever comes first
cache.put("sku-42", product, 60, TimeUnit.SECONDS, 20, TimeUnit.SECONDS);
Product p = cache.get("sku-42");
RMapCache runs on any Valkey or Redis version, because Redisson uses a background task to evict expired entries. If you are on Redis 7.4 or newer (or a compatible Valkey release), RMapCacheNative does the same job more cheaply by delegating expiration to the server's native hash-field TTL commands — there is no Redisson-side eviction loop, since the server expires fields itself:
RMapCacheNative<String, Product> cache = redisson.getMapCacheNative("products");
cache.put("sku-42", product, Duration.ofSeconds(60));
The trade-off is simple: RMapCacheNative is leaner and offloads cleanup to the server but needs a recent server version, while classic RMapCache adds max-idle eviction and runs anywhere. When reads vastly outnumber writes, RLocalCachedMap keeps a near-cache inside the JVM and invalidates entries across the cluster automatically, so most reads never touch the network. And on Spring or JPA, Redisson plugs in as a Spring Cache provider, a Hibernate second-level cache, and a JCache (JSR-107) implementation — distributed caching with annotations and no custom code.
Production tip. Default to a near-cache (RLocalCachedMap) for reference data that's read constantly and changes rarely. Always set a TTL — an unbounded cache is a memory leak with extra steps.
2. Distributed Locking Done Right
The problem. Two instances of your service must not both charge a customer, ship an order, or run a nightly batch. You need mutual exclusion across JVMs, which a synchronized block cannot give you.
The raw-Redis way. The canonical recipe is SET resource token NX PX 30000 to acquire, and a Lua script to release only if the token still matches. It looks simple, and that simplicity is the trap: if your work outlasts the 30-second lease, the lock expires mid-operation, a second instance acquires it, and two workers run concurrently — the exact thing the lock was meant to prevent. Avoiding that requires a renewal ("watchdog") thread, and getting that right is genuinely hard.
The Redisson way. RLock implements java.util.concurrent.locks.Lock, so it reads like ordinary Java — and it ships the watchdog for you. As long as the holding process is alive, Redisson automatically extends the lease in the background; if the process dies, the lock is released when the lease lapses.
RLock lock = redisson.getLock("order:1042");
lock.lock();
try {
// critical section — lease auto-renews while this thread holds the lock
fulfillOrder(1042);
} finally {
lock.unlock();
}
If you would rather fail fast than block, tryLock takes an explicit wait time and lease time. Beyond the basic lock, Redisson provides RReadWriteLock for read-heavy data, RFairLock to grant the lock in request order, RSemaphore and RPermitExpirableSemaphore for limited-concurrency pools, and RCountDownLatch for cross-JVM coordination — plus a RedLock-style multi-lock for multi-node setups.
The watchdog covers a holder that dies, but not one that merely stalls — a long GC pause or a network hiccup can freeze a healthy process long enough for its lease to lapse and a second client to acquire the lock. When it wakes, the first client still believes it holds the lock and may write stale data. The watchdog cannot prevent this, because from Redis's point of view nothing is wrong. The fix is a fencing token, and Redisson provides it through RFencedLock: every acquisition returns a monotonically increasing number that you pass to the resource you are protecting. The resource keeps the highest token it has seen and rejects any request carrying a lower one, so a revived stale holder is shut out.
RFencedLock lock = redisson.getFencedLock("account:1042");
Long token = lock.tryLockAndGetToken(100, 10, TimeUnit.SECONDS);
if (token != null) {
try {
// pass `token` to the guarded service; it accepts only if token >= last seen
debitAccount(1042, amount, token);
} finally {
lock.unlock();
}
}
RFencedLock extends the same Lock interface as RLock, so it stays idiomatic. Use it when a lock guards an external system — a database row, a payment gateway, a file — and correctness depends on no two holders ever acting at once.
Production tip. Prefer tryLock(waitTime, leaseTime, unit) with a sensible wait timeout over an unbounded lock() in request-handling paths, so a stuck lock holder degrades into a clean error instead of a pile of blocked threads.
3. Rate Limiting
The problem. An API needs protection from abuse, runaway clients, and traffic spikes. Behind a load balancer, the limiter has to be shared — three replicas each counting locally means your "100 requests per minute" quietly becomes 300.
The raw-Redis way. The classic INCR + EXPIRE counter is close, but the two commands are not atomic together, and the simple fixed-window version allows a burst at the window boundary. A correct token-bucket or sliding-window limiter is, once again, a Lua script you have to write and maintain.
The Redisson way. RRateLimiter is a distributed rate limiter built in — no scripting required. You declare the rate once, and every instance shares the same budget:
RRateLimiter limiter = redisson.getRateLimiter("api:checkout");
// at most 100 permits per second across the whole cluster
limiter.trySetRate(RateType.OVERALL, 100, 1, RateIntervalUnit.SECONDS);
if (limiter.tryAcquire()) {
handleRequest();
} else {
rejectWithTooManyRequests();
}
RateType.OVERALL enforces the limit globally; RateType.PER_CLIENT applies it per Redisson instance. Use acquire() when you want callers to wait for a permit and tryAcquire() when you want to reject immediately.
Production tip. Key the limiter by what you are actually protecting — per user, per IP, per API key — by encoding that into the limiter name (api:checkout:user-123). One global limiter rarely matches real abuse patterns.
4. Session Storage for Horizontally Scaled Apps
The problem. Run several replicas of a web app behind a load balancer with no shared session layer and you get "ghost" logouts: a user authenticates on replica A, the next request lands on replica B, and the session is gone. The usual workaround — sticky sessions — ties users to specific pods and makes scaling and rolling deploys fragile.
The raw-Redis way. You can store serialized sessions in Redis yourself, but then you are reimplementing serialization, expiry, and the integration glue with your web framework.
The Redisson way. Redisson ships drop-in session managers, so this is mostly configuration rather than code. There is an Apache Tomcat session manager, full Spring Session support, and a Micronaut session store. Sessions live in Redis, every replica reads the same state, and sticky sessions disappear from your architecture.
Production tip. Decouple session identity from server affinity entirely and set a TTL that matches your security policy; Redis expiry then handles idle-session cleanup for free.
5. Real-Time Leaderboards and Rankings
The problem. Leaderboards look trivial and are not. You need fast inserts, fast score updates, and fast rank queries — "what's the top 10?" and "what rank is this player?" — while millions of scores churn.
The raw-Redis way. Redis sorted sets are the right primitive, but on a raw client you are juggling ZADD, ZREVRANGE, and ZREVRANK calls and the serialization around them.
The Redisson way. RScoredSortedSet wraps a sorted set as a Java collection with O(log N) updates and ranking:
RScoredSortedSet<String> board = redisson.getScoredSortedSet("game:leaderboard");
board.addScore("player-7", 250); // atomically increment the score
Integer rank = board.revRank("player-7"); // 0-based rank, highest score first
Collection<ScoredEntry<String>> top10 = board.entryRangeReversed(0, 9);
The same structure powers anything ranked: trending content, priority queues by score, or time-windowed analytics where the score is a timestamp.
Production tip. Use addScore for relative updates (e.g., "+10 points") rather than reading, adding, and writing back — the latter is a race condition; the former is atomic.
6. Reliable Messaging and Pub/Sub
The problem. Services need to broadcast events — a price changed, an order shipped, a user came online. Redis has pub/sub built in, and for genuinely fire-and-forget notifications it is perfect. But the built-in version delivers at most once: if a subscriber is offline or crashes mid-message, that message is simply gone. There is no acknowledgment, no redelivery, and no way to inspect a message that failed to process.
The Redisson way (basic). For ephemeral notifications, RTopic is clean and idiomatic:
RTopic topic = redisson.getTopic("user:online");
topic.addListener(String.class, (channel, userId) -> notifyFriends(userId));
topic.publish("user-123");
The Redisson way (production-grade). When losing a message is not acceptable, Redisson PRO's Reliable PubSub (RReliablePubSubTopic) turns pub/sub into a delivery-guaranteed channel. Unlike native Redis pub/sub, it follows a topic–subscription–consumer model with real delivery semantics:
- A visibility timeout hides a message while a consumer works on it and automatically redelivers it if that consumer stalls or dies — so a crash never silently drops work.
- Consumers can acknowledge success, or negatively acknowledge with a distinction between failed (retry after a delay) and rejected (route straight to a dead-letter topic).
- A delivery limit stops a poison message from being retried forever; once it exceeds the limit it moves to a dead-letter topic for later investigation.
- Message grouping by group ID guarantees that related messages — say, all updates for one order — are always handled by the same consumer.
- It supports competing consumers for load-balanced throughput and a single-active-consumer mode for strict ordering.
The model is topic → subscription → consumer: a publisher sends to the topic, each subscription keeps its own independent offset into the message stream, and each subscription is drained by one or more named pull consumers that fetch messages, process them, and acknowledge. Here it is end to end:
// Producer — publish an event, grouped so related events keep their order
RReliablePubSubTopic<OrderEvent> topic = redisson.getReliablePubSubTopic("orders");
topic.publish(PublishArgs.messages(
MessageArgs.payload(new OrderEvent("order-1", "created"))
.groupId("customer-123")));
// Consumer — one subscription (with a dead-letter topic), drained by a named pull consumer
Subscription<OrderEvent> subscription = topic.createSubscription(
SubscriptionConfig.name("order-processing")
.deadLetterTopicName("orders-dlt")
.deliveryLimit(3));
PullConsumer<OrderEvent> consumer = subscription.createPullConsumer(
ConsumerConfig.name("worker-1"));
List<Message<OrderEvent>> messages = consumer.pullMany(PullArgs.defaults()
.count(10)
.visibility(Duration.ofSeconds(30))); // hidden for 30s; redelivered if not acked
for (Message<OrderEvent> msg : messages) {
try {
processOrder(msg.getPayload());
consumer.acknowledge(MessageAckArgs.ids(msg.getId())); // success — remove it
} catch (Exception e) {
// leaving it unacked is enough: the message reappears after the visibility
// timeout, and moves to "orders-dlt" once it exceeds deliveryLimit
}
}
Each subscription tracks its own offset, so one consumer group can process live events while another replays history from the same topic, untouched. Topic-level behavior is declarative through TopicConfig.defaults(), and with Valkey/Redis persistence plus synchronized replication, messages survive node failures and restarts. In practice this is the feature set people normally reach for RabbitMQ to get — delivered on infrastructure you may already be running.
Production tip. Use plain RTopic for notifications you can afford to miss (presence pings, cache-invalidation hints) and Reliable PubSub for anything that triggers business logic downstream. Mixing the two by importance keeps the cheap path cheap.
7. Background Jobs and Reliable Queues
The problem. Offloading work — sending emails, processing uploads, generating reports — to background workers. The hard requirements show up under failure: a job must not be lost if a worker dies, must not be processed twice, and a permanently failing job must not block the rest of the queue.
The raw-Redis way. A list with LPUSH/BRPOP gives you a basic queue, but the moment a worker pops a job and then crashes, that job is gone.
The Redisson way (basic). RQueue and RBlockingQueue cover simple producer/consumer work and implement the standard java.util.Queue interfaces.
The Redisson way (production-grade). Redisson PRO's Reliable Queue (RReliableQueue) is built for exactly-once delivery: a message is removed only when the consumer acknowledges it, so a worker that dies mid-job loses nothing — the message simply becomes visible again for another consumer.
RReliableQueue<Order> queue = redisson.getReliableQueue("orders");
queue.setConfigIfAbsent(QueueConfig.defaults()
.deliveryLimit(5)
.visibility(Duration.ofSeconds(60))
.timeToLive(Duration.ofHours(24))
.deadLetterQueueName("orders-dlq"));
// Producer
queue.add(QueueAddArgs.messages(MessageArgs.payload(new Order("ORD-123", 99.99))));
// Consumer
Message<Order> msg = queue.poll(QueuePollArgs.defaults()
.visibility(Duration.ofSeconds(30))
.acknowledgeMode(AcknowledgeMode.MANUAL));
if (msg != null) {
if (processOrder(msg.getPayload())) {
queue.acknowledge(QueueAckArgs.ids(msg.getId())); // done — remove it
} else {
queue.negativeAcknowledge(QueueNegativeAckArgs
.failed(msg.getId())
.delay(Duration.ofSeconds(10))); // retry later
}
}
On top of acknowledgments it adds payload/ID-based deduplication within a configurable window, rich message headers for routing and filtering, and a dead-letter queue for messages that exhaust their delivery limit — so a single bad message is quarantined for inspection instead of jamming the pipeline.
Production tip. Set visibility to comfortably exceed your worst-case processing time. Too short and a slow-but-healthy consumer's message gets redelivered and processed twice; the visibility timeout is your safety net, not a deadline.
8. Distributed Task Scheduling
The problem. You have recurring or deferred work — a cleanup job every hour, a reminder fired in seven days — and several app instances. You want it to run once across the cluster, on whichever node is available, not once per node.
The raw-Redis way. Coordinating "run exactly one of these, somewhere" with raw commands means combining a queue, a lock, and a scheduler by hand.
The Redisson way. RExecutorService and RScheduledExecutorService let you submit tasks to a distributed pool; any worker node connected to the same Redis can pick them up and run them, with built-in support for delays and cron expressions:
RScheduledExecutorService scheduler = redisson.getExecutorService("jobs");
scheduler.schedule(new ReportTask(), CronSchedule.of("0 0 * * * ?")); // top of every hour
Because the task object is serialized into Redis and executed remotely, scheduling survives restarts and naturally load-balances across your workers.
Production tip. Keep scheduled task classes small and serializable, passing IDs rather than large object graphs; the task should look up what it needs at execution time, not carry it across the wire.
9. Shared Distributed Objects and Atomic Counters
The problem. Sometimes you just need a single shared value across the cluster — a feature flag, a config blob, a live counter of views or likes that many instances increment at once.
The raw-Redis way. Counters are easy to get subtly wrong: read-modify-write from the application is a race condition under concurrency.
The Redisson way. RAtomicLong gives you a cluster-wide atomic counter, and RBucket is a typed holder for any serializable object:
RAtomicLong views = redisson.getAtomicLong("post:42:views");
views.incrementAndGet(); // atomic across every instance
RBucket<AppConfig> bucket = redisson.getBucket("config:current");
bucket.set(latestConfig);
AppConfig current = bucket.get();
For richer domain data, RLiveObject provides object-to-Redis mapping that feels like a lightweight ORM: annotate a class with @REntity and an @RId, and field reads and writes map directly onto a Redis hash, so a shared object stays consistent across JVMs without manual serialization.
Production tip. Reach for RAtomicLong (or incrementAndGet on the relevant object) for any counter touched by more than one instance. If you find yourself reading a value, changing it in Java, and writing it back, that is the bug.
10. Deduplication and Membership at Scale
The problem. "Have we already seen this URL / processed this event / emailed this user?" — at small scale a Set answers it, but at hundreds of millions of items, storing every member becomes expensive in memory.
The raw-Redis way. A Redis set is exact but grows linearly with the number of elements; that is a lot of RAM for a yes/no question where a tiny false-positive rate would be acceptable.
The Redisson way. RBloomFilter is a space-efficient probabilistic membership test: it can tell you an item is definitely not present, or probably present, using a fraction of the memory of a full set.
RBloomFilter<String> seen = redisson.getBloomFilter("seen-urls");
seen.tryInit(100_000_000L, 0.03); // 100M expected items, 3% false-positive rate
seen.add("https://example.com/page");
boolean maybeSeen = seen.contains("https://example.com/page");
It is ideal as a cheap first-line filter — deduplicating a crawl frontier, suppressing repeat notifications, or skipping a cache lookup that will certainly miss.
Production tip. Size tryInit for your real expected volume up front. A Bloom filter cannot be resized after initialization, and overflowing it pushes the false-positive rate well past what you configured.
Choosing the Right Approach
The pattern across all ten use cases is the same: raw Redis hands you fast, atomic primitives, and Redisson turns them into Java objects that handle the distributed-systems details you would otherwise write — and debug — yourself. As a quick map from problem to tool:
| You need to… | Reach for |
|---|---|
| Cache data with TTL or a near-cache | RMapCache / RMapCacheNative, RLocalCachedMap, Spring/JCache/Hibernate integration |
| Coordinate exclusive access across JVMs | RLock, RFencedLock, RReadWriteLock, RFairLock, RSemaphore |
| Throttle requests cluster-wide | RRateLimiter |
| Share web sessions across replicas | Tomcat / Spring Session / Micronaut session managers |
| Rank items in real time | RScoredSortedSet |
| Broadcast events (fire-and-forget) | RTopic |
| Broadcast events with delivery guarantees | Reliable PubSub (RReliablePubSubTopic) |
| Process background jobs reliably | RQueue/RBlockingQueue, Reliable Queue (RReliableQueue) |
| Run scheduled work once across a cluster | RExecutorService, RScheduledExecutorService |
| Share counters or objects | RAtomicLong, RBucket, RLiveObject |
| Test membership at huge scale | RBloomFilter |
Most of these objects are available in open-source Redisson; the delivery-guaranteed messaging objects — Reliable Queue and Reliable PubSub — along with local-cache data partitioning and a few other production features live in Redisson PRO. A reasonable approach is to prototype on the open-source objects and graduate to the PRO equivalents at the point where losing a message or a job actually costs you something.
Whichever tier you land on, the win is the same: you spend your time on data models and business logic instead of reimplementing locks, limiters, and queues on top of a key-value store. That is the whole point of treating Redis as a Java platform rather than a pile of commands.
Redis Use Cases: Frequently Asked Questions
When Should You Use Redis?
Reach for Redis when you need fast, in-memory access to shared state across processes or services: caching database results, coordinating distributed locks, enforcing rate limits, maintaining real-time leaderboards, storing sessions, or moving jobs and events between services. Anywhere a single low-latency source of shared truth beats repeatedly round-tripping to a disk-based database, Redis is a strong fit.
Is Redis a Database or a Cache?
Both. Redis is most often introduced as a cache, but it is a full in-memory data store with durability options — point-in-time snapshots and append-only logging — so it can serve as a primary database for the right workloads. In practice, most teams run it alongside a traditional database: Redis for hot, latency-sensitive data, and the database as the system of record.
What Can Redis Be Used for Besides Caching?
Quite a lot: distributed locking, rate limiting, session storage, real-time leaderboards, pub/sub and reliable messaging, background job queues, distributed task scheduling, atomic counters, and probabilistic membership tests with Bloom filters. This guide covers ten of the most common patterns, each with a Java implementation.