Overhead of web frameworks

Some benchmarks of Go's standard library against popular frameworks.

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:

SpecValue
ModelMacBook Pro M4 Pro
CPU14 cores
GPU20 cores
Neural Engine16 cores
RAM48 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:

FrameworkRequests/secAvg LatencyTransfer/sec
Fiber163,1502.23ms18.83 MB
Gin90,4744.34ms10.35 MB
net/http90,1574.35ms10.32 MB
Echo89,4524.38ms10.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:

FrameworkBaselineUnder LoadStable 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:

FrameworkRequests/secAvg LatencyTransfer/sec
Fiber163,1502.23ms18.83 MB
Fastify (nodejs)103,8954.36ms16.65 MB
Gin90,4744.34ms10.35 MB
net/http90,1574.35ms10.32 MB
Echo89,4524.38ms10.24 MB
Express (nodejs)67,6767.27ms14.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.

TRIP COMPUTER / SESSION

TRIP A

Current drive.

A private counter for this browser session. Nothing here is transmitted or retained after the session ends.

Elapsed
00:00
Sections
0
Notes
0
Screens
0

Route/

Build platebe587d2
Chassis
v7.1.3
Revision
be587d2
Last serviced
09 Sept 2026

OWNER’S MANUAL / WTHRAJAT

OPERATING NOTES

How this thing moves.

The header behaves like a small mechanical system. Its readings respond to how you move through the site.

Throttle
Scrolling is input. Faster downward movement builds more momentum and engine speed.
Transmission
Upshifts follow sustained input. Scrolling upward slows and downshifts, and may briefly show reverse.
Idle
When input stops, RPM settles near 850 with mechanical drift. The gearbox eventually returns to neutral.
Tachometer
The needle always follows the reported RPM. It is never calculated from your position on the page.
Trip A
Session time, explored sections, opened notes and approximate screens travelled stay in this tab session.

Controls

Ctrl K
Search notes
?
Open this manual
Esc
Close an instrument
Tab
Move through controls