Skip to Content
GuideHTTP Performance

HTTP Performance and Load Behavior

The headline throughput number is in the Comparison table (CI-measured, release build, firewall limits raised for the test). This page describes how 3va behaves at its shipped default limits, which is a deliberate design choice, not a performance regression — a single-machine load test against defaults will see most requests rejected with 403/429, and that’s intentional. vvva_firewall operates in the connection-accept loop, before any byte reaches JavaScript, so the runtime never processes a request that exceeds a safety limit.

Default limits

FieldDefaultPurpose
rateLimitRps100 req/s per IPtoken-bucket sustained rate
rateLimitBurst200burst capacity before throttling kicks in
maxConnectionsPerIp50simultaneous connections per IP
maxConnectionsTotal10,000simultaneous connections, all IPs
headerTimeoutMs10,000deadline to receive full headers (Slowloris)
bodyTimeoutMs30,000deadline to receive the full body
minBodyRateBps100 B/sminimum sustained body receive rate (RUDY)
maxHeaderCount100max headers per request
maxHeaderBytes16 KiBmax combined header size
autoBlockThreshold10 violationsviolations before auto-block
blockDurationSecs300 sbase auto-block duration
blockEscalationFactor2multiplier per repeat offense
maxBlockDurationSecs3,600 scap on escalated block duration
strikeDecaySecs3,600 scalm period before strike history resets

At high concurrency, the connection limiter deliberately sheds excess connections rather than queuing them indefinitely, trading raw throughput for protection against overload.

Slowloris and RUDY protection

  • Slowloris (headers trickled in one line per second) — headerTimeoutMs gives every read_line an independent deadline.
  • RUDY (R-U-Dead-Yet — an extremely slow POST body) — bodyTimeoutMs is the total body-read deadline, and minBodyRateBps is a minimum average receive rate: a body arriving below the floor is aborted after ~2s of grace, without waiting for the full 30s deadline.

Adaptive auto-block escalation

autoBlockThreshold + blockDurationSecs are the base layer: repeated violations trigger an automatic block of fixed duration. On top of that, the firewall is adaptive — each auto-block records a strike per IP, and the block duration escalates with reincidence:

auto-block #n → duration = min(blockDurationSecs × blockEscalationFactor^(n-1), maxBlockDurationSecs)

With defaults (base 300s, factor ×2, cap 3600s): 1st offense → 300s, 2nd → 600s, 3rd → 1200s, 4th → 2400s, 5th → 3600s (capped). Strike history is not cleared when a block expires — a repeat attacker gets the next tier. It’s only cleared once the IP goes strikeDecaySecs without another auto-block. This escalation penalizes the block duration for repeat offenders — it does not touch the per-IP token bucket rate itself.

Adaptive rate limiting (EWMA baseline)

A separate, opt-in mechanism (adaptiveRateLimit: true) solves the inverse problem: legitimate traffic spikes. With a fixed threshold, an IP whose legitimate traffic grows gradually (a heavy client, a corporate proxy) crosses rateLimitRps and accumulates violations even though nothing malicious is happening.

window: 1s baseline: ewma = α × count_window + (1 − α) × ewma_prev (α = ewmaAlphaPct / 100, default 20 → α = 0.20) effective rps: max(rateLimitRps, ceil(ewma × 1.5)), capped at rateLimitRps × 4.0

The token bucket consumes against the effective rate, so a high-baseline IP gets more tokens/second without its burst changing. If the IP returns to low traffic, the EWMA decays back down over subsequent windows. An attacker starting from zero has no observed history, so ewma = 0 and the threshold stays static. Disabled by default.

Trusted reverse proxies

trustedProxies (empty by default) lists proxy IPs/CIDRs allowed to supply a client IP via X-Forwarded-For. Connection tracking is per-source-IP — behind an untrusted or unconfigured reverse proxy that collapses many clients onto one IP, the proxy’s own IP is what gets limited.

Request smuggling guard

The request parser supports Transfer-Encoding: chunked and refuses to guess when the framing is ambiguous. It answers with an error instead of forwarding the request:

RequestResponse
Both Content-Length and Transfer-Encoding400
A malformed chunk size400
An unsupported transfer coding501
Content-Length that is not a number †400
Two Content-Length headers that disagree †400
Content-Length above the body limit †413 (the body is never truncated)
Two Transfer-Encoding headers that disagree †400
Whitespace before the : of a header, or an obsolete folded header line †400

† Added in 2.12.1. Version 2.12.0 already rejected the first three.

WebSocket via the 'upgrade' event

http servers emit 'upgrade' for any Connection: upgrade request, like Node, so ws-style libraries work end to end:

server.on('upgrade', (req, socket, head) => { /* 101 handshake; drive frames */ });

socket is the raw duplex socket and head is the bytes read behind the handshake, delivered once. The socket is bridged by a per-connection driver task that reads at most 1 MiB ahead of the application (past that, TCP flow control applies to the peer) and drops a peer that stops reading while the app has queued more than 32 MiB for it. A socket with no 'upgrade' listener is closed instead of left open.

HTTPS is real TLS

https.createServer({ key, cert }) is a real TLS listener — TLS 1.2 and 1.3 with the runtime’s crypto provider (the FIPS module in a fips build), and the hybrid post-quantum key exchange X25519MLKEM768 with clients that offer it. Every firewall limit applies as for HTTP. key/cert must be PEM; a server created without them fails on listen() and never serves plaintext.

Startup snapshot

3va run starts from a V8 startup snapshot of the builtins, built on the first run and cached in ~/.cache/3va/snapshot/ (VVVA_NO_SNAPSHOT=1 disables it).

Configuring limits

3va.config.ts
export default { firewall: { enabled: true, rateLimitRps: 100, rateLimitBurst: 200, trustedProxies: [], autoBlockThreshold: 10, blockDurationSecs: 300, blockEscalationFactor: 2, maxBlockDurationSecs: 3600, strikeDecaySecs: 3600, maxConnectionsPerIp: 50, maxConnectionsTotal: 10_000, headerTimeoutMs: 10_000, bodyTimeoutMs: 30_000, minBodyRateBps: 100, maxHeaderCount: 100, maxHeaderBytes: 16_384 } }