Skip to main content
TEEI in Practice
Engineering note · Research & Engineering · 15 October 2026 Why We Still Handle WebSockets Before Astro

TEEI in Practice

Engineering noteResearch & Engineering

Part of: Astro on Workers

Why We Still Handle WebSockets Before Astro

We keep WebSocket upgrades outside Astro as a precaution. Locally, a copied response kept its socket; a rebuild without the socket broke the upgrade.

On this page
  1. A 101 response must carry the socket
  2. Five ways to return the same upgrade
  3. Keep the upgrade’s return path explicit
  4. Preserve the class export through the build
  5. Keep connection state and retained history separate
  6. What the production probes establish
  7. Decide whether to put the socket before Astro
  8. Reproduce the contract in your stack
  9. Configuration examined
  10. Continue in the Astro series
  11. Frequently asked questions

A WebSocket upgrade on Cloudflare Workers carries something an ordinary HTTP response does not: the socket itself. Return status 101 and the headers while omitting that property, and the upgrade fails. Copying the whole response, however, preserved the socket in our local test. That distinction changed the explanation for a boundary we had already built.

Our Astro website answers the homepage feed’s upgrade in a custom Worker entry, before calling Astro. We introduced that boundary on 14 October 2026 as a precaution, expecting response copying to break the connection. A five-route workerd experiment the next day showed exactly which copies worked and which failed. We kept the boundary because it gives the upgrade a short, explicit return path, while ordinary requests still use Astro.

This article covers the response contract, the experiment and the generated module that Wrangler deploys. The Astro on Workers homepage case follows the handoff from a rendered activity fragment to the browser’s updates; the Cloudflare overview maps the wider platform. The implementation and production probes described below are dated 15 October 2026. The probes establish a handshake and message exchange for that deployment, without establishing delivery of completed sessions.

Built through volunteer contributions. TEEI’s software platforms are built through volunteer work, with NOK 0 spent on paid software development. Infrastructure and third-party services are separate from that figure.

A 101 response must carry the socket

The browser starts with a GET carrying Upgrade: websocket. In Workers, the successful response has status 101, a null body and a webSocket property holding the client end of a WebSocketPair. The runtime uses that property to connect the client when the response leaves the Worker. Status and headers alone cannot carry it.

An Astro endpoint can return the upgrade, sending the response back through Astro and the adapter. A custom Worker entry can instead return it before Astro runs. The Cloudflare adapter guide supports that entry through Wrangler’s main, using a standard Worker object with custom exports such as Durable Object classes.

Our original reason for choosing the entry was a claim that new Response(response.body, response) would lose the socket. Cloudflare itself uses that construction to make response headers mutable. Types exposed the webSocket property but did not establish what the constructor copied, so we tested the runtime.

Five ways to return the same upgrade

On 15 October 2026, we built a small Worker and Durable Object. The object accepted a socket through the Hibernation API, sent hello and returned new Response(null, { status: 101, webSocket: client }). The Worker obtained that response as res and returned it through five branches:

Adapted for publication.

js
if (pathname === '/orig') return res;
if (pathname === '/copy') return new Response(res.body, res);
if (pathname === '/copy-set-header') {
  const r = new Response(res.body, res);
  r.headers.set('Content-Language', 'en');
  return r;
}
if (pathname === '/named') {
  return new Response(res.body, { status: res.status, headers: res.headers });
}
if (pathname === '/named-with-ws') {
  return new Response(res.body, {
    status: res.status,
    headers: res.headers,
    webSocket: res.webSocket,
  });
}

We ran the experiment with Miniflare 4.20260701.0, workerd 1.20260701.1 and Node.js 22.23.2, once with compatibility date 2024-09-23 and once with 2026-04-07. For each branch, a client attempted to open, receive hello, send ping, receive pong and close.

Table 1. Local workerd results on 15 October 2026.Both compatibility dates produced the same outcomes.
BranchConstructionResult
/origReturn the object’s responseOpened, hello, pong, close 1000
/copyUse res as the constructor’s initThe same successful exchange
/copy-set-headerCopy, then set Content-LanguageThe same successful exchange
/namedPass only status and headersConstructor threw RangeError; upgrade failed
/named-with-wsAdd webSocket to the named fieldsThe same successful exchange

The failing constructor reported that response statuses must be between 200 and 599, inclusive. In this runtime, 101 is permitted with a socket. Adding webSocket: res.webSocket to the same named fields turned the failed construction into a working upgrade.

Our recorded explanation was wrong about the whole-response copy: it preserved the socket, even after a header change. The experiment isolates omission of webSocket as the difference between the two named-field branches. It does not establish that every response transformation is safe.

We also read the response-handling code in Astro 7.2.10 and adapter 14.2.4. The adapter uses the whole-response copy in a header-setting fallback; Astro’s i18n and error handling use named fields on some paths. Replacing a 101’s status with 404 would end the upgrade; retaining 101 without its socket would fail construction as /named did. We have not established that those branches receive an upgrade response, or sent a 101 through a complete Astro build. The five-route result belongs to local workerd, rather than the production runtime.

Keep the upgrade’s return path explicit

We still answer before Astro. The response then depends on the Durable Object and the Worker layers we control, without depending on how a renderer or adapter version rebuilds it. That is a maintenance choice supported by the documented Worker-to-Durable-Object flow, rather than a demonstrated requirement that Astro cannot carry a socket.

Adapted for publication, with a stand-in path, import and object name.

ts
import { handle } from '@astrojs/cloudflare/handler';

export { LiveLedger } from './live-ledger';

const LIVE_SOCKET_PATH = '/socket';
const LIVE_LEDGER_NAME = 'feed';

export default {
  async fetch(request: Request, env: Cloudflare.Env, ctx: ExecutionContext): Promise<Response> {
    const { pathname } = new URL(request.url);
    const isUpgrade = request.headers.get('Upgrade')?.toLowerCase() === 'websocket';
    if (pathname === LIVE_SOCKET_PATH && isUpgrade) {
      const ledger = env.LIVE_LEDGER;
      if (!ledger) {
        return new Response('Live ledger is not configured', {
          status: 503,
          headers: { 'Cache-Control': 'no-store' },
        });
      }
      const stub = ledger.get(ledger.idFromName(LIVE_LEDGER_NAME));
      return stub.fetch(new Request('https://durable-object/ws', request));
    }
    return handle(request, env, ctx);
  },
};

Both conditions must match: the socket path and the Upgrade header. The same header on another path goes to Astro. new Request(url, request) preserves the original handshake headers, including Sec-WebSocket-Key and Sec-WebSocket-Version; the URL shown is a stand-in for routing within the object.

We return stub.fetch() directly. A missing binding produces 503 instead of falling through. A plain request to the socket path goes to an Astro endpoint that answers 426 Upgrade Required with an explanation. This keeps an ordinary HTTP visit useful without confusing it with the upgrade.

Sequence diagram with two requests to the same path. In the first, a GET with an Upgrade header passes Cloudflare and the build-generated wrapper, our Worker entry sends it to the Durable Object, which accepts the socket, sends a hello and returns a 101 with the client socket; the 101 goes back through the entry and the wrapper unchanged, and Astro is never called. In the second, the same GET without the Upgrade header goes from our entry to Astro, whose route answers 426 with an explanation.
Figure 1. Two requests to the socket path. With the Upgrade header, the 101 travels from the Durable Object to the browser without entering Astro. Without it, Astro answers 426. Drawn from the website’s code, 15 October 2026. Source: TEEI, drawn from the website’s Worker entry, its build step and the Durable Object, 15 October 2026.

Preserve the class export through the build

The source entry is not necessarily the deployed entry. After the adapter bundles ours, a postbuild step moves that module aside and writes a wrapper that adds a scheduled handler, redirects and video handling. On the way back, its response repair only handles redirects missing Location:

Adapted for publication.

js
function repairFallbackRedirect(res, url) {
  if (res.status < 300 || res.status > 399 || res.headers.has('Location')) return res;
  // Repair the redirect that reached this branch.
  // …
}

A 101 returns unchanged at the first line. That check matters because answering before Astro still leaves our own wrapper in the path.

The wrapper must also re-export the Durable Object class. Wrangler binds the class by name in the module deployed as main; importing another module’s default Worker object does not export its named classes. Each replacement entry has to preserve both contracts: the handlers and the class export.

Our build step looks for the class name in the adapter module and for the re-export line in the wrapper it generates. Those are text checks. The recorded fixture test goes further: it imports the generated module and checks that the class, fetch and scheduled exports are functions. Given an adapter module without the class, the step must fail and leave that module unwrapped.

That test establishes an export contract for a fixture build. The local workerd experiment establishes the response-copy behaviour. The production probe establishes that the observed deployment could upgrade. Each answers a different question; a correct export does not prove a working socket.

Builds with our error tracker’s wrapper add another outer module. The diagram and code discussion describe the build without that optional layer; another wrapper must preserve the same exports and response contract.

Keep connection state and retained history separate

The object creates the socket pair and accepts its server side with the Hibernation API:

Adapted for publication; capacity handling omitted.

ts
private openSocket(request: Request): Response {
  if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
    return new Response('Expected a WebSocket upgrade', {
      status: 426,
      headers: { Upgrade: 'websocket' },
    });
  }
  const pair = new WebSocketPair();
  const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
  this.ctx.acceptWebSocket(server, [SOCKET_TAG]);
  const hello = {
    type: 'hello',
    ts: new Date().toISOString(),
    latest: this.latest(RECENT_LIMIT).map(toPublicView),
  };
  try {
    server.send(JSON.stringify(hello));
  } catch {
    // The client may have gone away.
  }
  return new Response(null, { status: 101, webSocket: client });
}

acceptWebSocket() lets the object hibernate between messages while connections remain open. With server.accept(), open sockets would keep it in memory. For a broadcast we ask the runtime for getWebSockets('live'), serialise the message once and catch failures per send; we do not depend on a JavaScript connection array surviving hibernation.

History lives in the object’s SQLite storage. The constructor creates its table and index with IF NOT EXISTS inside blockConcurrencyWhile(). Before returning an upgrade, the object sends a hello containing a public view of retained events, using the projection described in the live-feed article.

The object retains up to 200 events and includes up to 50 in hello. Pruning happens before broadcasting an event, so a later connection receives retained state, rather than a replay of everything an earlier connection saw. Reconnecting sends another snapshot. Receiving, applying and displaying that snapshot are client responsibilities; our probes below do not establish browser restoration.

Connections can end during runtime updates even when hibernated. Cloudflare’s 12 October 2026 change increased the grace period for requests and connections to up to 20 minutes, while still allowing earlier disconnection. Reconnection needs a state-restoration policy, not just another successful handshake. Keeping open browser tabs working across Worker deploys examines that client-side problem.

What the production probes establish

On 15 October 2026, three curl probes against the public website distinguished the routing paths:

Table 2.
RequestObserved response
Socket path with Upgrade101 Switching Protocols, followed by a hello frame
Same path without Upgrade426, an explanation and Cache-Control: no-store
Public JSON snapshot path with the same Upgrade header200 JSON from Astro

Three separate Node.js client runs that day opened, received hello and pong, and closed with 1000. Every recorded hello had an empty latest list. These runs establish that the observed path could negotiate a socket and exchange messages. They do not establish completed-session delivery, a latency distribution, concurrent capacity, hibernation savings or what clients experience during a deployment.

Decide whether to put the socket before Astro

The custom entry fits when you want a protocol-specific response path alongside Astro’s ordinary routes. Its cost is a second place where request handling must be understood and tested.

If a socket needs a signed-in session, an Origin check or values normally computed in Astro middleware, arrange for that work to run before accepting the upgrade. Shared functions can serve both paths; keeping the socket outside Astro does not require duplicating their implementation.

Choose the Durable Object’s scope separately. A tag groups sockets inside one object; it does not distribute connections across objects. A single named object keeps history and fan-out together. Rooms or regions can divide that work, but a feed spanning them then needs an explicit event-distribution design. We have not load-tested this stream’s capacity.

You can upgrade in a plain Worker without a Durable Object. We use the object because the feed needs retained history and a set of connected clients together, with hibernation and persistent state. Choosing the upgrade boundary alone does not decide where those responsibilities belong.

Reproduce the contract in your stack

Start with the five response branches above in a local Worker and Durable Object, using wrangler dev or the Workers Vitest integration. For each working branch, assert the complete exchange: open, hello, ping/pong, then close 1000. For /named, assert a constructor error and failed upgrade. A status-only assertion misses the message exchange the connection is intended to support.

Then add your own response-handling layers one at a time. Record whether each rebuilding branch actually receives a 101, separately from whether the connection still works afterwards. A branch that would discard a socket is not an observed failure until the upgrade reaches it. Finally, check the generated module’s exports as well as the socket through the entry you intend to deploy.

This reproduction extends the local experiment to your own stack. It does not turn the dated production probes into delivery or load evidence.

Configuration examined

The following excerpt is adapted from the website configuration read on 15 October 2026. Choose an actual entry path for your project; the placeholder is explanatory.

Adapted for publication.

toml
main = "<your Worker entry>"
compatibility_date = "2024-09-23"

[[durable_objects.bindings]]
name = "LIVE_LEDGER"
class_name = "LiveLedger"

[[migrations]]
tag = "live-ledger-v1"
new_sqlite_classes = ["LiveLedger"]

Heartbeat and close. Our setWebSocketAutoResponse registration pairs the text messages ping and pong. The runtime can answer them without waking a hibernating object; these are application messages, distinct from WebSocket Ping and Pong control frames. A reply alone does not establish that the object was hibernating.

With a compatibility date before 2026-04-07 and without web_socket_auto_reply_to_close, the runtime does not complete the close handshake automatically. Our webSocketClose closes the object’s side, converting reported code 1005 to 1000 because RFC 6455 reserves 1005 for reporting absence of a status and forbids sending it in a Close frame. We close an errored socket with 1011.

Versions read locally on 15 October 2026: Astro 7.2.10, @astrojs/cloudflare 14.2.4, Wrangler 4.107.0, workerd 1.20260701.1 and @cloudflare/workers-types 4.20260702.1. Those installed versions are the source-reading context, not versions read from the deployed Worker. The copy experiment’s Miniflare and Node.js versions are recorded with its results above.

Continue in the Astro series

Return to the Astro on Workers hub for the complete reading path. To see why a request can reach the Worker without reaching Astro middleware, continue with Worker-first and assets-first routing.

Frequently asked questions

How do I accept a WebSocket in an Astro site on Cloudflare Workers?

Point main in your Wrangler configuration at a Worker entry of your own. In its fetch handler, check the path and the Upgrade header, pass a matching request to a Durable Object stub and return the stub's response as it is. Send every other request to handle() from @astrojs/cloudflare/handler.

Does new Response(response.body, response) keep the webSocket of a 101 on Workers?

In our local test in workerd on 15 October 2026, yes: the copy kept the socket, which opened, exchanged messages and closed with 1000. A copy that names only the status and the headers failed instead, because a 101 needs its webSocket. We found no description of this in the Cloudflare pages we checked that day, and we have not tested it in production, so we answer the upgrade before Astro.

Why must the Durable Object class be exported from the deployed entry?

Wrangler binds the class by name in the module it deploys as main. If a build step wraps the adapter's entry in a new module, the new module has to re-export the class too. We stop the build when the class name is missing from the adapter's entry, and in a test on a sample build we check that the deployed module exports the class.

Does my Durable Object have to answer the WebSocket close handshake?

With a compatibility date before 2026-04-07, and without the web_socket_auto_reply_to_close flag, the runtime does not complete it for you. In our object we close our side in webSocketClose and send 1000 when the runtime reports 1005, the code for a close frame without a status, because 1005 may not be sent in a close frame.

More in Research & Engineering

Partnerships

Talk to us about building with TEEI

We use these details only to reply.