You have probably seen Go's pitch: lightweight goroutines, channel-based concurrency, a fast HTTP server in a few lines. The syntax is small enough to learn in a weekend. Then you deploy a service, and a week later it is eating 4 GB of RAM, the p99 latency is spiking, and go tool pprof shows hundreds of thousands of goroutines, most of them blocked forever on a channel send. The language did not lie to you — the mental model you built from tutorials did.
This article is about the parts of Go that the syntax tour skips: what the M:N scheduler actually does, why go func() costs about 2 KB while an OS thread costs megabytes, what happens inside a channel operation, and where the standard library stops being enough. If you are learning Go for backend work, these are the things that separate code that compiles from code that survives production.
What the runtime is actually doing
Go does not map one goroutine to one OS thread. The runtime implements an M:N scheduler: M goroutines multiplexed onto N OS threads, mediated by a structure called a GMP model.
- G — a goroutine: a stack, an instruction pointer, and a small state struct. Starts at 2 KB and grows or shrinks as needed.
- M — a machine, i.e. a real OS thread managed by the kernel. Expensive: default stack is typically 8 MB of virtual address space, and context switches go through the kernel.
- P — a processor: a logical scheduling context with a local run queue (up to 256 goroutines) and a reference to an M. The number of Ps is set by
GOMAXPROCS, which defaults to the number of CPU cores.
When you write go doWork(), the runtime puts a new G on the current P's local run queue. If the P's queue is full, the G goes to the global queue. An idle P steals work from another P's queue — that is work stealing, and it is why a Go program with thousands of goroutines still saturates all cores without you writing a thread pool.
The non-obvious part is what happens when a goroutine blocks on I/O. The runtime does not park the OS thread. It detaches the P from the M, hands the P to another M, and lets the blocked M sit in a syscall. When the syscall returns, the G tries to reattach to a P; if none is free, it goes to the global queue and the M parks. This is why a Go server can hold tens of thousands of concurrent connections with a handful of threads — and why a badly written cgo call can starve the scheduler if it blocks for a long time.
A walkthrough: from naive to production HTTP server
Take a concrete task: a service that accepts POST /shorten with a URL and returns a short code, backed by Postgres, with a redirect handler on GET /{code}. The naive version is forty lines. Here is what a practitioner changes, in order, and why.
Step 1 — the handler and the request context. net/http gives you http.ServeMux, http.HandlerFunc, and a *http.Request whose Context() is cancelled when the client disconnects. The first rule: pass r.Context() into every downstream call — the database query, the outbound HTTP call. If you use context.Background() instead, a client that gives up leaves your query running, and under load those pile up until the connection pool is exhausted.
Step 2 — the database layer. The standard library's database/sql is a connection pool, not an ORM. You write db.QueryRowContext(ctx, ...) and scan into variables. This is more code than an ORM, and it is also why Go services stay predictable: there is no lazy loading, no hidden N+1, no magic. Set db.SetMaxOpenConns explicitly — the default is unlimited, which is a common way to take down Postgres under traffic.
Step 3 — background work. Suppose you want to record click analytics without blocking the redirect. The tempting move is go recordClick(code). Now every redirect spawns a goroutine with no bound. Under a traffic spike, goroutines accumulate faster than they drain, and the process either OOMs or the scheduler spends more time switching than working. The fix is a bounded worker pool: a fixed number of goroutines reading from a buffered channel, and a non-blocking send (select with a default branch) that drops or logs when the queue is full.
Step 4 — graceful shutdown. http.Server.Shutdown(ctx) stops accepting new connections and waits for in-flight requests. Without it, a deploy kills requests mid-flight. With it, you get clean drains — but only if your handlers respect the context you just passed down.
Step 5 — tests. net/http/httptest lets you exercise handlers without a real network. Table-driven tests are the Go idiom: a slice of cases, a loop, t.Run for each. This is where a lot of self-taught Go code falls apart — the handler works, but nobody ever tested the edge case where the database returns sql.ErrNoRows.
GODEBUG=gctrace=1 and watch the GC cycles. If GC is running more than a few times per second under load, your problem is allocation, not goroutines. Fix the allocations first — the scheduler is usually fine.
Where Go's concurrency model breaks
The model is good, but it has failure modes that are specific to it. These are not beginner mistakes; they are places where the scheme itself gives way.
Goroutine leaks
A goroutine that blocks on a channel send with no reader never exits. The runtime does not kill it; it just sits there, holding its stack and any references it captured. In a long-running service, a leak of a few goroutines per request compounds. How to spot it early: expose runtime.NumGoroutine() as a metric and alert on a rising trend, not an absolute number. If it is already too late: take a goroutine profile with pprof.Lookup("goroutine").WriteTo(w, 1) — the 1 gives you full stack traces, and you will see the exact line where they are stuck.
Channel deadlocks
Unbuffered channels require both sides to be ready. A classic pattern: a worker sends a result to a channel, the consumer has already returned because of a timeout, and the worker blocks forever. select with a case <-ctx.Done(): branch is the standard defence. The subtler version: a for range ch loop that never terminates because nobody closes the channel. Rule of thumb: the sender closes the channel, and only the sender.
GC pauses and allocation pressure
Go's garbage collector is concurrent and low-pause — typically sub-millisecond — but it is not free. Every allocation adds to the mark phase's work. A handler that builds strings with + in a loop, or converts []byte to string on every request, generates garbage faster than the collector can keep up. The symptom is not a long pause; it is CPU spent on GC instead of serving. Fix: strings.Builder, sync.Pool for hot buffers, and pprof's alloc profile to find the top allocators.
cgo and the scheduler
A cgo call blocks the OS thread it runs on, and the runtime cannot preempt it. A cgo call that takes 10 ms is fine; one that takes 10 seconds can starve a P for that long, and if enough of them run concurrently, the scheduler runs out of Ms. The workaround is runtime.LockOSThread and a dedicated pool, or — better — avoiding cgo in the hot path altogether.
context on a database call.
Does your service actually need Go?
Go is not the right answer for every backend. Here is a checklist that practitioners actually use, with the trade-offs made explicit.
| Situation | Go is a good fit | Go is probably overkill |
|---|---|---|
| Concurrency profile | Thousands of long-lived connections, fan-out/fan-in | A handful of CRUD endpoints behind a load balancer |
| Latency budget | p99 under tens of milliseconds, GC pauses matter | p99 in the hundreds of ms is acceptable |
| Team | Willing to write explicit SQL and explicit error handling | Wants an ORM and a framework that does the wiring |
| Deployment | Single static binary, small container image | Existing platform already runs Node or Python well |
| Ecosystem | Standard library covers HTTP, JSON, crypto, TLS | Heavy reliance on a specific framework's plugin ecosystem |
If your service is a CRUD API with modest traffic, Node or Python will get you there faster and with a larger hiring pool. Go pays off when the concurrency model is the point: proxies, gateways, streaming, high-fanout services, CLI tools that need to ship as one binary.
A useful non-obvious insight: Go's standard library is the framework. net/http with ServeMux (since Go 1.22 it supports method and path-parameter patterns like POST /shorten/{code}) covers routing, middleware via handler wrapping, TLS, and graceful shutdown. You reach for a third-party router when you need something specific, not by default. The same is true of encoding/json, crypto/tls, and database/sql. The trade-off: you write more boilerplate, and you own the decisions a framework would have made for you.
Learning paths and what they actually cost
There are three realistic routes into Go, and they differ less in price than in feedback loops.
Self-study from the official tour and docs. Free, and the documentation is genuinely good. The cost is time spent on wrong turns: you will write a goroutine leak, not notice it for a week, and have no one to tell you what the profile means. This route works for people who already have a backend mental model from another language.
A structured course. Paid, but the value is in the sequence and the feedback. Golang from Scratch is built around exactly this progression — syntax, then the type system, then goroutines and channels, then HTTP servers and clients, then databases and tests — with projects (a REST API, CLI tools, web services) that force you to hit the failure modes above in a controlled setting rather than in production. The lessons are text-based and adapted to your level, and the AI chat is where you can ask for feedback on a design decision or a piece of code, which is the part self-study cannot give you.
Practice on a real codebase. The fastest route if you can get it: join a team that already runs Go, read their code, and copy their idioms. The cost is opportunity — you need the job first, and you will inherit whatever mistakes the team already made.
flag and os, then an HTTP server with net/http and encoding/json, then add a database with database/sql, and only then introduce goroutines and channels. Concurrency without a working service to attach it to is theory.
Who this suits and who it does not
Go rewards people who like explicit code: error returns instead of exceptions, SQL instead of an ORM, a small standard library instead of a framework. If you enjoy that, the language will feel like a relief. If you want the framework to make decisions for you, you will find Go verbose and stubborn.
It suits backend developers who need to reason about concurrency, DevOps and platform engineers who want a single binary to ship, and anyone building services where the standard library is enough. It suits less well people who need a huge ecosystem of ready-made plugins, or teams whose existing stack already works and whose bottleneck is not the runtime.
If you have never written a backend service in any language, start there first — Go will not teach you HTTP or SQL, it will just give you a fast way to get them wrong.
The course walks through the scheduler, the channel semantics, and the HTTP and database layers with runnable examples, and gives you a place to ask whether your design holds up before it reaches production. If that is the gap you are trying to close, start with Golang from Scratch.