Skip to main content
Create your own
Lesson illustration

Benchmarking HTTP Handlers with net/http/httptest

Hello! Last lesson, you measured CleanLabels with a clear boundary around one call. An HTTP handler adds two pieces of machinery to that boundary: an incoming request and a place to write the response.

In this lesson, you’ll benchmark a small handler that uses CleanLabels, without starting a server or making a network request. You’ll use net/http/httptest to supply the request and capture each response, then check that the benchmark is still measuring the operation you intended. Allow about 35–40 minutes to watch, read, and try the code.


Call a handler without a server

An HTTP handler’s essential interface is ServeHTTP(http.ResponseWriter, *http.Request). A running server normally supplies both arguments, but a benchmark can supply them directly. httptest.NewRequest creates an incoming server request; httptest.NewRecorder creates a ResponseWriter that records what the handler writes.

Watch the request-and-recorder portion of codeHeim’s API Testing in Go with net/http/httptest. The example is a correctness test rather than a benchmark, but the way it invokes the handler is the same.

#53 Golang - API Testing in Go with net/http/httptest

CodeHeim demonstrates how to give a handler a request and capture its response without launching a server. Focus on those three objects rather than the example's database setup.

Watch the handler call: identify where the request and recorder are created, where the handler is invoked, and when the recorded response is inspected.

The official httptest documentation makes the two roles precise. In particular, httptest.NewRequest is intended for a handler receiving a request; it is not the http.NewRequest you would give to an HTTP client.

httptest package - net/http/httptest - Go Packages

Read the Go package documentation to establish what the benchmark's request and recorder actually represent.

Under “Functions,” read the request API, from NewRequest through the end of NewRequestWithContext. Then, under “Types” and “ResponseRecorder,” read the recorder API, from the type description through Result. Notice especially that Result is called after the handler finishes.

A direct handler call measures neither socket I/O nor HTTP client behavior. That is useful when the question is, “How much does this handler cost for this request?” It is not an end-to-end service latency measurement.


Give the benchmark a concrete handler

Keep CleanLabels and its tests from the earlier lessons. For this example, add labels_http.go in the same labels package:

package labels

import (
	"encoding/json"
	"net/http"
)

func LabelsHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}

	body, err := json.Marshal(CleanLabels(r.URL.Query()["label"]))
	if err != nil {
		http.Error(w, "could not encode labels", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	_, _ = w.Write(body)
}

For a GET request containing three label values—" RUST ", " Go ", and " RUST "—the handler should write ["rust","go"]. Parsing the query, cleaning the labels, encoding JSON, and writing the response are all handler work. The benchmark must not move any of them into setup merely to produce a smaller number.

Add labels_http_bench_test.go. This version uses b.Loop() and therefore requires Go 1.24 or later:

package labels

import (
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"testing"
)

func BenchmarkLabelsHandler(b *testing.B) {
	// Prepare an incoming request and the handler before timing.
	query := url.Values{
		"label": {" RUST ", "  Go  ", " RUST "},
	}
	req := httptest.NewRequest(
		http.MethodGet,
		"/labels?"+query.Encode(),
		nil,
	)
	handler := http.HandlerFunc(LabelsHandler)

	var last *httptest.ResponseRecorder

	for b.Loop() {
		rec := httptest.NewRecorder()
		handler.ServeHTTP(rec, req)
		last = rec
	}

	// Inspect the final response after timing has stopped.
	res := last.Result()
	defer res.Body.Close()

	body, err := io.ReadAll(res.Body)
	if err != nil {
		b.Fatal(err)
	}
	if res.StatusCode != http.StatusOK || string(body) != `["rust","go"]` {
		b.Fatalf("status = %d, body = %s", res.StatusCode, body)
	}
}

Here, one benchmark operation is creating a fresh recorder and serving one prepared request. The recorder belongs inside the loop: a handler writes into it, so reusing it would accumulate response data across iterations. Creating it is consequently part of this particular measurement. By contrast, the request has no body and this handler does not modify it, so preparing it once gives each iteration the same input.

The final status-and-body check guards against a misleading benchmark that quickly produces the wrong answer. It checks only the last response; it does not replace correctness tests for the handler’s other cases. Calling Result, reading its body, and closing it happen after b.Loop() stops timing.


Run it and interpret the result

Run your correctness tests first, then select only this benchmark:

go test ./...
go test -run '^$' -bench '^BenchmarkLabelsHandler$' -benchtime=1s .

The reported ns/op is time per direct handler invocation with a new recorder. It includes query parsing, CleanLabels, JSON construction, response writing, and the cost of recording that response. It excludes construction of the initial request and inspection of the final response. It does not estimate the time a client would observe over a network.

If you use Go before 1.24, replace only the b.Loop() loop with the older timing pattern from the previous lesson:

b.ResetTimer()
for i := 0; i < b.N; i++ {
	rec := httptest.NewRecorder()
	handler.ServeHTTP(rec, req)
	last = rec
}
b.StopTimer()

Keep the request and handler setup above ResetTimer, and the response check below StopTimer.

The prepared request is safe to reuse for this handler. A handler that consumes a request body or changes request state might see a different input on later iterations; in that case, the benchmark design would need to change. Likewise, if you want to measure routing or middleware, call your configured router rather than LabelsHandler directly. Name and interpret the benchmark according to the work it actually includes.


Takeaways

httptest.NewRequest and a fresh httptest.NewRecorder let you call an HTTP handler entirely within the benchmark process. Define one operation explicitly, keep response capture fresh for each call, and verify a result outside the timed loop. Your number is a handler-and-recorder baseline, not network latency.

Next, you’ll apply the same boundary-setting approach to a command-line program’s processing core, using in-memory input and output instead of HTTP requests and responses.

Can't find a good explanation? Sign up and we'll make it for you

Sign up