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:
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:
Java:
one request → one thread (or virtual thread)
thread pools introduce implicit backpressure
starvation is a real failure mode
Worth remembering:
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)
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:
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:
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:
Difference:
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:
Neither is “better”. They fail differently.
If You Do Only One Thing
Those should be your checklists:
Go checklist:
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: