Go / Gin

How to Fix "Gin Error: c.JSON() Not Sending Response Headers"

4 min read by DebuggedIt

Quick answer

You call c.JSON() in a Gin handler expecting custom headers (CORS, cache-control, auth tokens) to reach the client, but they're missing from the actual HTTP...

You call c.JSON() in a Gin handler expecting custom headers (CORS, cache-control, auth tokens) to reach the client, but they're missing from the actual HTTP response. This almost always comes down to header calls happening after the response body has already started writing.

The Problem

Your handler looks correct, but the client never sees the header you set:

func GetUser(c *gin.Context) {
    c.JSON(http.StatusOK, gin.H{"id": 1, "name": "Alice"})
    c.Header("X-Request-Id", "abc123") // never reaches the client
}

Checking the response in the browser network tab or with curl confirms the header simply isn't there:

$ curl -i http://localhost:8080/user
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 32

{"id":1,"name":"Alice"}

Sometimes Gin also logs a warning if you try to write to the response after it's already been written:

[GIN] 2026/08/07 - 14:02:11 | 200 |    1.203ms |             ::1 | GET      "/user"
http: superfluous response.WriteHeader call from github.com/gin-gonic/gin.(*Context).JSON (context.go:...)

Why It Happens

HTTP requires headers to be sent before the body, and once Go's net/http writes the first byte of the response body, the header section is locked β€” anything set afterward is silently ignored (or triggers a "superfluous WriteHeader" warning if you also try to change the status code). c.JSON() writes both the status code and the body immediately, so anything called after it is too late. This typically happens because:

  • Headers are set after c.JSON() instead of before, as in the example above.
  • Middleware that's supposed to set response headers runs after the handler instead of before it, due to incorrect middleware ordering.
  • The handler calls c.JSON() more than once (a common copy-paste mistake with early returns), and only the first call's headers actually take effect.
  • A header is set on a different *gin.Context or goroutine than the one handling the actual request, so it never touches the real response writer.

The Fix

Set every header you need before calling c.JSON(), not after:

func GetUser(c *gin.Context) {
    c.Header("X-Request-Id", "abc123")
    c.JSON(http.StatusOK, gin.H{"id": 1, "name": "Alice"})
}

Verify it now shows up correctly:

$ curl -i http://localhost:8080/user
HTTP/1.1 200 OK
X-Request-Id: abc123
Content-Type: application/json; charset=utf-8

{"id":1,"name":"Alice"}

If the header needs to be set by middleware, make sure that middleware is registered before the route handler and calls c.Next() at the right point β€” headers set after c.Next() in middleware run after the handler already wrote the response:

func RequestIDMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Header("X-Request-Id", uuid.NewString()) // set BEFORE calling Next
        c.Next()
    }
}

router.Use(RequestIDMiddleware())
router.GET("/user", GetUser)

If you're setting a header conditionally based on logic that only resolves after generating the response body, restructure the handler to compute everything first, then write once:

func GetUser(c *gin.Context) {
    user, err := repo.FindByID(c.Param("id"))
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
        return
    }
    c.Header("X-Cache-Status", "MISS")
    c.JSON(http.StatusOK, user)
}

Still Not Working?

If headers are set correctly in your code but still don't reach the client, check whether a reverse proxy (nginx, Cloudflare, an API gateway) sits in front of your Gin service and is stripping or overwriting the header before it reaches the browser. Test directly against the Go service, bypassing the proxy, to isolate where the header is actually being dropped:

curl -i http://127.0.0.1:8080/user   # direct to Go service
curl -i https://yourdomain.com/user  # through the proxy

If the header appears in the first test but not the second, the proxy configuration β€” not your Gin code β€” is where you need to look next.

It's also worth understanding why Gin doesn't warn you more loudly about this by default. Under the hood, c.JSON() calls c.Writer.WriteHeaderNow() implicitly if it hasn't already been called, which locks in the status line and headers before the body bytes go out. Any subsequent call to c.Header(), c.SetCookie(), or direct manipulation of c.Writer.Header() is silently writing into a map that's no longer read by anything β€” Go doesn't panic on this, it just does nothing, which is exactly what makes the bug so easy to miss during local testing with a single quick response.

A useful debugging habit is checking whether the response has already been written before attempting to set anything further, using Gin's own Written() helper:

if !c.Writer.Written() {
    c.Header("X-Cache-Status", "MISS")
}
c.JSON(http.StatusOK, gin.H{"id": 1})

This won't fix a structural ordering problem in your handler, but it's a fast way to confirm during debugging whether a specific header call is happening before or after the point of no return, without needing to trace the entire request lifecycle manually.