Cloudflare Workers
The REST API in a Worker, reaching PostgreSQL through Hyperdrive, and every way it differs from the server: what it serves, how it answers, what it costs, and what it does not do.
What is the same
The Worker runs the server’s own REST request pipeline (postrust-engine), unchanged. Parsing, planning, embedding, counts, media types and errors are the same code. Every request runs in a transaction that switches to the token’s role and sets its claims before anything else, so row-level security applies exactly as on the server. Connect through a NOINHERIT authenticator that holds no privileges of its own, as on the server.
Only the database driver differs. It speaks the PostgreSQL wire protocol over a Workers socket, and it is tested to answer as the server’s does. The same statements go through both drivers, and the answers must match exactly: every column type, how parameters are typed, and errors down to the SQLSTATE, detail and constraint.
What each serves
| Server | Cloudflare Worker | |
|---|---|---|
| REST API (PostgREST dialect) | Yes | Yes |
| Where REST answers | /api, or the root in compatibility mode | The root, always |
| GraphQL (Hasura dialect) | Yes | No |
| Apollo Federation subgraph | Yes | No |
| GraphQL subscriptions | Yes, over WebSockets | No |
| JWT, roles and row-level security | Yes | Yes, the same pipeline |
| OpenAPI document at the REST root | Yes | Yes |
| Admin UI, Swagger, custom routes | Yes, with the admin-ui feature | No |
| Database connections | A pool, kept open | One per request, to Hyperdrive |
| Schema cache | Loaded at start-up; reloaded on NOTIFY when the channel is enabled | Kept in Workers KV; reloaded by cron or POST /_/reload |
| Row ceiling when PGRST_DB_MAX_ROWS is unset | None | 1000 (unlimited lifts it) |
| Database driver | sqlx | tokio-postgres over a Workers socket |
For GraphQL, Federation or subscriptions serverless, use the AWS Lambda adapter, which serves REST and GraphQL, Federation included, but not subscriptions.
Where the answers differ
Asked the same thing, the Worker answers as the server does, with these exceptions:
- A row ceiling by default. An isolate has 128 MB, so a read is capped at 1000 rows unless
PGRST_DB_MAX_ROWSsays otherwise. The server applies no ceiling unless one is set.PGRST_DB_MAX_ROWS=unlimitedmakes the Worker match. - The REST API is at the root.
/todos, not/api/todos, as PostgREST serves it. - No named prepared statements. The server caches prepared statements on its pooled connections. The Worker sends each statement unnamed, in one round trip, because its connection never outlives the request. A function that inspects
pg_prepared_statementssees the difference; nothing else does. - Compression is Cloudflare’s. workerd may gzip a response, an empty one included, when the client accepts it. Once decoded, the body is the server’s.
- Key order is a build choice, as on the server. Object keys come out alphabetically unless the Worker is built with
--features compat-key-order, which keeps select order as PostgREST does. The values are identical either way.
Conformance
PostgREST’s own spec suite, replayed over HTTP against PostgREST and each candidate in turn, all on identical fixture databases (see PostgREST conformance). The Worker ran in workerd, the Workers runtime, through a Hyperdrive binding:
| Candidate | Full contract | Status and body | What differs from the server |
|---|---|---|---|
| Server (sqlx) | 1424 / 1499 | 1451 | The reference figure |
| Server, REST through the Worker's driver | 1423 / 1499 | 1450 | Named prepared statements |
| Cloudflare Worker in workerd | 1422 / 1499 | 1449 | Named prepared statements; a gzip'd empty preflight body |
The Worker’s first run scored 1405, and the gap was a real bug. When a write was refused for its token, its request body was never read. The unread stream then threw after the response had gone, which restarted the isolate and failed whatever request it served next. The Worker now reads the body itself first. Its test suite sends exactly that sequence, and also checks the Worker’s log for uncaught exceptions.
What it costs
Measured on Cloudflare through a real Hyperdrive, with its query caching off so every request reached the database, from a client in Singapore. Each figure is the request’s wall time inside the Worker, as wrangler tail reports it, so the client’s own distance to Cloudflare is not in it: the median of 64 to 150 point lookups and one-row inserts on the benchmark dataset.
| Where the Worker ran | Read | Write |
|---|---|---|
| Singapore, database in Singapore | 41 ms | 45 ms |
| Singapore, database in Delhi (80 ms away) | 255 ms | 318 ms |
| The same, Worker placed in Delhi | 170 ms | 190 ms |
| London, database in Delhi | 428 ms | 574 ms |
| Amsterdam, database in Delhi | 586 ms | 813 ms |
- The distance to the database is the cost. Connecting to Hyperdrive takes 3 to 6 ms, because Hyperdrive answers the connection near the Worker. Every wait on a statement is a round trip to the database: about 75 to 80 ms from Singapore to Delhi.
- Put the Worker near the database when it is far from your users. A request waits on the database several times and on the client once.
[placement]inwrangler.toml, a region ormode = "smart", moves it: placed in Delhi, a read from Singapore went from 255 ms to 170 ms. - CPU is 4 to 11 ms a request. Hyperdrive does not ask the Worker for a SCRAM exchange, which costs about 43 ms of CPU against a PostgreSQL in local workerd. Workers Free allows 10 ms of CPU a request, so production belongs on Workers Paid.
- The bundle is 2.7 MB of WebAssembly, 0.94 MB gzipped, within the free plan’s 3 MB.
- The server is unaffected. Separating the pipeline from its driver was measured against the tree it started from on pinned cores, six alternating rounds per build: throughput within the noise of the host on every scenario, and the binary 1.0% larger.
Statement pipelining
A request sends its statements in as few flights as it can. A plain read sends BEGIN, SET TRANSACTION READ ONLY, the settings (role, claims, request headers), the query and COMMIT in one — a GET to a function too. A read with a count, or a media type the schema renders, takes a flight or two more. A write or a POST to a function sends everything up to reading back its response settings in one, and COMMIT in a second, once the response is known to be one it can send. Each statement keeps its own sync point, so a failure is reported as if they had been sent one at a time.
Before and after were measured as an A/B pair on one hostname, in a run of their own, so an “after” figure need not match the table above: from Singapore it reads 240 and 306 ms here against 255 and 318 ms there, and 48 and 53 ms against 41 and 45 ms in the database’s own region. That is the spread between runs.
| On Cloudflare | Read, before | Read, after | Write, before | Write, after |
|---|---|---|---|---|
| Singapore, database in Singapore | 69 ms | 48 ms | 60 ms | 53 ms |
| Singapore, database in Delhi | 380 ms | 240 ms | 424 ms | 306 ms |
| London, database in Delhi | 869 ms | 428 ms | 836 ms | 574 ms |
| Amsterdam, database in Delhi | 1283 ms | 586 ms | 1266 ms | 813 ms |
Hyperdrive does not pass a pipeline on to the database whole: from Singapore, five statements sent together took about 218 ms against 78 ms for one. With the database in another region a read still came out 37–54% faster and a write 28–36% faster; in its own region, 30% for a read and 12% for a write.
With the round trip set exactly, by delaying every packet the database sends, in local workerd beside the server:
| Added round trip | Read, before | Read, after | Write, before | Write, after | Server |
|---|---|---|---|---|---|
| 0 ms | 11.5 ms | 10.4 ms | 11.5 ms | 10.7 ms | 1.0 ms |
| 5 ms | 49 ms | 26 ms | 50 ms | 32 ms | 34 ms |
| 20 ms | 155 ms | 72 ms | 156 ms | 93 ms | 124 ms |
| 60 ms | 436 ms | 192 ms | 436 ms | 253 ms | 364 ms |
Over a plain connection the pipeline is whole: a plain read is three round trips (two to connect, one flight) where it was seven. Past a few milliseconds of distance the Worker answers sooner than the server, whose driver waits on every statement.
Cold starts
The first request in a new isolate, on Cloudflare, timed inside the Worker: a handful of trials each from Singapore, so these are sizes rather than precise figures.
| Without KV | Schema cache in KV | |
|---|---|---|
| Database in Singapore | ~440 ms | ~230 ms |
| Database in Delhi | ~2,000 ms | ~400 ms |
- Keep the KV binding. Without it, a new isolate reads the catalog itself, one query per table, each a round trip to the database. With it, the isolate reads one KV value.
- An isolate’s first request uses 60 to 250 ms of CPU, against 4 to 11 ms after it, mostly starting the WebAssembly. Workers Free allows 10 ms a request.
- A reload spreads at KV’s pace. KV is eventually consistent, and a location can serve the previous value for a minute or more after a write.
Settings only the Worker has
PGRST_WORKER_SCHEMA_TTL(default 60): how many seconds an isolate trusts its copy of the schema cache before checking KV for a newer one, and how long the KV copy lives (at least 60 seconds, KV’s minimum). Once it expires, the next isolate to miss reloads the catalog, so a migration is seen without a reload.POSTRUST_RELOAD_SECRET, a secret: enablesPOST /_/reload, which reloads the schema cache from the catalog and writes it to KV for every isolate.- The
POSTRUST_SCHEMA_CACHEKV binding: optional. Without it, each new isolate reads the catalog itself, one query per table for its columns, and again every TTL.
Everything else is the server’s own variables, set as Worker variables or secrets (see Configuration). Settings that only a long-running server can use are ignored: the pool size, the listen address, PGRST_DB_CHANNEL.
Deploying
Create a Hyperdrive configuration and a KV namespace, copy crates/postrust-worker/wrangler.example.toml to wrangler.toml, put the JWT secret in with wrangler secret put, and run wrangler deploy. The full guide, with the database role to create, is docs/workers.md.
postrust-worker is stable from 1.0.0, on a version of its own. Its semver covers what a deployment sees rather than a Rust API: the variables it reads, the bindings it looks for, its endpoints and defaults. A change that would break a wrangler.toml nobody touched needs 2.0.0.