← Back to DevBytes

Go Frontend Bottleneck Detection and Resolution

Go Frontend Bottleneck Detection and Resolution

When you build a Go backend that serves a web frontend—whether it's a React SPA, a server-rendered HTML application, or a mobile app's API—the performance your users experience depends heavily on how quickly and reliably your Go service delivers data. A bottleneck anywhere in the request-response pipeline can degrade the frontend experience, causing slow page loads, dropped connections, or stuttering UI updates. This tutorial walks you through systematically detecting and resolving these bottlenecks, from the Go application layer outward.

What Is a Go Frontend Bottleneck?

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

A Go frontend bottleneck is any point in the backend stack where the flow of data to the frontend client is slowed down or blocked. The term "frontend bottleneck" refers to the fact that the bottleneck's impact is felt on the frontend—the user sees a spinner, a blank screen, or a timeout error. The root cause, however, lives in the Go backend: an under-optimized database query, an unbuffered channel that blocks a handler goroutine, a missing response cache, or a serialization step that allocates too much memory under load.

Common Categories of Bottlenecks

Why It Matters

Frontend performance is not just a backend concern—it directly affects user retention, conversion rates, and SEO rankings. Studies consistently show that a delay of even 100 milliseconds in page load time can reduce conversion by 7% or more. For mobile apps, slow API responses lead to users abandoning the app. Since Go backends often sit directly in front of users (via REST, GraphQL, or gRPC-gateway), every millisecond counts. Identifying and fixing bottlenecks in your Go service ensures that the frontend team can build snappy, responsive interfaces without fighting a slow data layer.

How to Detect Bottlenecks

1. Middleware-Based Request Timing

The simplest and most immediate way to spot slow endpoints is to wrap your HTTP handlers with timing middleware. This gives you a per-endpoint latency histogram that you can log or expose as metrics.

package main

import (
    "log"
    "net/http"
    "time"
)

// TimingMiddleware logs the duration of each request along with its route pattern.
func TimingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        // Wrap the response writer to capture the status code.
        wrapped := &responseWriterWrapper{ResponseWriter: w, statusCode: http.StatusOK}
        next.ServeHTTP(wrapped, r)

        elapsed := time.Since(start)
        log.Printf("[TIMING] %s %s — %d — %v", r.Method, r.URL.Path, wrapped.statusCode, elapsed)
    })
}

type responseWriterWrapper struct {
    http.ResponseWriter
    statusCode int
}

func (w *responseWriterWrapper) WriteHeader(statusCode int) {
    w.statusCode = statusCode
    w.ResponseWriter.WriteHeader(statusCode)
}

// Sample handler to demonstrate the middleware.
func userHandler(w http.ResponseWriter, r *http.Request) {
    // Simulate some processing delay.
    time.Sleep(120 * time.Millisecond)
    w.Write([]byte(`{"user":"alice"}`))
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/user", userHandler)

    wrappedMux := TimingMiddleware(mux)

    server := &http.Server{
        Addr:         ":8080",
        Handler:      wrappedMux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }
    log.Fatal(server.ListenAndServe())
}

With this middleware in place, you can quickly identify endpoints whose p95 or p99 latency exceeds your frontend SLA. For production, consider exporting these timings to Prometheus via a histogram rather than just logging.

2. Go's Built-in pprof Profiling

For deeper investigation, Go's net/http/pprof package exposes CPU, heap, goroutine, and mutex profiles over HTTP. This is invaluable for finding hot functions, excessive allocations, and goroutine leaks.

package main

import (
    "log"
    "net/http"
    _ "net/http/pprof" // Exposes /debug/pprof/ endpoints
    "time"
)

func main() {
    // Register pprof handlers on a separate port for security.
    go func() {
        pprofMux := http.NewServeMux()
        pprofMux.Handle("/debug/pprof/", http.DefaultServeMux) // redirect handler
        // You can also explicitly register each endpoint if you prefer.
        log.Println("pprof server listening on :6060")
        log.Fatal(http.ListenAndServe(":6060", nil))
    }()

    // Main application server.
    appMux := http.NewServeMux()
    appMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        // Simulate allocation-heavy work.
        data := make([]byte, 1024)
        for i := range data {
            data[i] = byte(i % 256)
        }
        w.Write(data)
    })

    server := &http.Server{
        Addr:         ":8080",
        Handler:      appMux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
    }
    log.Fatal(server.ListenAndServe())
}

Once running, you can interact with the profiler using go tool pprof:

# Capture a 30-second CPU profile
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30

# Capture a heap profile
go tool pprof http://localhost:6060/debug/pprof/heap

# Capture a goroutine profile
go tool pprof http://localhost:6060/debug/pprof/goroutine

Inside the pprof interactive shell, use top, list, and web commands to pinpoint functions consuming excessive CPU or memory. If you see a large number of goroutines blocked on a mutex or channel, investigate the mutex profile with /debug/pprof/mutex.

3. Distributed Tracing with OpenTelemetry

When your Go backend calls other services (databases, external APIs, other microservices), distributed tracing helps you visualize the entire request timeline and pinpoint exactly where time is spent. OpenTelemetry is the industry standard for this.

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "time"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
    "go.opentelemetry.io/otel/sdk/trace"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

func initTracer() (*trace.TracerProvider, error) {
    exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
    if err != nil {
        return nil, fmt.Errorf("creating stdout exporter: %w", err)
    }

    provider := trace.NewTracerProvider(
        trace.WithBatcher(exporter),
        trace.WithSampler(trace.AlwaysSample()),
    )
    otel.SetTracerProvider(provider)
    return provider, nil
}

func slowDatabaseCall(ctx context.Context) error {
    _, span := otel.Tracer("database").Start(ctx, "query_users")
    defer span.End()

    span.SetAttributes(attribute.String("db.system", "postgresql"))
    span.SetAttributes(attribute.String("db.operation", "SELECT"))

    // Simulate a slow query.
    select {
    case <-time.After(350 * time.Millisecond):
    case <-ctx.Done():
        return ctx.Err()
    }
    return nil
}

func userHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    // Create a child span for the handler logic.
    _, handlerSpan := otel.Tracer("http").Start(ctx, "handle_get_user")
    defer handlerSpan.End()

    if err := slowDatabaseCall(ctx); err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    w.Write([]byte(`{"user":"bob","status":"active"}`))
}

func main() {
    provider, err := initTracer()
    if err != nil {
        log.Fatal(err)
    }
    defer func() {
        if err := provider.Shutdown(context.Background()); err != nil {
            log.Printf("shutting down tracer provider: %v", err)
        }
    }()

    mux := http.NewServeMux()
    mux.HandleFunc("/api/user", userHandler)

    // Wrap the entire mux with OpenTelemetry HTTP instrumentation.
    wrappedHandler := otelhttp.NewHandler(mux, "frontend-api",
        otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string {
            return r.Method + " " + r.URL.Path
        }),
    )

    server := &http.Server{
        Addr:         ":8080",
        Handler:      wrappedHandler,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
    }
    log.Fatal(server.ListenAndServe())
}

Run this and hit /api/user. The stdout exporter prints nested spans showing that the handler took ~350ms, with a child "query_users" span consuming nearly all of it. In production, you'd export to Jaeger, Zipkin, or an OTLP-compatible backend for a visual waterfall view.

4. Database Query Profiling

If tracing reveals that database calls dominate response time, you need to profile individual queries. For PostgreSQL, use EXPLAIN ANALYZE. For application-level visibility, log slow queries with the database/sql driver or an ORM hook.

package main

import (
    "context"
    "database/sql"
    "log"
    "time"

    _ "github.com/lib/pq"
)

// SlowQueryLogger wraps a database connection to log queries exceeding a threshold.
type SlowQueryLogger struct {
    db      *sql.DB
    threshold time.Duration
}

func (s *SlowQueryLogger) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
    start := time.Now()
    rows, err := s.db.QueryContext(ctx, query, args...)
    elapsed := time.Since(start)
    if elapsed > s.threshold {
        log.Printf("[SLOW QUERY] %s — %v — args: %v", query, elapsed, args)
    }
    return rows, err
}

func main() {
    db, err := sql.Open("postgres", "postgres://user:pass@localhost/mydb?sslmode=disable")
    if err != nil {
        log.Fatal(err)
    }
    db.SetMaxOpenConns(25)
    db.SetMaxIdleConns(5)
    db.SetConnMaxLifetime(5 * time.Minute)

    logger := &SlowQueryLogger{db: db, threshold: 100 * time.Millisecond}

    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    rows, err := logger.QueryContext(ctx, "SELECT id, name FROM users WHERE active = $1", true)
    if err != nil {
        log.Printf("query failed: %v", err)
        return
    }
    defer rows.Close()

    for rows.Next() {
        var id int
        var name string
        if err := rows.Scan(&id, &name); err != nil {
            log.Printf("scan error: %v", err)
            return
        }
        log.Printf("user: %d %s", id, name)
    }
}

Combine this with database-level tools like pg_stat_statements (PostgreSQL) or the slow query log in MySQL to find queries that need index optimization or rewriting.

How to Resolve Bottlenecks

1. Optimize JSON Serialization

JSON encoding/decoding is one of the most common bottlenecks in Go web services. The standard encoding/json package uses reflection heavily and can allocate a lot of memory under load. Several strategies reduce this overhead.

Use a faster JSON library. Libraries like github.com/bytedance/sonic or github.com/mailru/easyjson dramatically outperform the standard library by using code generation or SIMD-accelerated parsing.

package main

import (
    "net/http"
    "github.com/bytedance/sonic"
)

type User struct {
    ID       int    `json:"id"`
    Name     string `json:"name"`
    Email    string `json:"email"`
    Role     string `json:"role"`
    AvatarURL string `json:"avatar_url"`
}

func userHandler(w http.ResponseWriter, r *http.Request) {
    user := User{
        ID:       42,
        Name:     "Alice Johnson",
        Email:    "alice@example.com",
        Role:     "admin",
        AvatarURL: "https://cdn.example.com/avatars/alice.jpg",
    }

    // sonic encodes JSON roughly 2-4x faster than encoding/json
    // and with significantly fewer allocations.
    data, err := sonic.Marshal(user)
    if err != nil {
        http.Error(w, "marshal error", http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.Write(data)
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/user", userHandler)
    http.ListenAndServe(":8080", mux)
}

Pre-allocate buffers and reuse encoders. If you must use the standard library, avoid creating a new encoder for every request. Pool bytes.Buffer instances or use json.Encoder with a pooled writer.

package main

import (
    "encoding/json"
    "net/http"
    "sync"
)

var bufferPool = sync.Pool{
    New: func() interface{} {
        return new(json.Encoder)
    },
}

func writeJSON(w http.ResponseWriter, v interface{}) error {
    encoder := bufferPool.Get().(*json.Encoder)
    defer bufferPool.Put(encoder)

    // Reset the encoder to use the current response writer.
    // We can't truly reset an encoder, so this is a simplification.
    // In practice, use a pooled bytes.Buffer and then write to w.
    encoder = json.NewEncoder(w)
    return encoder.Encode(v)
}

// For a more robust pooling approach:
type pooledWriter struct {
    buf []byte
}

func (p *pooledWriter) Write(data []byte) (int, error) {
    p.buf = append(p.buf, data...)
    return len(data), nil
}

func writeJSONOptimized(w http.ResponseWriter, v interface{}) error {
    // Use a buffer pool for the marshalled bytes.
    buf := bufferPool.Get().(*bytes.Buffer)
    defer func() {
        buf.Reset()
        bufferPool.Put(buf)
    }()

    encoder := json.NewEncoder(buf)
    if err := encoder.Encode(v); err != nil {
        return err
    }
    w.Header().Set("Content-Type", "application/json")
    _, err := w.Write(buf.Bytes())
    return err
}

2. Implement Response Caching

If a response is expensive to compute but changes infrequently, cache it. In-memory caches (like a sync.Map or a library like github.com/patrickmn/go-cache) work for single-instance deployments. For multi-instance deployments, use Redis or another external cache.

package main

import (
    "encoding/json"
    "net/http"
    "sync"
    "time"
)

type CacheEntry struct {
    Data       []byte
    ExpiresAt  time.Time
}

type InMemoryCache struct {
    mu    sync.RWMutex
    store map[string]CacheEntry
}

func NewInMemoryCache() *InMemoryCache {
    c := &InMemoryCache{store: make(map[string]CacheEntry)}
    // Background goroutine to purge expired entries.
    go func() {
        ticker := time.NewTicker(30 * time.Second)
        for range ticker.C {
            c.mu.Lock()
            now := time.Now()
            for key, entry := range c.store {
                if now.After(entry.ExpiresAt) {
                    delete(c.store, key)
                }
            }
            c.mu.Unlock()
        }
    }()
    return c
}

func (c *InMemoryCache) Get(key string) ([]byte, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    entry, ok := c.store[key]
    if !ok || time.Now().After(entry.ExpiresAt) {
        return nil, false
    }
    return entry.Data, true
}

func (c *InMemoryCache) Set(key string, data []byte, ttl time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.store[key] = CacheEntry{
        Data:      data,
        ExpiresAt: time.Now().Add(ttl),
    }
}

var productCache = NewInMemoryCache()

func expensiveProductQuery() ([]byte, error) {
    // Simulate a heavy database query or computation.
    time.Sleep(200 * time.Millisecond)
    products := []map[string]interface{}{
        {"id": 1, "name": "Widget", "price": 9.99},
        {"id": 2, "name": "Gadget", "price": 24.99},
    }
    return json.Marshal(products)
}

func productsHandler(w http.ResponseWriter, r *http.Request) {
    cacheKey := "products:all"

    if cached, ok := productCache.Get(cacheKey); ok {
        w.Header().Set("Content-Type", "application/json")
        w.Header().Set("X-Cache", "HIT")
        w.Write(cached)
        return
    }

    data, err := expensiveProductQuery()
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    // Cache for 30 seconds.
    productCache.Set(cacheKey, data, 30*time.Second)

    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("X-Cache", "MISS")
    w.Write(data)
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/products", productsHandler)
    http.ListenAndServe(":8080", mux)
}

3. Eliminate N+1 Query Patterns

The N+1 problem occurs when you fetch a list of entities and then loop through them, making a separate database call for each one. This multiplies latency linearly with the number of items. The fix is to batch-load related data in a single query using IN clauses or joins.

// BEFORE: N+1 pattern — one query for users, then one query per user for their posts.
func getUsersWithPostsNaive(db *sql.DB) ([]User, error) {
    rows, err := db.Query("SELECT id, name FROM users")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var users []User
    for rows.Next() {
        var u User
        rows.Scan(&u.ID, &u.Name)

        // This inner query runs for EVERY user — that's the N+1 problem.
        postRows, err := db.Query("SELECT id, title FROM posts WHERE user_id = $1", u.ID)
        if err != nil {
            return nil, err
        }
        for postRows.Next() {
            var p Post
            postRows.Scan(&p.ID, &p.Title)
            u.Posts = append(u.Posts, p)
        }
        postRows.Close()
        users = append(users, u)
    }
    return users, nil
}

// AFTER: Single batched query with an IN clause, then assemble in Go.
func getUsersWithPostsOptimized(db *sql.DB) ([]User, error) {
    // Step 1: Fetch all users.
    userRows, err := db.Query("SELECT id, name FROM users")
    if err != nil {
        return nil, err
    }
    defer userRows.Close()

    var users []User
    userIDs := make([]int, 0)
    userMap := make(map[int]*User)

    for userRows.Next() {
        var u User
        userRows.Scan(&u.ID, &u.Name)
        users = append(users, u)
        userIDs = append(userIDs, u.ID)
        userMap[u.ID] = &users[len(users)-1]
    }

    if len(userIDs) == 0 {
        return users, nil
    }

    // Step 2: Fetch all posts for all users in ONE query.
    // Convert []int to []interface{} for the IN clause.
    args := make([]interface{}, len(userIDs))
    for i, id := range userIDs {
        args[i] = id
    }

    query := "SELECT id, title, user_id FROM posts WHERE user_id IN ("
    placeholders := make([]string, len(userIDs))
    for i := range placeholders {
        placeholders[i] = fmt.Sprintf("$%d", i+1)
    }
    query += strings.Join(placeholders, ", ") + ")"

    postRows, err := db.Query(query, args...)
    if err != nil {
        return nil, err
    }
    defer postRows.Close()

    for postRows.Next() {
        var p Post
        var userID int
        postRows.Scan(&p.ID, &p.Title, &userID)
        if u, ok := userMap[userID]; ok {
            u.Posts = append(u.Posts, p)
        }
    }

    return users, nil
}

4. Tune the HTTP Server

The default http.Server settings in Go are not optimized for high-throughput production traffic. You need to explicitly set timeouts, connection limits, and consider using a reverse proxy or a faster multiplexer.

package main

import (
    "net/http"
    "time"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte(`{"status":"ok"}`))
    })

    server := &http.Server{
        Addr:         ":8080",
        Handler:      mux,

        // Timeouts prevent slow clients from hogging connections.
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,

        // MaxHeaderBytes limits the size of request headers (security + performance).
        MaxHeaderBytes: 1 << 20, // 1 MB

        // ConnContext can be used to set connection-level deadlines.
        // ConnState hook can track connection lifecycle.
    }

    // For TLS, configure modern, fast cipher suites.
    // Consider using certmagic for auto-renewing Let's Encrypt certificates.
    // server.TLSConfig = &tls.Config{
    //     MinVersion: tls.VersionTLS13,
    //     CurvePreferences: []tls.CurveID{tls.X25519},
    // }

    // Use SO_REUSEPORT on Linux by starting multiple listeners.
    // Or place the Go server behind nginx/caddy for connection pooling,
    // compression, and static file serving.
    http.ListenAndServe(":8080", mux)
}

If your frontend serves many small API requests, consider using fasthttp (though it has caveats around compatibility) or placing the Go server behind a reverse proxy like Caddy or Envoy that handles connection pooling, gzip compression, and TLS termination more efficiently.

5. Add Response Compression

Large JSON responses benefit enormously from gzip compression. You can add compression middleware that wraps the response writer, reducing bandwidth and transfer time for the frontend.

package main

import (
    "compress/gzip"
    "net/http"
    "strings"
)

// GzipMiddleware compresses responses for clients that support it.
func GzipMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Skip compression for WebSocket upgrades and certain content types.
        if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
            next.ServeHTTP(w, r)
            return
        }

        // Wrap the response writer.
        gw := gzip.NewWriter(w)
        defer gw.Close()

        gzipWriter := &gzipResponseWriter{ResponseWriter: w, Writer: gw}
        gzipWriter.Header().Set("Content-Encoding", "gzip")
        gzipWriter.Header().Set("Vary", "Accept-Encoding")

        next.ServeHTTP(gzipWriter, r)
    })
}

type gzipResponseWriter struct {
    http.ResponseWriter
    Writer *gzip.Writer
}

func (w *gzipResponseWriter) Write(data []byte) (int, error) {
    return w.Writer.Write(data)
}

// If your handler also uses WriteHeader, ensure compression works correctly.
func (w *gzipResponseWriter) WriteHeader(statusCode int) {
    w.ResponseWriter.WriteHeader(statusCode)
}

func largeDataHandler(w http.ResponseWriter, r *http.Request) {
    // Generate a large JSON payload.
    items := make([]map[string]interface{}, 1000)
    for i := 0; i < 1000; i++ {
        items[i] = map[string]interface{}{
            "id":       i,
            "name":     "Product Name Here",
            "category": "electronics",
            "price":    99.99,
            "in_stock": true,
        }
    }
    // In a real app, use sonic.Marshal(items)
    w.Header().Set("Content-Type", "application/json")
    // The gzip wrapper compresses the output transparently.
    json.NewEncoder(w).Encode(items)
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/large-data", largeDataHandler)

    wrapped := GzipMiddleware(mux)
    http.ListenAndServe(":8080", wrapped)
}

6. Use Pagination and Cursors for Large Collections

Returning thousands of records in a single API response is a guaranteed bottleneck. Implement cursor-based pagination to serve data in manageable chunks.

package main

import (
    "database/sql"
    "encoding/json"
    "net/http"
    "strconv"
)

type PaginatedResponse struct {
    Items      []map[string]interface{} `json:"items"`
    NextCursor string                   `json:"next_cursor,omitempty"`
    HasMore    bool                     `json:"has_more"`
}

func paginatedUsersHandler(db *sql.DB) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        cursor := r.URL.Query().Get("cursor")
        limitStr := r.URL.Query().Get("limit")
        limit := 50
        if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
            limit = l
        }

        // Fetch one extra row to determine if there are more.
        query := "SELECT id, name FROM users WHERE id > $1 ORDER BY id ASC LIMIT $2"
        rows, err := db.Query(query, cursor, limit+1)
        if err != nil {
            http.Error(w, "query error", http.StatusInternalServerError)
            return
        }
        defer rows.Close()

        items := make([]map[string]interface{}, 0, limit)
        var lastID int
        for rows.Next() {
            var id int
            var name string
            if err := rows.Scan(&id, &name); err != nil {
                continue
            }
            lastID = id
            items = append(items, map[string]interface{}{
                "id":   id,
                "name": name,
            })
        }

        resp := PaginatedResponse{Items: items}
        if len(items) > limit {
            // We fetched limit+1, so truncate and set the next cursor.
            resp.Items = items[:limit]
            resp.NextCursor = strconv.Itoa(lastID)
            resp.HasMore = true
        }

        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(resp)
    }
}

7. Fix Goroutine Leaks and Mutex Contention

A goroutine leak gradually consumes memory and eventually degrades the entire server. Contended mutexes cause latency spikes under load. Use the pprof goroutine and mutex profiles to find these issues.

// BAD: Goroutine leak — the goroutine blocks forever because
// no one ever sends on the channel, so it's never collected.
func leakyHandler(w http.ResponseWriter, r *http.Request) {
    ch := make(chan string)
    go func() {
        // This goroutine waits forever; it's a leak.
        result := <-ch
        fmt.Println(result)
    }()
    // We forget to send on ch, and we don't cancel the goroutine.
    w.Write([]byte("ok"))
}

// GOOD: Use context cancellation to clean up goroutines.
func safeHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    ch := make(chan string, 1)

    go func() {
        select {
        case result := <-ch:
            fmt.Println(result)
        case <-ctx.Done():
            // Context cancelled (client disconnected or timeout).
            return
        }
    }()

    // Send with a select that respects cancellation.
    select {
    case ch <- "result":
    case <-ctx.Done():
        return
    }
    w.Write([]byte("ok"))
}

// BAD: Mutex contention — holding a lock during I/O.
var mu sync.Mutex

func contendedHandler(w http.ResponseWriter, r *http.Request) {
    mu.Lock()
    // Doing I/O while holding the mutex blocks all other requests.
    time.Sleep(100 * time.Millisecond)
    mu.Unlock()
    w.Write([]byte("done"))
}

// GOOD: Lock only for the critical section.
func optimizedHandler(w http.ResponseWriter, r *http.Request) {
    // Fetch data or compute without the lock.
    data := prepareData()

    mu.Lock()
    // Only protect the minimal shared state update.
    sharedState = data
    mu.Unlock()

    // Do I/O after releasing the lock.
    w.Write([]byte("done"))
}

8. Connection Pool and Database Optimization

A bottleneck often hides in the database connection pool configuration. Too few connections starve concurrent requests; too many overwhelm the database.

package main

import (
    "database/sql"
    "log"
    "time"
)

func main() {
    db, err := sql.Open("postgres", "postgres://user:pass@localhost/mydb?sslmode=disable")
    if err != nil {
        log.Fatal(err)
    }

    // Set connection pool parameters based on your workload.
    // For a typical frontend API with 100-500 concurrent requests:
    db.SetMaxOpenConns(25)       // Maximum concurrent database connections.
    db.SetMaxIdleConns(10)       // Keep connections warm for reuse.
    db.SetConnMaxLifetime(5 * time.Minute)  // Recycle connections periodically.
    db.SetConnMaxIdleTime(1 * time.Minute)  // Close idle connections after 1 minute.

    // Verify pool health periodically.
    go func() {
        ticker := time.NewTicker(30 * time.Second)
        for range ticker.C {
            stats := db.Stats()
            log.Printf("DB Pool — Open: %d, Idle: %d, InUse: %d, WaitCount: %d, WaitDuration: %v",
                stats.OpenConnections,
                stats.Idle,
                stats.InUse,
                stats.WaitCount,
                stats.WaitDuration,
            )
            if stats.WaitDuration > 100*time.Millisecond {
                log.Println("WARNING: Database connection pool contention detected")
            }
        }
    }()

    // Use the database normally.
    // ...
}

9. Stream Large Responses

For endpoints that return large datasets (reports, exports, log streams), avoid buffering the entire response in memory. Use streaming to send data incrementally.

package main

import (
    "encoding/json"
    "net/http"
    "time"
)

func streamHandler(w http.ResponseWriter, r *http.Request) {
    // Tell the client we're sending a stream of JSON objects.
    w.Header().Set("Content-Type", "application/x-ndjson")
    w.Header().Set("Transfer-Encoding", "chunked")

    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "streaming not supported", http.StatusInternalServerError)

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles