Go / Gin

Go "Fatal Error: Concurrent Map Writes" in HTTP Handler

· by DebuggedIt

Quick answer

"Concurrent map writes" specifically inside an HTTP handler is an especially common version of Go's map-safety crash, because every incoming HTTP request in...

"Concurrent map writes" specifically inside an HTTP handler is an especially common version of Go's map-safety crash, because every incoming HTTP request in Go's standard net/http server runs in its own goroutine automatically — any shared map written to by handler code is inherently being accessed concurrently the moment your server receives more than one simultaneous request.

The Problem

An HTTP server crashes under real traffic (multiple simultaneous requests), though it may never crash during simple, one-request-at-a-time local testing:

fatal error: concurrent map writes

goroutine 87 [running]:
main.trackRequest(...)
    /app/handlers.go:23
main.handleRequest(...)
    /app/handlers.go:45

This is a fatal, unrecoverable crash — it takes down the entire server process, not just the specific request being handled at the time, which makes it especially disruptive in production.

Why It Happens

Go's net/http package launches a new goroutine for every incoming request by default — this is precisely what allows a Go HTTP server to handle many concurrent requests efficiently, but it also means any shared, package-level or struct-level map that handler code writes to is automatically being written to concurrently across simultaneous requests, with zero implicit synchronization. Common patterns leading to this crash:

  • An in-memory cache, rate limiter counter, or session tracking map declared at package level, written to directly by handler functions without any mutex protecting access
  • A metrics or analytics tracking map (counting requests per endpoint, for example) updated on every request without synchronization
  • A map used for simple in-memory state that worked fine during single-threaded local development and testing, where the concurrency issue never actually manifested because requests were never genuinely simultaneous

The Fix

Protect the shared map with a mutex, ensuring every access — from every concurrent request's goroutine — is properly synchronized:

type RequestTracker struct {
    mu     sync.Mutex
    counts map[string]int
}

func (t *RequestTracker) Track(endpoint string) {
    t.mu.Lock()
    defer t.mu.Unlock()
    t.counts[endpoint]++
}

var tracker = &RequestTracker{counts: make(map[string]int)}

func handleRequest(w http.ResponseWriter, r *http.Request) {
    tracker.Track(r.URL.Path)
    // rest of handler
}

Every handler now goes through the same mutex-protected method rather than touching the map directly, guaranteeing safe concurrent access regardless of how many simultaneous requests are being handled.

For simpler cases, or when you specifically want a drop-in concurrent-safe map without manually managing a mutex, use sync.Map:

var requestCounts sync.Map

func handleRequest(w http.ResponseWriter, r *http.Request) {
    val, _ := requestCounts.LoadOrStore(r.URL.Path, 0)
    requestCounts.Store(r.URL.Path, val.(int)+1)
}

Be aware that sync.Map's load-then-store pattern for incrementing a counter, as shown above, technically has its own race window between the load and the store under extremely high concurrency — for a genuinely correct atomic increment pattern, a regular map with an explicit mutex (as in the first example) is usually more straightforward to reason about correctly than trying to build atomic increment semantics on top of sync.Map's more limited API.

Audit your entire codebase for every place a shared map used across HTTP handlers is accessed, confirming consistent protection everywhere — a single unprotected access site anywhere, even if every other access point correctly uses the mutex, is enough to trigger this crash under sufficient concurrent load, since the safety only holds if it's applied universally.

Consider whether request-scoped data genuinely needs to be shared across requests at all, versus being scoped to a single request's own goroutine — data that's only relevant within a single request's lifecycle (rather than genuinely shared server-wide state) doesn't need any synchronization at all if it's kept local to that request's own handling, avoiding the entire category of concurrency concern for that specific data.

Still Not Working?

Run your server under Go's race detector during load testing (not in production, due to overhead, but during a dedicated testing phase) to proactively catch this class of bug before it ever manifests as a production crash:

go build -race -o server-race main.go
./server-race
# then run a load testing tool against it while race detection is active

The race detector will report the exact location of any unsynchronized concurrent access, including but not limited to maps, giving you a precise, actionable location to fix well before a real production traffic spike would otherwise trigger the same underlying bug as an actual server crash.