Jingo: How bet365 Achieves 11x Faster JSON Encoding in Go
Hook
What if the most expensive part of JSON encoding—runtime reflection—could be done exactly once, then never again? That's the core insight behind bet365's Jingo, which treats struct serialization like a JIT compiler treats code.
Context
Go's standard library encoding/json package is remarkably convenient: pass any struct to json.Marshal(), and it produces valid JSON. But that convenience comes at a cost. Every single time you marshal a struct, the stdlib uses reflection to examine field types, read struct tags, and determine how to serialize each value. For applications marshaling thousands or millions of structs per second—think real-time betting platforms, financial data feeds, or high-throughput APIs—this repeated reflection work becomes a measurable bottleneck.
The performance-sensitive corners of the Go ecosystem have produced numerous alternatives: json-iterator maintains stdlib compatibility while optimizing hot paths, easyjson generates specialized marshal code at build time, and gojay requires implementing custom interfaces. Each makes different trade-offs between performance, convenience, and flexibility. bet365 developed Jingo to push performance to the extreme for their specific use case: serializing known struct types in latency-critical paths where every microsecond and every allocation matters. Rather than generating code or requiring interface implementations, Jingo compiles type metadata into optimized instruction sets at encoder creation time, then executes those instructions with direct memory access during marshaling.
Technical Insight
Jingo's architecture splits JSON encoding into two distinct phases: compile-time analysis and runtime execution. When you create a StructEncoder, Jingo uses reflection to recursively analyze your type, but instead of directly producing JSON, it generates an instruction set—a sequence of operations that describes exactly how to serialize that specific type. This happens once per type, not once per value.
Here's a practical example showing the API and where the performance gains materialize:
import (
"github.com/bet365/jingo"
"encoding/json"
)
type Trade struct {
Symbol string `json:"symbol"`
Price float64 `json:"price"`
Volume int64 `json:"volume"`
Timestamp int64 `json:"timestamp"`
}
// Create encoder once - reflection happens here
enc := jingo.NewStructEncoder(Trade{})
// Reuse for millions of trades - no reflection
trade := Trade{
Symbol: "BTCUSD",
Price: 45231.50,
Volume: 150,
Timestamp: 1704067200,
}
// Zero allocations in steady state
buf := jingo.NewBufferFromPool()
defer buf.ReturnToPool()
enc.Marshal(&trade, buf)
jsonBytes := buf.Bytes()
// Compare to stdlib (reflection on every call)
stdlibBytes, _ := json.Marshal(&trade)
The instruction set approach is where Jingo's speed comes from. During the initial type analysis, Jingo creates instructions like "write field name 'symbol'", "write string value from offset 0", "write comma", "write field name 'price'", "write float64 from offset 24". These instructions include pre-encoded static JSON metadata—the field names are already properly quoted and escaped, brackets and commas are pre-allocated. At marshal time, Jingo iterates through these instructions and uses the unsafe package to read struct field values directly from memory offsets, writing them to the buffer without any reflection calls.
The buffer pooling is equally critical to achieving zero allocations. Jingo provides a custom buffer type with built-in sync.Pool integration. Buffers grow as needed to accommodate JSON output, and when returned to the pool, they retain their capacity for reuse. This means after an initial warm-up period, your application stops allocating buffers entirely—the same memory is recycled across millions of marshal operations.
// Buffer pooling in action
func handleRequest(trades []Trade) []byte {
// Get buffer from pool (likely already sized correctly)
buf := jingo.NewBufferFromPool()
defer buf.ReturnToPool() // Critical: returns capacity to pool
// Create slice encoder (wraps struct encoder)
enc := jingo.NewSliceEncoder(Trade{})
enc.Marshal(trades, buf)
// Copy bytes before returning buffer
result := make([]byte, buf.Len())
copy(result, buf.Bytes())
return result
}
The instruction-set architecture has another subtle benefit: it enables Jingo to write JSON in a single forward pass without backtracking. Because the structure is known in advance, there's no need to buffer partially-complete objects or deal with conditional formatting. The encoder knows exactly which commas and brackets to write and when, eliminating branches from the hot path.
For slice encoding, Jingo creates a composite instruction set that includes array delimiters and iterates through elements. This is particularly powerful for batch operations where you're serializing arrays of structs—exactly the pattern you'd see in paginated API responses or bulk data exports:
trades := []Trade{
{Symbol: "BTCUSD", Price: 45231.50, Volume: 150, Timestamp: 1704067200},
{Symbol: "ETHUSD", Price: 2341.25, Volume: 500, Timestamp: 1704067201},
// ... thousands more
}
sliceEnc := jingo.NewSliceEncoder(Trade{})
buf := jingo.NewBufferFromPool()
defer buf.ReturnToPool()
sliceEnc.Marshal(trades, buf)
// Produces: [{"symbol":"BTCUSD","price":45231.5,...},{...}]
The performance characteristics change dramatically compared to stdlib. In bet365's benchmarks, encoding a slice of 1000 structs drops from ~280µs with encoding/json to ~40µs with Jingo—a 7x improvement. Allocations go from thousands of small heap objects to zero in steady state. For applications at scale, this translates directly to lower CPU usage, better latency percentiles, and reduced garbage collection pressure.
Gotcha
Jingo's instruction-set architecture comes with hard constraints that aren't immediately obvious. The most significant: no omitempty support. When you use omitempty in stdlib, the encoder checks each field's value at marshal time and conditionally includes it. This requires runtime branching—exactly what Jingo eliminates for speed. The instruction set is static: it always writes the same fields in the same order. If your API contracts depend on omitting zero values or nil pointers to reduce payload size, Jingo won't work without restructuring your types.
Map support was initially absent entirely, and while the documentation suggests it's been added, this reveals a deeper limitation: Jingo is optimized for predictable, homogeneous data structures. Maps have unpredictable iteration order and unknown key sets, which conflicts with the pre-compiled instruction model. If your JSON output includes dynamic key-value pairs or you're serializing arbitrary nested structures, you're fighting against Jingo's design. The trade-off is explicit: bet365 designed this for their domain—high-frequency serialization of well-known betting data structures—not for general-purpose JSON handling. You also need to manage encoder lifecycle carefully. Unlike json.Marshal() which is stateless, you must create and ideally reuse encoder instances. In concurrent environments, this often means either creating encoders per goroutine (adding initialization overhead) or sharing encoders with synchronization (adding contention). The pattern works well for request handlers that serialize the same response types repeatedly, but adds cognitive overhead compared to stdlib's simplicity.
Verdict
Use if: You have hot-path JSON serialization of known struct types where profiling shows stdlib marshaling is a bottleneck—think request handlers returning the same struct shapes thousands of times per second, real-time data streams, or high-throughput microservices where every allocation contributes to GC pressure. The 3-11x speedup and zero-allocation characteristics deliver measurable value at scale, and your types don't rely on omitempty behavior. Skip if: You need flexible JSON output with conditional field inclusion, you're serializing diverse or dynamic structures, or you haven't actually measured JSON encoding as a bottleneck. The API complexity and limitations aren't worth it for typical CRUD applications or infrequent serialization. Start with stdlib or json-iterator (a drop-in replacement that's 1.2-1.4x faster) and only reach for Jingo when you have concrete performance requirements that justify the constraints.