Frameworks

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

RuntimeCloudflare Workers with the nodejs_compat flag
Driverokmodel/pg/neon + @neondatabase/serverless
Exampleexamples/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

Terminal
bun add okmodel @neondatabase/serverless
bun add -D wrangler miniflare

wrangler.toml must set the nodejs_compat flag, which the Neon driver needs. NEON_MODE is a plain variable:

wrangler.toml
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.

src/db.ts
/**
 * 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.

src/schema.ts
export { app, notes } from "../../shared/schema.js";
shared/schema.ts
/**
 * 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

SettingWhereNotes
DATABASE_URLA Worker secretThe 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.tomlwebsocket or http. Unset means websocket.

WebSocket or HTTP

websockethttp
ConnectionA pool over WebSocketOne HTTP request per statement
Several writes that commit togetherdb.tx(async (tx) => …)db.batch([...]), sent as one atomic HTTP transaction
A later statement needs an earlier resultRead it inside the tx callbackNot possible in one batch. Use separate calls, or websocket.
tx()SupportedRefused 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.

src/worker.ts
/**
 * 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.

RequestStatusBody
GET /health200{"ok":true,"mode":"websocket"}, or "http"
POST /notes with {"title":"hello"}201{"id":"1","title":"hello"}
GET /notes200Up 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 JSON400{"error":"body must be JSON"}
Any other path or method404not 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:

okmodel.config.ts
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 push

okm 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:

Terminal
bunx wrangler dev
curl -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/notes

To 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.ts

The 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:

Terminal
bunx okm migrate apply
bunx wrangler secret put DATABASE_URL
bunx wrangler deploy

A 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 prepared unset.
  • There is no Cloudflare D1 adapter.
SymptomCauseFix
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 valueSet websocket or http, or remove it.
OKM1111 on tx()NEON_MODE is httpUse batch([...]), or switch to websocket.
OKM1120 on a call, tx(), or batch() with timeoutNeon does not take a positive timeout, in either modeRemove timeout.
OKM1520 The database is behind the app on the first requestThe Worker was deployed before okm migrate applyApply the migrations, then deploy.
OKM1101 find() needs a limit.A read with no limitPass limit, as the example route does.

What is tested

EvidenceWhereWhat runs
CIexamples / frameworks jobwrangler 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.
NightlyScheduled run with the NEON_URL secretThe bundled Worker in Miniflare against Neon, in http then websocket mode: POST /notes (201) and GET /notes.
Not coveredA 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.