The dashboard problem
You have a service running in production. It handles requests, processes data, and returns responses. Everything works until a user reports slow load times. You check the logs and see nothing unusual. You need numbers. You need to know how many requests hit the endpoint, how long each one takes, and what the memory footprint looks like over time. You build a dashboard to visualize that data. The dashboard needs a backend to serve it. That backend does one job: collect metrics, store them temporarily, and return them as JSON when a client asks.
Go's standard library handles this without external dependencies. You do not need a web framework. You do not need a router package. The net/http package gives you everything required to build a functional metrics endpoint. The challenge is not syntax. The challenge is understanding how HTTP handlers, JSON encoding, and concurrent access interact in a real deployment.
What a metrics backend actually does
A metrics backend is a data pipe. It sits between your application logic and whatever tool visualizes the numbers. The backend does not calculate percentiles or aggregate histograms unless you explicitly tell it to. It holds state, listens for network requests, and returns a structured response.
Think of it like a scoreboard at a stadium. The scoreboard does not track the game. It does not decide when a point is scored. It simply displays the values that the referees feed it. Your Go program is the scoreboard. It maintains a slice or map of metric structs, exposes an HTTP route, and marshals that data into JSON bytes. The browser or monitoring tool receives those bytes and renders them.
Three concepts drive this pattern. An HTTP handler is a function that receives a request and writes a response. JSON marshaling converts Go structs into a text format that browsers and APIs understand. Concurrency safety ensures that multiple requests do not corrupt the shared data structure at the same time. Master these three, and you can build any metrics endpoint.
The simplest possible server
Here is the minimal working version. It defines a struct, stores metrics in a global slice, and serves them over HTTP.
package main
import (
"encoding/json"
"net/http"
"time"
)
// Metric holds a single data point for the dashboard.
type Metric struct {
Name string `json:"name"`
Value float64 `json:"value"`
Time time.Time `json:"time"`
}
// metrics stores all recorded data points in memory.
var metrics []Metric
// getMetrics writes the current slice as JSON to the HTTP response.
func getMetrics(w http.ResponseWriter, r *http.Request) {
// Set the content type so the client knows how to parse the body.
w.Header().Set("Content-Type", "application/json")
// Stream the slice directly to the response writer.
json.NewEncoder(w).Encode(metrics)
}
func main() {
// Seed the slice with one example data point.
metrics = append(metrics, Metric{Name: "cpu_usage", Value: 45.2, Time: time.Now()})
// Register the handler for the /metrics path.
http.HandleFunc("/metrics", getMetrics)
// Start listening on port 8080 and block until the process exits.
http.ListenAndServe(":8080", nil)
}
Run this with go run main.go and visit http://localhost:8080/metrics. The browser receives a JSON array containing your single metric. The server stays alive, waiting for more requests.
Go enforces strict formatting rules across the ecosystem. Run gofmt on your files before committing. Most editors execute it automatically on save. You do not need to debate indentation or brace placement. The tool decides. Focus your energy on logic, not whitespace.
How the request flows
When http.ListenAndServe starts, it opens a TCP socket on port 8080. It enters a loop that accepts incoming connections. Each connection spawns a new goroutine to handle the HTTP protocol. The goroutine reads the request line, parses headers, and matches the URL path against your registered handlers.
When a request hits /metrics, the router calls getMetrics. The function receives two arguments. http.ResponseWriter is an interface that lets you write status codes, headers, and body bytes back to the client. *http.Request contains the method, headers, URL, and body of the incoming request.
The handler sets the Content-Type header to application/json. This tells the client to expect JSON. If you skip this step, browsers often guess text/plain, which breaks automatic parsing in many dashboard tools. The handler then creates a json.Encoder bound to the response writer. Encode walks the metrics slice, converts each struct field to its JSON representation, and writes the bytes directly to the network socket. The encoder streams data instead of building a massive string in memory, which keeps allocations low.
After Encode returns, the handler function exits. The net/http server automatically flushes the remaining bytes, closes the connection, and recycles the goroutine. You never manually close the response writer. The framework manages the lifecycle.
Adding structure and safety
The global slice works for a prototype. It breaks under load. HTTP handlers run concurrently. Two requests arriving at the same millisecond will read and write the metrics slice simultaneously. Go does not protect shared memory by default. You will get corrupted data or a runtime panic.
Wrap the state in a struct and protect it with a mutex. A mutex ensures only one goroutine accesses the slice at a time.
package main
import (
"encoding/json"
"net/http"
"sync"
"time"
)
// MetricStore holds the data and a lock for safe concurrent access.
type MetricStore struct {
mu sync.Mutex
metrics []Metric
}
// Metric holds a single data point for the dashboard.
type Metric struct {
Name string `json:"name"`
Value float64 `json:"value"`
Time time.Time `json:"time"`
}
// NewMetricStore initializes an empty store with a ready mutex.
func NewMetricStore() *MetricStore {
return &MetricStore{
metrics: make([]Metric, 0, 100), // pre-allocate to reduce resizing overhead
}
}
Now attach the store to the handler and use the lock around every read and write.
// getMetrics safely reads the store and returns JSON.
func (s *MetricStore) getMetrics(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock() // release the lock when the function returns
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(s.metrics)
}
// AddMetric safely appends a new data point to the store.
func (s *MetricStore) AddMetric(m Metric) {
s.mu.Lock()
defer s.mu.Unlock()
s.metrics = append(s.metrics, m)
}
func main() {
store := NewMetricStore()
store.AddMetric(Metric{Name: "cpu_usage", Value: 45.2, Time: time.Now()})
http.HandleFunc("/metrics", store.getMetrics)
http.ListenAndServe(":8080", nil)
}
The receiver name s matches the type MetricStore. Go convention favors one or two letter receivers that mirror the type name. You will see (b *Buffer) or (c *Config) in standard library code. Stick to that pattern. It keeps signatures readable.
Error handling in Go is explicit by design. You will write if err != nil { return err } repeatedly. The community accepts the boilerplate because it forces you to acknowledge failure paths instead of swallowing them. Make the unhappy path visible.
Where things go wrong
Concurrency bugs in metrics backends rarely crash the process. They produce silent data corruption. Two goroutines append to the slice at the same time. The internal array pointer shifts. One request returns a partial array. Another request panics with a slice bounds error. The compiler cannot catch this. You must run the race detector with go run -race main.go. The detector instruments memory accesses and prints a stack trace the moment two goroutines touch the same variable without synchronization.
JSON marshaling has its own traps. The encoding/json package only serializes exported fields. If you name a struct field value instead of Value, the encoder silently drops it. Your dashboard receives empty objects. The compiler does not warn you. It treats unexported fields as private implementation details. Always capitalize struct fields that must appear in JSON.
Forget to import a package and the compiler rejects the file with undefined: http. Forget to use an imported package and you get imported and not used. Go enforces strict import hygiene. Every import must be referenced, and every reference must be imported. This rule prevents dead code from accumulating.
Background metric collectors often leak goroutines. If you spawn a goroutine that reads system stats every second, it runs forever unless you cancel it. Always pass a context.Context to long-running background tasks. Context always goes as the first parameter, conventionally named ctx. Check ctx.Done() in your loop. When the server shuts down, cancel the context and the goroutine exits cleanly.
The worst goroutine bug is the one that never logs. Always attach a cancellation path to background work.
Picking the right tool
Use a global slice with net/http when you are prototyping a single-endpoint service and concurrency is not a concern. Use a struct with a sync.Mutex when multiple goroutines will read and write metrics concurrently. Use a dedicated metrics library like Prometheus or Datadog when you need histogram buckets, percentiles, or distributed tracing. Use a database or time-series store when you need to persist data across restarts. Use a reverse proxy like Nginx or Caddy when you need TLS termination and load balancing in front of your Go binary.