Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions blog/bun-serve-vs-node-http.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
title: "Why Bun.serve Beats the node:http Bridge (a ~1.9x Story)"
date: 2026-06-07T10:00:00+05:30
slug: bun-serve-vs-node-http
description: "On Bun, WebJs serves through native Bun.serve instead of routing every request through Node's compatibility bridge. That is roughly 1.9x more requests per second on the listening path, at near-complete feature parity, with 103 Early Hints the one honest gap."
tags: bun, nodejs, performance, runtime, throughput
author: Vivek
---

You have a WebJs app in production. It is buildless, so the same `.ts` source runs on Node 24 and on Bun with nothing to recompile. One afternoon you try Bun under it, mostly because you have heard it is faster. You flip the runtime, run a load test, and the requests-per-second number on the listening path jumps by roughly 1.9x. You changed no code. That number is the whole subject of this post: where it comes from, what "the listening path" actually is, and the one feature you give up to get it.

If you want the mechanics of how WebJs runs on two runtimes at all (the runtime-neutral seam, the two TypeScript strippers, the parity test matrix), that is a separate story in the companion post `node-and-bun-no-build`. This one is purely about throughput.

# What "requests per second" and "the listening path" mean

Let me define the pieces in plain terms, because the win lives in one specific place and it is easy to overstate.

Every web server has a listening path, sometimes just called the listener. It is the code that accepts an incoming network connection, reads the raw HTTP bytes off the socket, hands a request object to your application, takes the response your app hands back, and writes it to the socket. It is the plumbing between the network and your code. Separate from it is your application work: the SSR, the routing, the queries, the actual WebJs logic.

Requests per second (req/s) is simply how many of those accept-read-respond cycles the server turns through in one second under load. A leaner listening path means more req/s for the same application work, because less of each request's time is spent in the plumbing.

So the 1.9x is a listening-path number. It is not 1.9x on your whole app end to end, because your SSR and queries cost the same on either runtime. It is 1.9x on the plumbing, which you get for free by choosing the runtime.

# A compatibility bridge, and why it costs you

Bun can run Node's built-in `node:http` module, which is a big reason so much of the Node ecosystem runs on Bun unmodified. But when Bun runs `node:http`, it runs a compatibility bridge (a compat bridge for short): a translation layer that emulates Node's HTTP request and response objects on top of Bun's own native machinery. Every single request pays that translation cost. You are asking Bun to pretend to be Node on the hottest path in the server.

Bun also ships its own native HTTP server, `Bun.serve`, which speaks Bun's request and response objects directly with zero emulation. The catch is that `Bun.serve` is not shaped like `node:http`, so a framework that wants the native path cannot just flip a config value; it has to write a second listener that talks to `Bun.serve` on its own terms.

WebJs writes that second listener. On Bun it serves through a native `Bun.serve` shell and skips the compat bridge entirely. On Node it serves through `node:http`. Your application code sits above that boundary and never knows which shell is underneath.

```sh
# same app, same source. the runtime is a command choice:
npm run dev # Node, node:http listener
bun --bun run dev # Bun, native Bun.serve listener
```

Skipping the bridge is where the 1.9x comes from. Nothing in your app changed. You stopped paying a per-request translation tax that existed only so Bun could look like Node.

# The one honest gap: 103 Early Hints

I will not pretend the native path is a perfect superset of the bridged one, because the missing piece is worth naming plainly.

The one Node-only feature the Bun listener cannot match is 103 Early Hints. That is an informational HTTP response (a preliminary status the server can send before the real one) that lets the server tell the browser to start preloading assets while the actual response is still being produced. It is a first-paint latency optimization. `Bun.serve` has no informational-response API at all, so there is nothing for WebJs to build on, and on Bun that optimization is simply off.

That is the entire tradeoff. You give up one preload-timing feature and you get 1.9x on the listener in exchange. For most apps that is a clear win, and I would rather document the gap honestly than paper over it with a shim that fakes an API Bun does not have.

# Trimming the per-request cost on the Bun path

The listener choice is the headline, but a buildless server has to earn throughput in the small places too, because there is no build step ahead of time to absorb overhead. So the Bun request path got its own passes.

Brotli compression on the Bun listener runs through `node:zlib` (#518), so a Bun-served response gets the same compression a Node-served one does, no native gap there. And the per-request overhead got trimmed directly (#773): one pass removed a full request-object clone that existed only to stamp the client's IP address onto every request, and another removed an extra stream hop that every compressed response was being bridged through. Neither was large alone, but they are per-request costs, so they multiply by your traffic. On the hot path, a clone you do not need and a stream hop you can collapse are exactly the kind of thing worth cutting by hand.

# You pick the runtime

The runtime is your choice, not the framework's. You write one WebJs app. Run it on Node and you get the mature `node:http` listener and 103 Early Hints. Run it on Bun and you get the native `Bun.serve` listener and roughly 1.9x the listening-path throughput, minus that one Early Hints feature. Everything else behaves the same. Pass `--runtime bun` to `webjs create` and the generated app is wired for Bun from the first commit, or run `bun create webjs <name>` and it detects the runtime for you.

# The takeaway

The 1.9x is not magic and it is not the whole app; it is the listening path, the plumbing that accepts a connection and produces a response. On Bun, WebJs serves through native `Bun.serve` instead of running `node:http` over a compatibility bridge, and skipping that per-request translation tax is where the throughput comes from. The honest cost is 103 Early Hints, which Bun has no API to support. Brotli compression rides `node:zlib` and the per-request overhead has been trimmed to keep the Bun path lean. You pick the runtime, and if you pick Bun you get a real throughput win on the hot path with almost no change in behavior.
95 changes: 95 additions & 0 deletions blog/cancel-server-actions-abortsignal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
title: "Cancelling Server Actions With AbortSignal (No Wasted Work on Disconnect)"
date: 2026-06-15T10:00:00+05:30
slug: cancel-server-actions-abortsignal
description: "WebJs wires the platform's own AbortSignal through the server-action RPC boundary in both directions, so a client that navigates away actually cancels the in-flight request and the server stops paying for work nobody is waiting for."
tags: server-actions, abortsignal, performance, cancellation, web-standards
author: Vivek
---

Picture a typeahead search. The user types "web", you fire an action, they type "webj", you fire another, they type "webjs", you fire a third. By the time they stop, the first two requests are answers nobody wants. In most setups those first two still run to completion on the server, still hit the database, still serialize a result, and only then get thrown away on the client. You paid full price for two answers that were obsolete the moment they were requested.

I did not want WebJs to pay for work nobody is waiting for. The web platform already has the primitive for this. It is `AbortSignal`, the same object `fetch` accepts to be cancelled. So WebJs wires that signal through the server-action RPC (remote procedure call) boundary, in both directions, and a cancelled request is actually cancelled on the wire.

# The server side reads the request's signal

Inside an action, you can read the current request's `AbortSignal` and stop working the moment the client goes away. It is one import.

```ts
// modules/search/queries/search.server.ts
'use server';
import { actionSignal } from '@webjsdev/server';

export async function search(term: string) {
const signal = actionSignal();
const res = await fetch(`https://api.example.com/q?term=${term}`, { signal });
return res.json();
}
```

`actionSignal()` returns the `AbortSignal` tied to the in-flight request. When the client disconnects or aborts, that signal fires, and anything you passed it to unwinds. You hand it to a `fetch`, or to a database query that accepts a signal, or you check `signal.aborted` yourself inside a loop.

```ts
export async function crunch(rows: Row[]) {
const signal = actionSignal();
const out = [];
for (const row of rows) {
if (signal.aborted) break; // client left, stop chewing
out.push(expensiveTransform(row));
}
return out;
}
```

There is a deliberate safety detail here. When you call an action directly server-to-server (not across an HTTP request, just one server function calling another), `actionSignal()` returns a signal that never aborts. So the pattern is always safe to write. Your action does not need to know whether it is being reached over the network or called inline, and it will never spuriously abort just because there was no request context around it.

# The client side aborts the superseded render for you

The server half is only useful if the client actually signals the abort, and this is where the RPC boundary earns its keep. When a component's `async render()` is superseded by a newer render (a prop changed, the user typed again), WebJs does not merely ignore the old render's result. It aborts the in-flight action fetch that render started.

The generated RPC stub binds every fetch it issues to a per-render `AbortController`. When that render is superseded, its controller is aborted, the underlying HTTP request is torn down, and the `AbortSignal` you read on the server with `actionSignal()` fires. The whole chain connects. Client supersedes a render, the fetch is aborted on the wire, the server's signal trips, your action stops.

```ts
class Search extends WebComponent({ term: String }) {
async render() {
if (!this.term) return html`<ul></ul>`;
const hits = await search(this.term); // superseded by the next keystroke
return html`<ul>${hits.map((h) => html`<li>${h.title}</li>`)}</ul>`;
}
}
Search.register('search-box');
```

Type fast into this and every keystroke supersedes the last render. Without cancellation, each stale request runs to completion server-side before its result is dropped. With it, the stale request is actually cancelled the moment the next keystroke lands, so the server stops the query it was running and moves on. Under a burst of typing that is the difference between one live query and five, and you wrote none of the plumbing. It falls out of `async render()` being cancellable and the stub binding each fetch to the render that issued it.

# Streaming actions cancel their source too

The same principle extends to streaming results. An action that returns a `ReadableStream`, an async iterable, or an async generator streams its chunks over the single RPC response instead of buffering them all first.

```ts
// modules/tokens/actions/stream-tokens.server.ts
'use server';
export async function* streamTokens(n: number) {
for (let i = 0; i < n; i++) {
yield { i, token: await nextToken() };
}
}
```

```ts
for await (const chunk of await streamTokens(8)) {
this.tokens = [...this.tokens, chunk.token];
}
```

If the client disconnects, or the render that started the stream is superseded, WebJs cancels the source generator. The `for await` on the server stops advancing, so a long or infinite stream (think a token feed from a model) does not keep producing into a void. The producer is torn down at the source, not just ignored at the consumer. Back-pressure is respected the same way, so a slow reader does not force the generator to run ahead of what anyone is consuming.

# Why this is the right shape

The thing I like about this is that it is not a WebJs invention layered on top of the platform. `AbortSignal` and `AbortController` are the web's own cancellation primitives, the same ones `fetch` has accepted for years. WebJs just threads them through the one place they were previously missing, which is the RPC boundary between a client component and a server action. The server reads the platform signal, the client drives the platform controller, and the framework connects the two ends across the network so an abort on one side becomes an abort on the other.

You get to write the ordinary, boring, correct thing. Pass a signal to your `fetch`, check `signal.aborted` in a hot loop, let a superseded render clean up after itself. No cancellation library, no request-ID bookkeeping, no manual "is this response still the latest" guard on the client.

# The takeaway

Work that nobody is waiting for is work you should not be doing. WebJs makes server actions cancellable by wiring the platform's own `AbortSignal` through the RPC boundary in both directions. On the server, `actionSignal()` gives you the request's signal to pass to a fetch, a DB query, or an `aborted` check, and it returns a never-aborting signal outside a request so server-to-server calls stay safe. On the client, a superseded `async render()` aborts the previous render's in-flight fetch through a per-render `AbortController`, so a fast typeahead cancels stale requests on the wire instead of letting them finish and drop. Streaming actions cancel their source generator on disconnect or supersession too. It is the platform's cancellation model, threaded through the one boundary that was missing it, so you stop paying for answers no one wants.
Loading
Loading