Skip to main content

Fetch Is Not Enough

JavaScript used to have no common vocabulary for HTTP.

On the client side, Node.js had http.request() with callbacks and Node streams. On the server side, http.createServer() gave you IncomingMessage and ServerResponse. Deno and Bun came along and said "let's just use fetch()" for clients and build servers around the same Request/Response types. Cloudflare Workers did the same.

Browsers, of course, are where the Fetch API originated. fetch() was designed as the browser's HTTP client, complete with CORS enforcement, restricted headers, opaque responses, and other security constraints that make sense when untrusted web pages are making requests. But the Web is more than just Web browsers; and browser use cases aren't the only use cases that matter.

Server-side runtimes adopted the types but not the security model. Workers, Node.js, Deno, and Bun all strip out CORS handling and allow headers like Host and Set-Cookie that browsers restrict. The result is that Request, Response, and Headers look like the same types everywhere but don't actually behave identically. Each runtime has made different decisions about which parts of the browser spec to keep and which to discard.

So the convergence is real but imprecise. Types are shared but the semantics are not fully aligned. It's a better starting point than what we've had and it's worth preserving and building on.

But Fetch is not enough.

It was designed for browser clients making simple requests. It models a simplified request/response exchange and deliberately ignores everything outside that use case. This was a reasonable decision for browsers in 2015. HTTP has moved on since then, though, and the uses of HTTP have expanded well beyond simple request/response. The gaps show up on both sides: there are things a client can't express with fetch(), and things a server can't express with new Response().

The mess we're in

Node.js has four separate HTTP modules:

Each was designed at a different time with different abstractions and different API conventions. Each provides both client and server APIs, and the client and server sides of each module look completely different from the corresponding sides of the others. Part of this has been to preserve backwards compatibility and to avoid breaking existing frameworks, but the result is a confusing mess that promises to get worse as new protocol versions and features arrive.

On the client side, node:http gives you http.request() which returns a ClientRequest (a writable stream) and emits a response event with an IncomingMessage. node:http2 gives you Http2Session.request() which returns an Http2Stream you read and write on. node:quic gives you QuicSession and QuicStream at the transport level, on top of which HTTP/3 would be layered. None of these use Request or Response.

On the server side, node:http gives you createServer() with IncomingMessage and ServerResponse, which are Node.js Readable and Writable streams tied to HTTP/1.1's sequential request processing model. Headers arrive in a raw array. The request URL is a path string, not a full URL. The response is a stateful object where you call .writeHead(), then .write(), then .end(), and if you get the order wrong you get a runtime error. node:https is a thin wrapper that adds TLS. node:http2 replaces everything with Http2Stream and Http2Session, uses createSecureServer() with a stream event, and ships a compatibility layer that wraps Http2Stream in objects shaped like IncomingMessage and ServerResponse, but they're not the same.

Now we have node:quic for HTTP/3. QUIC is UDP-based with multiplexed streams, built-in encryption, and connection migration. The module exposes QuicEndpoint, QuicSession, and QuicStream. Another set of abstractions, another programming model, on both client and server sides. Not specific to HTTP/3 but necessary to be used for HTTP/3 since QUIC is the new transport.

If you're a framework author who wants to support all three protocol versions, you need adapters for three APIs with different object models, different stream types, and different lifecycle patterns. A future protocol version means a fourth.

The problem is not unique to Node.js.

The runtime divergence

On the client side, other runtimes like Deno, Bun, and Workers all provide fetch() as the client API. It returns a Response. It works. The Fetch spec was designed for this case and it fits reasonably well.

Unfortunately, all of these other runtimes also have the requirement to be Node.js compatible and have implemented the Node.js http APIs to varying degrees. Some also have their own native HTTP client APIs unique to their runtime.

The server side is messier. Runtimes have generally converged on Request and Response as the types, but diverge on specifics.

  • Deno uses Deno.serve() with a function handler: (request, info) => Response. WebSocket upgrades go through Deno.upgradeWebSocket(request), which returns a { socket, response } pair. Deno-specific, no equivalent elsewhere.
  • Bun uses Bun.serve() with an object handler: { fetch(request, server) }. WebSocket upgrades go through server.upgrade(request) and a separate websocket handler object on the server config.
  • Cloudflare Workers use export default { fetch(request, env, ctx) }. WebSocket is handled via WebSocketPair: construct a pair, return one end in the Response, keep the other.

They all use Request and Response. But WebSocket, server lifecycle, error handling, background work? Runtime-specific code in every case.

Deno and Bun have experimental QUIC/HTTP/3 support, and Workers handles HTTP/3 at the network layer outside the runtime. But none of them have APIs for WebTransport, Extended CONNECT, HTTP Datagrams, trailers, informational responses, full-duplex streaming, or typed stream resets.

This divergence matters because developers — framework authors especially — want to write HTTP handling once and run it on Node.js, Deno, Bun, or Workers without rearchitecting. The fact that Deno, Bun, and Workers all invest heavily in Node.js API compatibility tells you what developers actually want: portable code. Not a different API for every runtime. There will always be runtime differences and implementation-specific details, but the core programming model shouldn't require a rewrite when you change deployment targets.

Adapters bridge some of the current gaps. Frameworks like Hono and h3 maintain per-runtime adapters so application code can stay mostly portable. But an adapter is an admission that the underlying APIs don't match. Each adapter is maintained separately, tested separately, and has its own edge cases. A standard that all runtimes implement consistently is better than different adapters maintained by different teams.

What Fetch doesn't model

The Fetch API models a subset of HTTP that is useful in browsers and covers the majority of historically simple and common use cases. But there are gaps that limit the ecosystem's ability to take full advantage of newer HTTP features. These gaps exist on both the client and server side but are felt most acutely on the server:

  • Trailers: HTTP requests and responses can have trailing header fields sent after the body (RFC 9110 §6.5). gRPC depends on trailers for grpc-status and grpc-message. Content integrity digests computed during streaming can only be sent as trailers. On the server side, Response.headers is the header section with no place for the trailer section. On the client side, fetch() gives you no way to read trailers from a response either.
  • Informational responses (1xx): A server can send informational responses before the final response. 103 Early Hints (RFC 8297) tells the client to start preloading resources while the server generates the response. 100 Continue gates upload bodies. On the server side, there's no way to send them: Response models exactly one status code. On the client side, there's no way to observe them: fetch() resolves once with the final response.
  • Full-duplex streaming: In HTTP/2 and HTTP/3, the request and response are independent streams on the same logical channel. You can read the request body while writing the response body, and half-close each direction independently. gRPC bidirectional streaming depends on this. Fetch partially supports concurrent read/write but has no concept of independent half-close. This matters on both sides: a server can't half-close its response while continuing to read, and a client can't half-close its request body while continuing to read the response.
  • Typed stream resets: Canceling a stream in HTTP/2 (RST_STREAM) or HTTP/3 (RESET_STREAM / STOP_SENDING) sends a frame with an error code that the peer interprets. AbortSignal carries a reason but has no mapping to typed protocol error codes. This affects both client and server. Some of these error codes allow unambiguous signaling about whether a request can be retried, but that granularity is lost with the current API.
  • Priority: Clients express urgency and incremental delivery preferences (RFC 9218). Servers read those preferences to schedule responses. fetch() has no way to set priority on a request outside of setting a header. Server handlers have no way to read it outside of reading a header.
  • Tunnels and extended CONNECT: HTTP CONNECT creates a tunnel through an HTTP connection. Extended CONNECT (RFC 8441, RFC 9220) adds a :protocol pseudo-header that identifies what's being tunneled: WebSocket, WebTransport, CONNECT-UDP, CONNECT-IP, and future protocols all use this mechanism. Fetch has no model for CONNECT requests, tunnel establishment, or the long-lived bidirectional sessions they create.
  • Unreliable datagrams: HTTP Datagrams (RFC 9297) allow unreliable message delivery over HTTP/3 via QUIC DATAGRAM frames (RFC 9221). WebTransport, CONNECT-UDP, and CONNECT-IP all depend on this. Fetch models HTTP as a reliable byte stream; there is no concept of discrete unreliable messages.
  • Structured Fields: Modern HTTP fields like Priority, Digest, Cache-Status, and Proxy-Status use typed values defined by RFC 9651. Headers gives you raw strings on both sides.

Runtime-specific APIs have been implemented to address these features but they are not standardized. Each runtime has its own ad-hoc solution, and each solution is different.

Extended CONNECT

HTTP/2 and HTTP/3 define "extended CONNECT," where a :protocol pseudo-header identifies the protocol being tunneled through the HTTP connection. Current standardized uses:

  • :protocol = websocket -- WebSocket over HTTP/2 (RFC 8441) and HTTP/3 (RFC 9220)
  • :protocol = connect-udp -- UDP proxying / MASQUE VPN (RFC 9298)
  • :protocol = connect-ip -- IP proxying / MASQUE VPN (RFC 9484)
  • :protocol = webtransport -- WebTransport (in-progress draft)

WebSocket, WebTransport, and MASQUE are all instances of the same mechanism. Every runtime has built ad-hoc WebSocket support without recognizing this. When WebTransport arrives, they'll build another ad-hoc handler. When CONNECT-UDP arrives, another. The mechanism is designed to be extended, and new protocols will keep arriving.

On top of extended CONNECT sits the Capsule Protocol (RFC 9297), which provides typed TLV framing for control signaling, and HTTP Datagrams, which are potentially unreliable messages that on HTTP/3 use QUIC DATAGRAM frames and on HTTP/2 fall back to capsules on the data stream. CONNECT-UDP, CONNECT-IP, and WebTransport all use these mechanisms. No runtime exposes them.

Fetch has no model for extended CONNECT, capsules, datagrams, or WebTransport sessions with multiplexed streams. These are standardized (or in late-stage standardization) HTTP features with no API anywhere. The W3C is working on a WebTransport API but whether and how it intersects with Fetch APIs is still being worked through.

WebTransport and unreliable datagrams

WebTransport is worth expanding on because it's the clearest example of HTTP outgrowing the request/response model.

WebSocket gives you a single bidirectional message stream over an HTTP connection. That works for chat and notifications, but it's a single ordered channel. If you're building a multiplayer game or a media streaming application, a single ordered channel creates problems. A large message blocks smaller ones behind it. A lost packet stalls the entire stream while TCP retransmits. There's no way to send data where you'd rather drop it than wait for retransmission.

WebTransport provides:

  • Multiple independent streams: A WebTransport session can open many bidirectional and unidirectional streams, each with its own flow control. A slow stream doesn't block a fast one. This is the same multiplexing that HTTP/2 promised for requests, applied to application-level data channels.
  • Unreliable datagrams: Messages sent via the datagram channel are not retransmitted if lost. For real-time applications (game state updates, live sensor data, media frames), this is the right tradeoff. Retransmitting a position update from 200ms ago is worse than dropping it and sending the current one. Until now, getting unreliable delivery in a browser required WebRTC's data channels, which carry significant complexity (ICE, STUN/TURN, DTLS, SCTP). WebTransport datagrams are just QUIC DATAGRAM frames (RFC 9221) associated with an HTTP request.
  • Connection sharing: WebTransport sessions run inside an existing HTTP/3 connection. No separate handshake, no new port, no NAT traversal problems. Multiple WebTransport sessions can share the same QUIC connection alongside regular HTTP requests.

The transport for all of this is QUIC. On HTTP/3, WebTransport streams are native QUIC streams and datagrams are QUIC DATAGRAM frames. No head-of-line blocking between streams, sub-RTT datagram delivery, connection migration when the network changes.

There is also an HTTP/2 fallback that tunnels WebTransport over TCP using the Capsule Protocol. Streams become capsule sequences on the CONNECT data stream. Datagrams become DATAGRAM capsules (type 0x00). It works, but the streams are no longer independent (TCP head-of-line blocking returns) and the datagrams are reliable (TCP guarantees delivery). The API can expose this via a property like session.transport so the application knows whether it's running on native QUIC or the capsule fallback, but the programming model is the same either way.

WebTransport is bootstrapped via extended CONNECT over HTTP. It's not a separate protocol that happens to use the same port. It's HTTP. The session establishment is an HTTP request. The streams and datagrams flow within an HTTP connection. A fetch() handler returns a Response, but a WebTransport session is a long-lived interaction with multiple streams and an unreliable datagram channel. The handler model needs to accommodate interactions that outlive a single request/response exchange.

The same is true of MASQUE. CONNECT-UDP (RFC 9298) uses HTTP Datagrams to carry UDP payloads through an HTTP connection. On HTTP/3, those datagrams are unreliable QUIC DATAGRAM frames, which means a MASQUE proxy can carry UDP traffic without adding TCP's reliability overhead. On HTTP/2, the datagrams fall back to capsules on the data stream (reliable, but functional). CONNECT-IP (RFC 9484) does the same for IP packets, using both the Capsule Protocol for control signaling (address assignment, route advertisement) and datagrams for the data plane.

HTTP is no longer just request/response. It carries long-lived sessions, multiplexed streams, and unreliable datagrams. The API needs to reflect that.

What might a future API look like?

Fetch isn't going anywhere, and we're not going to replace it. But we can build on it.

Request, Response, Headers, ReadableStream remain the vocabulary. A new layer can extend them for the full scope of what HTTP actually is.

Extensions to the core types (trailers on Request and Response, priority on Request, structured field access on Headers) can apply to both client and server. A fetch() call that returns a Response with a .trailers property is just as useful as a server handler that sends one. The server-side design is where most of the new surface area lives, though, because that's where the biggest gaps are: handler model, tunnel protocols, lifecycle, configuration.

I've been working on sketching a design for this, initially targeting Node.js and Cloudflare Workers but intended to be portable across runtimes.

Two handlers, one model

Here's a sketch of what the server-side API might look like. One handler for request/response, one for tunnels:

import { serve } from 'node:http';
 
serve({
  // Standard request/response
  fetch(request) {
    return new Response('Hello');
  },
 
  // All tunnel protocols: WebSocket, WebTransport, MASQUE, custom
  async connect(request) {
    switch (request.connectProtocol) {
      case 'websocket': {
        const ws = request.upgradeWebSocket();
        ws.addEventListener('message', e => ws.send(`Echo: ${e.data}`));
        return;
      }
      case 'webtransport': {
        const session = await request.upgradeWebTransport();
        // multiplexed streams + datagrams
        return;
      }
      case 'connect-udp': {
        const tunnel = await request.accept();
        const datagrams = await tunnel.datagrams();
        // pipe UDP payloads
        return;
      }
      default:
        return new Response(null, { status: 501 });
    }
  },
}, { port: 443, tls: { cert, key }, quic: true });

fetch() handles request/response. connect() handles tunnels. Both receive extended Request subtypes. Both serve HTTP/1.1, HTTP/2, and HTTP/3 transparently. The handler doesn't know or care which protocol version the request arrived on.

HTTP/1.1 WebSocket upgrades are routed to connect() too. The server synthesizes a ConnectRequest with connectProtocol = 'websocket' regardless of HTTP version, so WebSocket handling is in one place.

Extensions on standard types

The existing Fetch types get extended rather than replaced:

// ServerRequest extends Request with connection metadata and lifecycle
request.remoteAddress;          // SocketAddress
request.alpnProtocol;           // 'http/1.1', 'h2', 'h3'
request.trailers;               // Promise<Headers>
request.sendInformational(103, headers);  // Early Hints
request.waitUntil(promise);     // background work after response
 
// ServerResponse extends Response with trailers
return new ServerResponse(body, {
  headers: { 'Trailer': 'Digest' },
  trailers: digestPromise,  // Promise<Headers> resolved during streaming
});

Some of these could also, alternatively, live in a new .info property:

request.info.remoteAddress;
request.info.alpnProtocol;
request.info.trailers;

A handler that just uses Request and Response works unchanged. Where exactly the extensions are added and what they are named is something we can debate and figure out.

Application separated from infrastructure

Environmental bindings (databases, caches, secrets) are separate from the handler; and can be injected by the application or the runtime. I mention these only because they are a common requirement for server-side runtimes, not because they are specific to HTTP semantics. A common API pattern for servers needs to account for them while they are largely unnecessary for client side.

const app = {
  env: {
    db: createPool(process.env.DATABASE_URL),
  },
 
  async fetch(request) {
    const rows = await this.env.db.query('SELECT * FROM users');
    return Response.json(rows);
  },
 
  async [Symbol.asyncDispose]() {
    await this.env.db.end();
  },
};
 
// Imperative
serve(app, { port: 443, tls: { cert, key }, quic: true });
 
// Or declarative -- same object
export default app;
// $ node --serve app.js --port 443

The application object contains handlers and bindings. Server configuration (port, TLS, QUIC settings) is separate. The app is portable; the config varies per deployment. this.env provides typed access to services from within any handler. Symbol.asyncDispose runs cleanup on shutdown.

The tunnel primitives

When connect() accepts a tunnel, the Tunnel object exposes three communication layers corresponding to the layers in RFC 9297:

  • Raw data stream (readable / writable). Bidirectional bytes. WebSocket frames flow here. Plain CONNECT proxies use this and nothing else.
  • Capsule Protocol (capsules()). Typed TLV framing for control signaling. Consuming, like calling text() on a Request.
  • HTTP Datagrams (datagrams()). On HTTP/3 these are QUIC DATAGRAM frames (unreliable). On HTTP/2 they're DATAGRAM capsules (reliable fallback). An unreliable property tells you which you got.

WebTransportSession (from upgradeWebTransport()) provides native QUIC streams and datagrams on HTTP/3, with capsule-based fallback on HTTP/2.

Different protocols use different combinations. CONNECT-UDP uses datagrams. CONNECT-IP uses capsules for control and datagrams for data. The API provides the primitives while leaving specifics up to the application.

One server, all protocols

serve(app, {
  port: 443,
  tls: { cert: readFileSync('cert.pem'), key: readFileSync('key.pem') },
  quic: true,
});

One serve() call can support HTTP/1.1 over TCP, HTTP/2 over TCP+TLS, HTTP/3 over QUIC. For an environment like Workers, it could still even allow the runtime to fully determine the ingress path. All of the details are handled under the covers. The handler doesn't see any of it. Adding a new protocol version should never require a new application-level API except when the protocol itself introduces new features and semantics.

Who actually needs this

To be clear: the majority of HTTP applications will continue to be simple request/response flows. A JSON API, a form submission, a page render. The Fetch API as it exists today covers these cases adequately. Most application developers will never need trailers, capsules, or typed stream resets, and that's fine.

Browsers are also unlikely to need much beyond what they already have. The browser is an HTTP client. fetch() works well for that. The W3C's WebTransport API will give browsers access to multiplexed streams and unreliable datagrams. Beyond that, the browser's needs are relatively contained.

The demands on server-side, edge, and proxy runtimes are different. These runtimes sit in the middle of the protocol. They accept connections from clients using every HTTP version. They terminate TLS. They proxy traffic. They run application code that needs to do more than return a Response. They need to send Early Hints, attach trailers, accept WebTransport sessions, proxy UDP traffic, handle graceful shutdown with GOAWAY, and integrate with protocol features that browsers never see.

This is not a router. Routing is userland. This is not a middleware framework. This does not replace node:http or node:http2. Existing APIs keep working, existing frameworks keep working. This is additive.

Beyond a subset

WinterTC (Ecma TC55) originally began with the goal of defining a subset of web platform APIs suitable for non-browser runtimes. That was a reasonable starting point: figure out which browser APIs make sense outside the browser and specify their behavior so runtimes have a common target.

But a subset is not actually enough. The problem isn't just that server-side runtimes need fewer browser APIs. It's that they need capabilities the browser APIs were never designed to express. Informational responses, extended CONNECT, WebTransport sessions, datagram routing -- none of these are part of the Fetch spec because the Fetch spec is a browser client API.

What is needed is a specification that extends beyond Fetch, covering the full capabilities of HTTP rather than being limited to the browser's view of it. The browser sees a subset of the protocol. Server-side runtimes need APIs for the rest.

The design sketched above is one attempt at what that might look like. I'm a big fan of the approach of presenting an idea that others might look at and say, "That's terrible! Here's a better idea...". Let's figure out what the right API is.