Skip to content

Cloudflare Workers: serverless path spends extra CPU on marker verification and large runtime bundle #648

@sorenbs

Description

@sorenbs

Summary

While investigating why Prisma Next reports higher Cloudflare Workers CPU ms than Drizzle, I found two likely contributors in the current serverless/runtime path:

  1. A fresh @prisma-next/postgres/serverless runtime verifies the contract marker before the first user query, producing two extra pg queries before application SQL.
  2. A Worker-shaped Prisma Next bundle is substantially larger than a Drizzle + pg bundle and appears to include duplicate pg copies plus control-plane-looking modules.

This matches the external context from Seven Du's X post: Prisma Next on Cloudflare is much improved versus Prisma v7, but still trails Drizzle on CPU ms: https://x.com/shiweidu/status/2060593944828739941

I filed the dogfood findings in Linear as:

  • TML-2749: serverless first query performs marker table probe and marker read before user SQL
  • TML-2750: serverless Worker bundle includes duplicate pg copies and control-plane modules

Environment

Dogfood repo: linear-prisma-next-3

Runtime/tool versions used in the repro:

  • Bun 1.3.13
  • Node v24.2.0
  • Wrangler 4.95.0
  • Prisma Next packages in app: 0.10.0
  • Drizzle fixture: drizzle-orm@0.45.2
  • pg in app root: 8.21.0
  • @prisma-next/postgres / @prisma-next/driver-postgres nested pg: 8.20.0

Note: npm reports latest @prisma-next/* as 0.11.0; I have not rerun this repro on 0.11.0 yet.

Repro fixture

I created a small fixture with these files:

  • src/prisma-worker.ts: Worker-shaped @prisma-next/postgres/serverless fixture.
  • src/drizzle-worker.ts: Worker-shaped Drizzle + pg fixture.
  • src/local-db-bench.ts: local Postgres CPU/wall benchmark.
  • src/query-count.ts: patched pg.Client.prototype.query probe.

The fixture has these scripts:

{
  "build:prisma": "../../node_modules/.bin/esbuild src/prisma-worker.ts --bundle --format=esm --platform=node --target=es2022 --outfile=dist/prisma-worker.mjs --metafile=dist/prisma-metafile.json --conditions=worker,node,import,default",
  "build:drizzle": "../../node_modules/.bin/esbuild src/drizzle-worker.ts --bundle --format=esm --platform=node --target=es2022 --outfile=dist/drizzle-worker.mjs --metafile=dist/drizzle-metafile.json --conditions=worker,node,import,default"
}

Bundle repro

cd research/prisma-next-cloudflare-cpu
bun install
bun run build:prisma
bun run build:drizzle
wc -c dist/prisma-worker.mjs dist/drizzle-worker.mjs
gzip -c dist/prisma-worker.mjs | wc -c
gzip -c dist/drizzle-worker.mjs | wc -c

Observed:

Fixture Bundle bytes Gzip bytes
Prisma Next serverless 1,242,039 241,475
Drizzle + pg 393,947 76,615

Largest Prisma bundle contributors included:

  • pg: 258,020 bytes in output
  • local contract.json: 160,973 input bytes
  • @ark/schema: 209,464 bytes in output
  • @prisma-next/family-sql: 99,965 bytes in output
  • @prisma-next/sql-runtime: 55,635 bytes in output

The Prisma metafile showed three pg copies available to the bundle graph:

  • root pg@8.21.0
  • @prisma-next/postgres/node_modules/pg@8.20.0
  • @prisma-next/driver-postgres/node_modules/pg@8.20.0

It also pulled control-plane-looking modules into the runtime/serverless bundle path, including @prisma-next/family-sql/dist/control.mjs, @prisma-next/sql-contract-emitter, and @prisma-next/migration-tools descendants.

Worker-local no-DB plan repro

bunx wrangler dev --config wrangler-prisma.jsonc --local --port 8787
bunx wrangler dev --config wrangler-drizzle.jsonc --local --port 8788
curl 'http://127.0.0.1:8787/raw-plan?n=10000'
curl 'http://127.0.0.1:8787/plan?n=10000'
curl 'http://127.0.0.1:8788/plan?n=10000'

Observed:

Endpoint n Elapsed ms
Prisma raw plan 10,000 12
Prisma SQL-builder plan 10,000 151
Drizzle query-builder .toSQL() 10,000 238

This suggests the gap is probably not basic query/SQL construction. Prisma raw plans are cheap, and in this synthetic test the Prisma SQL builder loop was faster than the Drizzle .toSQL() loop.

Local DB CPU proxy repro

Start local Prisma dev Postgres from the dogfood repo root and seed it:

bun scripts/dev-db.ts
# copy DATABASE_URL from output
DATABASE_URL='postgres://postgres:postgres@localhost:<port>/template1?sslmode=disable' bun run db:verify
DATABASE_URL='postgres://postgres:postgres@localhost:<port>/template1?sslmode=disable' bun run seed

Then run:

cd research/prisma-next-cloudflare-cpu
DATABASE_URL='postgres://postgres:postgres@localhost:<port>/template1?sslmode=disable' \
  CONNECT_ITERATIONS=1000 \
  QUERY_ITERATIONS=50 \
  bun src/local-db-bench.ts

Observed:

Benchmark CPU ms/op Wall ms/op
Prisma Next connect+close only 0.067 0.033
Drizzle pg construct+close only 0.019 0.007
Prisma Next fresh runtime+query 3.543 5.206
Prisma Next fresh runtime+query without lints/budgets 3.194 4.694
Drizzle fresh client+query 1.669 2.031
Prisma Next reused runtime query 0.494 0.585
Prisma Next reused runtime query without lints/budgets 0.296 0.530
Drizzle reused pg client query 0.419 0.504

Interpretation:

  • Fresh Prisma serverless runtime + query was about 2.1x Drizzle fresh client + query in CPU ms/op.
  • Removing lints/budgets saved about 0.35 CPU ms/op in the fresh path.
  • Reusing a runtime largely removes the gap, but @prisma-next/postgres/serverless intentionally does not expose closure-cached runtime() / orm helpers because stale connections and concurrent query races are unsafe in serverless fetch lifecycles.
  • The dominant fresh-path overhead appears to be request-scoped runtime setup plus first-use verification, not steady-state row execution.

Query count probe

Run:

DATABASE_URL='postgres://postgres:postgres@localhost:<port>/template1?sslmode=disable' \
  bun src/query-count.ts

A single Prisma Next fresh-runtime query emitted:

select 1 from information_schema.tables where table_schema = $1 and table_name = $2
select core_hash, profile_hash, contract_json, canonical_version, updated_at, app_tag, meta, invariants from prisma_contract.marker where space = $1
select id, title, priority from issue where "archivedAt" is null order by "updatedAt" desc limit $1

The equivalent Drizzle fresh-client query emitted:

select "id", "title", "priority" from "issue" where "issue"."archivedAt" is null order by "issue"."updatedAt" desc limit $1

Likely root causes

1. Per-request marker verification in the serverless path

@prisma-next/postgres/serverless creates a fresh runtime per connect() and defaults to:

verify: { mode: 'onFirstUse', requireMarker: false }

In @prisma-next/sql-runtime, execute() calls verifyMarker() before the first query when verification mode is onFirstUse. That makes every documented fresh-runtime serverless request pay for:

  1. marker table existence check
  2. marker row read
  3. user query

I could not find a public off mode; RuntimeVerifyOptions currently supports onFirstUse | startup | always.

2. Larger Worker module graph and duplicate dependencies

The Prisma Worker-shaped bundle was ~3.15x larger uncompressed and gzip than the Drizzle + pg fixture. The bundle included duplicate pg copies and control-plane-looking modules in a runtime/serverless path.

This likely affects Cloudflare Workers CPU ms through cold-start parse/evaluate cost, even if the steady-state query execution path is competitive once a runtime is warm/reused.

3. Middleware overhead is real but probably secondary

Removing lints() and budgets() saved ~0.35 CPU ms/op in the fresh-runtime local benchmark. That is not nothing, but it is much smaller than the total fresh Prisma vs Drizzle gap in this repro.

Cloudflare-specific caveats

Cloudflare production CPU ms is not the same as local wall time. Local wrangler dev --local can exercise Worker runtime shape, but production CPU should be confirmed with Workers logs, Tail Workers, Logpush, or DevTools CPU profiling.

I was able to run no-DB endpoints in local workerd, but direct /query endpoints from local workerd to local Prisma dev Postgres timed out in my environment. The DB CPU numbers above are therefore from a Bun process using the same ORM/driver code paths as a proxy, not production Cloudflare CPU measurements.

Requested improvements

  1. Add a serverless-safe verification strategy that avoids marker table probe + marker read on every fresh request. Possible options: deploy-time verification, signed marker metadata, per-isolate TTL cache, or a documented risk-accepted verify: off mode.
  2. Collapse the marker table existence check and marker read into one query, or cache marker storage existence per isolate.
  3. Remove control-plane imports from runtime packages. In particular, avoid importing APP_SPACE_ID from @prisma-next/framework-components/control in runtime code if that pulls the control graph into serverless bundles.
  4. Deduplicate pg across @prisma-next/postgres and @prisma-next/driver-postgres, or make pg a peer/single runtime dependency where practical.
  5. Make lints/budgets opt-in or cheaper for latency-sensitive Worker paths.
  6. Publish an official Cloudflare Workers benchmark that reports production Tail/Logpush CPU ms, not only local wall time.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingneeds-triageAwaiting maintainer triage

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions