Skip to main content

HTTP Client and Server in Go

Published: December 17, 2025 Updated: August 29, 2026 Larry Qu 7 min read

Go’s net/http package is a complete HTTP/1.1 and HTTP/2 implementation. Every request the server handles runs in its own goroutine. The client has built-in connection pooling and keep-alive. There’s no external dependency needed for most web services — the standard library is production-capable.

That said, http.ListenAndServe(":8080", nil) without timeouts is a denial-of-service waiting to happen, and http.Get(url) without a client timeout will hang indefinitely on a slow server. This guide covers both server and client with the configuration you actually need in production.

For framework-level routing see Go Gin framework and for REST API design see Go building REST APIs.

HTTP Server: The Right Setup

The default http.ListenAndServe uses http.DefaultServeMux and has no timeouts. Never use it in production — a slow client can hold connections open forever, eventually exhausting your goroutines and memory.

Always configure an http.Server with explicit timeouts:

func main() {
    mux := http.NewServeMux()
    registerRoutes(mux)

    srv := &http.Server{
        Addr:    ":8080",
        Handler: mux,

        // ReadHeaderTimeout: time to read the request headers
        // Prevents Slowloris attacks where clients send headers very slowly
        ReadHeaderTimeout: 5 * time.Second,

        // ReadTimeout: time to read the entire request (headers + body)
        ReadTimeout: 15 * time.Second,

        // WriteTimeout: time to write the response
        // Set >= ReadTimeout so slow request bodies don't cut off response writing
        WriteTimeout: 15 * time.Second,

        // IdleTimeout: keep-alive connections are closed after this duration
        IdleTimeout: 60 * time.Second,
    }

    log.Println("listening on :8080")
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatalf("server: %v", err)
    }
}

Routing with http.ServeMux

Go 1.22 significantly upgraded http.ServeMux with method-qualified patterns and path parameters. You no longer need a third-party router for most APIs:

mux := http.NewServeMux()

// Method-qualified routes (Go 1.22+)
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /users/{id}", getUser)      // {id} is a path parameter
mux.HandleFunc("PUT /users/{id}", updateUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)

// Extract path parameters
func getUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")  // Go 1.22+
    // ...
}

For Go versions before 1.22, or for more complex routing (regex patterns, groups), use a router like Gin or chi.

Handlers: Writing Correct Responses

An http.Handler writes to http.ResponseWriter and reads from *http.Request. The response writer is buffered — headers are not sent until you call WriteHeader or start writing the body:

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

func getUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    if id == "" {
        http.Error(w, "missing user id", http.StatusBadRequest)
        return
    }

    user, err := db.GetUser(r.Context(), id)
    if errors.Is(err, ErrNotFound) {
        http.Error(w, "user not found", http.StatusNotFound)
        return
    }
    if err != nil {
        log.Printf("getUser %s: %v", id, err)
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    // WriteHeader must come after setting headers, before writing the body
    // Omitting it defaults to 200 OK on the first Write
    if err := json.NewEncoder(w).Encode(user); err != nil {
        log.Printf("encode user: %v", err)
    }
}

Two common mistakes: calling w.Header().Set(...) after w.WriteHeader(code) (headers are already sent, the Set is silently ignored), and calling w.WriteHeader twice (second call is a no-op but logs a warning).

Decoding Request Bodies

Always use json.NewDecoder for request bodies rather than io.ReadAll + json.Unmarshal — it streams without buffering the full body:

func createUser(w http.ResponseWriter, r *http.Request) {
    // Enforce a maximum request size to prevent memory exhaustion
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB

    var input struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    }
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()  // reject typos in field names

    if err := dec.Decode(&input); err != nil {
        var maxErr *http.MaxBytesError
        if errors.As(err, &maxErr) {
            http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
            return
        }
        http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
        return
    }

    if input.Name == "" {
        http.Error(w, "name is required", http.StatusBadRequest)
        return
    }

    // process...
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(map[string]string{"id": "new-id"})
}

Middleware

Middleware wraps http.Handler with additional behavior. The pattern is a function that takes a handler and returns a handler:

// Logger middleware — logs every request after it completes
func logger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        // Wrap the writer to capture the status code
        rw := &statusWriter{ResponseWriter: w, code: http.StatusOK}

        next.ServeHTTP(rw, r)

        slog.Info("request",
            slog.String("method", r.Method),
            slog.String("path", r.URL.Path),
            slog.Int("status", rw.code),
            slog.Duration("duration", time.Since(start)),
        )
    })
}

type statusWriter struct {
    http.ResponseWriter
    code int
}

func (sw *statusWriter) WriteHeader(code int) {
    sw.code = code
    sw.ResponseWriter.WriteHeader(code)
}

// Recovery middleware — catch panics, return 500
func recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("panic: %v\n%s", rec, debug.Stack())
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

// Chain middleware — apply outermost first
func chain(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler {
    for i := len(middleware) - 1; i >= 0; i-- {
        h = middleware[i](h)
    }
    return h
}

// Usage: logger runs first, recovery second
handler := chain(mux, logger, recovery)

HTTP Client: The Right Configuration

http.DefaultClient has no timeout — it will hang indefinitely on a slow or unresponsive server. Always create a client with explicit timeouts:

var httpClient = &http.Client{
    Timeout: 30 * time.Second,  // total request timeout (connect + send + read)
    Transport: &http.Transport{
        DialContext: (&net.Dialer{
            Timeout:   5 * time.Second,   // TCP connection timeout
            KeepAlive: 30 * time.Second,
        }).DialContext,
        TLSHandshakeTimeout:   10 * time.Second,
        ResponseHeaderTimeout: 10 * time.Second, // time to first response byte
        MaxIdleConns:          100,
        MaxIdleConnsPerHost:   10,
        IdleConnTimeout:       90 * time.Second,
        DisableCompression:    false,
    },
}

Create this once as a package-level variable and reuse it. Each &http.Client{} creates a new transport and connection pool — creating one per request defeats keep-alive and leaks file descriptors.

Making Requests with Context

Always use http.NewRequestWithContext rather than http.Get or http.Post — it threads the caller’s context into the request, enabling cancellation and timeout propagation:

func fetchUser(ctx context.Context, id string) (*User, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet,
        "https://api.example.com/users/"+id, nil)
    if err != nil {
        return nil, fmt.Errorf("building request: %w", err)
    }
    req.Header.Set("Accept", "application/json")
    req.Header.Set("Authorization", "Bearer "+getToken())

    resp, err := httpClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("fetching user %s: %w", id, err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        // Read and discard body to enable connection reuse
        io.Copy(io.Discard, resp.Body)
        return nil, fmt.Errorf("unexpected status %d for user %s", resp.StatusCode, id)
    }

    var user User
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        return nil, fmt.Errorf("decoding user: %w", err)
    }
    return &user, nil
}

Always defer resp.Body.Close() and always drain the body before closing (or io.Copy(io.Discard, resp.Body)) — this lets the connection return to the pool for reuse. If you close without draining, the connection is discarded.

POST Requests with JSON Body

func createUser(ctx context.Context, name, email string) (*User, error) {
    payload, err := json.Marshal(map[string]string{
        "name":  name,
        "email": email,
    })
    if err != nil {
        return nil, err
    }

    req, err := http.NewRequestWithContext(ctx, http.MethodPost,
        "https://api.example.com/users",
        bytes.NewReader(payload))
    if err != nil {
        return nil, err
    }
    req.Header.Set("Content-Type", "application/json")

    resp, err := httpClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("creating user: %w", err)
    }
    defer resp.Body.Close()
    io.Copy(io.Discard, resp.Body)

    if resp.StatusCode != http.StatusCreated {
        return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
    }

    var created User
    json.NewDecoder(resp.Body).Decode(&created)
    return &created, nil
}

Testing Servers and Clients

net/http/httptest lets you test handlers without starting a real server and test clients against a real server without external dependencies:

// Test a handler directly
func TestGetUser(t *testing.T) {
    handler := http.HandlerFunc(getUser)
    req := httptest.NewRequest(http.MethodGet, "/users/42", nil)
    rec := httptest.NewRecorder()

    handler.ServeHTTP(rec, req)

    if rec.Code != http.StatusOK {
        t.Fatalf("status = %d; want 200", rec.Code)
    }
}

// Test a client against a real test server
func TestFetchUser(t *testing.T) {
    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode(User{ID: 1, Name: "Alice"})
    }))
    defer ts.Close()

    user, err := fetchUserFromURL(context.Background(), ts.URL+"/users/1")
    if err != nil {
        t.Fatal(err)
    }
    if user.Name != "Alice" {
        t.Errorf("name = %s; want Alice", user.Name)
    }
}

Summary

  • Always configure http.Server with ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout — no timeouts means denial-of-service vulnerability
  • Use http.NewServeMux() with method-qualified patterns (Go 1.22+) for most routing needs
  • Set headers before WriteHeader; once written, headers cannot be changed
  • Wrap r.Body with http.MaxBytesReader to enforce request size limits
  • Create one *http.Client with a configured Transport and reuse it everywhere — never create one per request
  • Use http.NewRequestWithContext for all outgoing calls; always drain and close the response body

Resources

Comments

👍 Was this article helpful?