Three Invisible Bugs That Broke My App — And They Were All "Helpers"
How moving from Cloudflare Workers to Node.js exposed 3 layers of polyfill chaos — process.env, fetch, and Buffer all silently replaced by browser shims in the server bundle.
How moving from Cloudflare Workers to Node.js exposed 3 layers of polyfill chaos
I'm building AI Planet Studio — an AI-powered app builder where non-technical users describe what they want, and we generate a full React application for them. The app connects to AI workflows on our platform, so users who build AI agents but don't know how to code can instantly get a working UI.
It was working great in development. Then I tried to deploy it with a real database, and everything fell apart — not because of the database, but because of "helper" code that was silently breaking things I didn't even know it was touching.
This post is about what went wrong, why it happened, and the 24-line fix that solved everything.
Why We Needed to Switch Runtimes
First, some context on what a "runtime" means in simple terms.
When your app runs on a server, it needs an environment — a program that executes your JavaScript code. Think of it like choosing between different engines for your car. They all run JavaScript, but they have different capabilities:
- Cloudflare Workers — a lightweight engine that runs in Cloudflare's cloud. Fast, simple, but limited. It can't connect to a traditional database like PostgreSQL.
- Node.js — the full-featured engine. Can do everything — databases, file system, encryption, the works.
Our app was built on Remix (a React framework) and originally ran on Cloudflare Workers. That worked fine when we were storing data in the browser's local storage. But we needed to add real features:
- User accounts with signup/login
- Encrypted API key storage (so users' secrets are safe)
- PostgreSQL database for persistent data across browsers
None of this works on Cloudflare Workers. PostgreSQL needs real database drivers. Encryption needs Node.js's built-in crypto module. So we had to switch the engine from Cloudflare Workers to Node.js.
Simple enough, right? Change the engine, everything else stays the same.
That's what I thought too.
Bug #1: process.env Was a Lie
The first thing that broke was our database connection. The app started, but every database query failed with:
PG_PASSWORD environment variable is required
But the password WAS set. I could prove it — running node -e "console.log(process.env.DATABASE_URL)" inside the same Docker container printed the correct value.
So why couldn't the app see it?
What's a Polyfill?
Here's where I need to explain something. A polyfill is a piece of code that fakes a feature that doesn't exist in your environment. For example, if you're running JavaScript in a browser and need Node.js's process.env (which browsers don't have), a polyfill creates a fake process object so your code doesn't crash.
Polyfills are genuinely useful — they let code work across different environments. The problem is when a polyfill replaces something that already exists.
What Happened
Our build tool, Vite, uses a plugin called vite-plugin-node-polyfills. Its job is to add browser-compatible versions of Node.js features for the client-side code (the part that runs in users' browsers). Makes total sense.
But here's the catch — it was also replacing Node.js built-ins in our server-side code. The server bundle had this at the top:
import process from 'vite-plugin-node-polyfills/shims/process';
This line replaces the real process (which has all our environment variables) with a fake browser version (which has an empty env object). Our database code calls process.env.DATABASE_URL — but it was talking to the fake process, which knows nothing.
The Fix
We told the polyfill plugin: "Don't replace process when we're building for Node.js — it already has the real one."
// vite.config.ts
nodePolyfills({
globals: {
process: !process.env.REMIX_NODE_RUNTIME, // false = don't fake it
},
})
One line. Database connected.
Bug #2: The AI Couldn't Stream Responses
With the database working, I tested the core feature — sending a prompt to the LLM (Large Language Model) to generate code. The request went out, got a successful HTTP 200 response from the AI... and then immediately crashed:
AI_APICallError: Failed to process successful response
The AI responded. The data came back. But our app couldn't read it.
I tested the exact same AI call in a standalone Node.js script — worked perfectly. I tested it with curl — worked perfectly. It only broke inside the Remix route handler.
This one took 10 parallel research agents to figure out.
What Happened
When you use remix-serve (Remix's built-in Node.js server) to run your app, it does something sneaky at startup:
// Inside remix-serve's source code
installGlobals({ nativeFetch: build.future.v3_singleFetch });
This replaces Node.js's native fetch function (the thing that makes HTTP requests) with a polyfill called @remix-run/web-fetch. Again — a helper meant to ensure compatibility.
The AI SDK uses fetch internally to stream responses from the LLM. It expects the native fetch that returns native ReadableStream objects. But it got the polyfilled version, which returns polyfilled streams. These two types of streams look identical but are actually different classes — like two people named John who don't know each other.
When the AI SDK tried to pipe the response through a TransformStream, it failed the instanceof ReadableStream check. The stream type from the polyfill didn't match the stream type the SDK expected.
The response was right there. The data was valid. But the plumbing was incompatible.
Why I Couldn't Just Flip a Switch
Remix has a config flag called v3_singleFetch that tells remix-serve to use native fetch instead of the polyfill. I turned it on. The AI streaming worked!
But then the entire app stopped rendering. Every page returned:
TypeError: Invalid state: ReadableStream is locked
Our app uses a custom server-side rendering setup (with remix-island for HTML head injection). The v3_singleFetch flag changed how Remix handles SSR responses internally, and it conflicted with our custom rendering pipeline.
So: polyfill ON = AI breaks. Polyfill OFF = rendering breaks.
The Fix
Instead of using remix-serve (which forces the polyfill choice on you), I wrote a custom 24-line Express server:
// server.mjs
import { createRequestHandler } from '@remix-run/express';
import { installGlobals } from '@remix-run/node';
import express from 'express';
// Use native fetch — fixes AI SDK streaming
installGlobals({ nativeFetch: true });
const app = express();
app.use(express.static('build/client'));
const build = await import('./build/server/index.js');
app.all('/{*path}', createRequestHandler({ build }));
app.listen(process.env.PORT || 5174, '0.0.0.0');
This calls installGlobals({ nativeFetch: true }) directly — giving us native fetch without enabling v3_singleFetch. Best of both worlds: AI streaming works, rendering works.
Bug #3: Buffer.from Is Not a Function
With AI streaming and rendering both working, I tested saving encrypted API keys. Crash:
TypeError: Buffer.from is not a function
Same pattern, third time. The Buffer class (used for binary data operations like encryption) was being polyfilled by the same Vite plugin. The polyfill's default export worked differently than Node's native Buffer in how ESM imports resolved it.
The Fix
This one resolved itself once we rebuilt with the corrected polyfill configuration from Bug #1. The updated build produced import Buffer from 'vite-plugin-node-polyfills/shims/buffer' (default import) instead of import { Buffer } from ... (named import), which correctly resolved to a working Buffer class.
The Pattern
All three bugs had the same shape:
- A tool designed to help (polyfill) silently replaced a working built-in
- Everything looked normal on the surface
- The failure happened deep inside a third-party library that expected the real thing
- The error messages gave zero indication that a polyfill was involved
The polyfills weren't wrong — they're essential for browser environments. The bug was that they leaked into the server environment where real implementations already existed.
What I'd Do Differently
1. Check your SSR bundle imports first. Before debugging anything else, look at the first 30 lines of your built server bundle:
head -30 build/server/assets/server-build-*.js
If you see import process from 'vite-plugin-node-polyfills/shims/process' or import Buffer from 'vite-plugin-node-polyfills/shims/buffer' — that's your problem.
2. Never use remix-serve for AI/streaming workloads. Write a custom Express server. It's 24 lines and gives you full control over installGlobals.
3. Test the full stack locally before deploying. Not just "does the page load" — test signup, encryption, AI streaming, the whole flow. Each of these three bugs would have been caught with a proper local E2E test.
4. When something works standalone but fails in the framework — suspect the framework's globals. If node -e "..." works but the same code inside a route handler doesn't, something is patching the global environment.
The Numbers
- 3 polyfill layers broke independently (
process,fetch,Buffer) - 10 research agents dispatched in parallel to find the
fetchroot cause - 24 lines of code (
server.mjs) fixed the hardest bug - 5 end-to-end tests (signup → save API key → verify → models → chat) all pass
The app is live. Users can sign up, connect their AI workflows, and build applications — all backed by PostgreSQL with encrypted credentials. The polyfills are still there for the browser code where they belong. They're just no longer pretending to be something they're not on the server.