# Cloudflare Workers

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

```sh
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:

```toml title="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`.

```ts title="src/db.ts" no-check="imports ./schema.js, which re-exports a file outside the example root (shown below)"
/**
 * 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`.

```ts title="src/schema.ts" no-check="re-exports ../../shared/schema.js, which is outside the example root"
export { app, notes } from "../../shared/schema.js";
```

```ts title="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

| 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](/docs/reference/errors#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](/docs/operate/drivers#neon) 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.

```ts title="src/worker.ts" no-check="uses the Workers ExecutionContext type, and imports ./db.js, which needs the schema file outside the example root"
/**
 * 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`:

```ts title="okmodel.config.ts"
import { defineConfig } from "okmodel/migrate";

export default defineConfig({
  schema: "./src/schema.ts",
  database: process.env.DATABASE_URL!,
});
```

```sh
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](/docs/reference/cli/migrate)). `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:

```sh
bunx wrangler dev
```

```sh
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:

```sh
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:

```sh
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](/docs/guides/migrations-in-ci), [Environments](/docs/migrations/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.

| 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](/docs/reference/errors#okm1111) on `tx()` | `NEON_MODE` is `http` | Use `batch([...])`, or switch to `websocket`. |
| [OKM1120](/docs/reference/errors#okm1120) on a call, `tx()`, or `batch()` with `timeout` | Neon does not take a positive `timeout`, in either mode | Remove `timeout`. |
| [OKM1520](/docs/reference/errors#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](/docs/reference/errors#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.

Index: [/llms.txt](/llms.txt)
