Cloudflare Workers
A typed Postgres client in a Cloudflare Worker over Neon, opened and closed per request, with the example's files, a migrate step, and what CI and Nightly prove.
You get a Worker that opens a client for each request that needs the database, reads and writes a table on Neon, and closes the client after the response. One variable chooses WebSocket or HTTP.
Runtime and driver
| Runtime | Cloudflare Workers with the nodejs_compat flag |
| Driver | okmodel/pg/neon + @neondatabase/serverless |
| Example | examples/workers-neon |
A Worker has no TCP socket, so postgres.js cannot run there. okm needs TCP too, so run migrations from Node.js or Bun on a machine that can reach Postgres.
Install
bun add okmodel @neondatabase/serverlessbun add -D wrangler miniflarewrangler.toml must set the nodejs_compat flag, which the Neon driver needs. NEON_MODE is a plain variable:
name = "okmodel-workers-neon"
main = "src/worker.ts"
compatibility_date = "2025-10-01"
compatibility_flags = ["nodejs_compat"]
[vars]
NEON_MODE = "websocket"db module and lifecycle
The client lives for one request: an isolate serves many requests and can be stopped at any time, so it must not keep a pool. connect returns a promise on Neon, so the Worker awaits openRequestDb.
/**
* Per-request Neon client for Cloudflare Workers.
*
* Create inside `fetch`. Close with `ctx.waitUntil(db.close())` so the
* isolate does not hold the pool across requests.
*/
import { connect, type NeonMode } from "okmodel/pg/neon";
import { app } from "./schema.js";
/**
* Opens a Neon client for one request.
*
* @param url - Neon connection string
* @param mode - `websocket` (default) or `http`
*/
export function openRequestDb(url: string, mode: NeonMode = "websocket") {
return connect(url, { schema: app, mode });
}src/schema.ts re-exports the table every framework example shares. In your own Worker, define the table in src/schema.ts directly. The table is named example_notes, so the client property is db.example_notes.
export { app, notes } from "../../shared/schema.js";/**
* Shared notes table for framework examples.
*
* Each example imports this schema. Tests create an isolated database and
* apply the matching SQL before booting the app.
*/
import { schema, table, t } from "okmodel/pg";
/** Notes table used by every framework example. */
export const notes = table("example_notes", {
id: t.identity(),
title: t.text(),
});
/** Schema for the examples. */
export const app = schema({ tables: [notes] });
/** SQL that creates {@link notes} on an empty database. */
export const NOTES_DDL = `
create table example_notes (
id bigint generated by default as identity primary key,
title text not null
);
`;Connection config
| Setting | Where | Notes |
|---|---|---|
DATABASE_URL | A Worker secret | The Neon connection string. Set it with bunx wrangler secret put DATABASE_URL. For wrangler dev, put it in .dev.vars. |
NEON_MODE | [vars] in wrangler.toml | websocket or http. Unset means websocket. |
WebSocket or HTTP
websocket | http | |
|---|---|---|
| Connection | A pool over WebSocket | One HTTP request per statement |
| Several writes that commit together | db.tx(async (tx) => …) | db.batch([...]), sent as one atomic HTTP transaction |
| A later statement needs an earlier result | Read it inside the tx callback | Not possible in one batch. Use separate calls, or websocket. |
tx() | Supported | Refused with OKM1111 |
batch returns one result per statement, so the example takes [row]. A single-row insert is atomic on its own; the example wraps it only to show both paths. Drivers covers the rest of the Neon driver.
One route that reads and writes
The Worker checks NEON_MODE first, so a typo fails every request instead of falling back. GET /health and unknown paths answer without opening a client. Only /notes opens one, and finally hands db.close() to ctx.waitUntil, so closing does not delay the response. close() returns a promise, which waitUntil keeps alive after the response.
POST /notes has no authentication and no body size limit. Add both before you copy it.
/**
* Cloudflare Worker: per-request Neon connect, read and write routes.
*
* Requires `nodejs_compat`. WebSocket mode supports `tx()`. HTTP mode uses
* `batch()` and refuses `tx()` (OKM1111).
*/
import { openRequestDb } from "./db.js";
export interface Env {
DATABASE_URL: string;
NEON_MODE?: string;
}
function neonMode(value: string | undefined): "websocket" | "http" {
if (value === undefined || value === "websocket") return "websocket";
if (value === "http") return "http";
throw new Error(`NEON_MODE must be "websocket" or "http", got ${JSON.stringify(value)}`);
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const mode = neonMode(env.NEON_MODE);
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/health") {
return Response.json({ ok: true, mode });
}
if (url.pathname !== "/notes") return new Response("not found", { status: 404 });
const db = await openRequestDb(env.DATABASE_URL, mode);
try {
if (request.method === "GET") {
const rows = await db.example_notes.find({
orderBy: { id: "asc" },
limit: 50,
});
return Response.json(rows);
}
if (request.method === "POST") {
// No authentication and no body size limit: add both before real traffic.
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "body must be JSON" }, { status: 400 });
}
const title =
body !== null && typeof body === "object" && "title" in body
? (body as { title: unknown }).title
: undefined;
if (typeof title !== "string" || title.length === 0) {
return Response.json({ error: "title is required" }, { status: 400 });
}
if (mode === "websocket") {
const row = await db.tx(async (tx) => tx.example_notes.insert({ title }));
return Response.json(row, { status: 201 });
}
// HTTP mode has no interactive transaction; batch is one atomic unit.
const [row] = await db.batch([db.example_notes.insert({ title })]);
return Response.json(row, { status: 201 });
}
return new Response("not found", { status: 404 });
} finally {
ctx.waitUntil(db.close());
}
},
};The responses are the same in both modes. id is a string, because an identity column is a Postgres bigint. These bodies follow from the code; no run against Neon was captured for this page.
| Request | Status | Body |
|---|---|---|
GET /health | 200 | {"ok":true,"mode":"websocket"}, or "http" |
POST /notes with {"title":"hello"} | 201 | {"id":"1","title":"hello"} |
GET /notes | 200 | Up to 50 rows, oldest first: [{"id":"1","title":"hello"}] |
POST /notes with {} | 400 | {"error":"title is required"} |
POST /notes with a body that is not JSON | 400 | {"error":"body must be JSON"} |
| Any other path or method | 404 | not found |
Dev and test
Use a Neon database you can drop, such as a branch, and create the table from your machine through the direct (non-pooler) host. Add okmodel.config.ts next to package.json:
import { defineConfig } from "okmodel/migrate";
export default defineConfig({
schema: "./src/schema.ts",
database: process.env.DATABASE_URL!,
});export DATABASE_URL='postgres://user:pass@ep-example.region.aws.neon.tech/neondb?sslmode=require'
bunx okm pushokm push writes no migration file, so use it only on a database you can drop. For one you will deploy, run bunx okm generate init then bunx okm migrate apply, and commit migrations/ (Migrate commands). okm refuses a pooler host unless you pass --allow-pooler.
Put the same DATABASE_URL in .dev.vars, start the Worker on port 8787, and call it from another terminal:
bunx wrangler devcurl -i http://localhost:8787/health
curl -i -X POST http://localhost:8787/notes -H 'content-type: application/json' -d '{"title":"hello"}'
curl -i http://localhost:8787/notesTo try HTTP mode, set NEON_MODE = "http", restart wrangler dev, and check that /health reports it and the POST still answers 201. To run the example's tests from a clone of this repository:
bun run build
bun install --cwd examples/workers-neon
cd examples/workers-neon
bun test ./workers-neon.test.ts
OKM_EXAMPLES_NIGHTLY=1 NEON_URL='postgres://...' bun test ./workers-neon.test.tsThe first test command needs no database and skips the live test. The second runs it, and it drops and recreates example_notes, so give it a throwaway branch.
Deploy and migrate-on-deploy
Apply migrations from a job that has TCP to Postgres, with DATABASE_URL set to the direct Neon host, before you deploy:
bunx okm migrate applybunx wrangler secret put DATABASE_URLbunx wrangler deployA migration that only adds tables or columns lets the old version keep serving while the new one rolls out (Migrations in CI, Environments).
Limits and gotchas
- A suspended Neon compute cold-starts on the first request, and that request waits for it.
- A transaction-mode pooler does not keep session state. Leave
preparedunset. - There is no Cloudflare D1 adapter.
| Symptom | Cause | Fix |
|---|---|---|
500 on every route, and NEON_MODE must be "websocket" or "http", got "<value>" in the Worker's logs (Miniflare also puts it in the body) | NEON_MODE has another value | Set websocket or http, or remove it. |
OKM1111 on tx() | NEON_MODE is http | Use batch([...]), or switch to websocket. |
OKM1120 on a call, tx(), or batch() with timeout | Neon does not take a positive timeout, in either mode | Remove timeout. |
OKM1520 The database is behind the app on the first request | The Worker was deployed before okm migrate apply | Apply the migrations, then deploy. |
OKM1101 find() needs a limit. | A read with no limit | Pass limit, as the example route does. |
What is tested
| Evidence | Where | What runs |
|---|---|---|
| CI | examples / frameworks job | wrangler deploy --dry-run, the upload size against Cloudflare's free-plan limit, nodejs_compat in wrangler.toml, and the 500 for NEON_MODE=ws in Miniflare. No database. |
| Nightly | Scheduled run with the NEON_URL secret | The bundled Worker in Miniflare against Neon, in http then websocket mode: POST /notes (201) and GET /notes. |
| Not covered | A deployed Worker, okm against Neon, and cold starts. |
The frameworks job runs on pull requests that touch examples/, the runtime, the dialects, or the Frameworks pages, and after each merge.