All posts

50k RPS Is a Security Problem, Not a Benchmark

Express, FastAPI, Gin, or Spring Boot at 50,000 requests per second: how each one breaks under hostile traffic, and the controls that keep it standing.

Saeid Zebardast12 min read

At 50,000 requests per second, a mistake that costs one extra millisecond per request stops being a performance detail. It becomes a denial of service waiting for someone to find it. Someone on X recently asked which framework you would trust with an API at that volume: Express.js, FastAPI, Gin, or Spring Boot. My reply was that 50k RPS isn't just about speed, because security hits harder at that volume. This is the longer version.

Benchmarks measure friendly traffic. Real traffic at 50k RPS includes scrapers and credential stuffers, plus the occasional person who just found your slowest regex. The framework you choose decides which failures you get by default and how much work it takes to close them.

Why volume changes the threat model

At high traffic every resource is already near its budget. If one request can make the server do a hundred times the normal work, an attacker needs only a few hundred requests per second to eat your capacity, hidden among 50,000 legitimate ones.

OWASP calls this API4:2023 Unrestricted Resource Consumption. Its list of missing limits is the one I check in every review: execution timeouts, memory, upload size, records per page, and the number of operations one request can trigger. So the useful question is not which framework is fastest. It is which cheap request can hurt this service, and how each runtime fails when it arrives.

Express.js: one event loop, one bad payload

Node.js runs your JavaScript on a single event loop per process. That is its strength for I/O and its sharpest security edge. The guide Don't Block the Event Loop is blunt about it: if some input can block a callback, a malicious client can send that input and deny service to everyone else on the process. If the event loop itself is new to you, I walked through how it schedules async work in Simulating Promise.all() in Vanilla JavaScript.

Regular expressions and JSON are the usual culprits. A pattern with nested quantifiers can take exponential time on a crafted string, which is ReDoS. JSON.parse is linear, but linear is not free. express.json() caps bodies at 100 KB by default, and the usual fix when a large upload fails is to raise that limit to 50mb and move on. From then on, every large body blocks the loop for the whole parse while every other request on that worker waits. Across dozens of Node processes, a steady trickle of such payloads keeps every worker busy, one after another.

The dependency tree is the other half. A typical Express API pulls in hundreds of transitive packages, each running with full access to your process. Express's own security best practices point you to Helmet for headers and npm audit for the tree, which says a lot about the defaults. Security headers, rate limiting, schema validation, and sane body limits are all middleware somebody has to pick and keep current. Node's HTTP server does ship with timeouts, but they are tuned for patience rather than hostility: a minute for headers and five minutes for the whole request.

I learned how unforgiving this is while load testing an internal API behind a rate limiter that looked fine on paper. The limiter was keyed on the first IP in X-Forwarded-For, copied from a README example. Half as a joke, someone scripted a random IP into that header on every request, and the limiter never fired. Every request looked like a new client, and the bcrypt login endpoint pinned every core in about a minute. Nothing in the framework was broken. We had trusted a header any client can set. Copying a snippet without owning what it trusts is the same problem I wrote about in Own the Diff, whether the snippet comes from a README or an AI assistant.

FastAPI: strict validation, with an async trap

FastAPI gets a lot right by default. Request bodies are Pydantic models, so a quantity that must be between 1 and 100 is rejected before your code runs. Since Pydantic v2 that validation lives in pydantic-core, which is implemented in Rust, so strict checks are cheap enough to leave on everywhere. The dependency graph is also small next to an enterprise Java stack, which in practice means far fewer CVEs to track. A shorter advisory list is still not a safer deployment. In early 2024, python-multipart, the form parser behind FastAPI's form handling, shipped a ReDoS in its Content-Type parsing, the same bug class as the Express section.

The sharp edge is concurrency. FastAPI runs async def endpoints directly on the event loop and plain def endpoints in a thread pool, as the FastAPI concurrency docs explain. Put one synchronous call inside an async def, such as a blocking database driver or a CPU-heavy hash, and it stalls the loop for the whole worker. It is the Express ReDoS failure in Python clothes.

Validation also starts later than it looks. Pydantic checks the body only after it has been read and parsed, and neither FastAPI nor Uvicorn sets a request body limit by default. A 500 MB JSON body is accepted in full before your max_length constraint ever runs, so the size cap has to come from the proxy in front or from your own middleware.

The usual fix is more Uvicorn workers, which has its own bill. Each worker is a full Python process with its own memory and connection pool. Going from 8 to 32 workers can quietly mean 32 pools of 20 connections against a database that allows 500, and a burst you could have shed at the edge becomes a connection storm at the database.

Gin: Go is solid, the defaults are yours to fix

A compiled Go binary with goroutines makes 50k RPS realistic on modest nodes, with a dependency graph small enough to actually read. Where Go helps with memory is footprint, not safety, since the other three runtimes are memory-safe as well. A goroutine starts with a few kilobytes of stack, so tens of thousands of open connections cost megabytes rather than the gigabytes a thread per connection would, and one process replaces a fleet of workers you would otherwise have to size.

What Gin and net/http leave to you is the boundary. The zero value of http.Server has no timeouts at all, and the net/http docs state that a zero ReadTimeout means no timeout. Most tutorials end with r.Run(":8080"), which accepts a client sending one header byte every few seconds forever. That is Slowloris, and it needs patience rather than bandwidth. Bodies are unbounded until you wrap them in http.MaxBytesReader, so a 500 MB POST can land in RAM.

Client IP is the next trap. Gin's ClientIP() reads X-Forwarded-For, and the Gin trusted proxies docs warn that it trusts all proxies by default, which lets an attacker forge the header to dodge rate limits.

Last comes orphaned work. Go cancels c.Request.Context() when the client disconnects, but only code that passes that context along will notice. A handler that calls a slow dependency without it keeps working after the client is gone, and a few seconds of a degraded downstream at 50k RPS can stack up hundreds of thousands of them.

Spring Boot: great tools, big surface

Spring Boot has the most mature security tooling here. Spring Security handles authentication and authorization properly, Resilience4j adds rate limiters and bulkheads, Actuator gives you health and metrics, and virtual threads on modern JVMs ease the old thread-per-request pain.

The trade-off is surface area. CVE-2022-22965, better known as Spring4Shell, was remote code execution through data binding on JDK 9+, scored 9.8. The Spring advisory is honest that the known exploit needed a WAR on Tomcat and that the default executable jar wasn't exposed to it, but also that the underlying flaw was more general. Data binding and expression languages are powerful features, and power is attack surface.

Actuator is where I look first. Spring Boot now exposes only health over HTTP by default, which is good. The problem is management.endpoints.web.exposure.include=*, added while debugging and shipped to production. That line puts env, threaddump, loggers, and the rest of the list in the Actuator docs on the wire, and opening access to heapdump as well is worse. A heap dump holds whatever is in memory, tokens included, and producing one can stall the JVM. Add a Maven or Gradle tree with hundreds of artifacts, and patching is real work. I argued in “I Don’t Know Rust, and That’s a Feature” that code nobody reads is a security decision. A dependency tree nobody reads is the same decision at a larger scale.

The four frameworks side by side

Here is my summary when the question is surviving hostile traffic, not winning a benchmark.

FrameworkSecurity surfaceFailure mode at 50k RPSDefault validationWhat bites you first
Express.jsDeep transitive npm treeOne CPU-bound request (ReDoS, huge JSON.parse) freezes the event loopNone until you add schema and body limit middlewareA vulnerable regex or a body limit raised to megabytes
FastAPISmall, focused dependency setA blocking call in async def stalls the worker; more workers multiply memory and DB pools; bodies are read in full before validationStrong: Pydantic v2, validated in RustSync I/O or CPU work inside async endpoints, and no body size limit
Gin (Go)Small module graph, single binarySlow clients and orphaned goroutines exhaust sockets while CPU looks idleBasic struct tags, no body limit by defaultMissing http.Server timeouts and trusting all proxies
Spring BootLarge framework plus a big Maven or Gradle treeFramework features such as data binding or Actuator become the entry pointBean Validation, once you annotateOver-exposed Actuator or a lagging dependency patch

Controls that keep a 50k RPS API standing

Whatever you pick, the controls that matter most sit around the framework, in the order a request meets them.

Every hop rejects something cheaply, so expensive work only runs on requests that earned it.

Rate limit at the edge, keyed on an identity you can trust. A limiter inside each instance sees only a slice of traffic, so the first limit belongs at the CDN or load balancer. X-Forwarded-For is a list any client can prepend to, and only the entries added by proxies you control mean anything. Take the client IP from the rightmost untrusted hop, or from a header your CDN sets and strips from client input. On authenticated routes, limit per API key too, because IPs are cheap.

Bound the request before you parse it. Reject oversized Content-Length up front and cap both body and headers, with read and write timeouts on top. Then bound the meaning of the input as well, with maximum array and string lengths plus a server-side ceiling on page size.

Price your authentication. bcrypt is slow on purpose, and the OWASP Password Storage Cheat Sheet recommends a work factor of 10 or more while warning that a high work factor can be abused for denial of service through many login attempts. At tens of milliseconds of CPU per hash, one core handles only a handful of logins per second. If every API call verifies a password or queries the database for its session, you have built a self-DoS. Hash at login only, behind strict per-account limits, then issue a token you can check cheaply on every request.

Carry a deadline through every call. Each request gets a time budget, and each downstream call has to respect it, whether that is a Go context, an AbortSignal in Node, an asyncio timeout in FastAPI, or a client timeout plus bulkhead in Spring. Without one, a slow dependency becomes an unbounded queue in your service.

The second table maps the attacks I see most at this volume to the control that stops each one.

Attack vectorWhat it does at 50k RPSMitigation that holds
SlowlorisHolds thousands of sockets open with almost no traffic until file descriptors run outReadHeaderTimeout and ReadTimeout, plus idle timeouts and buffering at the proxy
ReDoSOne crafted string pins a core or freezes an event loopLinear-time engines such as RE2 or Go regexp, length caps before matching
Spoofed X-Forwarded-ForEvery request claims a new IP, so rate limits stop workingTrust only proxy CIDRs (SetTrustedProxies), or the edge-provided client IP header
Oversized or deeply nested JSONThe parser burns memory and CPU before validation runsBody limits at edge and app (http.MaxBytesReader), depth and array limits in the schema
Auth exhaustionPer-request bcrypt or session queries saturate CPU or the databaseHash only at login behind per-account limits, then verify cheap tokens from a local cache

A hardened Gin server in one file

Here is the minimum I want to see before a Gin service takes public traffic. I compiled it and ran it locally: a valid order gets a 202, and a 200 KB body is cut off with a 413.

hardened_gin_server.go
package main
 
import (
	"context"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
 
	"github.com/gin-gonic/gin"
)
 
const maxBodyBytes = 64 << 10 // 64 KiB: the largest valid order is far smaller
 
// limitBody caps the request body before any handler or binder reads it.
func limitBody(n int64) gin.HandlerFunc {
	return func(c *gin.Context) {
		c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, n)
		c.Next()
	}
}
 
type orderRequest struct {
	SKU string `json:"sku" binding:"required,max=64"`
	Qty int    `json:"qty" binding:"required,min=1,max=100"`
}
 
func main() {
	gin.SetMode(gin.ReleaseMode)
	r := gin.New()
	r.Use(gin.Recovery(), limitBody(maxBodyBytes))
 
	// Gin trusts every proxy by default. Only our load balancer subnet
	// is allowed to tell us who the client is via X-Forwarded-For.
	if err := r.SetTrustedProxies([]string{"10.20.0.0/16"}); err != nil {
		log.Fatal(err)
	}
 
	r.POST("/orders", func(c *gin.Context) {
		var req orderRequest
		if err := c.ShouldBindJSON(&req); err != nil {
			var tooBig *http.MaxBytesError
			if errors.As(err, &tooBig) {
				c.AbortWithStatus(http.StatusRequestEntityTooLarge)
				return
			}
			c.AbortWithStatus(http.StatusBadRequest)
			return
		}
 
		// Tie downstream work to the client. If the client goes away or
		// the budget runs out, the goroutine stops instead of leaking.
		ctx, cancel := context.WithTimeout(c.Request.Context(), 800*time.Millisecond)
		defer cancel()
 
		if err := saveOrder(ctx, req); err != nil {
			c.AbortWithStatus(http.StatusServiceUnavailable)
			return
		}
		c.Status(http.StatusAccepted)
	})
 
	srv := &http.Server{
		Addr:              ":8080",
		Handler:           r,
		ReadHeaderTimeout: 2 * time.Second,  // slow header senders (Slowloris)
		ReadTimeout:       5 * time.Second,  // slow body senders
		WriteTimeout:      10 * time.Second, // slow readers on the response
		IdleTimeout:       60 * time.Second, // parked keep-alive connections
		MaxHeaderBytes:    16 << 10,         // 16 KiB instead of the 1 MiB default
	}
 
	go func() {
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatal(err)
		}
	}()
 
	stop := make(chan os.Signal, 1)
	signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
	<-stop
 
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Printf("forced shutdown: %v", err)
	}
}
 
// saveOrder stands in for a real database call that honors ctx.
func saveOrder(ctx context.Context, req orderRequest) error {
	select {
	case <-time.After(5 * time.Millisecond):
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

SetTrustedProxies means c.ClientIP() only believes X-Forwarded-For entries written by your load balancer, so a limiter keyed on it can't be fooled by a forged header. Server timeouts close slow connections, and the context timeout keeps a slow database from becoming a pile of goroutines.

So, which framework would I trust?

Any of the four can serve 50k RPS, and any of them can be taken down by a few hundred well-chosen requests. Gin gives me the most headroom per node and the smallest surface to audit, so it is my pick when the team knows Go. For a Python team that is disciplined about async, FastAPI is a strong choice. Spring Boot fits teams already running the JVM who will actually configure its security features, and Express works as long as someone owns the middleware list and treats every regex as a liability.

The framework sets your defaults. Your limits and trust boundaries decide whether you stay up. Miss rate limiting or ship one bad dependency, and 50k RPS stops being a capacity number and becomes the size of the attack you can't absorb.

Resources

securitybackendapigolang