First: is RowBinary even the right format?
RowBinary exists for throughput, but it is not automatically the fastest path — match the format to the shape of the data before committing to a bespoke parser.
Prefer a JSON* format (e.g. JSONEachRow) when the result is mostly
strings / JSON-like values that you consume wholesale — randomly accessing
essentially every field, running string/regexp methods on them, treating values
as text. V8's native JSON.parse is heavily optimized C++ and builds JS strings
and objects faster than a JS-level RowBinary decoder can; pair it with HTTP
response compression (gzip / zstd, which crushes JSON's repetitive keys) and
the wire cost shrinks too.
RowBinary clearly wins when the result is dominated by:
- Wide numerics —
Int128/Int256/UInt128/UInt256,Decimal128/Decimal256. - Binary / fixed-width blobs —
IPv4,IPv6,UUID,FixedString. - High-volume fixed-width numeric columns generally, where each value is a
single
DataViewread.
Prefer the Native format when columnar load and client-side analytics are
the main goal (fold/scan/filter columns, feed typed arrays to a Worker or WASM).
Native is column-major, so it loads straight into one typed array per column
with no transpose.
For help choosing and consuming a JSON* format (or CSV / TSV) instead, use the
clickhouse-js-node-coding skill.
Second: complete buffer, or incremental stream?
Decide this before writing the reader — it changes the shape of the code and is a real performance fork.
-
Incremental / streaming (the default here). You consume the HTTP response chunk by chunk as it arrives — low latency to the first row, bounded memory. It is generally the best choice for large results, but slower per-row.
-
Whole buffer in memory (faster, when it fits). If you already hold the entire response as one
Buffer, the bounds check never fires — so you can dropadvance()entirely and read at a running offset in one monolithic loop. This is 2-3x faster but introduces latency and unbounded memory use.
The exposed API is streaming by default and requires an optimisation pass.
Third: row objects, or columnar (typed arrays)?
The default output is one object per row (array-of-structs). For a numeric,
fixed-width result that the consumer reads column-wise, decode instead into one
typed array per column (struct-of-arrays) — it is ~4x faster and several times
smaller because it removes the per-row object, Date, and number-boxing
allocations that dominate a numeric decode (the byte reads are already at memory
bandwidth). Measured in tests/iot.columnar.bench.ts; rationale in
case-studies/wasm-vs-js.md.
- Use columnar when columns are numeric/fixed-width and the consumer
aggregates / filters / scans / plots them, or hands the buffers to a Worker or
WASM kernel (typed-array
ArrayBuffers are transferable — zero-copy). - The preallocation trick: if EVERY column is fixed-width the row stride is
known, so the exact count is
buf.length / stride— allocate each column once, write at[i], no growth, no per-row bounds check. - Streaming columnar is just that arithmetic per chunk. Fixed width means
honoring a partial buffer needs no
advance()/NeedMoreData/restart: the complete-row count is(chunk.length / stride) | 0, and the leftover bytes carry to the next chunk. Yield one typed-array batch per chunk, each owning a fresh transferableArrayBuffer(seestreamSensorColumnsinsrc/columnar.ts). - Stay row-oriented when downstream code is row-shaped, the row is
string-dominated (columnar's win is numeric — a JS string allocates either
way), or the schema is nested/heterogeneous (
Array/Map/Tuple). - Hybrid: store columnar, expose a lazy
rowAt(i)accessor that builds an object only for rows actually touched (seeiotRowAtinsrc/examples/iot.ts).
Fourth: are the column types known ahead of time?
- Known (the default). Generate a straight-line reader specialized to those types — everything below.
- Only at runtime (the schema varies, or you just want to decode an arbitrary
RowBinaryWithNamesAndTypesstream). CallcompileRowBinaryWithNamesAndTypes(cursor)(src/rowBinaryWithNamesAndTypes.ts): it reads the header, folds each column type's AST into aReader(astToReader,src/compile.ts; type strings parsed by@clickhouse/datatype-parser), and returns areadRowsdriver for the rest of the stream. Generic and unoptimized (no codegen), so prefer the specialized path whenever the types are fixed.
Core guidance
When generating a parser, follow these:
-
Little-endian only. RowBinary is little-endian; target x86/ARM. Read every multi-byte number with
DataViewaccessors passing a literaltruefor thelittleEndianflag. -
Correct first, then optimize. First emit a correct reader built from the plain per-type API. Only after it's correct (and tested) specialize it. Don't bake performance assumptions in before correctness.
-
Monomorphize generic/composite types. Emit specialized, inlined code per type combination instead of passing functions as arguments where the type is known ahead of time.
-
Streaming: throw + restart, not generators. To signal "need more bytes", a synchronous reader that throws a sentinel (
NeedMoreData) and restarts the row beats generators for realistic chunk sizes; -
Keep an eye on chunk sizes. Partial trailing rows, small chunks are a silent throughput killer:
streamRowBatcheswarns once when rows-per-chunk falls too low, andcoalesceChunks(source, { minSize, timeoutMs })merges small chunks in front of it when the source size isn't yours to raise. -
Shared scratch is not reentrant. Some hot methods reuse a module-level scratch buffer as a write-then-read pair — correct only because reads are fully synchronous. An
async/yieldboundary between populating and reading it corrupts the value. -
Hoist the cursor into locals. Prefer the working buffer and view declared once at the top of the generated reader, and keep the read offset in a local variable, operating on it directly instead of re-reading from an object.
-
Coalesce
advance()across adjacent fixed-width columns. A run of neighbouring fixed-width columns has a known combined size, so bounds-check it ONCE. -
Inline the leaf reads. The per-type
readXfunctions are the correct, composable reference; the generated parser should INLINE their bodies, not call them, so the row reader is straight-line with no per-field indirection (and so the two points above can fold the offset arithmetic together). -
Annotate the decoded type per column. Inlining erases the type structure, so put a short comment above each column's decode block naming the ClickHouse type it reads.
-
Pre-allocate small result arrays. RowBinary gives every array/map its element count up front (the LEB128 prefix), so DEFAULT is to
new Array(n). NOTE: for large arrays the application will iterate or compute over repeatedly, prefer[]+push(faster to traverse in V8) — or a typed array (Float64Array…) for numeric elements. -
TypeScript by default. Generate TypeScript parsers and helpers unless the user explicitly asks for plain JavaScript.
Type family references
The readers live as real code under src/, split by type family.
| Result contains (trigger) | Open |
|---|---|
Always — cursor state, advance(), NeedMoreData, Reader<T> |
src/core.ts |
LEB128 length/count prefixes for String/Array/Map (readUVarint) |
src/varint.ts |
Int8–Int256, UInt8–UInt256 |
src/integers.ts |
Bool |
src/bool.ts |
Enum8, Enum16 (resolve to the value's name; readInt8/readInt16 for the raw int) |
src/enums.ts |
Float32, Float64, BFloat16 |
src/floats.ts |
Decimal32/64/128/256, Decimal(P, S) |
src/decimals.ts |
String, FixedString(N) |
src/strings.ts |
UUID |
src/uuid.ts |
IPv4, IPv6 |
src/ip.ts |
Date, Date32, DateTime, DateTime(tz), DateTime64(P[, tz]) |
src/datetime.ts |
Time, Time64(P) |
src/time.ts |
IntervalNanosecond … IntervalYear |
src/interval.ts |
Array(T), Map(K, V), Tuple(...), Nullable(T), Variant(...), QBit(...) |
src/composite.ts |
Point, Ring, LineString, MultiLineString, Polygon, MultiPolygon, Geometry |
src/geo.ts |
Dynamic (and Variant/Interval/Nested/Dynamic nested inside it) |
src/dynamic.ts |
JSON |
src/json.ts |
The whole result — loop rows to EOF (readRows) |
src/rows.ts |
A chunked HTTP response — streamRowBatches, coalesceChunks |
src/stream.ts |
The RowBinaryWithNamesAndTypes header — column names + type strings (readHeader) |
src/header.ts |
Fold one parsed type AST into a Reader (astToReader) — AST in, reader out |
src/compile.ts |
Types known only at runtime — compile a whole header into a row reader (compileRowBinaryWithNamesAndTypes, typeStringToReader) |
src/rowBinaryWithNamesAndTypes.ts |
| Numeric/fixed-width result read column-wise (aggregate/scan/plot, hand to a Worker/WASM) → decode into typed arrays, not row objects (~4x) | src/columnar.ts (streamSensorColumns — streaming, yields transferable typed-array batches); decodeIotColumnar in src/examples/iot.ts is the whole-buffer form |
LowCardinality(T) — transparent, decode as T |
src/lowCardinality.ts |
SimpleAggregateFunction(f, T) — transparent, decode as T |
src/simpleAggregateFunction.ts |
Nested(...) — no wire of its own; Array(Tuple(...)) |
src/nested.ts |
Nothing — zero-width, never decoded (only wrapped) |
src/nothing.ts |
AggregateFunction(...) — opaque state; finalize server-side |
src/aggregateFunction.ts |
Worked examples
Six end-to-end examples with real speedup are catalogued in EXAMPLES.md.
Out of scope
- JSON / CSV / TSV / Parquet parsing → use
clickhouse-js-node-coding. - Connection errors, hangs, type mismatches → use
clickhouse-js-node-troubleshooting. - Browser / Web Worker / Edge →
@clickhouse/client-web.