Skip to content

Batch API for reading request headers: req.getAllHeaders() #1297

Description

@nigrosimone

What costs today

HttpRequest::forEach calls into JavaScript once per header. A browser request carries ten to fourteen of them, so any framework that materialises the headers, which every Express-compatible layer has to, pays ten to fourteen JS/native crossings per request before a single line of user code runs.

Concretely, in ultimate-express (an Express compatible layer on uWS) a CPU profile of a request carrying twelve headers puts the Request constructor at 7.6% of busy CPU. Walking the headers is what that constructor mostly does.

Proposal

req.getAllHeaders(): string[]   // [name1, value1, name2, value2, ...], one crossing

A flat array rather than an object, deliberately: no property lookups, no hidden class churn, and the caller decides whether to build an object at all. A framework that keeps its headers lazy, which is the reason this matters to us, never builds one.

It is additive. forEach stays exactly as it is, and nothing that exists today behaves differently.

Measured on a working prototype

I have this running on local branches of uWebSockets.js and the core, built with MSVC against Node 26 headers.

  • in process, on a request with nine headers: getAllHeaders() takes 0.38µs against 1.04µs for forEach(), so 2.75x
  • end to end: no measurable difference on my machine. It is worth around 1µs per request against 20k to 35k req/s on a Windows laptop over loopback, where run to run noise is about ±8%. That is well under what I can resolve. On a Linux server at 100k+ req/s I would expect something in the 5 to 10% range, but I have not measured that and I am not claiming it.

So: this is a request for something small and strictly cheaper, not a claim that it will make uWS visibly faster. The 2.75x is real and the end to end gain is one I cannot demonstrate on the hardware I have.

Implementation

src/HttpRequestWrapper.h

    /* Takes nothing, returns flat array [key, value, key, value, ...] of all headers.
     * One boundary crossing instead of one JS callback per header like forEach */
    template <int QUIC>
    static void req_getAllHeaders(const FunctionCallbackInfo<Value> &args) {
        Isolate *isolate = args.GetIsolate();
        auto *req = getHttpRequest<QUIC>(args);
        if (req) {
            /* Header count is bounded by the parser, collect handles on the stack */
            Local<Value> flat[UWS_HTTP_MAX_HEADERS_COUNT * 2];
            int length = 0;

            for (auto p : *req) {
                if (length == UWS_HTTP_MAX_HEADERS_COUNT * 2) {
                    break;
                }
                /* Headers are Latin-1, same as getHeader */
                flat[length++] = String::NewFromOneByte(isolate, (const uint8_t *) p.first.data(), NewStringType::kNormal, p.first.length()).ToLocalChecked();
                flat[length++] = String::NewFromOneByte(isolate, (const uint8_t *) p.second.data(), NewStringType::kNormal, p.second.length()).ToLocalChecked();
            }

            args.GetReturnValue().Set(Array::New(isolate, flat, length));
        }
    }

and the registration alongside the other request methods:

    reqTemplateLocal->PrototypeTemplate()->Set(String::NewFromUtf8(isolate, "getAllHeaders", NewStringType::kNormal).ToLocalChecked(), FunctionTemplate::New(isolate, req_getAllHeaders<QUIC>));

The buffer is bounded by UWS_HTTP_MAX_HEADERS_COUNT, which the parser already enforces, and the loop stops there rather than trusting it. Like forEach, the result is only meaningful during the request callback, since it reads the same memory.

One thing to decide: which encoding

getHeader and forEach do not agree with each other today, so a batch API cannot match both:

  • req_getHeader builds its string with NewFromOneByte, under the comment /* We want latin1 here */
  • req_forEach builds its two strings with NewFromUtf8

For anything above 0x7F that is a real difference. A raw 0xE9 in a header value comes back as é from getHeader and as U+FFFD from forEach, since it is not valid UTF-8 on its own.

The code above follows getHeader, on the reasoning that header values are ISO-8859-1 by the spec and that anything non-ASCII is supposed to arrive encoded per RFC 8187, which makes Latin-1 the safer reading of raw bytes. But it is your call, and if you would rather getAllHeaders match forEach it is a one-line change. Worth saying out loud rather than leaving for review to find.

Offer

Happy to open this as a PR against master if you want it. Equally happy for the answer to be no.

Disclaimer

I'm not a C++ expert. I used an LLM to help write the prototype and benchmark code, then reviewed it, built it locally, and verified that it behaves as described.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions