DocsCloudflare Workers

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

 ServerCloudflare Worker
REST API (PostgREST dialect)YesYes
Where REST answers/api, or the root in compatibility modeThe root, always
GraphQL (Hasura dialect)YesNo
Apollo Federation subgraphYesNo
GraphQL subscriptionsYes, over WebSocketsNo
JWT, roles and row-level securityYesYes, the same pipeline
OpenAPI document at the REST rootYesYes
Admin UI, Swagger, custom routesYes, with the admin-ui featureNo
Database connectionsA pool, kept openOne per request, to Hyperdrive
Schema cacheLoaded at start-up; reloaded on NOTIFY when the channel is enabledKept in Workers KV; reloaded by cron or POST /_/reload
Row ceiling when PGRST_DB_MAX_ROWS is unsetNone1000 (unlimited lifts it)
Database driversqlxtokio-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_ROWS says otherwise. The server applies no ceiling unless one is set. PGRST_DB_MAX_ROWS=unlimited makes 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_statements sees 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:

CandidateFull contractStatus and bodyWhat differs from the server
Server (sqlx)1424 / 14991451The reference figure
Server, REST through the Worker's driver1423 / 14991450Named prepared statements
Cloudflare Worker in workerd1422 / 14991449Named 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 ranReadWrite
Singapore, database in Singapore41 ms45 ms
Singapore, database in Delhi (80 ms away)255 ms318 ms
The same, Worker placed in Delhi170 ms190 ms
London, database in Delhi428 ms574 ms
Amsterdam, database in Delhi586 ms813 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] in wrangler.toml, a region or mode = "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 CloudflareRead, beforeRead, afterWrite, beforeWrite, after
Singapore, database in Singapore69 ms48 ms60 ms53 ms
Singapore, database in Delhi380 ms240 ms424 ms306 ms
London, database in Delhi869 ms428 ms836 ms574 ms
Amsterdam, database in Delhi1283 ms586 ms1266 ms813 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 tripRead, beforeRead, afterWrite, beforeWrite, afterServer
0 ms11.5 ms10.4 ms11.5 ms10.7 ms1.0 ms
5 ms49 ms26 ms50 ms32 ms34 ms
20 ms155 ms72 ms156 ms93 ms124 ms
60 ms436 ms192 ms436 ms253 ms364 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 KVSchema 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: enables POST /_/reload, which reloads the schema cache from the catalog and writes it to KV for every isolate.
  • The POSTRUST_SCHEMA_CACHE KV 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.