Skip to main content

Command Palette

Search for a command to run...

HTTP Server in Go vs Java: The Stuff That Actually Hurts in Production

How the defaults betray you (and how to fix them)

Updated
5 min read
HTTP Server in Go vs Java: The Stuff That Actually Hurts in Production

Building an HTTP server is easy.

Building one that survives real traffic, slow dependencies, deploys, and partial failures is where Go and Java start to diverge in interesting - and sometimes painful - ways.

This article is not about frameworks, annotations, or syntactic sugar. It’s about defaults, failure modes, and things that break at 2 in the morning.

Same Endpoint, Same Expectations

We’ll expose two ednpoints:

  • GET /health

  • GET /api/v1/items/{id}

Requirements (non-negotiable in production):

  • JSON response

  • request timeouts

  • graceful shutdown

  • bounded resource usage

  • request ID propagation

Minimal HTTP Server

For Go we’re going to use the net/http package (ignore unhandled errors, it’s for the simplicity of the article):

package main

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

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

    mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("ok\n"))
    })

    mux.HandleFunc("/api/v1/items/{id}", func(w http.ResponseWriter, r *http.Request) {
        id := r.PathValue("id")
        json.NewEncoder(w).Encode(map[string]string{
            "id": id,
        })
    })

    log.Fatal(http.ListenAndServe(":8080", mux))
}

FOr Java we go with the Spring Boot:

@RestController
@RequestMapping("/api/v1/items")
class ItemController {

    @GetMapping("/{id}")
    Map<String, String> getItem(@PathVariable String id) {
        return Map.of("id", id);
    }
}

@RestController
class HealthController {

    @GetMapping("/health")
    String health() {
        return "ok";
    }
}

Both work.

Both are dangerous.

The First Production Lie: “defaults are fine”

They’re not xD

Handle timeouts - the silent killers - first.

Go - explicit and unavoidable:

server := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadTimeout:       5 * time.Second,
    ReadHeaderTimeout: 2 * time.Second,
    WriteTimeout:      10 * time.Second,
    IdleTimeout:       60 * time.Second,
    MaxHeaderBytes:    1 << 20,
}

If you don’t set these:

  • slow clients can eat your goroutines

  • you’re vulnerable to trivial DoS attacks

Java - hidden behind layers. In Spring Boot, timeouts live in configuration, not code:

server:
  tomcat:
    connection-timeout: 5s
    max-connections: 200
    threads:
      max: 200

What hurts:

  • default vary by container

  • engineers assume “the framework handles it” (!)

  • thread pools silently saturate

Concurrency Model: Goroutines vs Threads (Where Pain Differs)

Go:

  • one request → one goroutine

  • cheap, but not free

  • unlimited concurrency unless limit it

Java:

  • one request → one thread (or virtual thread)

  • thread pools introduce implicit backpressure

  • starvation is a real failure mode

Worth remembering:

  • Go fails by overcommitment

  • Java fails by starvation

More about the concurrency comparison between both technologies you can find in the previous article - https://blog.skopow.ski/concurrency-in-go-vs-java

Backpressure: What Happens When Downstream is Slow?

This is where production systems die 💀

Go: you must build it yourself

var sem = make(chan struct{}, 100) // max 100 in-flight requests

func limit(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        select {
        case sem <- struct{}{}:
            defer func() { <-sem }()
            next.ServeHTTP(w, r)
        default:
            http.Error(w, "too many requests", http.StatusTooManyRequests)
        }
    })
}

What’s good in it: it’s explicit and predictable.

What’ bad: it’s easy to forget and every team reinvents it all over.

In Java it’s more like a backpressure by accident:

  • servlet thread pool fills up

  • requests queue

  • latency explodes

  • health checks start timing out

  • Kubernetes kills the pod 💀

Virtual threads help, but do not fix slow downstreams.

Context Propagation and Cancellation

Go: cancellation is contagious (in a good way):

req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
  • client disconnects → context cancelled

  • DB calls, HTTP calls, Kafka producers can stop

  • If you propagate the context correclty

Context are your powerful friend. Use them!

Java: cancellation exists, but is optional:

  • request cancellation rarely reaches DB drivers

  • blocking calls ignore interrupts

  • vierual threads improve ergonomics, not semantics

Reality:

  • Go makes cancellation hard to avoid

  • Java makes it easy to ignore

Graceful Shutdown (Where Many Apps Lie to Kube)

Go: explicit and correct by default:

go func() {
    if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatal(err)
    }
}()

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
<-ctx.Done()

shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
server.Shutdown(shutdownCtx)
  • stop accepting requests

  • wait for in-flight ones

  • deterministic

In Java it’s more like a framework magic (until it isn’t).

Spring does graceful shutdown if:

  • probes are configured correctly

  • timeouts match Kubernetes settings

  • no custom executors block shutdown

When it fails, debugging is painful.

Middleware / Filters: Order Matters More Than You Think

In Go:

  • the middleware is explicit

  • order is visible in code

  • ugly, but honest

In Java:

  • filters, interceptors, aspects

  • execution order spread across annotations and configs

  • someone will eventually log before request ID is set

Rule of thumb:
If you can’t draw the request flow from memory, it’s already too complex.

Observability: What You Need on Day One

Both ecosystems need the same signals:

  • request count by status

  • latency percentiles

  • in-flight requests

  • downstream dependency latency

  • structured logs with request ID

Difference:

  • Go forces you to add this consciously

  • Java gives you 80% for free - and hides the remaining 20%

Failure Patterns You Will See In Real Life

In Go those are:

  • mem spikes due to unbounded concurrency

  • goroutine leaks from missing context cancellation

  • accidental DoS due to missing limits

In Java:

  • thread pool exhaustion

  • cascading timeouts

  • “CPU is low, but latency is insane”

Neither is “better”. They fail differently.

If You Do Only One Thing

Those should be your checklists:

Go checklist:

  • see all HTTP timeouts

  • limit in-flight requests

  • propagate context everywhere

  • implement graceful shutdown

  • expose basic metrics

Java checklist:

  • cap thread pools intentionally

  • align server + Kubernetes timeouts

  • monitor queue length, not just CPU

  • test shutdown under load

  • don’t trust defaults blindly

Final Thoughts

Go makes danger visible.
Java makes danger comfortable.

Both can build excellent HTTP servers - but only if you understand how they fail, not how they start.

Sources

The complete Go server with all the good practices described today you can find - as always - in the K.I.S.S. repository: