Open Source
valkey-parser

Published: July 31, 2026 · by Srinu Desetti (creator & maintainer)

valkey-parser: The Wire-Protocol Decoder for the Valkey Client Stack

valkey-parser converts the raw bytes a Valkey server writes to a TCP socket into JavaScript values — strings, numbers, arrays, and error objects. It is the decoding layer of a Valkey client: nothing more, nothing less. I created and maintain it as a Valkey-native port of the battle-tested node-redis-parser, with Redis naming and dependencies removed — which is safe because Valkey speaks byte-for-byte the same RESP protocol as Redis.

Packagevalkey-parser (opens in a new tab) v1.0.0
LicenseMIT
Runtime dependencies1 (valkey-errors)
Node.js≥ 4
Sourcegithub.com/webdevelopersrinu/valkey-parser (opens in a new tab)

The Problem: Databases Don't Speak JavaScript

When a Node.js service talks to a Valkey server, the conversation happens over a raw TCP socket. What travels on that socket is not objects or JSON — it is a compact, text-framed byte format called RESP (REdis Serialization Protocol), which Valkey inherited unchanged from Redis.

When the application runs GET name, the server does not reply "srinu". It replies with this exact byte sequence:

$5\r\nsrinu\r\n
│└┬┘└┬┘└─┬─┘└┬┘
│ │   │   │   └─ frame terminator
│ │   │   └───── payload ("srinu")
│ │   └───────── frame terminator
│ └───────────── payload length (5)
└─────────────── type marker — "bulk string follows"

Every reply type carries its own marker byte: + simple string, - error, : integer, $ bulk string, * array — and arrays nest, so a single reply can be an arbitrarily deep tree of frames. Someone has to translate this stream into usable values. That translation is this package's entire job.

Why This Is Harder Than It Looks

TCP does not respect message boundaries

A socket delivers a stream, not messages. A 1 MB reply arrives split across many chunks; a single chunk can carry the tail of one reply plus the head of the next. The parser must hold partial state between calls and stitch frames back together correctly — including partially received nested arrays, which is where naive implementations break.

Here is the failure mode made concrete. The reply for LRANGE mylist 0 -1 is one logical frame — *2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n — but the kernel is free to deliver it as two packets, split mid-frame:

split-frame-demo.js
parser.execute(chunk1)  // *2\r\n$3\r\nfoo\r\n$3\r   ← split here, mid-frame
// → emits nothing — frame incomplete

parser.execute(chunk2)  // \nbar\r\n
// → returnReply(['foo', 'bar'])

Between the two calls, the parser holds the partially filled array and its position on an explicit stack, then resumes exactly where it stopped. No timeouts, no re-reads, no assumptions about packet sizes.

The parser sits on the hot path

A busy client decodes hundreds of thousands to millions of replies per second. If decoding is slow, the client becomes the bottleneck rather than the database. This is why the implementation uses hand-rolled byte-by-byte number parsing, a reusable 32 KB buffer pool, and avoids intermediate string allocations — optimizations inherited from years of production tuning in the upstream project.

Why I Forked Instead of Reused

Valkey is the open-source fork of Redis, created after Redis changed its license in 2024. Its wire protocol is identical — so the parsing logic did not need rewriting, and I deliberately did not rewrite it. What changed is provenance and naming:

  • All Redis branding removed — a Valkey ecosystem package should not depend on or advertise Redis-named modules.
  • redis-errors replaced with valkey-errors, so thrown errors (ReplyError, ParserError) are Valkey-native types.
  • Everything else is the proven node-redis-parser code path, unmodified.

The result: the client stack has a clean, Redis-free dependency tree without giving up a battle-tested decoder.

Where It Sits in the Client Stack

A full client involves four layers. This package is layer three only — it deliberately does not manage connections, encode commands, queue requests, or reconnect.

LayerResponsibility
Application codeCalls client.get('name')
ClientEncodes command, writes socket, queues callbacks
valkey-parserDecodes response bytes as they stream back
TCP socketRaw RESP byte stream to/from the Valkey server

API Surface

The whole public surface is one constructor and one method. You hand the parser raw socket chunks; it fires callbacks with fully decoded values.

usage.js
const Parser = require('valkey-parser')

const parser = new Parser({
  returnReply (reply)    { /* parsed value: string, number, array… */ },
  returnError (err)      { /* server-side ReplyError */ },
  returnFatalError (err) { /* unrecoverable protocol error */ }
})

socket.on('data', chunk => parser.execute(chunk))
OptionRequiredPurpose
returnReplyyesCalled once per fully decoded reply.
returnErroryesCalled when the server returns an error reply (ReplyError).
returnFatalErrornoCalled on an unrecoverable protocol violation; defaults to returnError.
returnBuffersnoReturn raw Buffers instead of strings — for binary payloads. Default false.
stringNumbersnoReturn numbers as strings, preserving integers beyond 2^53. Default false.

Engineering Notes — What's Under the Hood

These are the implementation decisions worth knowing before reviewing the code. All of them are inherited from the upstream project's years of production tuning; all are verifiable in lib/parser.js.

Adaptive shared buffer pool

Large binary replies are assembled in a module-level pool that starts at 32 KB (Buffer.allocUnsafe), grows 2–3× on demand, and — the part people miss — shrinks back: a 50 ms timer trims it 10% per tick once pressure drops, so a one-off 100 MB reply doesn't pin 100 MB of heap for the process lifetime. (resizeBuffer() · decreaseBufferPool())

Zero-copy fast path

When a reply fits inside a single socket chunk — the overwhelmingly common case — the value is sliced or toString'd directly off the incoming buffer. No intermediate copies, no staging area. (parseBulkString() · parseSimpleString())

Deferred concatenation for multi-chunk strings

A bulk string spanning N chunks is not concatenated N times (which would be quadratic). Chunks accumulate in a list untouched; one join happens when the final byte arrives. (bufferCache · concatBulkString() · concatBulkBuffer())

UTF-8 boundary safety

String assembly across chunks goes through Node's StringDecoder, so a multi-byte character split across two TCP packets decodes correctly instead of producing replacement characters. This is the bug most hand-rolled parsers ship with. (concatBulkString())

Explicit stacks instead of recursion state

A partially received nested array is tracked on two parallel stacks — the array objects and the fill positions. When the next chunk arrives, parsing resumes at the exact element it stopped on, at any nesting depth. (arrayCache · arrayPos · parseArrayChunks())

Allocation-free number parsing

Integers are folded byte-by-byte — n = n * 10 + (byte − 48) — instead of slicing a substring and calling parseInt. On a path that runs per reply, the avoided allocations are the point. (parseSimpleNumbers() · parseStringNumbers())

Scope Boundaries

Scope discipline is the design. Connections and reconnection strategy, command encoding, pipelining and request queues, pub/sub subscription state — all of that belongs to the client layer above. Keeping the parser to a single responsibility (bytes in, values out) is what lets it stay small — three files, one runtime dependency — and fast.

This is a solo project — I designed, built, and maintain valkey-parser independently as the decoding layer of the Valkey Node.js client stack.

What's Next?

With errors and parsing in place, the stack's top layer solves a different problem entirely: making multiple Socket.IO servers behave like one, using Valkey Pub/Sub as the message bus.

← Back to valkey-errors — the shared error model

Continue to socket.io-valkey-adapter — horizontal scaling for Socket.IO →


Frequently Asked Questions

Does valkey-parser handle RESP3?

No — it implements RESP2 (the five markers: + - : $ *), which is what Valkey speaks by default; RESP3 is opt-in via HELLO 3 and the client does not negotiate it. Adding RESP3 means new type markers (maps, sets, doubles, push messages), tracked as roadmap work, not a patch.

Why pure JavaScript instead of a native C binding?

The upstream project's benchmarks showed the JS implementation matching or beating the hiredis C binding for most workloads — V8's JIT handles this byte-loop code well, and staying in JS avoids the FFI boundary cost and the native build toolchain on every deploy platform.

What about integers larger than 2^53?

JavaScript numbers are 64-bit floats; beyond 2^53 they silently lose precision. The stringNumbers option returns every integer reply as a string, preserving exact values for the caller to handle.

How does it handle binary data like image blobs or protobufs?

The returnBuffers option returns raw Buffers instead of UTF-8 strings, end to end, including for multi-chunk payloads.

Why callbacks rather than promises or async iterators?

This is the per-reply hot path; promise or stream machinery costs an allocation per reply. Callbacks are the zero-overhead contract — the client layer above is free to wrap them in whatever async surface it exposes.

What happens on a corrupt stream?

An unknown type byte raises a ParserError carrying the offending byte and offset, delivered through returnFatalError. The parser drops its buffer; the correct client response is to tear down and reconnect — you cannot resynchronize a corrupt RESP stream safely.