Summary
internal/apijson.Marshal allocates O(n²) memory and runs in O(n²) time when given a large slice. For a 30,000-element []string, a single Marshal call allocates ~6.2 GB and takes ~4 seconds.
Root cause
newArrayTypeEncoder builds the array by repeatedly calling sjson.SetRawBytes(json, "-1", item) inside a loop:
https://github.com/cloudflare/cloudflare-go/blob/main/internal/apijson/encoder.go#L182-L202
sjson is designed for sparse field updates on existing JSON; appending element-by-element forces it to re-parse and reallocate the entire buffer per call.
Reproduction
Benchmark on Marshal([]string{...}) with varying N (Apple M4 Pro, go1.26):
| N |
ns/op |
B/op |
allocs |
| 100 |
137,896 |
92 KB |
436 |
| 1,000 |
7,061,292 |
6.9 MB |
4,071 |
| 5,000 |
118,919,562 |
173 MB |
20,315 |
| 10,000 |
437,616,980 |
692 MB |
40,996 |
| 30,000 |
4,090,896,583 |
6,178 MB |
128,698 |
1k → 30k (30x): ns/op grows 580x, B/op grows 893x — clear O(n²).
Real-world impact
We hit this calling Rules.Lists.Items.Update with ~30,000 IPs to sync an internal allow-list to a Cloudflare account-level IP list. A single Update call allocated ~6.2 GB and OOM-killed our controller (1 GiB limit), forcing us to bump the limit to 2 GiB.
Fix
Replace the sjson loop with a single-pass bytes.Buffer concatenation, restoring O(n). After the fix, 30,000 elements goes from 4,091 ms / 6.2 GB to 3.1 ms / 2.0 MB (1320x faster, 3,070x less memory). Output is byte-identical to the current implementation.
PR: #4316
Notes
The same sjson.SetRawBytes pattern is used by encodeMapEntries and newStructTypeEncoder — also technically O(n²), but typically don't hit the pathological case because struct field counts and map sizes stay small. The PR scopes the fix to the array encoder.
Summary
internal/apijson.Marshalallocates O(n²) memory and runs in O(n²) time when given a large slice. For a 30,000-element[]string, a singleMarshalcall allocates ~6.2 GB and takes ~4 seconds.Root cause
newArrayTypeEncoderbuilds the array by repeatedly callingsjson.SetRawBytes(json, "-1", item)inside a loop:https://github.com/cloudflare/cloudflare-go/blob/main/internal/apijson/encoder.go#L182-L202
sjsonis designed for sparse field updates on existing JSON; appending element-by-element forces it to re-parse and reallocate the entire buffer per call.Reproduction
Benchmark on
Marshal([]string{...})with varying N (Apple M4 Pro, go1.26):1k → 30k (30x): ns/op grows 580x, B/op grows 893x — clear O(n²).
Real-world impact
We hit this calling
Rules.Lists.Items.Updatewith ~30,000 IPs to sync an internal allow-list to a Cloudflare account-level IP list. A single Update call allocated ~6.2 GB and OOM-killed our controller (1 GiB limit), forcing us to bump the limit to 2 GiB.Fix
Replace the
sjsonloop with a single-passbytes.Bufferconcatenation, restoring O(n). After the fix, 30,000 elements goes from 4,091 ms / 6.2 GB to 3.1 ms / 2.0 MB (1320x faster, 3,070x less memory). Output is byte-identical to the current implementation.PR: #4316
Notes
The same
sjson.SetRawBytespattern is used byencodeMapEntriesandnewStructTypeEncoder— also technically O(n²), but typically don't hit the pathological case because struct field counts and map sizes stay small. The PR scopes the fix to the array encoder.