April 19, 2026 10 min read

Go's sync.Pool for High-Throughput Payment Message Parsing

How we cut GC pause times by 92% in a payment gateway processing 45,000 ISO 8583 messages per second, using nothing more than sync.Pool and some discipline around object reuse.

The Problem: Death by a Thousand Allocations

If you've ever run a payment gateway at scale, you know the pattern. Each inbound message — whether it's ISO 8583 from a card network, a JSON authorization request from a merchant, or a protobuf event from an internal service — needs to be deserialized into a struct, validated, routed, and then the struct is discarded. At 45k messages per second, that's 45,000 struct allocations and deallocations every single second.

In our case, each PaymentMessage struct was roughly 2KB once you account for the field map, bitmap, and parsed sub-fields. That's ~90MB/s of short-lived allocations hitting the garbage collector. We were seeing GC pause times spike to 8-12ms during peak traffic — which doesn't sound terrible until you realize that your P99 latency SLA is 50ms and the downstream acquirer timeout is 30 seconds. Those pauses compound, queues back up, and suddenly you're dropping transactions.

How sync.Pool Works (The Short Version)

sync.Pool is Go's built-in mechanism for reusing temporary objects. It's dead simple: you Get() an object from the pool (which either returns a previously pooled object or creates a new one), use it, then Put() it back when you're done. The runtime is free to clear the pool at any GC cycle, so you can't rely on objects persisting — but that's fine. The goal isn't to eliminate allocations entirely; it's to reduce them enough that GC pressure drops to acceptable levels.

Key insight: sync.Pool doesn't prevent GC. It reduces the rate of allocations so that when GC does run, it has far less work to do. Think of it as amortizing allocation cost across requests.

Without sync.Pool

New alloc GC'd

Every request = new allocation. GC runs constantly.

With sync.Pool

Reused In pool

Initial allocs reused across requests. GC has little to do.

Implementation: A Payment Message Parser Pool

Here's the pattern we settled on. The PaymentMessage struct holds all the parsed fields from an ISO 8583 message, and we pool the parser that produces it:

package parser

import (
    "sync"
)

// PaymentMessage represents a parsed ISO 8583 message.
type PaymentMessage struct {
    MTI       string
    Bitmap    [128]bool
    Fields    [128][]byte
    PAN       string
    Amount    int64
    Currency  string
    TraceNum  string
    RespCode  string
    // Pre-allocated scratch buffer for field parsing
    scratch   [512]byte
}

// Reset clears the message for reuse without deallocating.
func (m *PaymentMessage) Reset() {
    m.MTI = ""
    m.PAN = ""
    m.Amount = 0
    m.Currency = ""
    m.TraceNum = ""
    m.RespCode = ""
    m.Bitmap = [128]bool{}
    for i := range m.Fields {
        m.Fields[i] = m.Fields[i][:0] // keep underlying array
    }
}

var messagePool = &sync.Pool{
    New: func() interface{} {
        msg := &PaymentMessage{}
        // Pre-allocate field slices to avoid allocs during parsing
        for i := range msg.Fields {
            msg.Fields[i] = make([]byte, 0, 64)
        }
        return msg
    },
}

// AcquireMessage gets a message from the pool, ready for parsing.
func AcquireMessage() *PaymentMessage {
    return messagePool.Get().(*PaymentMessage)
}

// ReleaseMessage returns a message to the pool after use.
func ReleaseMessage(msg *PaymentMessage) {
    msg.Reset()
    messagePool.Put(msg)
}

The critical detail here is the Reset() method. When we clear the Fields slices, we use m.Fields[i][:0] instead of setting them to nil. This preserves the underlying allocated array so the next parse operation can append into it without allocating new memory. That single line is responsible for about 40% of our allocation reduction.

Using It in the Request Handler

func handleTransaction(conn net.Conn) {
    buf := bufPool.Get().(*bytes.Buffer)
    defer func() {
        buf.Reset()
        bufPool.Put(buf)
    }()

    msg := parser.AcquireMessage()
    defer parser.ReleaseMessage(msg)

    // Read raw bytes from connection
    if _, err := io.Copy(buf, io.LimitReader(conn, maxMsgSize)); err != nil {
        metrics.ParseErrors.Inc()
        return
    }

    // Parse ISO 8583 into the pooled message struct
    if err := parseISO8583(buf.Bytes(), msg); err != nil {
        metrics.ParseErrors.Inc()
        return
    }

    // Route and process - msg is valid for this scope only
    response := processAuthorization(msg)
    writeResponse(conn, response)
    // msg is released back to pool via defer
}

Benchmarks: Before and After

We ran these benchmarks on our staging environment under simulated production load (45k msg/s, mixed ISO 8583 and JSON payloads). The numbers speak for themselves:

GC Pause (P99)
0.9ms
down from 11.2ms (92% reduction)
Allocs/op
3
down from 47 per message
Heap In-Use
84MB
down from 612MB at peak
P99 Latency
12ms
down from 38ms
// Benchmark results (go test -bench=. -benchmem)
// BenchmarkParseWithoutPool-8   45000   26400 ns/op   2048 B/op   47 allocs/op
// BenchmarkParseWithPool-8      45000    8900 ns/op    128 B/op    3 allocs/op

The remaining 3 allocations per operation come from string interning for the PAN (which we intentionally copy out for security isolation) and the response struct that gets handed off to the writer goroutine.

The Gotchas: What Will Bite You

Warning: sync.Pool Pitfalls in Production

1. Pool items are cleared on GC. The runtime can (and will) evict all pooled objects during garbage collection. Your code must handle the case where Get() returns a freshly allocated object. Never assume warm pool state after a traffic lull.

2. Never store pointers to pooled objects. If you pass msg.Fields[2] to another goroutine and then release msg back to the pool, that slice header now points to memory that another goroutine might be writing to. This is a data race that won't show up until 3 AM on a Saturday.

3. Always reset before Put, not after Get. Resetting on Put ensures that sensitive data (PANs, CVVs) doesn't linger in pooled memory. In payment systems, this isn't just a correctness issue — it's a PCI DSS requirement. Stale cardholder data in reusable buffers is a finding.

I've seen teams get burned by gotcha #2 specifically. The pattern looks innocent enough:

// DANGEROUS: Don't do this
func extractPAN(msg *PaymentMessage) []byte {
    return msg.Fields[2] // returns slice header pointing into pooled memory
}

// SAFE: Copy the data out
func extractPAN(msg *PaymentMessage) []byte {
    pan := make([]byte, len(msg.Fields[2]))
    copy(pan, msg.Fields[2])
    return pan
}

Production Patterns: Combining Pools

In practice, you'll want multiple pools working together. Here's the pattern we use for JSON payment webhooks where the message size varies significantly:

var bufPool = &sync.Pool{
    New: func() interface{} {
        return bytes.NewBuffer(make([]byte, 0, 4096))
    },
}

var decoderPool = &sync.Pool{
    New: func() interface{} {
        return &PayloadDecoder{
            fieldBuf: make([]byte, 0, 256),
            result:   &WebhookPayload{},
        }
    },
}

func parseWebhook(r io.Reader) (*WebhookPayload, error) {
    buf := bufPool.Get().(*bytes.Buffer)
    dec := decoderPool.Get().(*PayloadDecoder)

    defer func() {
        buf.Reset()
        bufPool.Put(buf)
        dec.Reset()
        decoderPool.Put(dec)
    }()

    if _, err := buf.ReadFrom(r); err != nil {
        return nil, err
    }

    // Decode into pooled decoder's result struct
    payload, err := dec.Decode(buf.Bytes())
    if err != nil {
        return nil, err
    }

    // Return a copy - the pooled struct will be reused
    result := *payload
    return &result, nil
}

The key insight with layered pools: each pool handles one concern. The buffer pool manages I/O memory, the decoder pool manages parsing state. This separation makes it much easier to reason about lifetimes and prevents the "who owns this memory?" confusion that leads to data races.

Protobuf Parsing with Pools

For protobuf-based internal services, the pattern is even cleaner because proto's Reset() method already does what we need:

var protoPool = &sync.Pool{
    New: func() interface{} {
        return &paymentpb.TransactionEvent{}
    },
}

func decodeEvent(data []byte) (*paymentpb.TransactionEvent, error) {
    evt := protoPool.Get().(*paymentpb.TransactionEvent)
    evt.Reset() // proto's built-in reset

    if err := proto.Unmarshal(data, evt); err != nil {
        protoPool.Put(evt)
        return nil, err
    }
    return evt, nil
    // Caller is responsible for calling ReleaseEvent(evt) when done
}

When Not to Use sync.Pool

sync.Pool isn't a silver bullet. Skip it when:

Profile first. go tool pprof with the -alloc_objects flag will tell you exactly where your allocations are coming from. Only pool the hot allocations.

References