Non-Blocking Valkey & Redis in Java: Redisson with Project Reactor, RxJava and Spring WebFlux
Modern Java services are increasingly built on non-blocking I/O. Instead of dedicating one thread per request and letting it sit idle while waiting on the network, reactive stacks keep a small pool of event-loop threads busy and scale to tens of thousands of concurrent connections on modest hardware. If your data layer still blocks, though, the whole model breaks down — a single synchronous call to your cache or datastore stalls an event-loop thread and quietly destroys throughput.
This is where Redisson stands out as a Java client for Valkey and Redis. Rather than bolting reactive support on as an afterthought, Redisson ships three fully non-blocking programming models alongside its synchronous API: an asynchronous model built on RFuture, a reactive Redis Java model built on Project Reactor (Mono/Flux), and an RxJava3 model. All three sit on the same connection layer and work identically against Valkey or Redis. This guide walks through each model, shows how to wire RedissonReactiveClient into Spring WebFlux, covers reactive transactions, backpressure, and finishes with a Kotlin coroutines bonus.
Sync vs. Async (RFuture) vs. Reactive (Mono/Flux) vs. RxJava3
Every Redisson object — buckets, maps, locks, atomic longs, queues, topics — exposes the same operations across four API styles. You pick the style at the point where you obtain the object, and the underlying commands sent to the server are identical. The difference is purely in how you receive the result and how the calling thread behaves while the command is in flight.
Synchronous
The default RedissonClient returns blocking objects. The calling thread waits for the server round-trip to complete.
RedissonClient client = Redisson.create(config);
RBucket<String> bucket = client.getBucket("user:42:name");
bucket.set("Ada"); // blocks until the server acknowledges
String name = bucket.get(); // blocks until the value is returned
Simple and easy to reason about, but a blocking call is poison inside a WebFlux handler or any event-loop thread.
Asynchronous — RFuture
Every synchronous method has an ...Async twin that returns an RFuture. RFuture extends java.util.concurrent.CompletionStage, so it composes with the standard CompletableFuture toolkit.
RBucket<String> bucket = client.getBucket("user:42:name");
RFuture<Void> setFuture = bucket.setAsync("Ada");
RFuture<String> getFuture = bucket.getAsync();
getFuture.thenAccept(value -> {
// runs when the result arrives, on a Netty thread
}).exceptionally(ex -> {
// handle failure
return null;
});
RFuture is the lowest-level non-blocking model. It is callback-based, which is fine for a single operation but gets unwieldy once you start chaining and combining many calls. A practical caution: completion callbacks run on Netty I/O threads, so never perform blocking work directly inside them — use the ...Async variants (thenAcceptAsync, whenCompleteAsync) with your own executor when you must block.
Reactive — Mono / Flux
The reactive model wraps each operation in a Project Reactor publisher. Single-value and "void" operations return Mono; operations that stream multiple elements return Flux. Nothing executes until you subscribe — the pipeline is lazy and fully composable.
RedissonReactiveClient reactive = client.reactive();
RBucketReactive<String> bucket = reactive.getBucket("user:42:name");
Mono<Void> pipeline = bucket.set("Ada")
.then(bucket.get())
.doOnNext(value -> log.info("stored {}", value))
.then();
pipeline.subscribe(); // nothing happened before this line
This is the reactive redis java style most teams reach for today, because it slots directly into Spring WebFlux, R2DBC, and the wider Reactor ecosystem.
RxJava3 — Single / Maybe / Completable / Flowable
For teams standardized on RxJava3, Redisson speaks the same dialect. A value-returning call becomes a Single, an operation that may return nothing becomes a Maybe, a side-effect-only call becomes a Completable, and a multi-element stream becomes a Flowable.
RedissonRxClient rx = client.rxJava();
RBucketRx<String> bucket = rx.getBucket("user:42:name");
Completable set = bucket.set("Ada");
Maybe<String> get = bucket.get();
set.andThen(get)
.subscribe(value -> log.info("stored {}", value));
Choosing a Model
| Model | Entry point | Return types | Best when |
|---|---|---|---|
| Sync | Redisson.create() | plain values | Simple scripts, batch jobs, blocking servlet stacks |
| Async | getXxxAsync() | RFuture (CompletionStage) | Low-level fire-and-forget, integrating with CompletableFuture |
| Reactive | client.reactive() | Mono / Flux | Spring WebFlux, Reactor-based microservices |
| RxJava3 | client.rxJava() | Single / Maybe / Completable / Flowable | Codebases already built on RxJava |
The headline point: this isn't one reactive bridge with two wrappers around it. Each API is implemented natively, so you get idiomatic Mono/Flux or Single/Flowable types end-to-end — a genuine selling point compared with clients that expose only one model. (For a broader client-by-client breakdown, see Redisson vs Lettuce and Redisson vs Jedis.)
RedissonReactiveClient & RedissonRxClient
Both reactive clients are obtained from an existing RedissonClient, so they share its configuration, connection pool, codecs, and cluster topology. You don't open a second connection pool — you get a non-blocking view over the same one.
RedissonClient client = Redisson.create(config);
// Project Reactor view
RedissonReactiveClient reactive = client.reactive();
// RxJava3 view
RedissonRxClient rx = client.rxJava();
You can also create a reactive client directly when you never need the blocking API:
RedissonReactiveClient reactive = Redisson.createReactive(config);
RedissonRxClient rx = Redisson.createRx(config);
Because both views target the same server connection, switching between Valkey and Redis is purely a configuration concern — the API surface is identical. (If you're weighing the engines themselves, our Valkey vs Redis comparison breaks down the practical differences.)
Reactive Data Structures
The reactive client returns ...Reactive interfaces for the full Redisson object catalog. A few common examples:
RBucketReactive<User> bucket = reactive.getBucket("user:42");
RMapReactive<String, User> map = reactive.getMap("users");
RAtomicLongReactive counter = reactive.getAtomicLong("visits");
RListReactive<String> list = reactive.getList("events");
RSetReactive<String> set = reactive.getSet("tags");
Mono<User> user = bucket.get();
Mono<User> previous = map.put("42", new User("Ada"));
Mono<Long> visits = counter.incrementAndGet();
Flux<String> events = list.iterator(); // streamed as a Flux
A subtle but important detail: an operation like RListReactive.iterator() returns a Flux, not a Mono. Redisson reads the underlying list in batches and emits elements as a stream, which is exactly where backpressure (covered below) becomes relevant.
RxJava3 Data Structures
The RxJava client mirrors the same catalog with ...Rx interfaces:
RBucketRx<User> bucket = rx.getBucket("user:42");
RMapRx<String, User> map = rx.getMap("users");
RAtomicLongRx counter = rx.getAtomicLong("visits");
Maybe<User> user = bucket.get(); // may be empty if the key is absent
Single<Long> visits = counter.incrementAndGet();
Flowable<String> tags = rx.<String>getSet("tags").iterator();
Note how the type system communicates semantics: a bucket get() is a Maybe because the key may not exist, whereas incrementAndGet() is a Single because it always yields a value. (The full reactive and RxJava reference, including every return type, lives in the API models documentation.)
Coming From Spring Data Redis Reactive?
Many teams reach this point already using spring-boot-starter-data-redis-reactive, which is built on the Lettuce driver and exposes a single reactive API — ReactiveRedisTemplate — over the core Redis data types. Redisson takes a broader approach: it layers more than 50 distributed objects and services (maps, locks, rate limiters, queues, and the reactive transactions shown below) over Valkey and Redis, and gives you all three non-blocking models — RFuture, Project Reactor, and RxJava3 — rather than one. The two can run side by side, but if you want richer data structures out of the box, or the advanced capabilities in Redisson PRO such as data partitioning, local caching, and reliable messaging, Redisson's reactive client is the more complete foundation.
Integrating With Spring WebFlux
Spring WebFlux is the natural home for webflux redis workloads, and RedissonReactiveClient drops straight into it. The golden rule of WebFlux is never block the event loop; because the reactive client returns Mono/Flux and never blocks, it composes cleanly into your handler chains.
Dependency and Configuration
Add Redisson (or the Spring Boot starter for auto-configuration) — see the getting started guide for the current coordinates:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version><!-- latest --></version>
</dependency>
Then expose the reactive client as a bean:
@Configuration
public class RedissonConfig {
@Bean(destroyMethod = "shutdown")
public RedissonReactiveClient redissonReactive(
@Value("classpath:/redisson.yaml") Resource configFile) throws IOException {
Config config = Config.fromYAML(configFile.getInputStream());
return Redisson.create(config).reactive();
}
}
A Reactive Controller
@RestController
@RequestMapping("/users")
public class UserController {
private final RedissonReactiveClient redisson;
public UserController(RedissonReactiveClient redisson) {
this.redisson = redisson;
}
@GetMapping("/{id}")
public Mono<User> getUser(@PathVariable String id) {
return redisson.<User>getBucket("user:" + id).get();
}
@PostMapping("/{id}")
public Mono<Void> saveUser(@PathVariable String id, @RequestBody User user) {
return redisson.<User>getBucket("user:" + id)
.set(user, Duration.ofMinutes(30));
}
@GetMapping("/visits")
public Mono<Long> countVisit() {
return redisson.getAtomicLong("visits").incrementAndGet();
}
}
The handler returns a Mono, the framework subscribes for you, and no thread ever blocks waiting on Valkey or Redis. This is the same pattern you'd use to back a reactive cache layer in front of a slower datastore.
Reactive Web Sessions
Redisson also handles distributed HTTP sessions for WebFlux. Annotate a configuration class with @EnableLocalCachedRedisWebSession to store Spring WebFlux WebSession state in Valkey or Redis with a near-cache for read performance — the details are in the local cached web session management guide.
Reactive Transactions (ReactiveRedissonTransactionManager)
Spring's declarative @Transactional support has a reactive counterpart, and Redisson plugs into it through ReactiveRedissonTransactionManager. This lets you wrap multiple Valkey/Redis operations in an atomic unit — with READ_COMMITTED isolation — while staying fully non-blocking.
Wiring the Transaction Manager
@Configuration
@EnableTransactionManagement
public class RedissonReactiveTransactionConfig {
@Bean
public ReactiveRedissonTransactionManager transactionManager(RedissonReactiveClient redisson) {
return new ReactiveRedissonTransactionManager(redisson);
}
@Bean(destroyMethod = "shutdown")
public RedissonReactiveClient redisson(
@Value("classpath:/redisson.yaml") Resource configFile) throws IOException {
Config config = Config.fromYAML(configFile.getInputStream());
return Redisson.createReactive(config);
}
}
A Transactional Service
Inside a @Transactional method, ask the manager for the current transaction and obtain transaction-scoped objects (such as RMapReactive) from it. Every operation joins the same atomic unit and commits or rolls back together.
@Service
public class TransferService {
private final ReactiveRedissonTransactionManager transactionManager;
public TransferService(ReactiveRedissonTransactionManager transactionManager) {
this.transactionManager = transactionManager;
}
@Transactional
public Mono<Void> transfer(String from, String to, long amount) {
Mono<RTransactionReactive> tx = transactionManager.getCurrentTransaction();
return tx.flatMap(t -> {
RMapReactive<String, Long> balances = t.getMap("balances");
return balances.addAndGet(from, -amount)
.then(balances.addAndGet(to, amount));
}).then();
}
}
RTransactionReactive acquires locks for write operations and queues data modifications until commit. If anything fails, the rollback releases the locks and discards the queued operations — the manager surfaces a TransactionException on commit/rollback errors. For the conceptual model behind this (and the non-reactive variant), see the transactions reference.
Reactive transactions require that the
@Transactionalmethod returns a reactive type (Mono/Flux) and runs inside a reactive context — which WebFlux establishes for you automatically.
Backpressure Considerations
Backpressure is the mechanism by which a slow consumer tells a fast producer to ease off. In Reactor and RxJava this is built into the contract, but how much it matters with Redisson depends on what the operation returns.
Mono operations rarely need backpressure. A Mono emits at most one element, so there is nothing to throttle. The vast majority of Redisson reactive calls — get, set, incrementAndGet, compareAndSet — are single request/response commands and map to a Mono. The only resource concern here is connection pool sizing: every in-flight command borrows a connection, so a sudden burst of un-throttled flatMap fan-out can exhaust the pool. Use flatMap's concurrency parameter to cap parallelism:
Flux.fromIterable(userIds)
.flatMap(id -> redisson.<User>getBucket("user:" + id).get(), 16) // max 16 concurrent
.subscribe();
Flux operations are where real backpressure lives. Streaming reads — iterating a large list, set, or map, or scanning the keyspace — emit many elements. Redisson honors the downstream request(n) demand and reads in batches rather than pulling everything into memory at once. If your consumer is slower than the producer, the demand signal naturally paces the reads.
Push-based sources need an explicit strategy. Pub/sub topics (RTopicReactive) and stream consumers push messages at you regardless of how fast you consume them. There's no "request more slowly" for an external publisher, so apply an overflow strategy:
RTopicReactive topic = redisson.getTopic("events");
topic.getMessages(Event.class)
.onBackpressureBuffer(10_000) // or onBackpressureDrop() / onBackpressureLatest()
.subscribe(this::handle);
Don't block the pipeline — offload instead. If a downstream step must do blocking work (a slow legacy call, file I/O), move it off the event loop with publishOn(Schedulers.boundedElastic()) so the Netty threads stay free to keep draining Valkey/Redis responses. Blocking inside the reactive chain is the single most common way teams accidentally serialize an otherwise non-blocking system.
Kotlin Coroutines Bonus
If you're writing Kotlin, you can consume Redisson's reactive API with coroutines and skip the Mono/Flux ceremony entirely. Because RedissonReactiveClient returns Reactor types, the kotlinx-coroutines-reactor bridge converts them into suspend calls for free.
Add the bridge dependency:
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
Then await a Mono instead of subscribing to it. Use awaitSingleOrNull() for operations that may produce nothing (like a missing key) and awaitSingle() when a value is guaranteed:
class UserRepository(private val redisson: RedissonReactiveClient) {
suspend fun find(id: String): User? =
redisson.getBucket<User>("user:$id").get().awaitSingleOrNull()
suspend fun save(id: String, user: User) {
redisson.getBucket<User>("user:$id")
.set(user, Duration.ofMinutes(30))
.awaitFirstOrNull() // Mono<Void> completes with no value
}
suspend fun incrementVisits(): Long =
redisson.getAtomicLong("visits").incrementAndGet().awaitSingle()
}
For streaming reads, turn a Flux into a Kotlin Flow with .asFlow() and iterate with collect:
suspend fun allEvents(): Flow<String> =
redisson.getList<String>("events").iterator().asFlow()
The result reads like ordinary imperative code — no callbacks, no explicit subscriptions — while remaining 100% non-blocking under the hood. This pairs perfectly with Spring WebFlux's coroutine support, letting your controllers be suspend functions that talk to Valkey or Redis without ever touching a Mono directly.
Redisson is a Non-Blocking Java Client: The Bottom Line
Redisson gives Java developers a rare luxury: a single client that speaks four API styles over one connection pool, against either Valkey or Redis, with no behavioral difference between the engines. For non-blocking workloads you have three solid choices — the low-level RFuture, the Reactor-based Mono/Flux model that powers reactive Valkey Java and reactive Redis Java services, and the RxJava3 model — plus a clean coroutines path for Kotlin.