Skip to content

Performance: Inefficient memory allocations in response generation #38

@sgaunet

Description

@sgaunet

Description

The response generation uses many small fmt.Fprintf calls which cause repeated allocations and higher GC pressure. Using strings.Builder would be more efficient.

Location

http-echo.go:106-248 (all print methods)

func (h helloWorldhandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // ... 
    // Multiple fmt.Fprintf calls cause many allocations
    h.printRequestSummary(w, info)
    h.printURLInfo(w, info)
    h.printHeaders(w, info)
    h.printRequestBody(w, info)
    h.printFormData(w, info)
    h.printServerInfo(w, info, startTime)
    // ...
}

Impact

  • Severity: LOW-MEDIUM
  • Higher GC pressure under concurrent load
  • Slower response times with many simultaneous requests
  • Increased memory usage
  • Not critical but noticeable at scale

Performance Analysis

Each fmt.Fprintf(w, ...) call:

  1. Formats the string (allocation)
  2. Writes to http.ResponseWriter (buffer allocation)
  3. Repeated many times per request (50+ calls)

Under load (1000 req/s), this creates significant allocation churn.

Recommended Optimization

Use strings.Builder to assemble the entire response, then write once:

func (h helloWorldhandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    startTime := time.Now()
    info := h.collectRequestInfo(r, startTime)
    
    // Pre-allocate builder with estimated response size
    var sb strings.Builder
    sb.Grow(2048) // Adjust based on profiling
    
    h.printRequestSummary(&sb, info)
    h.printURLInfo(&sb, info)
    h.printHeaders(&sb, info)
    h.printRequestBody(&sb, info)
    h.printFormData(&sb, info)
    h.printServerInfo(&sb, info, startTime)
    
    fmt.Fprintf(&sb, "\n=== REQUEST COMPLETED ===\n")
    fmt.Fprintf(&sb, "Processing Time: %v\n", time.Since(startTime))
    
    // Single write to response
    w.Header().Set("Content-Type", "text/plain; charset=utf-8")
    if _, err := w.Write([]byte(sb.String())); err != nil {
        log.Printf("Write error: %v", err)
    }
}

Update all print methods to accept io.Writer instead of http.ResponseWriter:

func (h helloWorldhandler) printRequestSummary(w io.Writer, info requestInfo) {
    fmt.Fprintf(w, "=== REQUEST SUMMARY ===\n")
    // ... rest of method unchanged
}

Benefits

  • Fewer allocations (1 large allocation vs many small ones)
  • Reduced GC pressure
  • Better performance under load
  • Lower memory usage
  • Easier to add Content-Length header (response size known upfront)

Benchmarking

Before optimization:

go test -bench=. -benchmem
# Expected: ~50 allocs/op, ~8KB allocated

After optimization:

go test -bench=. -benchmem
# Expected: ~5-10 allocs/op, ~2-4KB allocated

Optional: Add Content-Length

With the full response in memory, we can set Content-Length:

response := sb.String()
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(len(response)))
w.Write([]byte(response))

This helps HTTP clients know the response size upfront.

Testing

  • Load test before/after with hey or wrk
  • Profile with pprof to measure allocation reduction
  • Verify response content is identical
# Load test
hey -n 10000 -c 100 http://localhost:8080/

# Memory profiling
go tool pprof -alloc_space http://localhost:8080/debug/pprof/heap

Priority

Medium - Performance optimization worth doing if touching the code anyway, especially for high-traffic scenarios.

Trade-offs

  • Pro: Better performance, less GC pressure
  • Con: Slightly more complex (needs io.Writer abstraction)
  • Con: Response fully buffered in memory (negligible for this use case)

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions