Go vs Java Spring Boot: Benchmarking CPU Performance, Latency, Throughput, and Memory
An experimental comparison using Go, Spring Boot, k6, and a 12-core-CPU Ubuntu machine.
Go vs Java Spring Boot: Benchmarking CPU Performance, Latency, Throughput, and Memory
Introduction
Go and Java are both widely used for building backend services, but they approach application development and runtime execution differently.
Go is known for its lightweight runtime, fast compilation, and straightforward concurrency model. Java, particularly with Spring Boot, offers a mature ecosystem, a powerful JVM, and extensive support for building production-grade backend applications.
I wanted to move beyond general opinions about which language is faster and run a small benchmark of my own.
The objective was to compare Go and Java Spring Boot under CPU-intensive workloads and observe how their response times, throughput, CPU consumption, and memory footprints behaved as concurrency increased.
This was not an attempt to prove that one language is universally better. It was an experiment to understand how two implementations behaved under a specific workload and testing environment.
1. Benchmark Environment
Both applications were tested on the same Ubuntu machine with 12 logical CPUs.
| Component | Configuration |
|---|---|
| Operating system | Ubuntu Linux |
| Available logical CPUs | 12 |
| Go API | Go HTTP server |
| Java API | Java Spring Boot |
| Load-testing tool | k6 |
| Go endpoint | http://localhost:7000/compute?n=1000000 |
| Java endpoint | http://localhost:8000/compute?n=1000000 |
| Workload | CPU-intensive prime counting |
| Concurrency levels | 10, 25, 50, and 100 virtual users |
Both APIs implemented the same prime-counting algorithm. The Go service ran on port 7000, while the Spring Boot service ran on port 8000.
An important limitation of this setup is that the load generator and both backend applications ran on the same machine. Consequently, the results reflect not only backend performance but also competition for CPU resources between k6 and the applications.
2. The Workload: Counting Prime Numbers
I chose a computational workload rather than a database-backed API because I wanted to examine CPU-intensive request processing without introducing database query performance, network latency, or external service dependencies.
The endpoint calculated the number of prime numbers up to 1,000,000 using trial division.
The implementation skipped even numbers and tested potential divisors up to the square root of each candidate number.
Both implementations returned the same expected result:
{
"n": 1000000,
"primeCount": 78498
}
This correctness check was important. A faster implementation would not be meaningful if it performed less work or returned an incorrect answer.
For an initial single-request test with a smaller input of 500,000, Go returned 41,538 primes in approximately 21.74 ms, while Java returned the same count in approximately 19.62 ms.
These initial timings were useful for validating the implementations, but the more interesting observations came from concurrent load testing.
3. How I Tested the APIs
I used k6 to simulate concurrent users sending requests to each service.
The test ran the Go scenario first and started the Java scenario approximately 35 seconds later. Each request checked the HTTP status, validated the JSON response, and verified the expected prime count.
The script also recorded two types of latency:
HTTP request duration, which includes the overall request-response time.
Application-reported computation time, measured around the prime-counting operation.
A short sleep of 100 milliseconds was included after each request.
The virtual-user counts were increased across separate test runs: 10, 25, 50, and 100.
The main metrics were average latency, median latency, p95 latency, request throughput, HTTP errors, and process resource usage.
The distinction between median and p95 matters. The median represents the middle request, while p95 indicates the latency below which 95% of measured requests completed. A system can have a reasonable median while still experiencing substantially slower requests in the tail.
4. Results: CPU Computation Time
The following table contains the application-reported computation times from the load tests.
| Virtual users | Go average | Java average | Go p95 | Java p95 |
|---|---|---|---|---|
| 10 | 59.53 ms | 55.24 ms | 70.86 ms | 64.15 ms |
| 25 | 129.25 ms | 123.96 ms | 164.47 ms | 186.77 ms |
| 50 | 327.69 ms | 327.85 ms | 412.28 ms | 563.69 ms |
| 100 | 715.82 ms | 752.11 ms | 926.60 ms | 1,369.85 ms |
What I observed
At 10 virtual users, Java had a lower average computation time than Go. The same was true at 25 virtual users, although Java's p95 was higher than Go's at that concurrency level.
At 50 virtual users, both implementations had nearly identical average computation times. However, Java showed a substantially higher p95.
At 100 virtual users, Go had a lower average computation time and a lower p95 than Java.
The 100-user test showed:
Go average computation time: 715.82 ms
Java average computation time: 752.11 ms
Go p95 computation time: 926.60 ms
Java p95 computation time: 1,369.85 ms
In this particular run, Go's average computation time was approximately 4.8% lower than Java's. Its p95 was approximately 32.3% lower.
However, these are observations from individual test runs, not statistically established performance differences. JVM warm-up, operating-system scheduling, garbage collection, and competition for CPU resources can affect results. Repeated, controlled experiments are necessary before drawing stronger conclusions.
5. Results: HTTP Request Latency
Application computation time does not capture the entire request lifecycle. I therefore compared the HTTP request durations reported by k6.
| Virtual users | Go average | Java average | Go p95 | Java p95 |
|---|---|---|---|---|
| 10 | 59.75 ms | 55.96 ms | 71.09 ms | 64.96 ms |
| 25 | 135.16 ms | 126.25 ms | 172.81 ms | 189.05 ms |
| 50 | 372.34 ms | 333.80 ms | 458.71 ms | 573.36 ms |
| 100 | 839.44 ms | 771.52 ms | Approximately 1.06 s | Approximately 1.40 s |
The pattern was not entirely consistent.
Java had lower average HTTP latency in all four runs. Go had a lower p95 at 25, 50, and 100 virtual users, while Java had a lower p95 at 10 virtual users.
This distinction is worth highlighting. A lower average does not necessarily mean better tail latency. Evaluate backend performance using multiple latency metrics rather than a single number.
At 100 virtual users, the results suggest that Go handled the tail of the observed workload better, while Java retained a lower average HTTP request duration.
Neither observation alone establishes a universal performance advantage.
6. Results: Throughput
Throughput indicates how many requests a service processes per second during the test.
| Virtual users | Go requests/sec | Java requests/sec |
|---|---|---|
| 10 | 28.78 | 29.44 |
| 25 | 48.93 | 50.78 |
| 50 | 48.91 | 53.06 |
| 100 | 49.25 | 52.89 |
Java recorded slightly higher throughput at every tested concurrency level.
At 100 virtual users, the measured rates were approximately 49.25 requests per second for Go and 52.89 requests per second for Java.
That is approximately 7.4% higher throughput for Java in this run.
However, throughput increased much less between 25 and 100 virtual users than it did between 10 and 25. This suggests that the test setup was approaching a resource or execution bottleneck.
The combined throughput of both sequential scenarios was approximately 102 requests per second in the 100-user run. This combined figure should not be treated as the capacity of either individual service.
The throughput results also need context. Because k6 and both APIs shared the same host, the load generator competed with the services for CPU time. These figures therefore describe this local experiment, not the maximum throughput the applications could achieve on dedicated servers.
7. What Happened at Higher Concurrency?
The latency results became more interesting as I increased the number of virtual users.
At 10 virtual users, average HTTP latency was around 56 to 60 milliseconds. At 100 virtual users, it rose to about 772-839 milliseconds.
The CPU-intensive operation remained the same. What changed was the number of requests competing to execute that operation.
A trial-division prime-counting algorithm performs substantial CPU work for each request. When many requests arrive concurrently, they compete for available CPU time. Increasing concurrency does not automatically increase throughput because the machine has finite processing capacity.
During an earlier system-level observation using mpstat, total CPU utilization was approximately 98%, with only around 0.25% idle time. All 12 logical CPUs were heavily utilized.
This is evidence that the machine was close to CPU saturation during that observation.
Once the CPU is saturated, adding more virtual users can increase the number of requests waiting for execution without producing a proportional increase in completed requests per second.
The results illustrate an important performance-engineering principle: concurrency and throughput are related, but they are not interchangeable.
8. CPU Usage: An Important Measurement Caveat
I also used pidstat to observe the backend processes.
The captured output included the following average values across the entire sampling period:
| Metric | Go process | Java process |
|---|---|---|
| Reported average CPU | 492.49% | 524.36% |
| Average resident memory (RSS) | Approximately 18.2 MiB | Approximately 201.4 MiB |
| Average virtual address space (VSZ) | Approximately 2.32 GiB | Approximately 15.24 GiB |
On Linux, process CPU utilization can exceed 100% because 100% represents one fully utilized logical CPU in this reporting convention. A process using roughly 1,100% CPU is consuming approximately 11 logical CPUs' worth of processing time.
However, the average CPU figures above must not be interpreted as evidence that Go uses less CPU per request.
The two processes were primarily active during different portions of the sequential test. Their averages included periods when they were idle. This makes the overall averages unsuitable for a direct comparison of CPU efficiency.
A more rigorous comparison would collect CPU samples only during each service's active load window, preferably with k6 running on a separate machine. It would also compare CPU time per completed request, alongside throughput and latency.
The resource measurements are useful as an initial observation, but they do not yet establish which implementation is more CPU-efficient.
9. Memory Usage: The Clearest Difference in This Experiment
The most pronounced difference in my process measurements was resident memory.
The Go process averaged approximately 18.2 MiB of RSS, compared with approximately 201.4 MiB for the Java Spring Boot process.
That is roughly an elevenfold difference in resident memory for these specific implementations.
RSS, or resident set size, represents the physical memory currently resident for a process. VSZ, or virtual size, represents its virtual address space and should not be interpreted as physical RAM consumption.
This difference is plausible because a minimal Go HTTP server and a Spring Boot application have different runtime architectures. The Java process includes the JVM and the Spring Boot application stack, along with the associated framework and runtime overhead.
Nevertheless, this result is specific to the applications and configurations tested. It does not mean that every Go service will use one-eleventh the memory of every Java service.
A production comparison would need equivalent functionality, representative dependencies, consistent runtime configurations, and measurements taken after the applications have reached stable operating conditions.
Even with those qualifications, the measurements demonstrate why memory footprint can be an important factor when selecting a runtime for resource-constrained environments.
10. What This Benchmark Does Not Prove
A benchmark is only as useful as the conclusions supported by its methodology.
There are several limitations to this experiment.
First, the load generator and both APIs ran on the same machine. CPU contention could affect application latency, throughput, and scheduling.
Second, the Go and Java scenarios ran sequentially. Differences in JVM warm-up, host temperature, background processes, and other system conditions could influence the results.
Third, each reported concurrency level represents a single run in the measurements available for this comparison. There are no repeated-run confidence intervals, so small differences should not be treated as definitive.
Fourth, this is a CPU-intensive synthetic workload. It does not represent every backend application. A real API may spend substantial time waiting for a database, Redis, an external HTTP service, or disk I/O.
Fifth, the reported computation time is elapsed wall-clock time around the calculation. It is not a direct measurement of CPU execution time. When a process is descheduled, its elapsed time can increase even though it is not actively using the CPU.
Finally, this experiment does not compare garbage collection behavior, startup time, throughput under database load, memory growth over long periods, or performance after JVM warm-up and optimization.
These limitations do not make the benchmark useless. They define what it can reasonably tell us.
11. How I Would Improve the Benchmark
My next iteration would focus on reducing measurement noise and collecting more useful resource metrics.
Separate the load generator. Run k6 on another machine so that the backend has more of the test host's CPU capacity available.
Run one backend at a time. Test Go and Java independently under identical conditions.
Warm up the JVM. Send warm-up requests before collecting Java measurements and ensure the measurement period is long enough for stable behavior.
Repeat each test. Run each concurrency level at least three to five times and compare the median results across runs.
Measure CPU time per request. Capture process CPU usage during the active interval and divide CPU consumption by successfully completed requests.
Record more latency percentiles. Collect p50, p95, and p99 rather than relying on average latency alone.
Measure memory over time. Track RSS during warm-up and steady-state operation, not just an average across a mixed period.
Test more realistic workloads. Add database-backed endpoints, JSON serialization, concurrent I/O, and representative application dependencies.
Verify the same conditions. Keep the algorithm, response payload, request rate or concurrency model, timeout settings, and application behavior equivalent.
This would provide a stronger basis for evaluating the trade-offs between Go and Java Spring Boot.
12. Final Verdict
Based on the measurements from this experiment, there is no universal winner.
Go showed lower average computation time and lower p95 computation latency in the 100-virtual-user run. Java recorded slightly higher throughput and lower average HTTP request latency across the tested concurrency levels.
The process measurements also showed a substantially smaller resident memory footprint for the Go implementation. The CPU averages, however, were collected across sequential active and idle periods and cannot establish that Go uses less CPU per request.
The most useful conclusion is that runtime performance depends on the workload, implementation, framework, resource allocation, and measurement methodology.
Go may be attractive when a small memory footprint and straightforward concurrent services are priorities. Java Spring Boot may be attractive when its mature ecosystem, framework capabilities, and integration with existing enterprise systems are important.
For a production decision, I would use these results as a starting point rather than a final verdict. I would first repeat the benchmark with an isolated load generator, warmed-up applications, and active-window CPU measurements.
The goal of benchmarking is not to find a language that wins every test. It is to gather enough reliable evidence to choose the right tool for a specific engineering problem.
Closing Thoughts
This experiment reminded me that measuring performance is more demanding than simply sending more requests and comparing two averages.
A meaningful benchmark requires correctness checks, repeatable conditions, resource monitoring, and a clear understanding of what each metric represents.
The initial results are useful, particularly the memory-footprint difference and the changes in latency under higher concurrency. But the next step is to improve the experimental design and determine which differences remain after controlling for system-level contention.
That is the part of performance engineering I find most valuable: not just collecting numbers, but understanding what those numbers actually mean.
Benchmark note: Results were collected from local tests on a 12-logical-CPU Ubuntu machine using k6, a Go HTTP API, and a Java Spring Boot API. Both applications and the load generator shared the same machine. Results represent individual test runs and should not be interpreted as universal language-performance rankings.
About the author
- Backend and DevOps engineer exploring distributed systems, cloud infrastructure, performance engineering, and reliability. I build, benchmark, and document real-world engineering projects using Go, Java, Spring Boot, Linux, Kubernetes, and AWS.
Comments
Join the discussionNo comments yet. Be the first to comment.
Backend 101
A step-by-step guide to backend development with Kotlin & Spring Boot — from APIs to security, databases, testing, scaling, and cloud deployments. Learn to build secure, scalable, production-ready backends.





