The pitch I made to myself was simple: it's just a redeploy. Voilae runs four apps — three Next.js apps and a Remix Shopify app — and they were all on Vercel. Moving to Cloudflare Workers meant repointing the build, copying the environment variables across, and cutting DNS. Both are serverless platforms that run my JavaScript. How different could they be?
Different enough that "copy the env vars and cut DNS" was maybe the last thing I got to do, not the first. The code compiled. The tests passed on my machine. And then the first request to the deployed Worker hung forever. What follows is the honest list of everything I assumed that turned out to be wrong — written down so the next person spends their week on the interesting problems instead of these.
I assumed I was moving code. I was actually moving a set of assumptions about the runtime — and those don't copy across.
Node.js vs. workerd is the whole story
Almost every surprise in this migration traces back to one fact I underweighted at the start. On Vercel, your functions run on Node.js — a real, long-lived process with the full standard library, native addons, and an ambient environment. On Cloudflare, your code runs on workerd: V8 isolates with Web-standard APIs, spun up and reused in ways you don't control, with hard CPU ceilings and no persistent process to lean on.
Cloudflare gives you a bridge — set nodejs_compat and a recent compatibility_date and you get process.env, a large slice of the Node API surface, and most of your code runs unchanged. That bridge is good enough to make you believe the runtimes are the same. They are not. The API surface is compatible; the execution model is not, and that's where the week goes.
Nothing async can run at boot
On Vercel, doing work at module scope is normal. The file loads once per cold start, inside a real process, and if the constructor opens a socket or reads a secret, fine — that's just startup. I had code all over the app that assumed this.
Cloudflare runs your Worker's global scope during deploy-time validation, before any secret is guaranteed to be applied, and there's no warm process to lazily initialize into. So module-scope side effects don't run "on first request" — they run when you deploy, in an environment that isn't finished yet. Three separate bugs, all the same root cause:
- Session storage broke OAuth silently. Shopify's
PrismaSessionStoragepolls the database in its constructor. On Workers that I/O is disallowed at module load, so sessions were never "ready" and the OAuth install flow just… didn't. The fix was to construct the whole Shopify app lazily, memoized on first request, so no database work happens in global scope. - Missing secrets failed the deploy, not the feature.
new Resend(process.env.RESEND_API_KEY)andRedis.fromEnv()at module scope threw"Missing API key"during deploy validation — the whole deploy failed because a key wasn't applied yet. Made all three clients lazy, and now a missing key only breaks the one feature that needs it, only until its secret is set.
On Vercel, top-level code runs on a cold start. On Workers, it runs at deploy. That one sentence explains most of the bugs.
Database connections belong to a request
This was the one that hung. The Postgres client was a module-scoped connection pool — the standard Node pattern, share one pool across every request, let it warm up and stay warm. On Vercel it's exactly right.
On Workers, a Postgres socket is bound to the request that opened it. A pool created in global scope and reused across requests meant that every request after the first in a warm isolate reached for a connection tied to a request that no longer existed — and the Worker hung, code that would never generate a response. It surfaced as a 500 on client-side single-fetch navigations and as intermittent hangs everywhere else. Nothing in the logs pointed at the pool.
The fix follows Cloudflare's own Hyperdrive + node-postgres guidance: each request gets its own Prisma client and pool, stashed on a per-request AsyncLocalStorage store, torn down after the response via ctx.waitUntil. Non-request contexts — local dev, tests, migrations — keep a module-scoped singleton, because there the old assumption still holds. Hyperdrive sits in front of Supabase Postgres to give back the connection pooling the persistent Node process used to provide implicitly.
Native dependencies don't exist
Voilae used sharp — the native libvips binding — for image work. There is no sharp on workerd; there are no native addons at all. So two things had to be rebuilt against WebAssembly and Web-standard crypto:
- The image dedup hash used a perceptual dHash that needed an image-decode WASM. That blob pushed the bundle past the Workers size limit, so it became a Web Crypto SHA-256 over the raw bytes — exact-bytes dedup, zero dependencies, tiny.
- The share-image renderer moved to
@cf-wasm/resvgwith an embedded open-licensed font, compositing the result photo as a data-URI under the overlay. Output went from JPEG to PNG along the way.
And the constraints behind those choices are real, not theoretical: Workers cap the bundle size (3 MiB on the Free plan) and the CPU time per request (~10 ms on Free). A WASM rasterizer and a WASM query engine can blow through both, which is why production runs on the Paid plan. On Vercel, bundle size and wall-clock were the mental model; on Workers, it's bundle size and CPU milliseconds, and they bite in different places.
The config that lies to you
The last category is the one that doesn't crash loudly — it just quietly does the wrong thing until you notice. A sampling:
- Plaintext env vars get wiped on every deploy.
wrangler deployreplaces the Worker's plaintext variables with the ones declared inwrangler.jsonc. Any value you added in the dashboard as a plaintext var disappears on the next deploy. Sensitive values have to be encrypted Secrets, which persist — a genuinely different model from pasting env vars into a project settings page. - There is no
VERCEL_ENV. Production-vs-preview detection was reading a Vercel-injected variable that doesn't exist on Workers. I introduced my ownAPP_ENVand set it per Worker. maxDurationis a Vercel concept. Long-running inference routes setmaxDuration = 60. Workers doesn't meter wall-clock time spent awaiting subrequests the same way — it meters CPU — so the limit that matters is a different one, and you find it withwrangler tail, not in a config value.- A bare Node version broke the build. An
.nvmrcof22made Cloudflare's builder resolve "latest 22.x" and download a version on the fly, which failed provisioning. Pinning to22.16.0— the version pre-installed in the build image — made it deterministic. - Preview URLs work differently. Vercel gives every branch a preview subdomain automatically. On Workers you get a stable preview by deploying a second Worker (an
env.preview) with its own secrets and database. It's more deliberate, and more setup.
What I'd tell myself before starting
None of this is an argument against Cloudflare — the app is faster to reason about now, the connection story through Hyperdrive is cleaner, and it deploys clean on workerd with all 457 tests passing. It's an argument against the word redeploy. If I were starting over, three things would save the most time:
- Run it on
workerdbefore you believe anything.next devand the Vite dev server run on Node and will happily hide every bug above. Thepreviewcommand that runs the app in the real runtime is the only honest gate — treat "passes locally" as meaningless until it passes there. - Migrate incrementally, cut DNS last. I left the
vercel.jsonfiles in place, deployed to the*.workers.devURLs, validated each app in parallel with production, and cut DNS one app at a time. There was never a big-bang cutover to be scared of. - The conveniences are what you unlearn. A long-lived process, warm global state, native modules, ambient env vars, automatic preview URLs — every one of those is a Vercel affordance my code had quietly grown to depend on. The migration wasn't porting code. It was finding all the places the platform had been doing something for me that the new platform expects me to do on purpose.
That's the real lesson, and it generalizes past this one move: the hard part of changing infrastructure is rarely the code you wrote. It's the assumptions the old platform let you get away with.
Planning a runtime migration of your own?
Get in touch