>_
# frontmatter
started:2024-01-22
ended:2024-01-22
updated:2024-01-22
status:EXEC: COMPLETE
topic:Performance
tags:[go, rust]
summary:Optimizing ingestion pipelines by avoiding GC pressure with custom unmarshalers.
metrics:
gc_pause_reduction: 90%throughput: 1.2 GB/smemory_saved: 70%
links:source

# Zero-Allocation JSON Parsing

Optimizing ingestion pipelines by avoiding GC pressure with custom unmarshalers.


Context

Our event ingestion pipeline was processing 100k events/second, but Go's standard encoding/json was causing significant GC pressure. The heap was growing uncontrollably during peak hours.

Performance tip: Always profile memory allocations before optimizing. We used go tool pprof to identify the bottleneck.

Background

The ingestion pipeline runs on a Kubernetes cluster with auto-scaling enabled. Under normal load, things were fine. But during traffic spikes:

  1. GC cycles increased from 2ms to 45ms
  2. Goroutine pauses caused downstream latency
  3. Heap grew to 8GB within minutes

Why Standard Library Falls Short

Go's encoding/json is excellent for general use, but it allocates heavily for:

  • Intermediate map[string]interface{} structures
  • Reflection-based type resolution
  • Temporary buffers during marshaling

Approach

We switched to a zero-allocation JSON parser using json-iterator with custom unmarshalers. By reusing buffers and avoiding intermediate allocations, we reduced memory churn significantly.

The Core Idea

Reuse, don't reallocate. Every allocation is a tax on your garbage collector.

We implemented three strategies:

  • Buffer pooling with sync.Pool
  • Custom struct tags for direct streaming
  • Pre-allocated slices sized to expected payload shapes
package main

import (
    "fmt"
    "github.com/json-iterator/go"
)

var json = jsoniter.ConfigCompatibleWithStandardLibrary

type Event struct {
    ID   string `json:"id"`
    Data []byte `json:"data"`
}

func main() {
    event := Event{ID: "evt_123", Data: []byte("payload")}
    b, _ := json.Marshal(event)
    fmt.Println(string(b))
}

Streaming Parser Example

For even larger payloads, we used a streaming approach:

use serde_json::StreamDeserializer;
use serde::Deserialize;

#[derive(Deserialize)]
struct LogEntry {
    timestamp: u64,
    level: String,
    message: String,
}

fn main() {
    let data = br#"{"timestamp":1705920000,"level":"INFO","message":"ok"}"#;
    let stream = StreamDeserializer::from_slice(data);
    for entry in stream {
        println!("{:?}", entry.unwrap());
    }
}

Benchmarks

Throughput Comparison

ParserSpeedGC TimeAllocations/sec
Standard library850 MB/s45%2.4M
Custom parser1.2 GB/s5%120K
Streaming (Rust)2.1 GB/sN/A80K

Key Improvements

  • Standard library: 850 MB/s, 45% GC time
  • Custom parser: 1.2 GB/s, 5% GC time
  • Memory usage: Reduced by 70%
  • Zero-allocation goal: Not fully reached yet

Memory Profile

  • Before optimization
    • Heap: 8GB
    • GC pauses: 45ms
    • Goroutines: 12,000
  • After optimization
    • Heap: 2.4GB
    • GC pauses: 3ms
    • Goroutines: 12,000

Trade-offs

The code is more verbose and harder to maintain. We only applied this optimization to the hot path (ingestion workers), keeping the standard library for less critical paths.

When to Optimize

Premature optimization is the root of all evil. But measured optimization is the root of all performance.

Consider zero-allocation parsing when:

  1. Your throughput exceeds 100k events/second
  2. GC pauses exceed your p99 latency budget
  3. Memory is constrained (e.g., edge devices, IoT gateways)

Don't optimize when:

  1. Your pipeline is I/O bound
  2. Your payloads are small (< 1KB)
  3. Team velocity is more important than marginal gains

Design Philosophy

┌─────────────────────────────────────────┐
│  Optimize for readability first         │
│  Measure with profilers second          │
│  Optimize hot paths third               │
│  Document trade-offs always             │
└─────────────────────────────────────────┘

Advanced Examples

Inline Elements

  • Bold text for emphasis
  • Italic text for terminology
  • Strikethrough for deprecated APIs
  • inline code for variable names like jsonConfig or bufferSize
  • Combined: bold code and italic code

Blockquotes

Single-level blockquote.

Nested blockquotes:

This is a nested quote. It helps show hierarchy of thoughts.

Deep nesting for dramatic effect.

Visit https://github.com/json-iterator/go directly — no brackets needed with GFM.



Summary

Key takeaway: Zero-allocation JSON parsing is powerful but not free. Use it where it matters, measure everything, and keep your code maintainable. The custom parser reduced GC pauses by 90% in high-throughput scenarios — but only after we proved the bottleneck with pprof.

Footnotes

  1. This is a footnote. GFM supports footnotes natively, and they appear at the bottom of the rendered document.

The custom parser reduced GC pauses by 90% in high-throughput scenarios.