This episode dissects context and concurrency in Gin: using c.Request.Context for cancellation, c.Copy for goroutines, context timeouts in handlers, simple rate limiting, and synchronizing data access with mutexes.

Go is known for cheap concurrency, and Gin is a framework built on top of it. This episode 12 dissects context, timeout & concurrency: how a request carries a cancellable context, how to run work in goroutines safely, how to set execution time limits, and how to protect shared data from concurrent access.
This understanding is critical because concurrency bugs are the hardest class of bugs to track down. Deadlocks, data races, and leaked goroutines often appear in applications handling thousands of requests per second. This episode builds the habits that prevent them from the start.
Every request carries a context that is cancelled when the client closes the connection. Pass this context to all blocking calls:
func getUserHandler(c *gin.Context) {
user, err := h.svc.GetUser(c.Request.Context(), id)
if err != nil {
c.Error(err)
return
}
c.JSON(200, user)
}c.Request.Context() returns the request context, which is cancelled automatically when the client disconnects or times out. If passed to a database query (the WithContext pattern from episode 9), the query stops as soon as the request is cancelled — saving database resources.
The Gin Context is a mutable object that isn't safe to share across multiple goroutines. If you need to run asynchronous work from a handler, create a copy with c.Copy():
func sendEmailHandler(c *gin.Context) {
email := c.PostForm("email")
cp := c.Copy()
go func() {
time.Sleep(2 * time.Second)
logger.Info("email dikirim", "to", cp.PostForm("email"))
notifyDone(cp.Request.Context())
}()
c.JSON(202, gin.H{"status": "diproses"})
}c.Copy() returns a copy of the context that's safe to use outside the handler lifecycle. The handler can immediately respond with 202 Accepted while the async work runs in the background. Avoid reading the original c fields inside a goroutine without a copy.
For heavier work — sending emails, processing files, sending notifications — don't tie up the request. Send it to a channel or queue:
var jobs = make(chan Job, 100)
func worker() {
for job := range jobs {
process(job)
}
}
func enqueueHandler(c *gin.Context) {
var job Job
if err := c.ShouldBindJSON(&job); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
jobs <- job
c.JSON(202, gin.H{"accepted": true})
}jobs <- job sends the job to a channel with a capacity of 100. Workers consume and process them outside the request. This is the simplest queue pattern; for production, use a message broker like Redis Streams or Kafka.
Some operations — third-party integrations, aggregating many sources — can run too long. Bound them with context.WithTimeout:
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
orders, err := h.svc.AggregateOrders(ctx, userID)
if err != nil {
c.Error(fmt.Errorf("aggregasi: %w", err))
return
}
c.JSON(200, gin.H{"orders": orders})context.WithTimeout(c.Request.Context(), 3*time.Second) creates a child context cancelled automatically after 3 seconds. defer cancel() ensures the context resources are released when the handler finishes — even if the deadline is reached. A sensible time limit prevents one slow request from hanging a goroutine forever.
Protecting your API from request surges can start with a simple limiter using golang.org/x/time/rate:
go get golang.org/x/time/ratevar (
mu sync.Mutex
buckets = make(map[string]*rate.Limiter)
)
func getLimiter(ip string) *rate.Limiter {
mu.Lock()
defer mu.Unlock()
lim, ok := buckets[ip]
if !ok {
lim = rate.NewLimiter(rate.Every(time.Second), 20)
buckets[ip] = lim
}
return lim
}
func rateLimit() gin.HandlerFunc {
return func(c *gin.Context) {
if !getLimiter(c.ClientIP()).Allow() {
c.AbortWithStatusJSON(429, gin.H{"error": "terlalu banyak request"})
return
}
c.Next()
}
}rate.NewLimiter(rate.Every(time.Second), 20) allows an average of 20 requests per second with a burst of 20. The mutex protects the buckets map because it's accessed by many goroutines. Note that the per-IP map should be cleaned up periodically so it doesn't grow — or use the global limiter in episode 14 for a more mature version.
When many goroutines modify the same data, use sync.Mutex:
type VisitCounter struct {
mu sync.Mutex
visits int
}
func (v *VisitCounter) Inc() {
v.mu.Lock()
defer v.mu.Unlock()
v.visits++
}v.mu.Lock() protects v.visits from data races. Without a mutex, two goroutines could read and write the same value simultaneously, producing incorrect numbers. For simple operations like this, sync/atomic can be faster, but a mutex is easier to understand for complex logic.
Key takeaways:
c.Request.Context() propagates cancellation to all blocking calls.c.Copy() is needed when using the context in a goroutine.context.WithTimeout bounds handler execution duration.errors.Is(err, context.DeadlineExceeded) to respond to timeouts with 504.rate.Limiter from golang.org/x/time protects against request surges.sync.Mutex prevents data races on shared data.In the next episode, episode 13, we'll dissect authentication & session authentication — BasicAuth, JWT with golang-jwt, the access token and refresh token flow, session cookies, and token storage and revocation in Redis.