A page that answers in 200 ms against a local Redis takes four seconds once the client sits behind a VPN, while the server counters stay idle. The slow log shows nothing above a millisecond, the CPU graph is flat, and the instance serves other clients without complaint. What changed is the distance, and the thing that multiplies distance is the number of times your code stops and waits. Walk the request path from the client outward and the fix is almost always fewer sequential waits rather than a faster server.

Start from the client, not from the server metrics

Watch the client process between the first command and the last. If the trace shows a reply, a pause, then the next command, you are paying a full round trip per command and no amount of server tuning will change that. The Slow Log is the wrong instrument here, because it records execution time and deliberately excludes communication I/O. A clean slow log next to a slow endpoint is the signature of a round-trip problem.

Measure the path itself before touching code. redis-cli -h redis.example.internal --latency sends repeated PINGs and prints the average request and reply time, which is your per-exchange cost. Multiply that by the number of sequential commands in one user action and you have a floor for the endpoint.

Count the round trips one user action actually needs

Take a single endpoint and write down every Redis call in order, including the ones hidden inside an ORM, a session middleware or a feature-flag lookup. Most slow endpoints turn out to make twenty or thirty calls where three would do, and the hidden ones are usually the majority.

A dependency chain cannot be collapsed: if the second command needs the key returned by the first, the wait is real and the only options are a different data model or a server-side operation that expresses the whole step at once. Independent reads are a different case, because nothing forces you to await the first reply before sending the second. Mark each call as dependent or independent before you edit anything, and the shape of the fix becomes obvious.

The number that matters is sequential waits, not total commands. Thirty commands in one batch still cost one wait.

Batch only the commands that do not depend on each other

A pipeline queues independent commands and collects the replies in order, turning N waits into one. Most clients expose it as a block, a callback or a context manager rather than a separate connection mode, so check the API your installed version ships. The gain scales with round-trip time, which is exactly why it matters on a VPN and barely registers on localhost.

import redis

r = redis.Redis(host="redis.example.internal", port=6379, socket_timeout=2)
pipe = r.pipeline(transaction=False)
for key in ("user:1001", "user:1002", "user:1003"):
    pipe.get(key)
print(pipe.execute())

Pass transaction=False when you only want batching. With the default the client wraps the batch in MULTI and EXEC, which makes it atomic and changes what other clients can observe mid-flight. Atomicity is a correctness decision rather than a performance knob, and a pipeline is not a transaction just because both of them reduce round trips.

Read every reply instead of only the batch submission. A queued command can return an error while the batch as a whole completes, and a client that throws away the reply list turns a failed write into a silent one. Compare the length of the reply list with the number of commands you queued.

Keep the connection alive between actions

Every new connection pays for name resolution, a TCP handshake, a TLS handshake and an AUTH, and over a VPN each of those is a round trip you cannot batch away. Create the client once per process and reuse it, or use the pool your library provides with a bounded size. Reconnecting per request is the second most common cause of this symptom after per-command waits.

Keep the private hostname in the configuration rather than a literal address. Certificate validation and service identity still apply on a private network, and pinning an IP breaks the moment the provider renumbers a subnet. If resolution is the slow part, fix the resolver path instead of the connection string.

Set an explicit socket timeout so a stalled tunnel fails fast rather than hanging a worker. Then confirm the pool is really reused by logging connection creation in the client.

Size the batch before it becomes a memory problem

Redis holds queued replies for the duration of a batch, and the client holds the parsed result afterwards. A pipeline of ten thousand large values trades network waits for memory spikes and long pauses at both ends.

Choose a batch size against response volume rather than command count. A few hundred small commands in one batch is usually comfortable, while a hundred multi-kilobyte payloads is not. Split by bytes as well as by count, and keep each batch inside the latency budget the endpoint actually has.

Measure end to end after the change: redis-cli -h redis.example.internal --latency-history shows whether the path itself is stable, and your own request timer shows whether the endpoint improved. If the endpoint is still slow while the latency history is flat, the remaining cost is server-side work or your own serialization, and the next step is a profile of the handler rather than another batching change.

Ebitabo ebifaananyi