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.
| Framework | Security surface | Failure mode at 50k RPS | Default validation | What bites you first |
|---|---|---|---|---|
| Express.js | Deep transitive npm tree | One CPU-bound request (ReDoS, huge JSON.parse) freezes the event loop | None until you add schema and body limit middleware | A vulnerable regex or a body limit raised to megabytes |
| FastAPI | Small, focused dependency set | A blocking call in async def stalls the worker; more workers multiply memory and DB pools; bodies are read in full before validation | Strong: Pydantic v2, validated in Rust | Sync I/O or CPU work inside async endpoints, and no body size limit |
| Gin (Go) | Small module graph, single binary | Slow clients and orphaned goroutines exhaust sockets while CPU looks idle | Basic struct tags, no body limit by default | Missing http.Server timeouts and trusting all proxies |
| Spring Boot | Large framework plus a big Maven or Gradle tree | Framework features such as data binding or Actuator become the entry point | Bean Validation, once you annotate | Over-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.
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 vector | What it does at 50k RPS | Mitigation that holds |
|---|---|---|
| Slowloris | Holds thousands of sockets open with almost no traffic until file descriptors run out | ReadHeaderTimeout and ReadTimeout, plus idle timeouts and buffering at the proxy |
| ReDoS | One crafted string pins a core or freezes an event loop | Linear-time engines such as RE2 or Go regexp, length caps before matching |
Spoofed X-Forwarded-For | Every request claims a new IP, so rate limits stop working | Trust only proxy CIDRs (SetTrustedProxies), or the edge-provided client IP header |
| Oversized or deeply nested JSON | The parser burns memory and CPU before validation runs | Body limits at edge and app (http.MaxBytesReader), depth and array limits in the schema |
| Auth exhaustion | Per-request bcrypt or session queries saturate CPU or the database | Hash 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.
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
- OWASP API Security Top 10 2023: API4:2023 Unrestricted Resource Consumption
- Node.js: Don't Block the Event Loop (or the Worker Pool)
- OWASP: Regular expression Denial of Service (ReDoS)
- Express: Production Best Practices: Security
- FastAPI: Concurrency and async / await
- Pydantic: Why use Pydantic Validation? (pydantic-core in Rust)
- Go: net/http package documentation (Server timeouts, MaxBytesReader)
- Gin: Trusted proxies
- Wikipedia: Slowloris (cyber attack)
- NVD: CVE-2022-22965 (Spring4Shell)
- Spring: CVE-2022-22965 advisory, Spring Framework RCE via Data Binding on JDK 9+
- Spring Boot: Actuator Endpoints
- OWASP: Password Storage Cheat Sheet