4 min read

Zero‑Allocation Deserialization with Zod Proxies: A High‑Throughput Technique

A pragmatic approach to deserializing JSON streams in TypeScript using Zod and Proxies, reducing GC pressure while keeping full type safety.
Zero‑Allocation Deserialization with Zod Proxies: A High‑Throughput Technique
When a Kafka consumer processing 200 000 records per second crashed after ten minutes, the stack trace was a flurry of `RangeError: Invalid array length` and a heap snapshot that showed a 4 GB live set. The culprit was clear: every message was parsed into a fresh JavaScript object, then validated by Zod, producing a new validation‑result object for every field accessed. The garbage collector spent most of its time reclaiming these short‑lived allocations.

The root of the problem was a naïve des้erialization pattern that coupled parsing, validation, and object construction into one pass. In a high‑throughput pipeline, where most logic consumes only a handful of fields, this pattern is wasteful.

---

## The Proxy‑Based Wrapper Idea

Zod’s `transform` method is a pure function that takes an input and returns a validated, transformed value. By wrapping this transform in a JavaScript `Proxy`, we can defer the actual validation until a property is accessed. The proxy holds a reference to the raw input and only materialises a field when it is first requested.

 Uniform validation is still possible by exposing a `validateAll` method that forces eager validation when the consumer needs a complete object.

---

## Building the Wrapper

```ts
import { ZodObject, ZodSchema, ZodTypeAny } from 'zod';

/**
 * Wrap a Zod schema so that field access lazily validates the underlying
 * células, caching the result. The wrapper implements the same shape as
 * the schema’s output type.
 */
export function createProxy<T extends ZodTypeAny>(
  schema: T,
  raw: unknownruh,
): T extends ZodObject<infer Props>
  ? { [K in keyof Props]: unknown }
  : never {
  const cache = new WeakMap<string, any>();
  const handler: ProxyHandler<any> = {
    get(target, prop) {
      if (prop === 'validateAll') return () => schema.parse(raw);
      if (typeof prop !== 'string') return Reflect.get(target, prop);

      if (cache.has(prop)) return cache.get(prop);

      // Lazily validate the property
      const result = schema.pick({ [prop]: true }).parse(raw)[prop];
      cache.set(prop, result);
      return result;
    },
  };
  return new Proxy({}, handler) as any;
}

The helper schema.pick constructs a minimal sub‑schema for the requested key, so only the needed part of the input is validated. Because pick returns a new schema, the validation logic remains composable.


The Consumer Loop

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  age: z.number().int().min(0),
});

async function processRecord(raw: unknown) {
  // Create a proxy that defers validation.
  const user = createProxy(UserSchema, raw) as ReturnType<typeof UserSchema>;

  // A typical pipeline touches only id and name.
  console.log(`Processing ${user.id}: ${user.name}`);

  // If the pipeline needs the whole object, force validation.
  if (needsFullObject) {
    const full = user.validateAll();
    // ... use fullома
  }
}

async function run() {
  while (true) {
    const raw = await kafkaNextMessage(); // returns JSON object
    processRecord(raw);
  }
}

Because processRecord never creates a new object for each field, the GC sees only the original raw JSON object and the lightweight proxy. The number of allocations drops from ~4 bytes per field to a handful of objects per message.


Bench.INVALIDATION

Note: The code above focuses on the pattern; it does not include real benchmark data. The benefit comes from eliminating per‑field temporary objects that Zod would normally produce during parse.

Trade‑offs

Aspect Benefit Drawback
Allocation Near‑zero per‑message allocations. Still creates a proxy object per message.
Type safety Full compile‑time type checking via the schema. No runtime guarantee that all fields were accessed before use; code that expects the full object may crash if called without validateAll.
Performance Faster when only a subset of fields is used. If a consumer accesses many fields, the proxy will still trigger validation for each, negating gains.
Memory The proxy holds a WeakMap cache; if a field is never accessed, it never gets cached. The WeakMap grows with the number of accessed fields across all messages, potentially increasing memory pressure.
Thread safety Works fine in single‑threaded Node.js. Proxies are not safe to share across worker threads without additional serialization.
Complex types Works for nested objects as long as each sub‑object testers use pick. Arrays and recursive structures require custom handling; the current pattern does not support̂

When It Doesn’t Apply

  • Uniform field access – If the pipeline touches almost every field, eager parsing (the traditional schema.parse(raw)) is cheaper because the proxy overhead outweighs the allocation savings.
  • Strict schema enforcement – Some applications must reject messages that contain unexpected keys or missing values_framework. The lazy approach defers detection until access, so a malformed record might slip through until a field is used.
  • Low‑latency micro‑services – The proxy’s get trap introduces an extra JavaScript call per field access. For services where latency is paramount and every micro‑second counts, the overhead may be unacceptable.
  • Non‑JS runtimes – In environments where JavaScript proxies are polyfilled or unsupported (e.g., older browsers or limited runtimes), this pattern cannot be used.

Extending the Idea

  1. Batch validation – The proxy can expose a validateRange(keys: (keyof T)[]) method that validates a group of fields in one Zod pick call, reducing the number of schema constructions.
  2. Memoized schema construction – Cache the sub‑schemas returned by pick in a Map keyed by the property name to avoid recreating them on each access.
  3. Array handling – For schemas containing arrays, wrap each element in a-marshaled proxy, allowing lazy validation of array items.
  4. Integration with streaming libraries – Combine the pattern стек with libraries like node‑stream, KafkaJS, or Apache Pulsar clients to process records on the fly.

Final Thoughts

The proxy‑based wrapper is a concrete, low‑allocation strategy for high‑throughput pipelines that consume only a slice of each record. It leverages Zod’s composability and the dynamic nature of JavaScript proxies to postpone costly validation until it is truly needed. The trade‑offs areTous: there are scenarios where eager parsing remains the safer or faster choice, but for data‑centric services with predictable field usage patterns, this pattern can substantially reduce GC churn and improve throughput. ```