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
M

Software Engineer x Data Engineer - I make the world a better place to live with software that enables data-driven decision-making

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: