What Is Redis Pipelining?
Pipelining is the practice of sending several Valkey or Redis commands to the server without waiting for each reply before sending the next one. The client writes the whole group to the connection at once, the server executes the commands in order, and the replies come back together. What would have been N network round trips becomes one.
The technique is also called batching, and the two words are used interchangeably. In Redisson, the Valkey and Redis Java client, pipelining is exposed through the RBatch object.
Pipelining matters because of a simple piece of arithmetic. Most Valkey and Redis commands execute in microseconds — the server is rarely the bottleneck. The waiting is. On a link with a half-millisecond round trip, a loop issuing a thousand sequential GET commands spends roughly half a second on the network and a few milliseconds doing actual work. Pipelined, the same thousand commands cost one round trip plus that same few milliseconds of execution. The server did not get faster; you simply stopped paying the network a thousand times.
How Pipelining Works
In the usual request/response cycle, a client writes one command and blocks until the reply arrives. The RESP protocol that Valkey and Redis speak does not actually require this. Nothing stops a client from writing a second command before the first reply has come back.
Pipelining exploits exactly that. Commands are written to the socket back to back, the server reads them off as fast as they arrive, executes each in sequence, and writes the replies out. Because the server never reorders commands on a single connection, the nth reply always corresponds to the nth command — which is what allows a client to match results to commands by position rather than by any kind of identifier.
There is a cost, and it is memory. While the batch runs, the server queues up replies that the client has not yet read, and that queue is real memory on the server. A pipeline of ten million commands is a very effective way to inflate it. The remedy is to chunk: the Valkey and Redis documentation suggests batches on the order of 10,000 commands — send a batch, read the replies, then send the next. Throughput is essentially unchanged, because the round-trip saving has already saturated well before that size, while the memory held at any one moment stays bounded.
Pipelining also reduces work on the server itself. Reading ten commands from one socket read costs far less than ten separate reads, so a heavily pipelined workload consumes noticeably less CPU in system calls than the same commands sent one at a time. The latency win is the headline, but the throughput win is real too.
Redis Pipelining vs Transactions (MULTI/EXEC)
This is the distinction that causes the most confusion, and it comes down to one word: interleaving.
A pipeline guarantees that your commands run in the order you sent them. It does not guarantee that nothing else runs in between. Another client's commands can be executed between the third and fourth commands of your batch. A transaction opened with MULTI and closed with EXEC does prevent that — the queued commands run as one isolated step with nothing interleaved.
The mistake teams make is choosing a transaction for performance. MULTI/EXEC is itself normally pipelined — the whole block travels to the server in a single round trip — so a transaction is not faster than a pipeline. It is simply a pipeline with an isolation guarantee bolted on, and isolation is not free. If what you want is throughput, pipelining already gives you the entire network saving with none of the added cost.
| Pipelining | Transaction (MULTI/EXEC) | |
|---|---|---|
| Network round trips | One for the whole batch | One, when the block is pipelined |
| Execution order | Preserved | Preserved |
| Other clients can interleave | Yes | No |
| Atomic | No | At queue time only |
| Rollback after a failure | None | None |
| If one command fails | The rest still execute | The rest still execute |
| Best for | Throughput on independent commands | Isolating a group of dependent writes |
Look closely at the two failure rows, where the columns agree. Neither mechanism rolls back. Once a native transaction begins executing, a command that fails at runtime does not undo the ones before it, and DISCARD only helps before EXEC. If you need genuine rollback across several objects, that is a different tool again — Redisson's RTransaction, covered in How to Manage Transactions in Valkey and Redis on Java.
The practical rule: reach for pipelining when the commands are independent and you want them to go fast, and for a transaction when they must not be observed half-applied.
When Pipelining Helps — and When It Doesn't
Pipelining pays off whenever a loop issues one command per iteration: bulk loading a dataset, warming a cache after a deploy, fanning out reads across many keys, or writing a burst of records where you do not need each reply before continuing.
It does not help in three situations, and recognising them saves a lot of wasted effort.
When one command depends on the result of another. A pipeline sends everything before reading anything, so it cannot branch on a reply. If you need to read a value, compute something from it, and write the result, the read has to complete first — no amount of batching changes that. Moving the logic to the server with a Lua script is the usual answer: one round trip, and the read-compute-write runs as one atomic step. Pipelining and scripting solve adjacent problems, and it is worth knowing which one you actually have.
When a native multi-key command already exists. A pipeline of a hundred GET commands is slower and more verbose than a single MGET, which is one command with one reply. Reach for MGET, MSET, or HMGET first; pipelining is what you use when the commands are heterogeneous enough that no single command covers them. That rule has an important exception once you shard, covered in MGET vs a pipeline below.
When the workload is not network-bound. Pipelining removes waiting; it does not make commands cheaper. If your bottleneck is a handful of O(N) commands over large collections, or memory pressure, batching them changes very little.
There is also a trade-off worth naming explicitly. The latency of any individual command inside a batch goes up, because its reply is not available until the batch completes. Pipelining buys throughput by spending per-command latency. For bulk work that is an excellent trade. For a single request on a user's critical path, it is not a trade at all.
Pipelining Across a Cluster
In a cluster, keys are distributed across hash slots owned by different nodes, and a single connection talks to a single node. A batch touching keys in several slots therefore cannot be one request — it has to be split by owning node, dispatched separately, and reassembled in the original order.
Low-level clients push that work onto you. The alternative, forcing keys into one slot with a {} hash tag, works but defeats the point of sharding for bulk operations: everything lands on one node.
This is where a higher-level client earns its place. Redisson executes a batch in a cluster in map/reduce fashion, grouping commands by node, sending the groups simultaneously, and merging the per-node results back into a single ordered response list. The code you write is identical to the single-node case.
MGET vs a Pipeline: Multi-Key Commands and Hash Slots
The rule above said to reach for a native multi-key command before reaching for a pipeline. It deserves its own treatment, because on a cluster it collides with the very thing that makes pipelines useful.
On a single node MGET is cheaper than a pipeline of a hundred GET commands: fewer bytes on the wire, one command to parse, one reply array to build. Two trade-offs come with that, and neither is usually mentioned. A large MGET is a single O(N) command that runs to completion before the server does anything else, whereas a large pipeline is read from the client's query buffer in chunks, so the event loop gets a look in and other clients keep being served. And MGET never fails: a key holding a hash rather than a string comes back as nil, indistinguishable from a key that does not exist, while a pipeline of GET commands surfaces a WRONGTYPE error per key.
Then you shard, and the native commands stop working. MGET, MSET and MSETNX require every key in the request to live in the same hash slot; hand them keys from two slots and the server answers CROSSSLOT Keys in request don't hash to the same slot. The command that was cheaper on one node is the first thing to break on a cluster — while a pipeline, which the client can split by owning node, keeps working unchanged.
So the real trade is not MGET is faster. It is that MGET is cheaper but slot-bound, and a pipeline is slightly more expensive but slot-agnostic.
What RBuckets Actually Does
Redisson's RBuckets hides the split, so the same code compiles and runs against a single node and a cluster:
RBuckets buckets = redisson.getBuckets();
// MGET semantics, but the keys may live in any slot. Missing keys — and
// keys holding a non-string value — are omitted from the returned Map.
Map<String, Product> loaded =
buckets.get("product:101", "product:102", "product:103");
Map<String, Product> toWrite = Map.of(
"product:101", productA,
"product:102", productB);
buckets.set(toWrite); // MSET semantics
boolean allSet = buckets.trySet(toWrite); // MSETNX semantics — see the caveat below
It is worth being precise about what happens underneath, because the shape is not what most descriptions of this feature imply. Redisson groups the keys by hash slot, not merely by node: it issues one native command per slot group, pipelines the groups belonging to a single node into one batch, and runs the per-node batches in parallel before merging the replies.
With 16,384 slots, arbitrary keys mostly land in distinct slots. A hundred unrelated keys on a three-node cluster therefore do not become three MGET calls — they become roughly a hundred single-key MGET calls, pipelined into three batches. In cluster mode RBuckets is, in effect, a well-organised pipeline. What you gain is that you do not write the partitioning, the parallel dispatch, the retries or the reassembly; what you do not gain is one command per node. Where keys genuinely share a slot — collocated with a {} hash tag, or naturally related — the per-slot MGET batches properly and the native command's efficiency comes back.
The Atomicity the Split Costs You
That convenience is not free, and what it spends is the guarantee that made the native commands attractive. MSET and MSETNX are atomic — the documentation is explicit that clients cannot observe some keys updated and others unchanged. Once the request is split into per-slot commands, that no longer holds: each group succeeds or fails on its own, and an observer can see half the map applied.
For trySet() this has a sharper edge that is easy to miss. Redisson merges the per-group booleans with an OR, so the call returns true when any group was written, not when all of them were:
// On a cluster, do NOT use this as an all-or-nothing guard across slots.
// If group A's keys are free and group B's are taken, A is written,
// B is not — and the call still returns true.
if (buckets.trySet(toWrite)) {
// reachable after a PARTIAL write
}
On a single node trySet() behaves exactly as documented. If you need genuine all-or-nothing semantics across several keys on a cluster, either keep them in one slot with a {} hash tag — accepting the concentration cost noted above — or use RTransaction, which does roll back.
One caveat on topology. This grouping happens only when Redisson is configured for a cluster. Behind a proxy that presents a sharded deployment as a single node, the client sends every key in one command; a proxy that enforces slot rules — Redis Enterprise's clustered databases, for example — then rejects it with CROSSSLOT. Proxy behaviour varies, so check yours before assuming either outcome.
Bulk Writes With Expiry
MSET has no expiration option, so for years a bulk write with TTLs meant a pipeline. Redis 8.4 added MSETEX, which takes a single shared expiry for the whole call — EX, PX, EXAT, PXAT or KEEPTTL, plus NX/XX. Redisson has exposed it since 4.1.0:
import org.redisson.api.bucket.SetArgs;
// MSETEX with NX — write them only if none of the keys already exists
buckets.setIfAllKeysAbsent(
SetArgs.entries(toWrite).timeToLive(Duration.ofMinutes(10)));
// MSETEX with XX — refresh values and TTL only if all the keys exist
buckets.setIfAllKeysExist(
SetArgs.entries(toWrite).expireAt(Instant.now().plusSeconds(600)));
The shared expiry is the limit. When each key needs its own TTL — staggering session lifetimes, or jittering a cache warm-up so the whole set does not expire at once — that is still a batch job, and an RBatch of setAsync(value, Duration) calls gives you per-key expiry in one round trip. The Redis SET command in Java covers the string commands in full.
Finally, MGET reads strings. For hashes the equivalents are HMGET for known fields and HGETALL for all of them, with HSCAN as the non-blocking alternative once a hash grows large.
Pipelining in Java With Redisson
Create a batch, queue commands by calling the asynchronous methods on its objects, and execute:
RBatch batch = redisson.createBatch(BatchOptions.defaults());
RMapAsync<Long, Product> products = batch.getMap("products");
products.getAsync(101L);
products.getAsync(102L);
products.getAsync(103L);
// every queued call also hands back a future you can read on its own
RFuture<Long> views = batch.getAtomicLong("views:101").incrementAndGetAsync();
// one network round trip for everything queued above
BatchResult<?> result = batch.execute();
List<?> responses = result.getResponses(); // in the order the commands were queued
Product first = (Product) responses.get(0);
Long viewCount = views.toCompletableFuture().join();
Nothing is sent until execute() is called. Each result can be read either from the RFuture that the queued method returned, or positionally from getResponses().
BatchOptions controls how the batch behaves:
BatchOptions options = BatchOptions.defaults()
// IN_MEMORY (default) buffers commands client-side, then sends them.
// IN_MEMORY_ATOMIC, REDIS_READ_ATOMIC and REDIS_WRITE_ATOMIC
// execute the batch atomically instead.
.executionMode(BatchOptions.ExecutionMode.IN_MEMORY)
// discard replies entirely — worthwhile for large write-only batches
.skipResult()
// wait for writes to reach 2 replicas, up to 1 second
.syncSlaves(2, 1, TimeUnit.SECONDS)
.responseTimeout(2, TimeUnit.SECONDS)
.retryInterval(2, TimeUnit.SECONDS)
.retryAttempts(4);
Two of these deserve attention. skipResult() tells the server not to send replies at all, which removes the reply-queue memory pressure described earlier — ideal for bulk loads where you only care that the writes landed. Leave it off whenever you actually need results, since there will be none to read. And the execution modes are how a Redisson batch becomes atomic when you need it to: IN_MEMORY is a plain pipeline, while the *_ATOMIC variants run the queued commands as a single atomic unit.
That separation is deliberate. Some clients overload one API for both jobs — Valkey GLIDE's Batch(isAtomic) flag is the clearest example — which makes it easy to pay for atomicity you never wanted. Redisson keeps RBatch for throughput and RTransaction for transactional semantics with real rollback, so the choice is explicit.
Batches are available across every Redisson programming model — RBatch covers the synchronous and asynchronous styles, with RBatchReactive and RBatchRx for Project Reactor and RxJava3. The full reference, including per-model examples, is in the pipelining documentation.
One Redisson-specific trap is worth naming, because it inverts the usual advice. deleteByPattern() and unlinkByPattern() are safe called on their own — they iterate with SCAN. Put either inside an RBatch and they switch to a server-side Lua script that calls KEYS internally, reintroducing exactly the blocking behaviour the SCAN-based path exists to avoid. In cluster mode the batched call does not run at all: it throws IllegalStateException and tells you to execute it as a non-batch method. Batching these makes them worse, not faster — see listing and deleting keys by pattern.
One last thing to keep an eye on: every batched command still pays serialization cost for its arguments and results. Once the network round trips are gone, codec choice is often the next thing that shows up in a profile — it and the client defaults around it are where the remaining latency usually hides.
Redis Pipelining: Frequently Asked Questions
What Is the Difference Between Redis Pipelining and a Transaction?
Pipelining sends multiple commands in one round trip to save network time; it guarantees the order of your commands but allows other clients' commands to interleave with them. A MULTI/EXEC transaction prevents interleaving by running the group as one isolated step. Because a transaction block is normally pipelined too, transactions are not faster — they add isolation, not speed.
Is Redis Pipelining Atomic?
No. The commands in a pipeline execute in the order sent, but other clients can run commands in between them, and a command that fails does not stop or undo the rest. If you need atomicity, use a transaction; if you need atomicity with rollback, use Redisson's RTransaction or a server-side Lua script.
How Many Commands Should You Put in One Pipeline?
The official guidance is around 10,000 commands per batch. The server queues every reply in memory until the client reads them, so a much larger pipeline inflates server memory without buying speed — throughput has already saturated well before that point. Send 10k, read the replies, send the next 10k. For write-heavy bulk loads, Redisson's skipResult() removes the replies from the equation entirely.
What Is the Difference Between MGET and Pipelining?
MGET is a single native command that reads many string keys and returns one reply; a pipeline sends many separate commands in one round trip. On a single node MGET is cheaper — fewer bytes on the wire and one command to parse instead of N. It gives up two things: a large MGET runs to completion before the server serves anyone else, while a large pipeline is read in chunks and yields between them, and MGET returns nil for a key holding a non-string value, where a pipeline of GET commands surfaces a WRONGTYPE error per key.
Does Pipelining Work With Redis Cluster?
Yes, but the batch has to be split across the nodes owning the relevant hash slots and the results merged back together. Redisson does this automatically, executing the batch in map/reduce fashion. With a low-level client you either partition the batch yourself or collocate keys with a {} hash tag, which concentrates the work on a single node.
Does MGET Work Across a Redis Cluster?
Not directly. MGET, MSET and MSETNX require every key in the request to hash to the same slot; keys from different slots return CROSSSLOT Keys in request don't hash to the same slot. Your options are to collocate the keys with a {} hash tag, to split the request yourself, or to use a client that splits it for you — Redisson's RBuckets groups the keys by slot, pipelines each node's commands and merges the replies. Note that the split costs you the native commands' atomicity: across slots, MSET and MSETNX apply per group rather than globally.
Does Pipelining Reduce Server Load, or Only Latency?
Both. The headline benefit is eliminating round trips, but reading many commands from a single socket read also cuts the number of system calls the server performs, so a pipelined workload consumes measurably less server CPU than the same commands sent individually.