Skip to content
Muhammet Şafak
tr
Languages 4 min read

Writing HTTP services in Go: is the standard library enough?

I explore what you get — and what you don't — when building a small HTTP service with Go's net/http package, and where the framework threshold sits.

Cover — a small server box on a grey surface, a violet front plate reading net/http above two RJ45 network ports

One rule I set for myself while learning Go was this: I wouldn’t pull in an external library until I’d tried to build the thing with the standard library first. That rule was occasionally frustrating, but it taught me a lot — it followed directly from the intent I described in why I started learning Go: to get to know the language itself rather than through its tooling. HTTP services turned out to be the area that really put it to the test.

In PHP, some framework is always running underneath everything — Laravel, Slim, whatever. Writing an HTTP server in raw PHP is theoretically possible, but nobody does it. In Go, on the other hand, the net/http package is genuinely usable; production services get written with the standard library every day. That difference forces a shift in mindset.

A basic server with net/http

Getting a Go HTTP server running takes just a few lines:

package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        fmt.Fprintln(w, `{"status":"ok"}`)
    })

    http.ListenAndServe(":8080", nil)
}

It runs, responds on port 8080, zero dependencies. That simplicity feels satisfying at first — but as soon as you start building a real API, the limitations surface.

What the standard library is missing

The HandleFunc-based registration mechanism in net/http isn’t flexible enough. Route parameters — dynamic path segments like /users/123 — did not exist in the standard library at the time I wrote this. You had to parse them by hand:

http.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
    // manually extract everything after "/users/"
    id := r.URL.Path[len("/users/"):]
    if id == "" {
        // return list
        return
    }
    // return single record
})

This approach is tolerable for two or three routes, but it becomes unmanageable at ten. HTTP method dispatch (GET/POST/PUT/DELETE) also has to be done by hand:

switch r.Method {
case http.MethodGet:
    // handle GET
case http.MethodPost:
    // handle POST
default:
    http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
}

Copy-pasting that switch block into every handler inflates the code quickly. The standard library doesn’t abstract this common pattern away — you either write it yourself or delegate it to a library.

A note from today: this gap has since closed. Go 1.22 added enhanced routing patterns to net/http’s ServeMux — wildcards like /items/{id} are now recognised by the standard library and the matched segment is read with Request.PathValue; registering a handler as "POST /items/create" restricts it to that method. So the hand-rolled parsing and the switch r.Method block above have not been necessary since 2024. The reasoning in the rest of this post — when to reach for a library — still holds; what moved is where the threshold sits.

Where is the framework threshold?

Before reaching for a library, I ask myself: how generic is the need that the standard library isn’t covering? Route parameters and HTTP method routing are generic and universal — every service needs them.

That’s when I look at third-party packages. In the Go ecosystem, gorilla/mux and chi are lightweight routers that fill this gap. They’re not frameworks; they simply add route parameters and method matching on top of net/http, and they conform to the standard http.Handler interface.

import "github.com/go-chi/chi"

r := chi.NewRouter()

r.Get("/users/{id}", func(w http.ResponseWriter, req *http.Request) {
    id := chi.URLParam(req, "id")
    // work with id
})

http.ListenAndServe(":8080", r)

The core philosophy is preserved: a plugin that conforms to the standard library’s interfaces and doesn’t conflict with other packages. Not a large dependency pile like Laravel.

What convinced me about chi is its full compliance with the standard http.Handler interface — its own README claims it is “100% compatible with net/http” with “no external dependencies — plain ol’ Go stdlib + net/http”. When evaluating a library, I pay close attention to this; a router that breaks the standard interface makes it harder to work with middleware or utilities that expect it. A handler written with chi can be used with any other net/http-compatible setup.

JSON responses and error handling

The standard library ships encoding/json for serialization, and it works well:

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

func writeJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

This small helper eliminates repetition; all handlers use it.

Comparison with PHP

Writing an HTTP service in Go is different from writing one in PHP without a framework: the standard library is genuinely production-ready, well-maintained, and fast. But the layers Laravel provides — authentication, ORM, queues, email — aren’t present in Go, nor do they need to be. Go services typically do one narrower responsibility very well; broader concerns bring in other languages.


The bottom line: Go’s standard library covers a great deal, but not everything. Reaching for a lightweight library for route parameters and method routing is a reasonable trade-off. Jumping to a full framework is a premature decision in most cases — something you can do when the need genuinely grows to that scale.


Sources

The research behind this post

Service & load Measurement

I measured PHP and Go on the same OAuth2 API: no gap at 10,000 writes, a real one at 50,000 reads

The same API verifies an OAuth2 token on every request and then writes to or reads from PostgreSQL. On four cores, how much CPU do PHP-FPM, FrankenPHP worker mode and Go need for 10,000 writes and 50,000 reads a second?

Finding

At 10,000 writes a second all three candidates hit the target in five runs out of five, and none had a p99 above 2.5 ms: at this load the language is not a capacity line item. At 50,000 reads a second only Go held the target on four cores (p99 6.45 ms); PHP-FPM stopped at 23,528 and FrankenPHP at 22,859. CPU per read request is 64 microseconds for Go, 117 for FrankenPHP and 168 for PHP-FPM. Sized by instance, 50,000 reads take 3.4 cores in Go and 8.2–8.8 cores for the two PHP candidates. FrankenPHP's CPU saving does not turn into capacity: at saturation it leaves about one of its four cores idle.

measured today

Medium confidence

Questions on this topic

Experiments on this topic

A frozen JSON envelope in place of language-specific serialization; it became BabelQueue, a polyglot queue standard.

What it does today

The frozen JSON envelope has four languages — PHP, Python, Go, Node.js — reading the same bytes, with no sidecar and no broker plugin. Teams running a polyglot queue can pick up the spec and the SDKs today.

Open source JSON Redis RabbitMQ +4 more
March 2026 — June 2026

A provider-agnostic Go CLI that has a model review my diff without the diff leaving the machine; it became CommitBrief.

What it does today

Reviews code locally, with the diff never leaving the machine; the `Provider` interface lets Anthropic, OpenAI, Gemini and Ollama plug into one contract. Signed binaries are published — anyone who does not want to hand their diff to a cloud provider can install it.

Open source Go Cobra go-git +4 more
January 2026 — April 2026
Tags: #Go
Expertise: Go Developer
Share:

Last updated:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind