When I started building web servers in Go, I kept running into the same slightly
annoying question: should I stay with net/http or pick a framework? “It depends”
is the correct answer and also the least satisfying one, so I spent an evening
running the simplest benchmark I could think of. I wanted to see how much speed a
framework costs before database calls, JSON, authentication, and the rest of a
real application arrive to make the router look unimportant.
The comparison includes net/http,
Fiber,
Gin, and
Echo. Fiber sits on top of
fasthttp, while the others stay in the net/http world. I later added
Fastify and Express because I
had Node.js installed and curiosity is how a small benchmark loses an evening.
This test measures raw routing and response overhead. Each server accepts a
request on /ping and returns a tiny string. A real endpoint has middleware,
parses and encodes JSON, waits on a database, and may call another service before
it sends anything back. The numbers below describe these almost empty handlers
on my machine. They do not predict the throughput of an application that happens
to use the same framework.
The setup on my desk#
I ran everything on this MacBook Pro:
| Spec | Value |
|---|---|
| Model | MacBook Pro M4 Pro |
| CPU | 14 cores |
| GPU | 20 cores |
| Neural Engine | 16 cores |
| RAM | 48 GB |
I used wrk for the HTTP load. On macOS with
Homebrew, brew install wrk installs it. Every result came
from this command:
wrk -t12 -c400 -d30s http://localhost:3000/ping
That means 12 threads, 400 concurrent connections, and a 30-second run. The
client and server both ran on the same laptop, so they also competed for its CPU.
Each implementation only matched /ping and returned a tiny body. There was no
JSON, middleware, database, or useful product attached to any of this :)
Go servers#
Standard library:
package main
import (
"net/http"
)
func main() {
http.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("pong"))
})
http.ListenAndServe(":3000", nil)
}
Nothing fancy. This is what the server looks like when I refuse to import a third-party library :P
Fiber:
package main
import (
"log"
"github.com/gofiber/fiber/v3"
)
func main() {
app := fiber.New()
app.Get("/ping", func(c fiber.Ctx) error {
return c.SendString("pong!")
})
log.Fatal(app.Listen(":3000"))
}
Fiber’s API immediately reminded me of Express.js, which is probably part of the appeal for people coming from Node lol.
Gin:
package main
import (
"github.com/gin-gonic/gin"
)
func main() {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.GET("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
r.Run(":3000")
}
I used gin.New() instead of gin.Default() so Gin would not bring its default
logger and recovery middleware into an otherwise empty comparison.
Echo:
package main
import (
"net/http"
"github.com/labstack/echo/v4"
)
func main() {
e := echo.New()
e.HideBanner = true
e.GET("/ping", func(c echo.Context) error {
return c.String(http.StatusOK, "pong")
})
e.Start(":3000")
}
Echo feels close to Gin in a tiny example like this. The API changes a little, but both stay pretty easy to read.
Go results#
These were the results from my run:
| Framework | Requests/sec | Avg Latency | Transfer/sec |
|---|---|---|---|
| Fiber | 163,150 | 2.23ms | 18.83 MB |
| Gin | 90,474 | 4.34ms | 10.35 MB |
| net/http | 90,157 | 4.35ms | 10.32 MB |
| Echo | 89,452 | 4.38ms | 10.24 MB |
Fiber reached about 163k requests per second, roughly 1.8 times the rest of the Go
group in this run. That gap makes sense given its foundation. fasthttp reuses
request and response objects aggressively and works hard to avoid allocations.
The speed also comes with its own interfaces, which means middleware written for
standard net/http does not plug straight in. Choosing Fiber means choosing more
of the Fiber and fasthttp ecosystem, and that can be completely fine when the
trade makes sense for the service.
Gin, net/http, and Echo all landed around 90k requests per second. Their spread
was close enough to the normal wobble of a laptop benchmark that I would not rank
them from this table. Gin finishing slightly above net/http surprised me tbh,
although the more useful result is how little overhead its router added here.
Within this particular test, I could pick Gin or Echo for routing, parameters,
binding, and middleware without giving up a meaningful amount of raw throughput.
That is a much nicer result than discovering my preferred API had a huge hidden
cost.
Real apps do more#
Raw throughput is only part of the story. I also watched memory usage during the benchmarks:
| Framework | Baseline | Under Load | Stable After Load |
|---|---|---|---|
| net/http | ~8 MB | ~45 MB | ~15 MB |
| Fiber | ~10 MB | ~55 MB | ~18 MB |
| Gin | ~12 MB | ~52 MB | ~20 MB |
| Echo | ~11 MB | ~50 MB | ~19 MB |
All four Go servers stayed in a pretty ordinary memory range. Usage climbed while
hundreds of connections and their buffers were active, then settled after the
load stopped and garbage collection caught up. Fiber sat a little higher in my
run, which fits with fasthttp maintaining its own pools. None of these numbers
would decide the framework for the kind of services I usually build, although a
small container with a hard memory limit might make me repeat the test more
carefully.
A real handler quickly makes the router overhead look tiny. Parsing input,
checking auth, waiting for a query, calling another service, and encoding a large
response can each take far longer than matching /ping. When one of my services
is slow, I would check queries, indexes, N+1 behavior, network calls, allocations,
and response size before replacing the framework. This
Go Prisma article
has a useful example of the N+1 problem. I also wrote separately about the amount
of work that can hide behind one innocent JSON encoder call because that part kept
bothering me after this benchmark.
For a normal REST API, Gin and Echo give me convenient routing and middleware
while staying very close to net/http in this test. Fiber becomes interesting
when its API feels better to the team or when the fasthttp throughput is useful
enough to accept the ecosystem boundary. The standard library remains a lovely
choice for a small server, a library, or any project where the extra framework
surface would mostly sit unused. The table cannot choose for me, but it did remove
the fear that a conventional net/http framework automatically wastes a pile of
performance.
Node.js comparison#
The post started because I was comparing Go and Node.js in the first place, so I
also ran Express and
Fastify with the same wrk command. By this point the
small Go test had officially escaped its original scope, but the servers were
already on my machine and I wanted to see the numbers.
Express:
const express = require('express');
const app = express();
app.get('/ping', (req, res) => {
res.send('pong');
});
app.listen(3000);
Fastify:
const fastify = require('fastify')({ logger: false });
fastify.get('/ping', async (request, reply) => {
return 'pong';
});
fastify.listen({ port: 3000 });
Results:
| Framework | Requests/sec | Avg Latency | Transfer/sec |
|---|---|---|---|
| Fiber | 163,150 | 2.23ms | 18.83 MB |
| Fastify (nodejs) | 103,895 | 4.36ms | 16.65 MB |
| Gin | 90,474 | 4.34ms | 10.35 MB |
| net/http | 90,157 | 4.35ms | 10.32 MB |
| Echo | 89,452 | 4.38ms | 10.24 MB |
| Express (nodejs) | 67,676 | 7.27ms | 14.84 MB |
Fastify beating Gin, net/http, and Echo was the result I did not expect. V8 and
Fastify are doing very well here, although the comparison still has the same giant
caveat: these handlers do almost nothing. Express finished last at around 67k
requests per second, which is still an absurd amount of pong for most apps I am
likely to build. Fiber kept the highest raw throughput in this run.
I am not much of a number person, which should be obvious from the fact that I ride a Classic 350 and drive a Honda lmao. I had some time, a question, and enough frameworks installed to make the laptop warm. The useful answer for me is that the Go frameworks I like are all fast at the empty part of a request. Once my app does actual work, I have much more interesting places to look.