Pipelines
Verified on Redis 8.10.2 (
redis:8.10.2-alpine),redis-cliandredis-benchmark8.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.
What is pipelining?
Section titled “What is pipelining?”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.
Pipelines in ioredis
Section titled “Pipelines in ioredis”// @runimport { 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-pipelininglet 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 sizesfor (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] tuplesawait 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 errorasync 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 thatconst 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 ms5000 SETs in one pipeline : 17 ms5000 SETs, Promise.all + autoPipelining: 32 msbatch 1: 3078 commands/sbatch 10: 27342 commands/sbatch 100: 182387 commands/sbatch 1000: 406093 commands/sbatch 10000: 472466 commands/spipeline 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 rangeMULTI 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. enableAutoPipeliningmakes ioredis batch commands issued in the same tick:Promise.allover 5000 plainsetcalls 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. TheINCRon a text value produced an error tuple at position 1 while theSETbefore it and theGETafter it succeeded. If you onlyawait pipeline.exec()inside atry/catch, nothing is ever caught.execOrThrowshows the loop you have to write.- A pipeline is not a transaction. The last line shows
multi()...exec()(aMULTI/EXECsent in the same round trip): you get both fewer round trips and atomicity. Its result has the same tuple shape.
redis-cli --pipe for bulk loads
Section titled “redis-cli --pipe for bulk loads”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:
# RESP: *<argc>, then $<len> and the bytes for each argumentprintf '*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 --pipeReal 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: 3ERR 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):
redis-benchmark -q -n 100000 -t set -P 1 # one command per round tripredis-benchmark -q -n 100000 -t set -P 16 # 16 commands per round tripredis-benchmark -q -n 100000 -t set -P 100SET: 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.
Pipelines versus transactions
Section titled “Pipelines versus transactions”| Feature | Pipeline | Transaction (MULTI/EXEC) |
|---|---|---|
| Round trips | One (or a few) | One if sent together, otherwise one per command |
| Atomicity | No: other clients can interleave | Yes: no interruption |
| Rollback | No | No |
| Per-command errors | Returned in the reply list | Returned in the reply list (queue-time errors abort, see Transactions) |
| Use case | Throughput / bulk load | Safe 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 cReading AI-generated code
Section titled “Reading AI-generated code”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}`);Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Pipeline / autoPipelining | 100x fewer round trips in the run (17 ms vs 1688 ms) | No atomicity; errors hide inside the reply array |
| Batches of about 1000-10 000 | Near-peak throughput, bounded reply memory | You chunk the work and wait for each batch |
| One giant pipeline | Fewest round trips | Server and client buffer every reply in memory |
redis-cli --pipe | Fast bulk load from a file or another program | Errors are printed but do not stop the load; you must generate valid input |
MULTI/EXEC in the pipeline | Atomic batch in one trip | The batch runs as one uninterrupted block; keep it small |
Common Misconceptions
Section titled “Common Misconceptions”- “A pipeline is atomic.” Other clients can run between its commands.
- “
try/catcharoundexec()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 16p50 0.51 ms vs 0.12 ms).
Common mistakes
Section titled “Common mistakes”- Assuming a pipeline is atomic — wrap it in
MULTI/EXECif 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
MULTIwithWATCHfor read-then-write. - Feeding
--pipeunvalidated 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.