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
| Field | Default | Purpose |
|---|---|---|
rateLimitRps | 100 req/s per IP | token-bucket sustained rate |
rateLimitBurst | 200 | burst capacity before throttling kicks in |
maxConnectionsPerIp | 50 | simultaneous connections per IP |
maxConnectionsTotal | 10,000 | simultaneous connections, all IPs |
headerTimeoutMs | 10,000 | deadline to receive full headers (Slowloris) |
bodyTimeoutMs | 30,000 | deadline to receive the full body |
minBodyRateBps | 100 B/s | minimum sustained body receive rate (RUDY) |
maxHeaderCount | 100 | max headers per request |
maxHeaderBytes | 16 KiB | max combined header size |
autoBlockThreshold | 10 violations | violations before auto-block |
blockDurationSecs | 300 s | base auto-block duration |
blockEscalationFactor | 2 | multiplier per repeat offense |
maxBlockDurationSecs | 3,600 s | cap on escalated block duration |
strikeDecaySecs | 3,600 s | calm 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) —
headerTimeoutMsgives everyread_linean independent deadline. - RUDY (R-U-Dead-Yet — an extremely slow POST body) —
bodyTimeoutMsis the total body-read deadline, andminBodyRateBpsis 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.0The 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:
| Request | Response |
|---|---|
Both Content-Length and Transfer-Encoding | 400 |
| A malformed chunk size | 400 |
| An unsupported transfer coding | 501 |
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
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
}
}