Go vs Spring Boot: After 2 years with both in production
Go vs Spring Boot: After 2 years with both in production
I've worked 2 years with microservices in Go and Spring Boot at the same company, same team, same infrastructure. No "I did a hello world and Go is 1000x better". Here's the real data.
Mental Model
The fundamental difference isn't the language, it's the philosophy:
| Aspect | Go | Spring Boot |
|---|---|---|
| Philosophy | Explicit > implicit | Convention over configuration |
| Errors | Return values | Exceptions |
| Concurrency | Goroutines (built-in) | Threads + reactive (opt-in) |
| Dependencies | Minimal, powerful stdlib | Many, rich ecosystem |
| Build | Static binary | JAR + JVM |
Go forces you to be explicit: if something can fail, return error. If you need concurrency, use goroutines. Everything is in plain sight.
Spring Boot abstracts for you: @Transactional handles transactions, @Async converts methods to async, Spring Security "just works". Until it doesn't.
Under the Hood: Where each one wins
Startup and memory
Spring Native (GraalVM) greatly improves startup, but the build takes 5-10 minutes and not the entire Spring ecosystem is compatible.
For serverless (Lambda, Cloud Functions), Go clearly wins. Cold starts of 100ms vs 4 seconds is the difference between acceptable UX and unacceptable.
For Kubernetes with many pods, the memory difference matters: 8MB vs 180MB means 20x more pods per node.
Performance under load
Benchmark: 10k requests, 100 concurrent connections
Endpoint: GET /api/users/:id (PostgreSQL query)
| Go (net/http) | Spring WebMVC | Spring WebFlux
--------------+---------------+---------------+----------------
Requests/sec | 45,000 | 12,000 | 38,000
p50 latency | 2.1ms | 8.2ms | 2.6ms
p99 latency | 4.8ms | 45ms | 12ms
CPU usage | 35% | 85% | 60%WebFlux can get close to Go performance, but it completely changes how you write code. Goodbye to readable stack traces, hello Mono<T> and flux chain debugging.
Concurrency: goroutines vs threads
Go:
func processUsers(ids []int) []User {
results := make(chan User, len(ids))
var wg sync.WaitGroup
for _, id := range ids {
wg.Add(1)
go func(id int) {
defer wg.Done()
user := fetchUser(id) // Blocking, but in goroutine
results <- user
}(id)
}
wg.Wait()
close(results)
var users []User
for u := range results {
users = append(users, u)
}
return users
}Spring Boot (WebFlux):
public Flux<User> processUsers(List<Integer> ids) {
return Flux.fromIterable(ids)
.flatMap(id -> webClient.get()
.uri("/users/{id}", id)
.retrieve()
.bodyToMono(User.class))
.collectList()
.flatMapMany(Flux::fromIterable);
}Go code is longer but you can debug it with a normal stack trace. In WebFlux, when something fails in the middle of a reactive chain, good luck.
Error handling
Go:
user, err := db.GetUser(id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("fetching user %d: %w", id, err)
}Spring:
try {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
} catch (DataAccessException e) {
throw new ServiceException("Error fetching user", e);
}Go's approach is verbose but explicit. In Spring, you need to know what exceptions each layer can throw (spoiler: documentation is never complete).
Real example: Same service in both
Payment processing API
Go (395 total lines):
// main.go - Entry point
func main() {
cfg := config.Load()
db := database.Connect(cfg.DatabaseURL)
defer db.Close()
paymentService := payment.NewService(db, stripe.NewClient(cfg.StripeKey))
handler := api.NewHandler(paymentService)
mux := http.NewServeMux()
mux.HandleFunc("POST /payments", handler.CreatePayment)
mux.HandleFunc("GET /payments/{id}", handler.GetPayment)
log.Printf("Starting server on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}Spring Boot (project structure):
src/main/java/com/example/payments/
├── PaymentsApplication.java
├── config/
│ ├── StripeConfig.java
│ └── SecurityConfig.java
├── controller/
│ └── PaymentController.java
├── service/
│ └── PaymentService.java
├── repository/
│ └── PaymentRepository.java
├── model/
│ ├── Payment.java
│ └── PaymentDTO.java
├── exception/
│ ├── PaymentNotFoundException.java
│ └── GlobalExceptionHandler.java
└── mapper/
└── PaymentMapper.java11 Java files vs 4 Go files for the same functionality.
More files = more places to look for bugs, more imports, more refactoring when you change something. Spring's "organization" is sometimes overhead.
When to choose each one
Choose Go when:
- Serverless/Lambda: Cold starts matter
- CLI tools: Static binary, trivial distribution
- Infrastructure: Proxies, load balancers, DevOps tools
- High concurrency: Thousands of simultaneous connections
- Limited resources: Edge computing, IoT
Choose Spring Boot when:
- Java ecosystem: Integration with existing Java libraries
- Enterprise features: OAuth2, SAML, AD integration
- Java team: Learning curve vs productivity
- Complex transactions: Spring Transaction Management is very mature
- Powerful ORM: JPA/Hibernate for complex schemas
Gotchas of each stack
Go
- Repetitive error handling:
if err != nileverywhere - No powerful generics: Until Go 1.18, copy/paste for each type
- Manual dependency injection: No magic, but also no magic
- Immature ORM: GORM has its quirks, sqlx is more raw
Spring Boot
- Startup time: 4+ seconds in development is frustrating
- Excessive magic: When
@Transactionaldoesn't do what you expect - ClassNotFoundException hell: Dependency version conflicts
- Memory footprint: 300MB+ for a CRUD
TL;DR Checklist
- Go for: serverless, CLI, infra tools, high concurrency, limited resources
- Spring for: enterprise, Java ecosystem, complex transactions, Java teams
- Go starts in ms, Spring in seconds (without GraalVM)
- Go uses 10x less base memory
- Spring WebFlux can match Go performance, but changes how you code
- Go's verbosity is explicit; Spring's magic is implicit
- Consider Spring Native to improve startup (with limitations)
- In Kubernetes, memory matters for pod density
My personal choice: Go for new services I control, Spring Boot when integrating with existing Java ecosystem. There's no universal answer.
Want me to go deeper into WebFlux vs goroutines or how to gradually migrate from Spring to Go?