Autonnel v0.1.0
Back to blog

Prisma on workerd: everything that broke and how we fixed it

Four failures from running Prisma 7 and Astro on Cloudflare Workers: hanging connections, and a compatibility date that corrupts every SSR page.

· 7 min read

Autonnel’s website and platform backend run on Cloudflare Workers: Astro in output: 'server' mode, Prisma 7 against Postgres through Hyperdrive, all inside workerd. It works well now. Getting here cost several days that I would like to give back to whoever reads this.

None of these were subtle bugs in anyone’s code. They were all cases where a pattern that is correct on Node is quietly wrong on Workers, and the failure mode was far away from the cause.

1. A module-level Prisma client hangs the second request

This is the one that cost the most time, because on Node it is not just fine, it is the recommended pattern.

// Correct on Node. Wrong on Workers.
const prisma = new PrismaClient({ adapter })
export default prisma

On a long-lived Node server, a module-cached pool is exactly what you want. On Workers, an opened connection cannot be reused across request I/O contexts. A module-cached pool holding idle connections means the next request that picks up one of those connections hangs, and it hangs at a point in your code with no obvious relationship to the pool.

The symptom is worse than an error: intermittent, load-dependent timeouts that never reproduce locally, because your dev server only ever handles one request at a time.

Cloudflare’s guidance is one connection per request, with Hyperdrive doing the pooling server-side. What we ended up with keeps a single client per request in AsyncLocalStorage and disconnects it after the response settles:

const requestScope = new AsyncLocalStorage<{ client?: PrismaClient }>()

export function getDb(env: DbEnv): PrismaClient {
  const scope = requestScope.getStore()
  if (scope) {
    scope.client ??= new PrismaClient({
      adapter: new PrismaPg({ ...adapterConfig(env), max: 1, connectionTimeoutMillis: 4000 }),
    })
    return scope.client
  }
  // Node (long-lived server): a module-cached pool is correct and efficient.
  nodeCached ??= new PrismaClient({
    adapter: new PrismaPg({ ...adapterConfig(env), max: 20, idleTimeoutMillis: 10_000 }),
  })
  return nodeCached
}

export async function withRequestDb<T>(
  fn: () => Promise<T>,
  waitUntil: (p: Promise<unknown>) => void,
): Promise<T> {
  const scope: { client?: PrismaClient } = {}
  return requestScope.run(scope, async () => {
    try {
      return await fn()
    } finally {
      if (scope.client) waitUntil(scope.client.$disconnect().catch(() => {}))
    }
  })
}

Two details that matter more than they look:

max: 1 on the Workers path. One request, one connection. Hyperdrive is the pool.

waitUntil for the disconnect. Cleanup must not block the response, but it also must not be dropped on the floor when the isolate is about to go away. waitUntil is the only thing that gets both.

The middleware wraps every request in withRequestDb, so every module can call getDb(getRuntimeEnv()) locally and they all share one connection. The rule we wrote into the repo’s agent guide, because it is the kind of thing that gets reintroduced by accident: never introduce a module-level Prisma singleton.

2. A compatibility date that turns every SSR page into “[object Object]”

This is my favourite failure of the year, in the sense that I never want to see it again.

Symptom: every server-rendered page returns HTML whose body is the literal string [object Object]. Not an error. Not a stack trace. A 200 response containing the stringification of an object where your page should be.

The chain, once we found it:

  1. nodejs_compat with a compatibility date before 2026-04-01 makes workerd’s process shim report "[object process]" when stringified.
  2. Astro checks that value to decide whether it is running on Node.
  3. It concludes yes, and takes the Node async-iterable render path.
  4. That path is wrong on workerd, and the page serialises to [object Object].

The fix is one line, and the comment above it is now the longest comment in our wrangler.toml:

# Keep >= 2026-04-01: earlier dates make workerd's nodejs_compat `process` report
# "[object process]", which trips Astro's isNode check into the Node async-iterable
# render path, so SSR pages serialize to literal "[object Object]".
compatibility_date = "2026-04-01"

The generalisable lesson: on Workers, compatibility_date is not metadata. It changes runtime semantics, including semantics that frameworks sniff to decide what environment they are in. When something behaves as though your framework has misidentified the runtime, check the date before you check anything else.

3. The bindings API moved, and the old one throws

Astro 6 with @astrojs/cloudflare v13 removed locals.runtime.env and locals.runtime.ctx. They do not return undefined, which would be easy to spot. They throw.

Environment and bindings now come from cloudflare:workers, and the ExecutionContext (the thing with waitUntil on it, which point 1 needs) is locals.cfContext.

Because we still want the code to run under plain Node for tests and local development, resolution has to be conditional:

async function resolveCfEnv(): Promise<Record<string, unknown>> {
  try {
    const mod = await import('cloudflare:workers')
    if (mod?.env) return mod.env as Record<string, unknown>
  } catch {
    // Not on the workerd runtime (plain Node). Fall through to process.env.
  }
  return (typeof process !== 'undefined' ? process.env : {}) as Record<string, unknown>
}

That gets bound once per request in the middleware, and everything else reads through one helper rather than reaching for bindings directly. It is worth the indirection: when this API moves again, and it will, there is a single place to change.

4. workerd validates your static import graph at deploy time

This one bites late, which is the worst time.

Cloudflare validates the eager static import graph when you deploy. A Node-only dependency that never executes at runtime, but is statically imported somewhere in the graph, will still fail the deploy. Your build passed. Your tests passed. The deploy does not.

For us the relevant facts were:

  • pg is fine, because it bundles through @prisma/adapter-pg.
  • jose and aws4fetch are edge-safe, which is why they are the JWT and S3 clients here rather than the more obvious choices.
  • Anything reaching for fs, crypto in its Node form, or a native binding is not fine, no matter how conditionally it is called.

The practical habit: when adding a dependency to a Workers project, check its import graph before you check its API. [unenv] fs.readFile is not implemented yet! at deploy time is the same lesson delivered later and more expensively.

What actually generalises

Strip out the specifics and the same shape appears four times: a pattern that is correct on a long-lived Node process is wrong on a per-request isolate, and the failure surfaces somewhere unrelated to the cause.

Connection pooling, module-level caching, environment access, and dependency loading all have that property. If you are porting a Node app to Workers, those four are where I would look first, in that order.

The upside is real, which is why we stayed. Static assets are unmetered, a typical funnel sits inside the free tier, and the whole thing is one wrangler deploy. The site you are reading this on is the same stack, and it serves pages in about half a second from a cold cache with zero bundled JavaScript on most routes. The full bill, worked from Cloudflare’s published rates, is in what a funnel costs on Cloudflare Workers; the licence and plan side is on pricing.

If you want to see the working version rather than the description, the whole thing is Apache-2.0 at github.com/autonnel/autonnel. The database code above is src/lib/db.ts. The installation docs cover the Workers deploy path end to end.