Skip to content

perf(http): allocate response headers lazily - #14

Merged
Upd4ting merged 2 commits into
mainfrom
perf/http-response-serialization
Aug 19, 2026
Merged

perf(http): allocate response headers lazily#14
Upd4ting merged 2 commits into
mainfrom
perf/http-response-serialization

Conversation

@Upd4ting

@Upd4ting Upd4ting commented Aug 19, 2026

Copy link
Copy Markdown
Member

🔗 Linked issue

None.

❓ Type of change

  • 📖 Documentation (updates to the documentation or readme)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality like performance)
  • ✨ New feature (a non-breaking change that adds functionality)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

HTTPResult previously allocated an empty headers object for every result, including the common case where no custom header is ever added or observed. This change creates that object only when addHeader() or mutable getHeaders() access needs it. removeHeader() remains a no-op when no store exists. The new peekHeaders() API returns the current store as readonly, or undefined, so HTTP implementations can inspect headers without creating it.

The response serialization and emission paths are intentionally unchanged. In particular, object bodies are still serialized immediately, getBody() still returns the serialized string, mutable headers retain stable identity once observed, and status/content type/HEAD/stream/error behavior is preserved. No API middleware fast path or benchmark-specific branch is added.

Profiling

A retained-allocation profile creates 500,000 small JSON results per run, alternates before/after order, forces GC before measurement, and repeats 7 times on Node 22.19.0:

Variant Construction Retained bytes/result
eager headers 2,350,142 ops/s ±175,261 168 B
lazy headers 2,851,435 ops/s ±50,311 112 B

This removes 56 retained bytes per headerless result in this profile and improves isolated construction throughput by 21.3%.

Header inspection microbenchmark

A second retained-allocation benchmark models an HTTP implementation inspecting headers on 500,000 small JSON results. It alternates getHeaders() (before) and peekHeaders() (after), forces GC, and repeats 7 times:

Inspection API Construction + inspection Retained bytes/result
getHeaders() 2,217,316 ops/s ±143,929 168 B
peekHeaders() 2,710,797 ops/s ±75,026 112 B

For headerless responses, non-allocating inspection saves 56 retained bytes/result and improves this isolated path by 22.3%. Once headers exist, peekHeaders() exposes the same store as readonly and getHeaders() remains mutable and identity-stable.

HTTP benchmark

Method: one Node process exposing matched before/after HTTPResult routes; the before route restores the eager empty headers allocation. autocannon, 50 connections, 3 s warm-up per variant/scenario, 7 alternating 3 s repetitions. Values are mean req/s ± sample standard deviation; p99 is the mean reported p99. Errors include connection errors, timeouts, and non-2xx responses.

Scenario Before After Delta p99 before / after Errors
small JSON 49,567 ±1,771 49,037 ±1,354 -1.07% 2.0 / 2.0 ms 0 / 0
large JSON 10,602 ±250 10,782 ±177 +1.70% 8.9 / 8.7 ms 0 / 0
string 51,894 ±2,908 53,194 ±2,009 +2.50% 2.0 / 2.0 ms 0 / 0
4 custom headers 47,865 ±1,280 47,672 ±1,791 -0.40% 2.0 / 2.3 ms 0 / 0
HEAD 60,634 ±3,190 60,094 ±4,937 -0.89% 2.0 / 2.0 ms 0 / 0
stream 32,140 ±1,427 31,984 ±1,813 -0.48% 3.0 / 3.1 ms 0 / 0

The full HTTP differences are smaller than run-to-run dispersion, so this PR does not claim a statistically clear req/s improvement. Its demonstrated benefit is the removed per-result allocation. Large JSON remains dominated by JSON.stringify; changing that would alter the public immediate-serialization contract.

Small JSON framework reference

A separate rotating-order run used the same machine, payload, autocannon settings, warm-up, and 7 repetitions:

Target req/s p99 Errors
bare Node HTTP + HTTPResult owner harness 34,501 ±1,538 2.4 ms 0
AdonisJS 6.21 slim, production build 11,163 ±332 8.4 ms 0
NestJS 11.2 + Fastify 5.12 29,803 ±1,333 3.1 ms 0

The owner harness exceeds both references, but it is not a full Antelope application and must not be compared directly with the external ~22.1k GET / ~15.2k POST Antelope figures. End-to-end gains beyond this allocation require work in the API implementation repository, which is deliberately outside this PR.

Validation

  • pnpm run lint
  • pnpm run build
  • pnpm dlx @antelopejs/core module test . — 73 passing
  • Added public-contract regressions for immediate JSON representation, non-allocating readonly header inspection, mutable/own headers, content type precedence, status/body emission, HEAD stream closure, and aborted streams
  • Existing 500 logging/error tests remain green

📝 Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

@Upd4ting
Upd4ting merged commit 4259ae0 into main Aug 19, 2026
2 checks passed
@Upd4ting
Upd4ting deleted the perf/http-response-serialization branch August 19, 2026 16:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants