Building Scalable Microservices with Go
Microservices architecture has become the default pattern for building applications that need to scale teams and traffic independently. Go has quietly become one of the most common languages for this — not because it's trendy, but because its design choices happen to match what microservices actually need: fast startup, low memory footprint, and concurrency that doesn't require a thread pool tuning guide.
This article goes beyond "why Go is good" and into the practical pieces that turn a single HTTP handler into a service you can actually run in production: structuring the service, handling failures gracefully, managing configuration, and making it observable once it's deployed.
Why Go for microservices
A few properties make Go a strong fit specifically for this architecture, not just for backend work in general:
- Performance — Go compiles to a single static binary with minimal runtime overhead, which means fast cold starts and small container images — both matter a lot when you're running dozens of independently deployed services.
- Concurrency — goroutines make it cheap to handle thousands of concurrent requests without the memory cost of OS threads, and channels give you a straightforward way to coordinate work across them.
- Simplicity — a small language surface and a capable standard library (
net/http,context,encoding/json) mean many services need few, if any, external dependencies, which keeps each service easier to audit and upgrade independently.
Core principles before writing any code
These four principles matter more than any specific library choice:
- Single responsibility — each service owns one clear domain (orders, payments, notifications). If you can't describe what a service does in one sentence, it's probably doing too much.
- API-first design — define the contract between services (REST, gRPC, or an event schema) before implementation, so teams on either side can build against a stable interface.
- Independent deployment — a service should be deployable, and rollback-able, without coordinating a release with every other service.
- Resilience by default — assume the services you call will sometimes be slow or unavailable, and design for that from the start rather than bolting it on after an incident.
A minimal service, and what's missing from it
A bare HTTP handler is a reasonable starting point:
package main
import (
"log"
"net/http"
)
func main() {
http.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "healthy"}`))
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
This works locally, but it's missing a few things every production service needs: a way to shut down cleanly, timeouts on the server itself, and a distinction between "the process is running" and "the process is actually ready to serve traffic."
Graceful shutdown and server timeouts
When Kubernetes (or any orchestrator) stops a Pod, it sends a termination signal and expects the process to finish in-flight requests before exiting — not drop them mid-response. http.Server combined with context handles this cleanly:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/status", statusHandler)
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
log.Println("shutting down gracefully...")
if err := server.Shutdown(ctx); err != nil {
log.Fatalf("forced shutdown: %v", err)
}
}
func statusHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "healthy"}`))
}
ReadTimeout and WriteTimeout also matter beyond graceful shutdown — without them, a slow or malicious client can hold a connection open indefinitely and quietly exhaust server resources.
Handling failures between services
Once a service calls another service over the network, failure is no longer an edge case — it's a certainty that will happen eventually. Three patterns cover most of it:
- Timeouts — every outbound call should have a context deadline, so a slow downstream service can't stall the caller indefinitely:
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req)
- Retries with backoff — transient failures (a brief network blip, a downstream service restarting) often succeed on a second attempt, but retrying immediately in a tight loop can make an already-struggling service worse. Add exponential backoff between attempts.
- Circuit breakers — if a downstream service is consistently failing, stop calling it for a short period instead of piling up more failing requests and timeouts on top of it. Libraries like
sony/gobreakerimplement this pattern directly.
Managing configuration
The same principle covered in an earlier article on Godotenv and Viper applies directly here: configuration — database URLs, timeouts, feature flags — should live outside the binary, not hardcoded into it. For a small service, environment variables loaded through a .env file are often enough; for a larger service juggling multiple config sources (files, environment, remote config), a tool like Viper is worth the extra dependency.
Observability: logs, metrics, and tracing
A single microservice is easy to reason about. Twenty of them calling each other is not — and that's where observability stops being optional:
- Structured logging — use a JSON logger (
slogin the standard library since Go 1.21, orzap/zerolog) instead of plainlog.Println, so logs can actually be queried and correlated across services. - Metrics — expose a
/metricsendpoint with Prometheus client libraries to track request rate, error rate, and latency per endpoint. - Distributed tracing — once a request crosses three or more services, logs alone can't show you where the time went. OpenTelemetry lets you trace a single request across service boundaries and see exactly which hop was slow.
Deploying with Kubernetes
Once the service itself handles graceful shutdown and exposes a health endpoint, deploying it on Kubernetes (covered in more detail in an earlier article on Kubernetes deployments) mostly comes down to wiring that health endpoint into readinessProbe and livenessProbe, setting sane resource limits, and letting Kubernetes handle restarts, rolling updates, and scaling based on load.
Conclusion
Go's performance and concurrency model make it a strong foundation for microservices, but the language alone doesn't make a service production-ready. Graceful shutdown, sane timeouts, retry and circuit-breaker patterns for downstream calls, externalized configuration, and basic observability are what actually separate a demo service from one you can run reliably at 2 a.m. without someone getting paged. Start with the minimal handler, then add each of these pieces as the service actually needs them — not all at once, and not before you know which failure modes you're really dealing with.
Which part of running microservices in Go has given you the most trouble — service-to-service resilience, configuration sprawl, or observability? Share it in the comments.

