Two Hundred Redis Calls, and Every One Was a Round Trip
Each Redis command took a tenth of a millisecond to execute and a full round trip to deliver, and two hundred of them in a loop made the request slow while…
The endpoint was slow and Redis was fast. The slow log was empty, the server's command latency was microseconds, and yet the request spent two hundred milliseconds in Redis calls. The reconciliation is arithmetic: each command executed in a microsecond but travelled a round trip of a millisecond, and two hundred commands in a sequential loop is two hundred round trips, and the round trips, not the commands, are the cost.
I have lost on-call weeks to this exact shape of slow burn, so the order below is the order I fix it in.
This is the most common Redis performance defect, and it is invisible in every Redis metric, because the server experiences two hundred trivial commands and the client experiences two hundred waits. The latency lives in the gap between them.
This was Redis 7.2 with a Node 22.14 client, round trips of about a millisecond in the same datacentre, and the fix was pipelining plus a redesign of the access pattern.
The arithmetic of the round trip
A Redis command's wall time is the command's execution plus one network round trip, plus client bookkeeping. In a datacentre the round trip is a fraction of a millisecond to a millisecond, which is hundreds of times the execution time. So a loop of N commands costs roughly N round trips, and the server's speed is irrelevant to that cost.
Two hundred commands at one millisecond each is two hundred milliseconds, which is the whole endpoint. The same two hundred commands pipelined, sent together and answered together, cost one round trip plus the server's total execution time, which is milliseconds. The work is identical. The conversation is different, and the conversation was the cost.
This is the latency versus throughput distinction from two hundred calls in miniature: Redis's throughput was never the problem. The number of conversations was.
Seeing it
The client side trace shows it immediately: a flame graph or a simple timer around the loop shows two hundred small waits, each bounded below by the round trip, rather than one block. The server side confirms innocence: the slow log is empty and the command count is high with tiny latencies.
The signature is a request whose Redis time equals the command count times the round trip, and the diagnostic question is simply "how many round trips does this request pay". Count the commands per request, multiply by the measured round trip, and compare to the request's Redis time. When they match, the conversation is the cost.
Pipelining
Pipelining sends a batch of commands without waiting for each reply, and reads the replies together. The commands still execute in order and the replies come back in order, but the round trips collapse to one per batch.
In most clients it is explicit, a pipeline object you append commands to and then flush. The shape:
const p = redis.pipeline();
for (const id of ids) p.get(`item:${id}`);
const results = await p.exec();
Two hundred gets become one round trip. The server processes them sequentially and quickly, and the client waits once.
The limit of pipelining is that it batches independent commands. If each command depends on the previous reply, pipelining cannot help, and the loop is structural. That is the signal to redesign, not to tune.
Redesigning the access pattern
Pipelining fixes the batch, but the deeper fix is asking why the request needs two hundred conversations at all.
Fetch the set, not the members. Two hundred gets by id is often a scan of a known id list, which can be one call returning the collection, or a hash storing the whole object graph under one key, read with one command. The data model decides the conversation count.
Use the compound commands. Redis has multi key and aggregate commands, mget for many gets, and the hash, set and sorted set types exist precisely to make a related group one key. A design that stores each field as its own key forces a conversation per field, which is the defect in the data model rather than the client.
Cache the composed answer. If the request always assembles the same two hundred piece answer, cache the assembly, per reviewing a cache layer, and pay the two hundred commands once per change instead of once per request.
When the round trip is not the cost
To be fair to the other half, pipelining does not help when the commands are genuinely sequential, when the dataset is remote across a slow link, or when the client library adds a lock or a connection wait per call, which is a client side queue rather than a network cost. The diagnosis distinguishes them: if the per call floor equals the network round trip, it is the wire. If it exceeds it, something client side is serialising, and that is the shared state under contention story wearing a Redis costume.
If it happens again
A Redis request's latency is the number of round trips it pays, not the number of commands the server executes, because the network conversation dwarfs the execution. Count the conversations per request, pipeline the independent ones into a single round trip, and redesign any access pattern that needs a conversation per member of a set.
The server metrics will never show this defect, because the server is innocent. The cost is in the shape of the client's conversation, and the shape is a design decision, made once, and paid on every request forever.
The serialisation cousin, where the cost is building the payload rather than the round trips, is the endpoint that spent nine hundred milliseconds building JSON.
One caveat keeps pipelining honest: a pipeline is a batch, and a very large batch holds a connection and a buffer, so cap the batch size at a few hundred commands and send several batches rather than one enormous one. The goal is one round trip per batch, not one round trip per request at any size, and the capped batch keeps the win without trading it for a memory or fairness problem of your own making.
Published on debugly.dev by Rohit Bhadani | Founder of Krova Cloud