The cold start problem
You deploy a Go service to the cloud. It handles a thousand requests per minute. Then traffic drops to zero for an hour. When the next request arrives, the response takes four seconds instead of forty milliseconds. You did nothing wrong. The cloud provider spun down your instance to save money, and now it has to boot your binary from scratch. This is the cold start. It happens to every serverless workload, but Go handles it differently than Node or Python.
What serverless actually means for Go
Serverless in Go usually means one of two things. You either ship a static binary to a Function-as-a-Service platform like AWS Lambda or Google Cloud Functions, or you containerize the binary and run it on a serverless container platform like Cloud Run or AWS Fargate. The underlying mechanism is the same. The cloud provider gives you an isolated execution environment, routes HTTP traffic to it, and scales the number of running instances up and down based on demand.
Go fits this model well because the compiler produces a single executable with zero external dependencies. You do not need to bundle a virtual machine or install a package manager on the host. The binary contains everything it needs to run. The tradeoff is that you are responsible for configuring the Go runtime to behave well under rapid scaling. The standard library expects long-lived processes. Serverless platforms expect short-lived, stateless bursts. Bridging that gap requires understanding how the scheduler, garbage collector, and HTTP server interact with cloud lifecycle signals.
Serverless is not magic. It is just process management at scale.
The minimal HTTP handler
Here is the simplest possible serverless entry point. It listens on a port, accepts HTTP requests, and returns a response.
package main
import (
"net/http"
)
// handler writes a plain text response to the client.
func handler(w http.ResponseWriter, r *http.Request) {
// Set the content type so browsers and proxies know how to render it.
w.Header().Set("Content-Type", "text/plain")
// Explicitly write the status code before the body.
w.WriteHeader(http.StatusOK)
// Send the payload. The standard library buffers this automatically.
w.Write([]byte("Hello from Go"))
}
func main() {
// Register the route on the default mux.
http.HandleFunc("/", handler)
// Start listening on port 8080. Cloud providers override this port via environment variables.
http.ListenAndServe(":8080", nil)
}
The code above works locally. It also works in production if you deploy it to a platform that expects a standard HTTP server. The standard library handles connection pooling, TLS termination, and request routing without any extra configuration. You do not need a web framework. The net/http package is designed to be the foundation, not a competitor.
Keep the handler fast. The cloud provider charges by the millisecond.
How the runtime boots your binary
When you run go build -o main, the compiler links the standard library directly into the executable. The resulting file contains the Go runtime, your code, and all dependencies. No shared libraries. No system packages. When the cloud provider boots the instance, it executes the binary. The Go runtime initializes the scheduler, allocates the heap, and runs main.
The scheduler is the part of the runtime that maps goroutines to operating system threads. By default, Go creates one OS thread per CPU core. When you call http.ListenAndServe, it spawns a listener goroutine. Each incoming request gets its own goroutine. The scheduler multiplexes thousands of goroutines across a handful of threads. This is why Go scales well under high concurrency. You do not need to manage thread pools or worry about context switching overhead.
The garbage collector runs concurrently with your program. It pauses the world for less than a millisecond in modern versions. In a serverless environment, this matters because memory allocation patterns affect cold start time. If your handler allocates heavily on the first request, the GC will trigger immediately and add latency. Pre-allocate buffers or reuse slices when you can. The runtime will thank you.
Goroutines are cheap. Channels are not magic.
A realistic deployment pattern
Production code looks different. You need environment variables for configuration, proper error handling, and a way to respect cancellation signals from the cloud provider. Cloud platforms send a shutdown signal before terminating the instance. If you ignore it, in-flight requests get dropped and clients receive connection reset errors.
Here is a realistic handler that reads configuration, uses context for timeouts, and handles graceful shutdown.
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
// greetHandler returns a personalized message using an environment variable.
func greetHandler(w http.ResponseWriter, r *http.Request) {
// Default to "World" if the env var is missing.
name := os.Getenv("TARGET")
if name == "" {
name = "World"
}
// Use the request context to respect client timeouts.
ctx := r.Context()
select {
case <-ctx.Done():
// Client disconnected or provider cancelled the request.
http.Error(w, "request cancelled", http.StatusServiceUnavailable)
return
default:
// Simulate a short processing delay.
time.Sleep(50 * time.Millisecond)
fmt.Fprintf(w, "Hello, %s!", name)
}
}
func main() {
// Read the port from the environment, defaulting to 8080.
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
mux := http.NewServeMux()
mux.HandleFunc("/", greetHandler)
srv := &http.Server{
Addr: ":" + port,
Handler: mux,
// Stop accepting new connections after 5 seconds during shutdown.
ReadHeaderTimeout: 5 * time.Second,
}
// Start the server in a separate goroutine so we can listen for signals.
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
os.Exit(1)
}
}()
// Wait for interrupt or terminate signals from the cloud provider.
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
// Gracefully shut down with a 30-second deadline.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
fmt.Fprintf(os.Stderr, "forced shutdown: %v\n", os.Exit(1))
}
}
The pattern above follows Go conventions. context.Context always goes as the first parameter in functions that need it, though HTTP handlers receive it via r.Context(). Functions that take a context should respect cancellation and deadlines. The receiver name in methods is usually one or two letters matching the type, but we are using package-level functions here, so it does not apply. Public names start with a capital letter. Private start lowercase. No keywords like public or private.
The if err != nil check is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Do not swallow errors. Return them or log them. The compiler will not save you from bad error handling.
Context is plumbing. Run it through every long-lived call site.
Where things go wrong
Serverless Go trips people up in three specific ways. The first is dynamic linking. If you compile on macOS or Windows without cross-compilation flags, the binary might depend on system libraries that do not exist inside the Linux container. Run GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o main to force a fully static Linux binary. The -s and -w flags strip debug symbols and reduce the file size. A smaller binary means faster cold starts.
The second pitfall is goroutine leaks. Cloud providers recycle instances, but they do not reset the Go runtime. If you spawn a background goroutine that waits on a channel or a database connection, it stays alive across invocations. Memory grows until the container hits its limit and crashes. Always tie background work to context.Context. When the context cancels, the goroutine exits. The worst goroutine bug is the one that never logs.
The third issue is missing context propagation. If you pass a bare context.Background() to a database call instead of r.Context(), the cloud provider cannot cancel the request when the client drops the connection. You waste compute time and hold database connections open. The compiler will not catch this. It is a runtime design flaw. If you forget to capture a loop variable in a closure, the compiler rejects the program with loop variable captured by func literal. If you pass the wrong type to a function, you get cannot use x (type string) as type int in argument. These are easy to fix. Context leaks are not.
Do not pass a *string. Strings are already cheap to pass by value. Use string directly. The compiler copies the pointer and length, not the underlying bytes.
When to pick serverless
Use a serverless function when you have sporadic traffic that spikes unpredictably and you want to pay only for actual execution time. Use a serverless container platform when your binary needs custom system libraries or requires a longer warm-up period for database connections. Use a traditional virtual machine when you need predictable sub-millisecond latency and want to avoid cold starts entirely. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.