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:
- A fresh
@prisma-next/postgres/serverless runtime verifies the contract marker before the first user query, producing two extra pg queries before application SQL.
- 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:
- marker table existence check
- marker row read
- 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
- 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.
- Collapse the marker table existence check and marker read into one query, or cache marker storage existence per isolate.
- 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.
- Deduplicate
pg across @prisma-next/postgres and @prisma-next/driver-postgres, or make pg a peer/single runtime dependency where practical.
- Make lints/budgets opt-in or cheaper for latency-sensitive Worker paths.
- Publish an official Cloudflare Workers benchmark that reports production Tail/Logpush CPU ms, not only local wall time.
References
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:
@prisma-next/postgres/serverlessruntime verifies the contract marker before the first user query, producing two extrapgqueries before application SQL.pgbundle and appears to include duplicatepgcopies 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:
pgcopies and control-plane modulesEnvironment
Dogfood repo:
linear-prisma-next-3Runtime/tool versions used in the repro:
1.3.13v24.2.04.95.00.10.0drizzle-orm@0.45.2pgin app root:8.21.0@prisma-next/postgres/@prisma-next/driver-postgresnestedpg:8.20.0Note: npm reports latest
@prisma-next/*as0.11.0; I have not rerun this repro on0.11.0yet.Repro fixture
I created a small fixture with these files:
src/prisma-worker.ts: Worker-shaped@prisma-next/postgres/serverlessfixture.src/drizzle-worker.ts: Worker-shaped Drizzle +pgfixture.src/local-db-bench.ts: local Postgres CPU/wall benchmark.src/query-count.ts: patchedpg.Client.prototype.queryprobe.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
Observed:
pgLargest Prisma bundle contributors included:
pg: 258,020 bytes in outputcontract.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 outputThe Prisma metafile showed three
pgcopies available to the bundle graph:pg@8.21.0@prisma-next/postgres/node_modules/pg@8.20.0@prisma-next/driver-postgres/node_modules/pg@8.20.0It 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-toolsdescendants.Worker-local no-DB plan repro
Observed:
.toSQL()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:
Then run:
Observed:
pgconstruct+close onlypgclient queryInterpretation:
@prisma-next/postgres/serverlessintentionally does not expose closure-cachedruntime()/ormhelpers because stale connections and concurrent query races are unsafe in serverless fetch lifecycles.Query count probe
Run:
DATABASE_URL='postgres://postgres:postgres@localhost:<port>/template1?sslmode=disable' \ bun src/query-count.tsA single Prisma Next fresh-runtime query emitted:
The equivalent Drizzle fresh-client query emitted:
Likely root causes
1. Per-request marker verification in the serverless path
@prisma-next/postgres/serverlesscreates a fresh runtime perconnect()and defaults to:In
@prisma-next/sql-runtime,execute()callsverifyMarker()before the first query when verification mode isonFirstUse. That makes every documented fresh-runtime serverless request pay for:I could not find a public
offmode;RuntimeVerifyOptionscurrently supportsonFirstUse | 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 +
pgfixture. The bundle included duplicatepgcopies 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()andbudgets()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 --localcan 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
/queryendpoints 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
verify: offmode.APP_SPACE_IDfrom@prisma-next/framework-components/controlin runtime code if that pulls the control graph into serverless bundles.pgacross@prisma-next/postgresand@prisma-next/driver-postgres, or makepga peer/single runtime dependency where practical.References