HTTP Server in Go vs Java: The Stuff That Actually Hurts in Production
How the defaults betray you (and how to fix them)

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/healthGET/api/v1/items/{id}
Requirements (non-negotiable in production):
JSONresponserequest 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 →
contextcancelledDB 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:



