Skip to main content
user@argobox:~/journal/2026-02-20-object-object-the-three-headed-hydra
$ cat entry.md

[object Object] — The Three-Headed Hydra That Ate My Admin Panel

○ NOT REVIEWED

The Setup

11 PM. My admin panel at argobox.com/admin is rendering [object Object]. Not an error page. Not a 500. The literal string [object Object], like JavaScript just gave up and went home.

The weird part? Rollback to an older deployment — works fine. Fresh build of the exact same commit[object Object]. Every time.

The Investigation

Dead End #1: The Compatibility Flag

Searched the Astro GitHub issues. Found #14511 and #14983. The recommended fix: add disable_nodejs_process_v2 to your Cloudflare compatibility flags.

Added it. Redeployed. [object Object].

Dead End #2: Error Boundary

Wrapped the middleware’s next() in a try/catch. If something was throwing, I’d see it.

Nothing threw. The response came back with status 200, content-type text/html, and a body of [object Object]. Astro thought everything was fine.

Dead End #3: Body Materialization

OK, maybe the response body is a stream that workerd can’t handle. I’ll just force-read it:

const body = await response.text();
return new Response(body, { status: response.status, headers: response.headers });

Still [object Object]. Which meant response.text() was literally returning the string "[object Object]". The body was already corrupted.

The Breakthrough

I started creating isolation tests. A raw API endpoint:

export async function GET() {
  return new Response(JSON.stringify({ ok: true }));
}

Works. Cloudflare Workers can serve responses just fine.

Then a hardcoded HTML response in middleware:

return new Response('<h1>Works</h1>', { headers: { 'Content-Type': 'text/html' } });

Works. Middleware runs, HTML renders.

Then a bare-minimum .astro page — no imports, no layout, just <h1>Test</h1>:

[object Object].

So the Worker runs. Middleware runs. API endpoints work. But the moment Astro’s page rendering pipeline touches something, it’s broken. Specifically, it’s the step where Astro creates new Response(body) with the rendered HTML.

The Three Heads

Here’s what was actually happening:

Head #1: The @astrojs/cloudflare adapter injects globalThis.process ??= {} as a Rollup banner on every Worker file at build time.

Head #2: Cloudflare’s nodejs_compat flag provides a full process object at runtime.

Head #3: With compatibility_date >= 2025-09-15, Cloudflare’s process v2 polyfill enhances it further.

Any ONE of these makes Astro think it’s running in Node.js. When Astro thinks it’s Node, it wraps rendered HTML in an AsyncIterable — a Node.js streaming pattern.

But Cloudflare Workers run on workerd, not Node. And workerd’s Response constructor doesn’t understand AsyncIterable. It’s not in the Web API spec. So JavaScript does what JavaScript does:

asyncIterable.toString() → "[object Object]"

That’s your page. That’s your admin panel. [object Object].

The Kill Shot

I couldn’t remove process from all three sources. I couldn’t make Astro stop detecting Node.js. But I could teach workerd’s Response what to do with an AsyncIterable.

One Rollup banner to rule them all:

(()=>{
  const O = Response;
  globalThis.Response = class extends O {
    constructor(b, i) {
      if (b && typeof b === 'object'
          && typeof b[Symbol.asyncIterator] === 'function') {
        const r = b;
        b = new ReadableStream({
          async start(c) {
            for await (const v of r) {
              c.enqueue(typeof v === 'string'
                ? new TextEncoder().encode(v) : v);
            }
            c.close();
          }
        });
      }
      super(b, i);
    }
  };
})();

Monkey-patch the Response constructor. If the body is an AsyncIterable, convert it to a ReadableStream (which workerd does understand). Pass everything else through unchanged.

Deployed. SSR pages render. Admin panel is back. Nine hours, seven failed attempts, one line of JavaScript.

Why Rollbacks Worked

The Cloudflare Pages dashboard had Compatibility date: Dec 1, 2025. Older cached deployments were built under an earlier date — before 2025-09-15 when the process v2 polyfill was introduced. Under that older date, nodejs_compat didn’t expose the full process object, so Astro never misdetected Node.js.

Fresh builds of any commit — even previously working ones — used the new date. Every fresh build broke.

The Lesson

When you see [object Object] in a browser, something called .toString() on an object that didn’t have a meaningful string representation. In Cloudflare Workers, that usually means you passed a body type to new Response() that workerd doesn’t understand.

The valid body types are: string, Blob, BufferSource, ReadableStream, FormData, URLSearchParams, or null. AsyncIterable isn’t on that list.

Three independent systems — the Astro adapter, the Cloudflare runtime, and the compatibility date — all conspired to create a process object that made Astro think it was somewhere it wasn’t. The fix was 10 lines of JavaScript that bridges the gap between what Astro produces and what workerd expects.

Sometimes the best debugging tool isn’t a profiler or a logger. It’s creating the simplest possible version of the thing that’s broken, and working backward from what works to what doesn’t.