TK
Back to all posts

When client:load breaks SSR: the Node-imports trap in Astro on Cloudflare

Why a perfectly working React component crashes the build with 'DOMMatrix is not defined' the moment you move from astro dev to a Cloudflare Workers deploy — and the one-line fix.

Published

We hit this twice while building TaskKit. Both times the symptom was the same: a tool that worked perfectly in astro dev either silently crashed at runtime in production with DOMMatrix is not defined, or threw a Vite warning at build time about “Unexpected Node.js imports for environment ‘ssr’.” Both times the fix was a one-character change. It’s worth a post because the diagnostic chain is non-obvious and the right answer isn’t documented in any one place.

The setup

TaskKit is Astro 6 with output: "server" on the Cloudflare Workers adapter. Tool components are React, mounted with client:load so they hydrate on page load:

<PdfToImagesTool client:load />

This pattern is right for almost everything. Astro server-renders the component once during the SSR pass, ships the HTML with the rest of the page, then the React bundle hydrates on the client. Two big wins: the LCP element is in the initial HTML, and the page is interactive without waiting on a JS download.

It’s right for almost everything. Not for libraries that import Node built-ins or DOM globals that don’t exist in the Workers runtime.

The first failure: DOMMatrix is not defined

The PDF-to-images tool uses pdfjs-dist to render PDF pages onto a canvas. The component looked fine in dev. We pushed to a preview deploy. First page load: 500. The Workers log showed:

ReferenceError: DOMMatrix is not defined
    at PdfToImagesTool (PdfToImagesTool.tsx:1:1)

DOMMatrix is a browser DOM type. It exists in browsers; it doesn’t exist in Workers. pdfjs-dist references it at import time — not runtime — because the library defines a class that extends a class that uses DOMMatrix in its prototype chain. Just resolving the module touches it. So the moment Astro’s SSR pass evaluates the component module to render its initial HTML, the import resolves, the class loads, and the runtime blows up.

The Vite-side clue (only visible when you scroll up far enough in the dev log) was:

[WARN] [vite] Unexpected Node.js imports for environment 'ssr':
  pdfjs-dist

That warning is exactly the diagnostic you want, but it doesn’t fail the build. It just sits there.

The fix

The directive you want is client:only, not client:load:

<PdfToImagesTool client:only="react" />

The difference is small but specific:

  • client:load — server-render once, then hydrate.
  • client:only="react" — never server-render. Ship a placeholder, mount on the client.

When the library can’t survive a Workers SSR pass, you skip the SSR pass. Astro is fine with this; it just emits a <div> placeholder and ships the React bundle as usual.

The second failure: qrcode

A few weeks later we shipped a QR-code generator. Same pattern: React component, client:load, used the qrcode npm package. This one didn’t crash at runtime because the SSR pass actually completed — but Vite’s warning showed up again:

[WARN] [vite] Unexpected Node.js imports for environment 'ssr':
  fs, util, zlib, buffer

qrcode is published as a single package that branches between a Node implementation and a browser one based on package.json exports. The browser entry doesn’t import fs/util/zlib/buffer — but Astro’s SSR resolver was picking the Node entry and pulling them in. The QR rendering works either way, but the bundle bloats and the cold-start cost on Workers gets worse for no reason.

Same one-line fix: client:only="react".

How to recognise it

The pattern: a library that ships a single npm name but does very different things in Node and the browser. Common offenders:

  • pdfjs-dist — pulls in DOM types at import.
  • qrcode — has both Node and browser entries; SSR can pick wrong.
  • canvas — Node-side image library; will resolve but never work in a browser context.
  • Anything with worker_threads, fs, path, crypto (the Node one) at the top of the import graph when you’re targeting Workers, where those don’t exist.

The diagnostic ladder, in order of how loud the failure is:

  1. Build crashes — easy. Read the error, switch to client:only.
  2. Vite warns about Node.js imports — easy if you read the log. The warning is right; trust it.
  3. Build succeeds, dev works, prod 500s — the painful one. The component happens to work in astro dev because astro dev runs in Node, where DOMMatrix may exist as a polyfill (or the failing path doesn’t hit). Production runs in Workers, which doesn’t polyfill, and the same code dies.

If you’re shipping to Cloudflare Workers and adding a library you didn’t write, do a preview deploy and load the page once before merging. The two-minute round-trip catches everything in tier 3.

The trade-off

client:only is not free. The component renders nothing until the React bundle lands and hydrates, so there’s a small empty-state flash. For PDF/image tools this is fine — the user is going to interact with a file picker anyway and the loading window is invisible. For a tool with text or a chart that should appear in the initial HTML for SEO, you want client:load. The implication is: if a library forces you to client:only, your tool’s page can’t have meaningful body content rendered server-side. That’s worth knowing before you commit to a library.

Why it’s client:only="react" specifically

The ="react" part isn’t a typo. Astro’s renderer needs to know which framework’s hydration runtime to ship in the bundle, because there’s no SSR pass to infer it from. If you forget the argument, you get a build-time error pointing right at the missing renderer name. Use the framework name lowercased.


That’s the whole bug. The fix is mechanical once you’ve seen it; the cost is mostly the half-hour staring at a Vite warning that turns out to mean exactly what it says.

If you’re picking a library for a browser-only tool and you can choose between two equivalents, prefer the one whose package.json exports map cleanly to a single browser entry. You’ll spend less time on this kind of resolver-edge case and more on the thing your tool is supposed to do.