Skip to content

Commit a4cc487

Browse files
vishrclaude
andcommitted
perf(json): reuse pooled buffer for JSON deserialization
DefaultJSONSerializer.Deserialize used json.NewDecoder(body).Decode(), which allocates a decoder and its internal read buffer on every JSON request. Read the body into a capped pooled buffer and decode with json.Unmarshal instead; Unmarshal does not retain the input slice, so the buffer is safe to reuse. The pool drops oversized buffers (>64 KiB) so a large body cannot pin memory. BenchmarkBind_JSON: 1008 B -> 312 B/op (-69%), 9 -> 7 allocs, ~12% faster. Behavioral note: json.Unmarshal is stricter than Decode — it rejects trailing data after the JSON value and reports "unexpected end of JSON input" for truncated bodies (both still 400 Bad Request). Two bind tests asserting the old "unexpected EOF" message are updated to match. Also add a general ServeHTTP/JSON benchmark suite (perf_bench_test.go): BenchmarkBind_JSON now builds the request once and resets a reusable body instead of calling httptest.NewRequest inside the loop (which dominated the old measurement), plus BenchmarkJSONSerialize/Deserialize and the routing benchmarks used to measure both this and the companion router PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 78c3d95 commit a4cc487

3 files changed

Lines changed: 95 additions & 6 deletions

File tree

bind_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -834,7 +834,7 @@ func TestDefaultBinder_BindToStructFromMixedSources(t *testing.T) {
834834
givenURL: "/api/real_node/endpoint?node=xxx",
835835
givenContent: strings.NewReader(`{`),
836836
expect: &Opts{ID: 0, Node: "node_from_path"}, // query binding has already modified bind target
837-
expectError: "code=400, message=Bad Request, err=unexpected EOF",
837+
expectError: "code=400, message=Bad Request, err=unexpected end of JSON input",
838838
},
839839
{
840840
name: "nok, GET with body bind failure when types are not convertible",
@@ -1004,7 +1004,7 @@ func TestDefaultBinder_BindBody(t *testing.T) {
10041004
givenContentType: MIMEApplicationJSON,
10051005
givenContent: strings.NewReader(`{`),
10061006
expect: &Node{ID: 0, Node: ""},
1007-
expectError: "code=400, message=Bad Request, err=unexpected EOF",
1007+
expectError: "code=400, message=Bad Request, err=unexpected end of JSON input",
10081008
},
10091009
{
10101010
name: "ok, XML POST bind to struct with: path + query + empty body",

json.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,25 @@
44
package echo
55

66
import (
7+
"bytes"
78
"encoding/json"
9+
"sync"
810
)
911

1012
// DefaultJSONSerializer implements JSON encoding using encoding/json.
1113
type DefaultJSONSerializer struct{}
1214

15+
// jsonBufPool reuses buffers for reading request bodies during JSON
16+
// deserialization, avoiding the per-request decoder and its internal read
17+
// buffer that json.NewDecoder allocates.
18+
var jsonBufPool = sync.Pool{New: newJSONBuf}
19+
20+
func newJSONBuf() any { return new(bytes.Buffer) }
21+
22+
// maxPooledJSONBuf caps the capacity of buffers returned to jsonBufPool so a
23+
// single large request body cannot pin an oversized buffer in the pool.
24+
const maxPooledJSONBuf = 1 << 16 // 64 KiB
25+
1326
// Serialize converts an interface into a json and writes it to the response.
1427
// You can optionally use the indent parameter to produce pretty JSONs.
1528
func (d DefaultJSONSerializer) Serialize(c *Context, target any, indent string) error {
@@ -21,8 +34,29 @@ func (d DefaultJSONSerializer) Serialize(c *Context, target any, indent string)
2134
}
2235

2336
// Deserialize reads a JSON from a request body and converts it into an interface.
37+
//
38+
// The body is read into a pooled buffer and decoded with json.Unmarshal rather
39+
// than streaming through json.NewDecoder. This avoids allocating a decoder and
40+
// its internal read buffer on every request. json.Unmarshal does not retain a
41+
// reference to the input slice, so the buffer is safe to reuse afterwards.
42+
//
43+
// Note: the full request body is read into memory before decoding. As with any
44+
// JSON parser, decoding untrusted input can allocate large amounts of memory;
45+
// guard such endpoints with middleware.BodyLimit (or http.MaxBytesReader),
46+
// which makes the body read here fail fast once the limit is exceeded.
2447
func (d DefaultJSONSerializer) Deserialize(c *Context, target any) error {
25-
if err := json.NewDecoder(c.Request().Body).Decode(target); err != nil {
48+
buf := jsonBufPool.Get().(*bytes.Buffer)
49+
buf.Reset()
50+
defer func() {
51+
// Do not return oversized buffers to the pool — they would pin memory.
52+
if buf.Cap() <= maxPooledJSONBuf {
53+
jsonBufPool.Put(buf)
54+
}
55+
}()
56+
if _, err := buf.ReadFrom(c.Request().Body); err != nil {
57+
return ErrBadRequest.Wrap(err)
58+
}
59+
if err := json.Unmarshal(buf.Bytes(), target); err != nil {
2660
return ErrBadRequest.Wrap(err)
2761
}
2862
return nil

perf_bench_test.go

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,24 +111,79 @@ type bindTarget struct {
111111
Active bool `json:"active" query:"active"`
112112
}
113113

114+
// nopReadCloser adapts a Reader to a ReadCloser without allocating, so the
115+
// request body can be reset and reused across benchmark iterations instead of
116+
// rebuilding the request (and its httptest machinery) inside the loop.
117+
type nopReadCloser struct{ r *strings.Reader }
118+
119+
func (n nopReadCloser) Read(p []byte) (int, error) { return n.r.Read(p) }
120+
func (nopReadCloser) Close() error { return nil }
121+
114122
func BenchmarkBind_JSON(b *testing.B) {
115123
e := New()
116124
body := `{"id":1,"name":"Jon Snow","email":"jon@winterfell.north","age":24,"active":true}`
117125
e.POST("/", func(c *Context) error {
118126
var t bindTarget
119127
return c.Bind(&t)
120128
})
129+
// Build the request once and reset its body each iteration so the benchmark
130+
// measures Echo's routing+binding cost, not httptest.NewRequest allocations.
131+
r := strings.NewReader(body)
132+
req := httptest.NewRequest(http.MethodPost, "/", r)
133+
req.Header.Set(HeaderContentType, MIMEApplicationJSON)
134+
req.Body = nopReadCloser{r}
135+
w := &nopResponseWriter{}
121136
b.ReportAllocs()
122137
b.ResetTimer()
123-
w := &nopResponseWriter{}
124138
for i := 0; i < b.N; i++ {
125139
w.h = nil
126-
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
127-
req.Header.Set(HeaderContentType, MIMEApplicationJSON)
140+
r.Reset(body)
128141
e.ServeHTTP(w, req)
129142
}
130143
}
131144

145+
// BenchmarkJSONSerialize/Deserialize exercise the DefaultJSONSerializer directly,
146+
// isolating JSON encode/decode cost from routing and request construction.
147+
func BenchmarkJSONDeserialize(b *testing.B) {
148+
e := New()
149+
body := `{"id":1,"name":"Jon Snow","email":"jon@winterfell.north","age":24,"active":true}`
150+
r := strings.NewReader(body)
151+
req := httptest.NewRequest(http.MethodPost, "/", r)
152+
req.Body = nopReadCloser{r}
153+
c := e.NewContext(req, &nopResponseWriter{})
154+
s := DefaultJSONSerializer{}
155+
b.ReportAllocs()
156+
b.ResetTimer()
157+
for i := 0; i < b.N; i++ {
158+
r.Reset(body)
159+
var t bindTarget
160+
if err := s.Deserialize(c, &t); err != nil {
161+
b.Fatal(err)
162+
}
163+
}
164+
}
165+
166+
func BenchmarkJSONSerialize(b *testing.B) {
167+
e := New()
168+
type payload struct {
169+
ID int `json:"id"`
170+
Name string `json:"name"`
171+
Tags []string
172+
}
173+
p := payload{ID: 1, Name: "Jon Snow", Tags: []string{"a", "b", "c"}}
174+
w := &nopResponseWriter{}
175+
c := e.NewContext(httptest.NewRequest(http.MethodGet, "/", nil), w)
176+
s := DefaultJSONSerializer{}
177+
b.ReportAllocs()
178+
b.ResetTimer()
179+
for i := 0; i < b.N; i++ {
180+
w.h = nil
181+
if err := s.Serialize(c, p, ""); err != nil {
182+
b.Fatal(err)
183+
}
184+
}
185+
}
186+
132187
func BenchmarkBind_Query(b *testing.B) {
133188
e := New()
134189
e.GET("/", func(c *Context) error {

0 commit comments

Comments
 (0)