Skip to content

Pipelines

Verified on Redis 8.10.2 (redis:8.10.2-alpine), redis-cli and redis-benchmark 8.10.2, ioredis 6.0.0, Node 24, Apple-silicon Mac. All timings are from my run through Docker’s port forward (the script) or over the container’s loopback (redis-benchmark); they show the shape, not your numbers.

Every command costs a round trip: the client sends it, waits for the reply, and only then sends the next. On a network with even modest latency that wait dominates: the server can do 100k commands a second, but a client that waits for each reply can send at most 1 / RTT of them (Redis pipelining). Pipelining sends many commands in one write without waiting; the server runs them in order and returns the replies together. It is a network optimisation, not an atomicity feature.

src/tsl-pipelines.ts
// @run
import { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;
const redis = new Redis(url);
const N = 5000;
const ms = (t: number) => `${(performance.now() - t).toFixed(0)} ms`;
// 1) N round trips vs one pipeline vs ioredis auto-pipelining
let t = performance.now();
for (let i = 0; i < N; i++) await redis.set(`b3:p:${i}`, 'x');
console.log(`${N} awaited SETs, one round trip each : ${ms(t)}`);
t = performance.now();
const pipe = redis.pipeline();
for (let i = 0; i < N; i++) pipe.set(`b3:p:${i}`, 'x');
await pipe.exec();
console.log(`${N} SETs in one pipeline : ${ms(t)}`);
const auto = new Redis(url, { enableAutoPipelining: true });
t = performance.now();
await Promise.all(Array.from({ length: N }, (_, i) => auto.set(`b3:p:${i}`, 'x')));
console.log(`${N} SETs, Promise.all + autoPipelining: ${ms(t)}`);
auto.disconnect();
// 2) batch size: 50 000 commands sent in batches of different sizes
for (const size of [1, 10, 100, 1000, 10000]) {
const total = size === 1 ? 5000 : 50000; // size 1 = no batching, keep it short
t = performance.now();
for (let i = 0; i < total; i += size) {
const p = redis.pipeline();
for (let j = i; j < Math.min(i + size, total); j++) p.set(`b3:b:${j}`, 'x');
await p.exec();
}
console.log(`batch ${String(size).padStart(5)}: ${((total / (performance.now() - t)) * 1000).toFixed(0)} commands/s`);
}
// 3) exec() does NOT reject when a command inside fails: you get [error, result] tuples
await redis.set('b3:str', 'text');
const res = await redis.pipeline().set('b3:a', '1').incr('b3:str').get('b3:a').exec();
console.log('pipeline result:', JSON.stringify(res!.map(([e, r]) => [e ? e.message : null, r])));
// the helper an assistant forgets to write: fail loudly on the first per-command error
async function execOrThrow(p: ReturnType<Redis['pipeline']>) {
const out = await p.exec();
const failed = out!.findIndex(([err]) => err);
if (failed !== -1) throw new Error(`command #${failed} failed: ${out![failed]![0]!.message}`);
return out!.map(([, r]) => r);
}
try {
await execOrThrow(redis.pipeline().set('b3:a', '2').incr('b3:str'));
} catch (e) {
console.log('execOrThrow ->', (e as Error).message);
}
// 4) a pipeline is not atomic; wrap it in MULTI when you need that
const tx = await redis.multi().incr('b3:n').incr('b3:n').exec();
console.log('MULTI inside the same round trip:', JSON.stringify(tx));
const del = [...Array.from({ length: 50000 }, (_, i) => `b3:b:${i}`), ...Array.from({ length: N }, (_, i) => `b3:p:${i}`), 'b3:str', 'b3:a', 'b3:n'];
for (let i = 0; i < del.length; i += 1000) await redis.unlink(del.slice(i, i + 1000));
await redis.quit();

Real output (this Mac):

5000 awaited SETs, one round trip each : 1688 ms
5000 SETs in one pipeline : 17 ms
5000 SETs, Promise.all + autoPipelining: 32 ms
batch 1: 3078 commands/s
batch 10: 27342 commands/s
batch 100: 182387 commands/s
batch 1000: 406093 commands/s
batch 10000: 472466 commands/s
pipeline result: [[null,"OK"],["ERR value is not an integer or out of range",null],[null,"1"]]
execOrThrow -> command #1 failed: ERR value is not an integer or out of range
MULTI inside the same round trip: [[null,1],[null,2]]

What it proves:

  • Round trips are the cost. 5000 awaited SETs took 1688 ms (about 0.34 ms each, mostly the Docker port forward); the same 5000 in one pipeline took 17 ms, roughly 100x faster.
  • enableAutoPipelining makes ioredis batch commands issued in the same tick: Promise.all over 5000 plain set calls took 32 ms without you building a pipeline by hand.
  • Batch size shows diminishing returns. Throughput climbs from 3k commands/s (no batching) to 406k at 1000 and 472k at 10 000. The Redis docs recommend batches of about 10k because the server queues all replies in memory until they are read; the speed is nearly the same and the memory stays bounded.
  • exec() does not reject when a command inside fails. It resolves with an array of [error, result] tuples. The INCR on a text value produced an error tuple at position 1 while the SET before it and the GET after it succeeded. If you only await pipeline.exec() inside a try/catch, nothing is ever caught. execOrThrow shows the loop you have to write.
  • A pipeline is not a transaction. The last line shows multi()...exec() (a MULTI/EXEC sent in the same round trip): you get both fewer round trips and atomicity. Its result has the same tuple shape.

redis-cli --pipe reads commands from stdin and streams them to the server without waiting for replies. It accepts RESP, and plain inline commands (one per line) also work:

scripts/tsl-pipelines-pipe.sh
# RESP: *<argc>, then $<len> and the bytes for each argument
printf '*3\r\n$3\r\nSET\r\n$2\r\nk1\r\n$5\r\nhello\r\n*3\r\n$3\r\nSET\r\n$2\r\nk2\r\n$5\r\nworld\r\n' | redis-cli --pipe
# inline commands, one per line (a file works the same way: cat commands.txt | redis-cli --pipe)
printf 'SET k1 hello\nBOGUS x\nSET k3 1\n' | redis-cli --pipe

Real output of the two calls:

All data transferred. Waiting for the last reply...
Last reply received from server.
errors: 0, replies: 2
All data transferred. Waiting for the last reply...
Last reply received from server.
errors: 1, replies: 3
ERR unknown command 'BOGUS', with args beginning with: 'x'

The summary line counts replies and errors, and each error is printed. The other commands still ran: like ioredis, --pipe does not stop at the first failure.

Measured with redis-benchmark inside the container (loopback, -n 100000 -t set):

scripts/tsl-pipelines-benchmark.sh
redis-benchmark -q -n 100000 -t set -P 1 # one command per round trip
redis-benchmark -q -n 100000 -t set -P 16 # 16 commands per round trip
redis-benchmark -q -n 100000 -t set -P 100
SET: 211864.41 requests per second, p50=0.119 msec (-P 1)
SET: 1351351.38 requests per second, p50=0.511 msec (-P 16)
SET: 1694915.25 requests per second, p50=2.783 msec (-P 100)

16 commands per round trip gave about 6x the throughput even on loopback, and the per-request latency rose (p50 0.12 to 0.51 ms) because each reply now waits for its batch. Over a real network, where the RTT is much larger than loopback, the gap grows.

FeaturePipelineTransaction (MULTI/EXEC)
Round tripsOne (or a few)One if sent together, otherwise one per command
AtomicityNo: other clients can interleaveYes: no interruption
RollbackNoNo
Per-command errorsReturned in the reply listReturned in the reply list (queue-time errors abort, see Transactions)
Use caseThroughput / bulk loadSafe grouped write

A pipeline is a network optimisation; a transaction is an atomicity guarantee. Combine them by sending MULTI/EXEC inside a pipeline, as multi() does in ioredis.

SET a 1
SET b 2
SET c 3
MGET a b c

An assistant writes a bulk importer and wraps it in try/catch “so failures are handled”:

🐛 Spot the bug

async function importUsers(rows: Array<{ id: string; name: string }>) {
try {
const pipeline = redis.pipeline();
for (const r of rows) pipeline.hset(`user:${r.id}`, 'name', r.name);
await pipeline.exec();
console.log(`imported ${rows.length} users`);
} catch (err) {
console.error('import failed', err);
}
}
Show the answer

pipeline.exec() resolves even when commands fail. In the run, an INCR on a text value produced an error inside the result array and nothing was thrown, so a WRONGTYPE on one key (say a user id that already exists as a string) is reported as “imported N users”. The catch only fires for connection-level problems. Inspect every tuple:

const results = (await pipeline.exec())!;
const failed = results.filter(([err]) => err);
if (failed.length) throw new Error(`${failed.length} of ${rows.length} commands failed: ${failed[0]![0]!.message}`);

A pipeline is posting a stack of letters in one trip to the post box instead of walking there once per letter. The clerk still reads them one at a time, in order, and other customers can be served between two of your letters. The replies come back as a single bundle, and one letter being refused does not stop the rest.

ChoiceBenefitCost
Pipeline / autoPipelining100x fewer round trips in the run (17 ms vs 1688 ms)No atomicity; errors hide inside the reply array
Batches of about 1000-10 000Near-peak throughput, bounded reply memoryYou chunk the work and wait for each batch
One giant pipelineFewest round tripsServer and client buffer every reply in memory
redis-cli --pipeFast bulk load from a file or another programErrors are printed but do not stop the load; you must generate valid input
MULTI/EXEC in the pipelineAtomic batch in one tripThe batch runs as one uninterrupted block; keep it small
  • “A pipeline is atomic.” Other clients can run between its commands.
  • “try/catch around exec() catches failed commands.” In ioredis it does not; check the tuples.
  • “Bigger batches are always faster.” The gain flattens (406k vs 472k commands/s from 1000 to 10 000) while reply memory grows.
  • “Pipelining lowers latency.” It raises throughput; each reply can wait longer (-P 16 p50 0.51 ms vs 0.12 ms).
  • Assuming a pipeline is atomic — wrap it in MULTI/EXEC if it must be.
  • Ignoring the result tuples — failures pass silently (proven above).
  • One enormous pipeline — send batches of roughly 10k or fewer.
  • Pipelining commands that depend on each other’s replies — they are all sent before any reply arrives; use Lua or MULTI with WATCH for read-then-write.
  • Feeding --pipe unvalidated input — bad lines become errors mid-load; check the summary line.

Real-world example

Redis’s own documentation measures the effect and gives the 10k batch guidance (Redis pipelining); it also compares pipelining with scripting: a script is better when a command needs the previous reply, since pipelined commands cannot read earlier results.

What is the primary benefit of pipelining in Redis?
Can other clients' commands run between the commands of your pipeline?
In ioredis, one command in a pipeline fails (INCR on a string). What does await pipeline.exec() do?
Which redis-cli flag streams commands from stdin without waiting for each reply?
Roughly how large should a pipeline batch be, according to the Redis documentation?
To get both fewer round trips AND atomicity you should: