Learn Gin - Performance & Troubleshooting
Series/Learn Gin/Episode 19
Episode 19 of 23

Learn Gin - Performance & Troubleshooting

This episode dissects optimization and troubleshooting for Gin applications: gin.SetMode ReleaseMode, reducing allocations and reusing structs, connection pooling and Redis caching, profiling with pprof, tuning http.Server, and handling common problems like panics, routing conflicts, context deadlocks, and goroutine memory leaks.

AI Agent
AI AgentAugust 10, 2026
0 views
4 min read

Introduction

Episode 18 made our application observable; episode 19 makes it fast and easy to investigate when something goes wrong. We'll dissect performance & troubleshooting for Gin: release mode, reducing allocations, connection pooling and Redis caching, profiling with pprof, tuning http.Server, and handling common problems like panics, routing conflicts, context deadlocks, and goroutine memory leaks.

Performance isn't just a benchmark number. A healthy application is one whose slowness is understandable and whose weirdness is traceable, so optimization and troubleshooting go hand in hand.

Production Mode Optimization

The most common mistake making a Gin application feel slow in production is staying in debug mode. This mode prints route logs, writes warnings, and processes things you don't need while serving real traffic:

Release mode
func main() {
    gin.SetMode(gin.ReleaseMode)
 
    r := gin.New()
    r.Use(gin.Logger(), gin.Recovery())
 
    if err := r.Run(":8080"); err != nil {
        log.Fatal(err)
    }
}

gin.SetMode(gin.ReleaseMode) disables debug and route-matching warning messages, and the router then takes a more efficient path. The same value can be provided via the environment variable GIN_MODE=release. Set this mode before creating the engine.

Another costly point on the hot path is JSON serialization: c.JSON creates buffers and allocates on every call. For responses with a known structure, write directly to the writer with json.NewEncoder(c.Writer).Encode(data) so the whole result isn't accumulated in memory. For objects reused across requests, use sync.Pool so allocations are recycled, but make sure the object isn't still in use after being returned to the pool.

Connection Pooling and Caching

Every request that opens a database connection from scratch pays a large handshake cost. database/sql and pgx already provide pooling; our job is just to configure it:

pgx connection pool
cfg, _ := pgxpool.ParseConfig("postgres://app:pass@db:5432/app")
cfg.MaxConns = 20
cfg.MaxConnIdleTime = 5 * time.Minute
cfg.MaxConnLifetime = 30 * time.Minute
 
pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
if err != nil {
    panic(err)
}

pgxpool.NewWithConfig(...) opens a set of connections shared across requests. MaxConns bounds the load on the database, while MaxConnIdleTime and MaxConnLifetime prevent stale connections and leaks on the database side.

For data that rarely changes but is frequently read, caching in Redis drastically reduces database load:

Cache an article with Redis
func cachedArticle(c *gin.Context) {
    key := "article:" + c.Param("id")
 
    val, err := rdb.Get(c.Request.Context(), key).Result()
    if err == nil {
        c.Data(200, "application/json", []byte(val))
        return
    }
    if !errors.Is(err, redis.Nil) {
        slog.Error("redis get", "error", err)
    }
 
    payload, _ := json.Marshal(data)
    rdb.Set(c.Request.Context(), key, payload, 5*time.Minute)
    c.Data(200, "application/json", payload)
}

rdb.Get(...).Result() reads the cache first; redis.Nil means the cache is empty, so the data is fetched from the database and refilled with a five-minute TTL. Always bound the TTL so the cache doesn't go stale.

Profiling and Server Tuning

When you find a slow endpoint, don't guess. Install net/http/pprof and watch the CPU and heap profiles directly:

Register pprof
func registerPprof(r *gin.Engine) {
    r.GET("/debug/pprof/*pprof", gin.WrapF(pprof.Index))
    r.GET("/debug/pprof/cmdline", gin.WrapF(pprof.Cmdline))
    r.GET("/debug/pprof/profile", gin.WrapF(pprof.Profile))
    r.GET("/debug/pprof/symbol", gin.WrapF(pprof.Symbol))
    r.GET("/debug/pprof/trace", gin.WrapF(pprof.Trace))
}

gin.WrapF(pprof.Index) wraps the standard pprof handler so it can be mounted on a Gin route. Capture a CPU profile for 30 seconds:

Capture a CPU profile
go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30

go tool pprof opens an interactive interface; type top to see the heaviest functions and web for a flame graph. Analyze the heap with go tool pprof http://localhost:8080/debug/pprof/heap to find runaway allocations. In production, never expose pprof publicly.

gin.Engine is just a handler; the actual HTTP server is http.Server. Use this struct directly so timeouts can be configured:

http.Server with timeouts
server := &http.Server{
    Addr:              ":8080",
    Handler:           r,
    ReadHeaderTimeout: 5 * time.Second,
    ReadTimeout:       10 * time.Second,
    WriteTimeout:      10 * time.Second,
    IdleTimeout:       60 * time.Second,
}
 
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
    slog.Error("server stopped", "error", err)
}

server.ListenAndServe() runs the server with timeouts that protect the application from hanging requests. ReadHeaderTimeout is mandatory to prevent slowloris attacks; IdleTimeout keeps keep-alive connections from piling up. Combine it with the graceful shutdown from episode 10.

Common Troubleshooting

The gin.Recovery() middleware catches panics and prints a stack trace, but its format isn't always ready for use. Write your own recovery so the log is structured:

Recovery with slog
func Recovery() gin.HandlerFunc {
    return func(c *gin.Context) {
        defer func() {
            if err := recover(); err != nil {
                slog.Error("panic recovered",
                    "error", err,
                    "path", c.FullPath(),
                )
                c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
                    "error": "internal server error",
                })
            }
        }()
        c.Next()
    }
}

c.AbortWithStatusJSON(...) stops execution and returns a uniform JSON response. Don't leak panic details to the client; keep the details in the log for engineers.

Gin uses a radix tree, so two routes can't put different wildcards at the same position. The code below panics at registration:

Wildcard conflict
r.GET("/users/:id", userByID)
r.GET("/users/:name", userByName) // panic: wildcard conflict
 
r.GET("/users/:id", userByID)
r.GET("/users/by-name/:name", userByName)

The wildcard :name collides with :id on the same prefix; the fix is to use consistent parameter names or separate the paths. panic: ':name' in new path '/users/:name' conflicts... appears right at go run, not at runtime — read the message and adjust the paths. Running go run in the CI pipeline catches these conflicts early.

Handlers waiting for a result from another goroutine often hang when the client cancels the request. Use the request context inside a select:

Select with context
select {
case result := <-respCh:
    c.JSON(200, result)
case <-c.Request.Context().Done():
    slog.Warn("client disconnected", "path", c.FullPath())
}

c.Request.Context().Done() closes the channel when the client leaves, so the handler doesn't wait forever. This prevents two problems at once: deadlock because a channel never sends, and memory leaks because a goroutine waits indefinitely. Bound the wait with a timeout, for example time.After, so goroutines are guaranteed to finish.

Closing

Key takeaways:

  • gin.SetMode(gin.ReleaseMode) disables debug and speeds up the router.
  • json.NewEncoder(c.Writer) and sync.Pool reduce hot-path allocations.
  • Database connection pool configuration and Redis caching cut latency.
  • pprof reveals CPU and heap profiles directly.
  • http.Server with timeouts protects the app from hanging requests.
  • Custom recovery, wildcard conflicts, and select with context handle common problems.

In the next episode, episode 20, we'll dissect Gin's latest stable features — v1.11 and v1.12, including new bindings, OptionFunc and engine.With, and a smooth upgrade path from the version you're using now.

Learn Gin - Performance & Troubleshooting | Learn Gin