A breakpoint for Redis means pausing a live command without breaking the client that sent it. Here's how the proxy holds, releases, and drops commands — and stays on the hot path while doing it.
Mira Kessler & Dan Mahon
· Updated · 4 min read
A debugger for code pauses a thread. A debugger for Redis has a harder job: it
has to pause one command on a live connection while the client on the other
end keeps waiting — patiently, and without noticing anything is wrong. Get it
subtly wrong and you either execute a command twice or hang a production client.
Here’s how Varc’s proxy does it.
The mental model: a queue you can stop
The proxy is a transparent man-in-the-middle. Your client connects to it; it
connects to Redis. With no breakpoints armed, it’s a pipe. Arm one, and the
matching command is held — parked in a queue — while everything about that
connection freezes in place.
Play the session below. Reads flow straight through; every write is held at
the breakpoint until you release or drop it.
varc · debuggerbreakpoint: writes
AUTH ****→ OK
GET session:42→ "{\"uid\":42}"
■ Paused · write held
SET session:42 {…} EX 3600
3 writes · 3 reads
Parse the header, forward the bytes
The temptation is to fully decode every command into a tidy struct. On the hot
path that’s death by allocation. The proxy instead reads just enough of each
RESP frame to know the command name and key — enough to match a breakpoint —
and forwards the original bytes untouched when nothing matches.
Naïve — decode everything
// Allocates a Vec<Bytes> for every command,// even when no breakpoint is armed.let cmd = RespValue::parse_owned(&mut buf)?;if breakpoints.matches(&cmd) { hold(cmd).await;} else { upstream.write_all(&cmd.encode()).await?; // re-encode!}
Varc — peek, then forward
// Borrow a header view; keep the original frame.let frame = RespFrame::peek(&buf)?; // no allocif breakpoints.matches(frame.name(), frame.key()) { hold(buf.split_to(frame.len())).await; // own only when held} else { upstream.write_all(&buf[..frame.len()]).await?; // pass through}
Only when a command is actually held does the proxy take ownership of its bytes
and lift it into a fully parsed, inspectable command. The common case — no
breakpoint hit — never allocates.
Held
14:22:07.118 GET session:42 → "{uid:42}"
14:22:07.119 SET session:42 … EX 3600
■ held · waiting on you
client blocked, 0 commands lost
Released
14:22:07.118 GET session:42 → "{uid:42}"
14:22:07.402 SET session:42 … EX 3600 → OK
14:22:07.402 GET cart:42 → "3 items"
▸ stream flowing
Drag the handle: the client’s view never diverges from reality. A held command
either runs exactly once when you release it, or — if you drop it — is answered
with a synthesised reply so the client never hangs. If the client disconnects
while a command is held, the command is discarded rather than executed against a
socket that’s gone.
Staying honest on a cluster
A single Redis is easy. A cluster moves keys between nodes and answers with
MOVED/ASK redirects. The proxy presents your client with one endpoint while
routing each held-and-released command to the node that owns its slot — so
stepping through a session feels identical whether you’re on one node or sixteen.
What it costs
Because the idle path is a straight byte forward, an armed-but-not-matching
proxy adds only the cost of a header peek per command:
Per-command overhead by proxy state
Forward (idle)peek only
6µs
Match, releaseown + parse
22µs
Match, hold+ round-trip to UI
30µs
Local loopback, pipelined GET/SET · lower is better
The debugger you’re not using should be free. The one you are using should feel
instant. That’s the whole design goal — and it’s why the proxy stays a pipe
until the moment you ask it not to be.
Redis has no undo. Varc captures a before-image of every write it debugs, so you can put a key back the way it was. Here's what rewind can and can't do.