Learn Echo - Context, Timeout & Concurrency
Series/Learn Echo/Episode 12
Episode 12 of 23

Learn Echo - Context, Timeout & Concurrency

This episode dissects concurrency in Echo: the request context for values and cancellation, running goroutines inside handlers, context timeouts to bound the duration of work, state synchronization with mutex, and safe patterns for sharing data between requests.

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

Introduction

Go is famous for its lightweight goroutines, and Echo uses that power to the fullest. But concurrency is a double-edged sword: without discipline, goroutine leaks, data races, and deadlocks lurk around the corner. This episode teaches how to use concurrency correctly inside Echo.

Episode 12 dissects the request context for values and cancellation, running goroutines inside handlers, context timeouts, state synchronization with mutex, and safe patterns for sharing data between requests.

Request Context and Values

Context from Echo to Standard Go

Every Echo handler has a context accessible through c.Request().Context(). This context is canceled when the request finishes or the client disconnects — an important signal for long-running operations:

Getting the context from a request
func handler(c echo.Context) error {
	ctx := c.Request().Context()
	user, err := repo.FindByID(ctx, id)
	if err != nil {
		return err
	}
	return c.JSON(http.StatusOK, user)
}

Always pass ctx to functions that accept a context — the repository from episode 9 was already designed for that. When the client cancels the request, the database operation is canceled along with it.

Request-Scoped Values

The context can also carry values that live for a single request. Middleware often uses it to store temporary data:

Storing values in the context
ctx := context.WithValue(c.Request().Context(), "request_id", reqID)
c.SetRequest(c.Request().WithContext(ctx))

Retrieve that value in the handler with the same key. In v5, use c.Set and c.Get for data needed by Echo middleware such as JWT values — covered in episode 13.

Goroutines Inside Handlers

The Safe Fan-Out Pattern

Several independent operations can run in parallel. A common pattern: run goroutines, collect results in a channel, combine them once all are done:

Safe fan-out in a handler
type result struct {
	user *model.User
	posts []*model.Post
	err  error
}
 
func handler(c echo.Context) error {
	ctx := c.Request().Context()
	results := make(chan result, 2)
 
	go func() {
		u, err := repo.FindByID(ctx, id)
		results <- result{user: u, err: err}
	}()
	go func() {
		p, err := repo.FindPosts(ctx, id)
		results <- result{posts: p, err: err}
	}()
 
	var user *model.User
	var posts []*model.Post
	for i := 0; i < 2; i++ {
		r := <-results
		if r.err != nil {
			return r.err
		}
		if r.user != nil {
			user = r.user
		}
		if r.posts != nil {
			posts = r.posts
		}
	}
	return c.JSON(http.StatusOK, map[string]interface{}{
		"user":  user,
		"posts": posts,
	})
}

A channel with capacity 2 means the goroutines don't wait for the receiver. Don't write the response from a goroutine — only produce values and return them to the main handler.

Context Timeout to Bound Duration

A hanging request must not run forever. Bound the duration of work with context.WithTimeout:

Timeout for parallel work
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()

When the timeout is reached, the context is canceled and every operation that respects the context returns an error. This is the second line of defense after WriteTimeout from episode 10.

State Synchronization

Mutex for Shared State

State accessed by many goroutines must be protected. sync.Mutex is the basic tool:

Safe counter with a mutex
type Counter struct {
	mu    sync.Mutex
	value int
}
 
func (c *Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.value++
}
 
func (c *Counter) Value() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.value
}

Every access to value goes through mu.Lock. Without it, concurrent increments produce data races that go test -race can detect.

Detect data races
go test -race ./...

Run go test -race ./... regularly — Go will report every data race it detects.

Rate Limiting and Backpressure

Protecting Shared Resources

High concurrency can overwhelm the database. Combine the rate limiter (episode 6) with caps on parallel work inside the service. For very heavy operations, use a worker pool or a semaphore from golang.org/x/sync:

Semaphore limiting goroutines
import "golang.org/x/sync/semaphore"
 
sem := semaphore.NewWeighted(10)
for _, item := range items {
	if err := sem.Acquire(ctx, 1); err != nil {
		return err
	}
	go func(item Item) {
		defer sem.Release(1)
		process(item)
	}(item)
}

The semaphore guarantees at most 10 goroutines run at once. This protects the database and other resources from concurrency explosions.

Closing

Episode 12 equips you with concurrency discipline: c.Request().Context() carries request cancellation to every operation, goroutines are used with channels to collect results, context.WithTimeout bounds duration, mutex protects shared state, and semaphores limit busyness.

Key takeaways:

  • Use c.Request().Context() as the context for every operation.
  • Goroutines must not write the response; return it through a channel.
  • context.WithTimeout bounds the duration of long-running work.
  • Mutex protects shared state from data races.
  • go test -race is mandatory to detect races.
  • A semaphore limits the number of parallel goroutines.
  • Rate limiters and backpressure protect shared resources.

In episode 13 next, we'll discuss authentication & authorization — JWT with middleware.JWT, Basic Auth, session cookies, refresh tokens, RBAC middleware for access control, and OAuth2 and OpenID Connect integration with Keycloak.

Learn Echo - Context, Timeout & Concurrency | Learn Echo