diff --git a/apps/blog/content/blog/designing-a-streaming-api-for-serverless-postgres/index.mdx b/apps/blog/content/blog/designing-a-streaming-api-for-serverless-postgres/index.mdx new file mode 100644 index 0000000000..ff279a2d55 --- /dev/null +++ b/apps/blog/content/blog/designing-a-streaming-api-for-serverless-postgres/index.mdx @@ -0,0 +1,140 @@ +--- +title: "Designing a Streaming API for Serverless Postgres" +slug: "designing-a-streaming-api-for-serverless-postgres" +date: "2026-09-16" +authors: + - "Ankur Datta" +metaTitle: "Designing a Streaming API for Serverless Postgres" +metaDescription: "How Prisma Postgres carries SQL over HTTP and WebSockets without buffering whole results: the API contract, the streamed Bind and DataRow paths, backpressure, and the limits that remain." +excerpt: "A serverless access path that handles database results incrementally." +heroImagePath: "/designing-a-streaming-api-for-serverless-postgres/imgs/hero.svg" +heroImageAlt: "Cover: a database tile and a client tile connected by a channel in which small row chunks move one at a time, a gauge labeled bounded memory above and a dashed arrow labeled a slow client slows the sender below, under the headline Stream rows, not results." +metaImagePath: "/designing-a-streaming-api-for-serverless-postgres/imgs/meta.png" +tags: + - "prisma-postgres" + - "education" + - "platform" +series: prisma-postgres-connectivity +seriesIndex: 3 +--- + +_This is the third post in a four-part series on how Prisma Postgres handles database connectivity. [Post 1](/why-prisma-postgres-needs-a-gateway) covered the gateway; [Post 2](/why-serverless-apps-are-hard-on-postgres-connections) covered connection pooling. This one is about reaching Postgres from runtimes where a Postgres socket is not the right tool._ + +After reading this post, you'll understand what an API has to promise in order to carry Postgres interactions over HTTP and WebSockets, and how designing that API around streaming changes what the gateway has to hold in memory. + +Start from a constraint. Vercel's Edge runtime exposes a subset of Web APIs: `fetch`, `Request`, `Response`, and a short list of Node modules that does not include `net` or `tls`. There is no way to open a TCP socket from it, which means no way to speak the Postgres protocol, which means no Postgres driver. Cloudflare Workers can open TCP sockets, but a socket cannot be shared across requests, and every request pays connection setup again. Browsers cannot open a raw socket at all. And even on a runtime with a full Node API, a request-scoped function is an odd home for a protocol built around sessions that expect to live for minutes or hours. + +Post 2 established that connection lifecycle pressure is one problem. This is a different one: the transport itself. The question is how to give code in those environments a database it can talk to using the tools it has, `fetch` and WebSockets, without giving up what makes Postgres useful. + +## Two ways to build it + +There are two broad designs, and the difference between them is where the Postgres protocol gets spoken. + +**Wrap the wire protocol.** Keep the client speaking real Postgres messages and carry those bytes over a WebSocket to a relay that opens the TCP connection on the client's behalf. The client is a normal Postgres driver with a different socket underneath. Every Postgres feature works because nothing about the protocol changed. The cost is that the relay is protocol-blind and the client still has to do everything a Postgres driver does, including a full connection handshake per WebSocket. + +**Define a database API.** Give the client a smaller, purpose-built contract: "run this SQL with these parameters, give me rows back", carried in whatever framing the transport supports. The server speaks Postgres on the client's behalf. Now the server can pool, meter, and stream, but every Postgres feature the API does not model is unavailable, and the API's own limits become the application's limits. + +Neon's serverless driver is a useful case study because it ships both. At version 1.1.0 (released April 2026), its [documentation](https://neon.com/docs/serverless/serverless-driver) describes an HTTP path, `neon()`, for "single, non-interactive transactions" or a batch of queries wrapped in one non-interactive transaction, and a WebSocket path, `Pool` and `Client`, for sessions and interactive transactions with node-postgres compatibility. The WebSocket path is the wrapped-protocol design: the driver talks Postgres over a WebSocket to a small open-source Go relay, `wsproxy`, that forwards bytes to the database. The HTTP path is a database API: the driver POSTs JSON to an `/sql` endpoint and reads a JSON response body. In the driver's source the HTTP client awaits the full response and parses it as JSON, and the documentation lists a 64 MB maximum on request and response size. Neither of those is a criticism; they are the natural shape of a request/response RPC. But they define the tradeoff: the HTTP path is stateless and easy to use from any runtime, and it holds a whole result in memory on both sides. + +Prisma chose the database-API design for its serverless path, and then spent most of the engineering effort on not holding whole results in memory. The rest of this post is about why and how. + +## What a fully buffered query RPC costs + +Nothing about HTTP requires buffering. An HTTP response is a byte stream; a client can read it incrementally and a server can write it incrementally. The buffering shows up when the API contract says "the response is one JSON document", because then the server has to know the whole document before it can send a valid one, and the client has to receive the whole document before it can parse it. + +For a query that returns twenty rows this is invisible. Two situations make it expensive. + +The first is a large result. A report query that returns a few hundred thousand rows, or a single row holding a large `bytea` or `text` value, becomes a few hundred megabytes of JSON on the server before a single byte reaches the client. That memory belongs to the tenant who ran the query, but it is taken from a process shared with other tenants: the noisy-neighbor problem from Post 1, now with a buffer attached to it. + +The second is a slow consumer. If the server must buffer until the query completes, a query that produces rows slowly holds memory for its whole duration, and a client that reads the response slowly holds the server's copy until it finishes. Both push a shared process toward caps: a cap on how long a query may run, and a cap on how large a response may be. + +Prisma has operated a system with exactly this shape. Accelerate, the successor to the Data Proxy described in Post 2, carries Prisma's query protocol over HTTPS to a query engine near the database, and its [documentation](https://www.prisma.io/docs/accelerate/faq) publishes per-plan limits on query duration (10 to 60 seconds), interactive transaction duration, and response size (5 to 20 MB). Caps like those are the natural consequence of the shape: an engine that materializes results for a JSON response has to bound what it will materialize. The team also learned that scaling the engine's connection pool horizontally, adding instances to spread the load, produced user-visible slowness rather than smoothing it out, and the replacement they designed scales vertically alongside each database VM instead. The lesson that carried into the next design is not "HTTP was wrong". It is that a contract which forces the server to hold whole results makes caps unavoidable, and that the fix has to be in the contract. + +## The contract Prisma built + +The serverless path lives inside the same gateway process that terminates TCP connections, which matters for Post 4. It exposes two transports: + +- **HTTP** at `/v0/statement`: one statement per request. The request is a multipart body carrying a JSON descriptor part (the SQL, and how to render the result) followed by one part per parameter that is too large to inline. The response is a stream of newline-delimited JSON by default. +- **WebSocket** at `/v0/session`: a persistent session that carries many statements over one socket, with an authentication message first, then statements and results. Results come back in the order statements were sent. + +Both are what the [`@prisma/ppg` driver](https://www.prisma.io/docs/postgres/database/serverless-driver) speaks, and both are pooled by default at the gateway, so a serverless function gets the pooling from Post 2 without configuring it. At the protocol level a request can opt out of pooling with a header, though the driver does not expose that as a documented option. + +The contract is deliberately small: a statement is a SQL string plus parameters, and a result is a column description, a sequence of rows, and a completion or an error. Sessions and transactions exist on the WebSocket path because a transaction needs the same backend connection for `BEGIN` through `COMMIT`. The driver's `transaction()` opens a session, sends `BEGIN`, runs your callback's statements, and sends `COMMIT` or `ROLLBACK` on that session. Its `batch()` is a transaction that runs statements in sequence; there is no separate batch endpoint on the server. + +The parts that took engineering are on either end of that contract: how the gateway turns a request into Postgres messages without reading the request into memory, and how it turns Postgres messages into a response without reading the result into memory. + +## Request side: forging the protocol from a stream + +When a statement arrives, the gateway has to speak the Postgres [extended query protocol](https://www.postgresql.org/docs/current/protocol-flow.html) on the client's behalf. A statement with no parameters becomes a single simple `Query` message. A statement with parameters becomes the extended sequence: `Parse` (the SQL), `Bind` (the parameter values), `Describe`, `Execute`, and `Sync`. + +`Bind` is the message that carries parameter values, and a parameter can be large. The naive implementation reads all parameters, computes the message length, and writes the message. The gateway instead builds `Bind` as a chain of readers: the header, then for each parameter a length prefix followed by a reader over that parameter's bytes, then the footer. Small parameters are inline in the JSON descriptor. A parameter above a size threshold (the driver's threshold is one kilobyte) arrives as its own multipart part or WebSocket frame, and the gateway's reader for that parameter pulls from the transport as the message is written to the database. Postgres requires the message length up front, so the client declares each streamed parameter's size in the descriptor, which lets the gateway stamp the header before it has read a single parameter byte. + +The result is that a parameter value flows from the client's request body, through the gateway, into the database's socket in bounded chunks. A one-kilobyte parameter and a hundred-megabyte one take the same code path and the same amount of gateway memory. + +## Response side: transcoding rows as they arrive + +Coming back, the database sends a `RowDescription` message, then one `DataRow` message per row, then `CommandComplete` and `ReadyForQuery`. A `DataRow` is a field count followed by, for each field, a four-byte length and that many bytes. The gateway needs to look inside it, because the client expects JSON, not Postgres binary framing. But a single field can be larger than any sane buffer. + +So the gateway reads `DataRow` structurally without materializing it. It reads the field count, then for each field reads the length and wraps the next that-many bytes of the upstream socket in a bounded reader. That reader is handed to the encoder, which writes the field to the client transport as it goes: text fields through a streaming JSON escaper, `bytea` fields through a streaming base64 transform. The upstream read and the client write interleave field by field. On the HTTP path the encoder flushes its output at a threshold; on the WebSocket path each result frame is sent as its own message. At no point does a whole row, let alone a whole result, exist in gateway memory. + +```mermaid +sequenceDiagram +%% Streaming path of the serverless API, response side, showing how a slow client +%% propagates backpressure to the database. Conceptual; based on the gateway's +%% field-by-field DataRow transcoding (ignite pg-protocol catalog 1.4; transports 6.2) +%% and the redesign's coupled copy loops (pdp-metal pg-api-adapter design). + participant App as Serverless function
(@prisma/ppg) + participant GW as Gateway
(API adapter + pipeline) + participant DB as PostgreSQL + + App->>GW: POST /v0/statement
(descriptor, streamed parameters) + GW->>DB: Parse, Bind, Describe,
Execute, Sync + Note over GW,DB: parameter bytes stream through as the client sends them + DB-->>GW: RowDescription + GW-->>App: column metadata + loop each row + DB-->>GW: DataRow + Note over GW: one field at a time: bounded read, encode, write + GW-->>App: row (NDJSON line) + end + Note over App,DB: a slow client blocks the gateway's write, so it stops reading; the TCP window closes and the database stops sending + DB-->>GW: CommandComplete, ReadyForQuery + GW-->>App: completion +``` + +_Response streaming on the serverless API. Each DataRow is read one field at a time and written to the client as it is decoded, so a client that reads slowly stops the gateway reading the database socket. Conceptual sequence; message names are the real protocol messages._ + +The diagram shows the property this buys, which is backpressure, and it is worth spelling out in plain language. If the client reads slowly, the encoder's write to the client transport blocks. Because the encoder is what is pulling bytes off the upstream socket, that read stops too. Because the gateway has stopped reading, the socket's receive buffer fills, TCP's window closes, and the database stops sending. A slow client slows the database down instead of filling the gateway with rows the client has not asked for yet. The same coupling runs the other way on the request side: a slow database blocks the gateway's write, which stops it reading the client's request body, which stops the client sending parameter bytes. There is no queue in the middle to grow, and that absence is the design. + +Precision matters here, because backpressure is one of those words that gets claimed more often than it is delivered. In the gateway implementation that serves production today, the response side is a pull iterator that the transport drains, and the coupling above holds along that pull. On the WebSocket path, however, the request side accepts pipelined statements into a queue that has no bound. A client that sends statements faster than the database answers them grows that queue, and the redesign in Post 4 replaces it with a bounded one that blocks the sender. On the client side, the driver's HTTP path streams rows to your code as they arrive, while its WebSocket path buffers rows in the driver if your code consumes them more slowly than the socket delivers them. Streaming is end to end on the HTTP path today, and end to end on the WebSocket path once the bounded queue lands. + +## On the client: streaming first, collection optional + +The driver exposes results as a `CollectableIterator`: an async iterator you can consume row by row, with a `collect()` method that gathers everything into an array when a result is small and an array is what you want. Streaming is the default representation and collection is a convenience on top of it, rather than the other way around. That ordering is the client-side expression of the same contract: the API never promises that a result fits in memory, so the driver never assumes it. + +Two things fall out of the session design for free, and both deserve a precise description. + +**Pipelining.** On a WebSocket session, the driver sends statements without waiting for the previous result, and results come back in send order. This removes a round trip of latency per statement for a sequence of independent statements. It does not run them in parallel: Postgres executes statements on a connection in the order it receives them, exactly as [libpq's pipeline mode](https://www.postgresql.org/docs/current/libpq-pipeline-mode.html) describes. Pipelining saves waiting, not execution time. + +**Transactions as ordinary statements.** Because results are ordered, the driver can send `BEGIN` and the first statement of a transaction without waiting for `BEGIN` to be acknowledged. A transaction is nothing more than statements that happen to share a session. That is also why interactive transactions belong on the WebSocket path and not the HTTP one: HTTP requests do not share a backend connection, so `BEGIN` in one request and `COMMIT` in another would land on different backends through the pool. + +## What still bounds a request + +Removing whole-result buffering removes one family of caps: there is no gateway limit on how many rows a result may contain or how large a field may be, because the gateway never holds them. It is not the same as saying nothing is limited, and the remaining limits are worth listing plainly. + +- Pooled connections, which the serverless path uses by default, carry the [10-minute query timeout](https://www.prisma.io/docs/postgres/database/connection-pooling) documented for pooled traffic. Work that needs longer belongs on a direct connection. +- The gateway arms per-operation read and write deadlines and an idle timeout on every connection, so a stalled client or database is torn down rather than held forever. Those are operational settings, not contract guarantees, and they are not published as product limits. +- The gateway does cap small control messages: the statement descriptor and the session authentication message have fixed size limits, and the protocol's own control messages are materialized into a bounded buffer. Only row data and parameter values are unbounded, and only because they are never materialized. +- Session state on pooled connections is subject to the same transaction-mode rules as Post 2 described. + +Two Postgres features are outside the contract. `COPY` is not exposed through the statement API, and asynchronous notifications (`LISTEN`/`NOTIFY`) need a direct connection. Both are cases where the wrapped-protocol design wins, and the tradeoff is the one stated at the top: a database API models what it models. + +## A note on where this goes + +The team has discussed the fact that a contract this small, framed over HTTP and WebSockets, could be implemented by a driver in any language, and has sketched what a JDBC driver might look like. Nothing beyond the TypeScript driver exists today, and there is no committed plan, so treat that as a direction the design leaves open rather than a roadmap item. + +## Takeaway + +A serverless database API has to promise something narrower than the Postgres protocol, and the value of the design lives in choosing that contract carefully. By making the response a stream of rows rather than a document, and by building the request as a stream of parameters rather than a message assembled in memory, Prisma's gateway carries arbitrarily large results and parameters through a shared process without buffering them, and a slow consumer slows its producer instead of consuming shared memory. What remains bounded is bounded on purpose: control messages, timeouts, and the transaction-mode rules of the pool behind it. + +The gateway now has two ways to speak Postgres: forwarding a client's own messages on the TCP path, and forging messages from an API request on the serverless path. For a long time those were two separate implementations of the same protocol inside one process. Unifying them is the subject of the final post. diff --git a/apps/blog/content/blog/unifying-tcp-and-serverless-connectivity-in-prisma-postgres/index.mdx b/apps/blog/content/blog/unifying-tcp-and-serverless-connectivity-in-prisma-postgres/index.mdx new file mode 100644 index 0000000000..e044cca3a4 --- /dev/null +++ b/apps/blog/content/blog/unifying-tcp-and-serverless-connectivity-in-prisma-postgres/index.mdx @@ -0,0 +1,132 @@ +--- +title: "Unifying TCP and Serverless Connectivity in Prisma Postgres" +slug: "unifying-tcp-and-serverless-connectivity-in-prisma-postgres" +date: "2026-09-16" +authors: + - "Ankur Datta" +metaTitle: "Unifying TCP and Serverless Connectivity in Prisma Postgres" +metaDescription: "Why Prisma separated connection lifecycle from protocol processing in its Postgres gateway, what the shared pipeline looks like, how the embedded pool reads transaction state, and which benefits are verified today." +excerpt: "A shared protocol pipeline and the connection lifecycle around it." +heroImagePath: "/unifying-tcp-and-serverless-connectivity-in-prisma-postgres/imgs/hero.svg" +heroImageAlt: "Cover: a lifecycle band listing accept, tls, auth, resolve, and teardown sits above two path tiles, tcp and http/ws, whose lines merge into one highlighted pipeline tile labeled shared handlers and continue to a database icon, under the headline Two paths, one pipeline." +metaImagePath: "/unifying-tcp-and-serverless-connectivity-in-prisma-postgres/imgs/meta.png" +tags: + - "prisma-postgres" + - "education" + - "platform" +series: prisma-postgres-connectivity +seriesIndex: 4 +--- + +_This is the final post in a four-part series on how Prisma Postgres handles database connectivity. [Post 1](/why-prisma-postgres-needs-a-gateway) introduced the gateway, [Post 2](/why-serverless-apps-are-hard-on-postgres-connections) covered pooling, and [Post 3](/designing-a-streaming-api-for-serverless-postgres) covered the streaming serverless API. This one is about the architecture that ties them together, and it describes work that is still landing._ + +After reading this post, you'll understand why the team separated protocol processing from connection lifecycle, what the shared pipeline that came out of that separation looks like, and which of its benefits are verified today versus planned. + +Start with the idea, because everything else follows from it. A database proxy does two unrelated jobs. One is **connection lifecycle**: accept a socket, negotiate TLS, authenticate the client, find and authenticate to the upstream, and eventually tear everything down in the right order. The other is **protocol processing**: while the connection is up, move Postgres messages between the two ends, look inside a few of them, and decide what to do. The first job runs once per connection, in sequence. The second runs per message, in both directions, concurrently. They have different shapes, and code that mixes them tends to fork every time a new transport or feature arrives. + +The gateway from Post 1 mixed them, twice. + +## Before: one protocol, two implementations + +By 2025 the gateway served two client populations from one process: raw Postgres over TCP, and the HTTP and WebSocket API from Post 3. They had been built on separate timelines. Direct TCP access arrived in mid-2025 and reached general availability that October; the serverless API and its driver were developed alongside it from May 2025 and reached version 1.0 in November; the pooled hostname followed. Each path grew the code it needed when it needed it, and in mid-2026, when the team catalogued the codebase before redesigning it, the shape was clear. + +The TCP path implemented the protocol as two streaming pumps, one per direction, in under two hundred lines. Each pump read a five-byte message header, switched on the type byte, and either inspected the message (`ErrorResponse` was buffered and decoded, `BackendKeyData` was captured for cancellation, `ReadyForQuery` flipped a state flag) or streamed the body straight through. The serverless path implemented the same protocol as a pull iterator of nearly eight hundred lines plus a three-hundred-line flow-control state machine, because it had to unpack `RowDescription` and `DataRow` into API results and discard the bookkeeping messages the API client never sees. The catalog counted seven message types that the two implementations treated differently. `ParameterStatus` was forwarded on one path and dropped on the other. An unknown message type streamed through silently on TCP and raised an invariant error on the API. `ReadyForQuery` drove a state machine on TCP and was discarded on the API, which tracked state with a different three-state machine of its own. + +Lifecycle diverged the same way. TCP had a hardcoded handshake sequence and a close-cascade teardown; the API built its own startup, consumed backend messages until `ReadyForQuery` in its own loop, and cleaned up through a pool release plus a cancellation goroutine. Query cancellation, which requires a fresh connection carrying the backend's secret key, had one builder and two orchestrations: TCP ran cancels through a bounded worker pool guarded by the circuit breaker, the API spawned an unbounded goroutine per session with no breaker. And the wire-level library the codebase used for message bodies, `pgproto3` from the `pgx` project, had leaked into nineteen files, including the interface that resolved upstreams (its `Connect` took a `pgproto3.StartupMessage`) and the throttling code, which hand-built a `pgproto3.ErrorResponse` to reject a connection. + +None of this was a mystery to the people maintaining it. The reason it became urgent was pooling. + +## Pooling as the forcing function + +Post 2 described today's pooling arrangement: a per-tenant PgBouncer microVM that the gateway routes to. Integrating even that external pooler touched more than a dozen files in the gateway, split into separate development and production provisioners, and required a wrapper type just to get connection-reset behavior right. The team's next step, pooling inside the gateway process, would need something the two-implementation codebase could not offer cheaply: one place that sees every query-like message and every `ReadyForQuery`, on both transports, with the transaction status byte in hand. Transaction pooling is exactly the decision "can this backend be handed to another client now", and that decision needs a single, trustworthy view of the protocol. + +That is what turned a cleanup into a redesign. The written goals of the project, which the team calls PPg Proxy Revamped, put it as a common protocol-handling architecture onto which capabilities can be mounted as composable handlers, with streaming as a first-class property rather than a bypass, and with the wire vocabulary owned outright instead of borrowed. The catalog phase, pinned to a specific commit of the old proxy and citing files and line numbers for every claim, was done before any design was written, so that the design could be argued from evidence. + +## After: one pipeline, two edges + +The new codebase lives in a monorepo as a set of small Go modules, each with one job, assembled at a single composition root that is not allowed to contain business logic. The pieces that matter for this post: + +- **`pgwire`** owns the wire protocol. A `Frame` is a five-byte header plus a body, and a body has exactly one of three dispositions: **forward** it (write the header, stream the body, constant memory), **materialize** it into a capped buffer and decode a typed view (the only buffering path, bounded by a maximum size so a peer cannot drive allocation by lying about a length), or **construct** it from readers (how the API path forges `Parse`, `Bind`, and friends). There is no `pgproto3` dependency anywhere in the new tree, and a README rule keeps it that way. +- **`pg-pipeline`** is the streaming phase: a double chain of responsibility, one chain per direction, over `pgwire` frames. The inbound chain (client to database) runs a throttle gate, then a query-state tracker, then the terminal that forwards. The outbound chain (database to client) runs an error tap, a backend-key tap, a `ReadyForQuery` tap, then the terminal. Each handler either calls the next one or does not, and the two chains never call each other; they share one small session object holding the connection's state and its cancel key. +- **`pg-api-adapter`** is how the serverless API rides that pipeline. Rather than a second protocol implementation, it presents an API request as a **virtual Postgres client**: a frame source that forges the extended-query sequence from the request, and a frame sink that transcodes result messages into the client's response format. The pipeline does not know it is talking to anything but a Postgres client. A non-negotiable rule in that module is that it never materializes a body whose size the peer controls. +- **`ppg-routing`** owns lifecycle: the listeners, and the once-through establishment chain (accept, TLS, credentials, resolution, upstream dial and SCRAM) that produces a connection and hands it to the pipeline. TCP and the API differ here by assembly, not by fork. +- **`ppg-pool`** is the embedded transaction pool, discussed below. +- Supporting modules own errors, logging, observability, throttling policy, upstream resolution against the configuration store, and usage metering, each behind a seam the pipeline emits events into rather than importing directly. + +```mermaid +%% Before: two protocol implementations in one process. After: one pipeline, transport-specific +%% code only at the client edge. Sources: ignite pg-protocol catalog (before); +%% pdp-metal pg-pipeline, pg-api-adapter, ppg-routing READMEs (after). +flowchart TB + subgraph before["Before: one protocol, two implementations"] + direction LR + b_tcp["TCP client"] --> b_pump["Streaming pumps
(per-direction switch on message type)"] --> b_db1[("PostgreSQL")] + b_api["HTTP / WebSocket client"] --> b_iter["Pull iterator, flow-control
state machine, bind sequence"] --> b_db2[("PostgreSQL")] + end + + subgraph after["After: one pipeline, two client edges"] + direction LR + a_tcp["TCP client"] --> src_tcp["Source:
socket reader"] + a_api["HTTP / WebSocket client"] --> src_api["Source: API adapter
forges Parse, Bind, Execute"] + src_tcp --> chain["Shared pipeline (pgwire frames)
inbound: throttle gate, query state, forward
outbound: error tap, backend-key tap,
ReadyForQuery tap, forward"] + src_api --> chain + chain --> conn["Connector: direct dial,
or embedded transaction pool"] --> a_db[("PostgreSQL")] + chain --> term_tcp["Terminal:
write to socket"] --> a_tcp + chain --> term_api["Terminal: transcode to
NDJSON or WS frames"] --> a_api + end +``` + +_Before and after. Two separate protocol implementations become one pipeline whose only transport-specific parts are the inbound source and the outbound terminal at the client edge. Handler names are the ones used in the new codebase._ + +The most important consequence is the one the diagram shows: the transport-specific code sits only at the client edge. On TCP, the inbound source is the client socket and the outbound terminal writes to it. On the API, the inbound source is the forging adapter and the outbound terminal is the transcoder. Everything between, the throttle gate, the query-state tracker, the error and key taps, and the metering events they emit, runs the same handler instances on both transports. A forged `Execute` and a native `Execute` are indistinguishable to the throttle gate, which closes a real gap: the old API path checked account holds once at connection time and never again, while TCP re-checked on every query. Now both paths run the same gate. + +Backpressure is a consequence of the same structure rather than a mechanism of its own. Each direction is one copy loop with no intermediate buffer, so a slow client blocks the transcoder, which stops reading the database socket. On the request side, the adapter handles one statement at a time and reads the next only when the current one has drained into the database, which replaces the unbounded pipelining queue that Post 3 flagged in the old API path. Every blocking operation carries a deadline, so a genuine stall tears the connection down rather than holding it forever. + +## Connection state, defined from the code + +The proposal for this series used the words "clean, busy, and dirty" for connection states. Those are not the terms the implementation uses, and it is worth being exact, because the real vocabulary explains how pooling works. + +The pipeline tracks each connection as **idle** or **active**: a query-like message from the client (`Query`, `Execute`, or the end of a `COPY`) makes it active, and a `ReadyForQuery` from the database makes it idle. That is enough for the gateway to know whether a cancel request is meaningful when a client disconnects mid-query. + +The pool needs more, because it has to know whether a backend can be reused, and that depends on transaction state. Postgres tells it: every `ReadyForQuery` carries a status byte, `I` for idle, `T` for inside a transaction block, `E` for inside a failed one. The pool's per-client session moves through named phases that follow from that byte. It starts **idle** with no backend attached. The first client frame moves it to **claiming**, where it asks the pool for a backend. Once lent, it is **serving**: the session keeps an account, raised by each client frame that demands a reply and lowered by each `ReadyForQuery`, so that a pipelined batch keeps the backend until the last reply. When the account settles and the status byte says `I`, the backend goes back to the pool as reusable and the session returns to idle. When the status byte says `T` or `E`, the backend cannot be shared, because it holds transaction state only this client can finish, so the session moves to **waiting**, the one phase where an idle-in-transaction timer is armed; if that timer fires, the session ends with the same error Postgres itself would use, and the backend is destroyed rather than reused. A **draining** phase handles a refused lend, and **ended** is terminal. + +Two details in that design are the kind of thing you only get right by owning the protocol. The `ReadyForQuery` that settles the account cannot simply be forwarded to the client, because by the time it would reach the client the backend has already been returned to the pool and the connection's frame reader has moved on; the session forges a byte-identical `ReadyForQuery` from the status it already decoded. And there is no repair path for a backend in an unknown state: if a session ends mid-request, the backend is destroyed, on the reasoning that guessing at session state is how pools leak one client's `SET` into another client's transaction. The pool's own documentation records that it chose destroy-only after comparing how two other open-source poolers handle the same question. + +## Testing became the proof + +The old codebase needed a Docker Compose stack, a real Postgres and a real configuration store, to run its default test suite, and it had no fuzz tests. The redesign treated a dependency-free harness as a requirement of the architecture rather than a nicety, and three pieces of it are verifiable in the new tree. + +A fake Postgres server, `fakepg`, built on `pgwire`, speaks the real wire protocol including SCRAM authentication with injectable randomness, so an authentication exchange is byte-for-byte deterministic. It runs in two modes. A router maps a SQL string to a canned result. A script is an ordered conversation, and this is the deterministic message sequence the proposal wanted to show, taken from the package's own documentation: + +```go +fakepg.New(fakepg.WithBehavior(fakepg.Script{ + fakepg.Expect(fakepg.Parse("", "SELECT 1")), + fakepg.Expect(fakepg.Tag(pgwire.TagBind)), + fakepg.Send(fakepg.ParseComplete()), + fakepg.Send(fakepg.BindComplete()), + fakepg.Send(fakepg.Rows(cols, rows)), + fakepg.Send(fakepg.ReadyForQuery()), +})) +``` + +The server expects the client's `Parse` and `Bind`, then replies with completions, rows, and `ReadyForQuery`; any deviation is an error surfaced to the test. On top of that it ships fault behaviors named for what they exercise: a response that sends part of a message and hangs, one that drips bytes slowly, one that truncates mid-message, one that declares an oversized header, one that emits an error after partial results, and a slow reader that exercises write-side backpressure. + +Time is virtualized with Go's `testing/synctest`, so deadline and timeout logic runs against a fake clock, and goroutine coordination in tests uses a small runner primitive that turns an assertion failure inside a spawned goroutine into a test failure instead of a deadlock. Several of the new modules, including the API adapter and the pool, hold statement coverage at one hundred percent and enforce it in CI. An end-to-end suite runs the real gateway against real Postgres containers and the actual published `@prisma/ppg` driver as a subprocess, and a separate fuzz harness generates a whole scenario, sixteen databases and two hundred clients coming and going over a minute by default, across all four entry points, and judges the run by what the proxy reported. + +None of that is a performance claim, and this post makes none. What it establishes is that a protocol bug on either transport is now reproducible in a unit test with a scripted database, which was not true before. + +## What has landed, and what has not + +This is an architecture post about work in progress, so here is the status as of the repository history in mid-September 2026. + +**Implemented in the new codebase.** The wire library, the pipeline, the API adapter, the lifecycle and listeners for TCP, HTTP, and WebSocket, the rqlite-backed resolver, the throttling policy ported from the old gateway, usage metering, the test harness above, and the embedded transaction pool. Pooling is selected by the pooled hostname the client presents in its TLS handshake, as today, or by a second listening port, and the API transports are pooled by default with a protocol-level opt-out. Client TLS termination and cross-region peering between gateways over mutual TLS were added in August. + +**Deployed.** The new gateway is deployed to development regions. The repository holds no production rollout for it at the time of writing, and the written plan calls for a bounded side-by-side period in which the old implementation is frozen. + +**Not yet settled.** The embedded pool's README states that it is complete and tested and that its design decisions are not yet approved; the project's original scope deferred in-process pooling to a follow-up, and the pool was built ahead of that decision. Whether production pooled traffic moves from per-tenant PgBouncer to the embedded pool, and when, is a decision the team has not published. + +**Roadmap ideas from the proposal, not evidenced as plans.** The series proposal described pooling as a combination of "protocol sensing" and a "connection factory", with a rollout order of query detection, then transaction-boundary tracking, then unsupported-statement detection. The codebase's `ReadyForQuery` tracking is the transaction-boundary piece, and the pipeline docs describe a future handler that inspects `Query` text for pooling decisions. The rollout order and the detection of unsupported statements do not appear in the code or its design documents, so treat them as ideas. + +## Takeaway + +Connection lifecycle and protocol processing are separate problems, and the gateway got simpler, safer, and more testable when the code was organized to say so. Lifecycle became a once-through chain assembled per transport. Protocol processing became one pipeline that both transports feed, with the only transport-specific code at the client edge, so a policy written once applies to every connection. Owning the wire vocabulary made streaming the default and bounded materialization the exception, which is what let the serverless path stop buffering and what lets a pool read transaction state from the protocol rather than guessing. The costs are real: a rewrite, a migration that is still under way, and a set of design decisions that are documented but not yet all approved. The benefit that is already verifiable is that the two paths to a Prisma Postgres database now share one definition of what a Postgres message means. diff --git a/apps/blog/content/blog/why-prisma-postgres-needs-a-gateway/index.mdx b/apps/blog/content/blog/why-prisma-postgres-needs-a-gateway/index.mdx new file mode 100644 index 0000000000..789ea284ea --- /dev/null +++ b/apps/blog/content/blog/why-prisma-postgres-needs-a-gateway/index.mdx @@ -0,0 +1,150 @@ +--- +title: "Why Prisma Postgres Needs a Gateway" +slug: "why-prisma-postgres-needs-a-gateway" +date: "2026-09-16" +authors: + - "Ankur Datta" +metaTitle: "Why Prisma Postgres Needs a Gateway" +metaDescription: "Every connection to a Prisma Postgres database crosses one gateway service on the database host. What it authenticates, routes, meters, throttles, and observes, and why relaying bytes was never enough." +excerpt: "The connectivity, security, and isolation problems a multi-tenant Postgres platform must solve, and the gateway that solves them." +heroImagePath: "/why-prisma-postgres-needs-a-gateway/imgs/hero.svg" +heroImageAlt: "Cover: three client tiles (a Postgres driver, a pooled client, a serverless function) flow into one highlighted gateway tile, which fans out across a dashed boundary to three isolated database icons, under the headline One gateway, every connection." +metaImagePath: "/why-prisma-postgres-needs-a-gateway/imgs/meta.png" +tags: + - "prisma-postgres" + - "education" + - "platform" +series: prisma-postgres-connectivity +seriesIndex: 1 +--- + +_This is the first post in a four-part series on how Prisma Postgres handles database connectivity. The next installment covers connection pooling: [Why serverless apps are hard on Postgres connections](/why-serverless-apps-are-hard-on-postgres-connections)._ + +After reading this post, you'll understand what sits between your application and a Prisma Postgres database, and why that component has to do far more than relay bytes. + +Start with the connection string. A [Prisma Postgres](https://www.prisma.io/docs/postgres) database gives you something that looks completely ordinary: + +```text +postgres://:@db.prisma.io:5432/postgres?sslmode=require +``` + +`psql` accepts it. So do node-postgres, Drizzle, Kysely, DBeaver, and Prisma ORM. That ordinariness is the point of the product, and it hides a real question: where does that TCP connection actually go? There is no single Postgres server at `db.prisma.io`. There are bare-metal hosts in several regions, and each host runs a large number of isolated database instances, one per customer database, as unikernel microVMs. (Nikolas Burk described that hosting design when Prisma Postgres launched, in [Building a Modern PostgreSQL Service Using Unikernels and MicroVMs](https://www.prisma.io/blog/announcing-prisma-postgres-early-access), and followed it with [a walk through the life of a query](https://www.prisma.io/blog/cloudflare-unikernels-and-bare-metal-life-of-a-prisma-postgres-query). This series takes the hosting layer as given and stays on the connection path.) Your database is one of those VMs, on one of those hosts, in one region, and the hostname you connect to knows nothing about which one. + +Something has to close that gap on every connection. In Prisma Postgres that something is a gateway service the team calls the PPg TCP Proxy, and this post is about why it exists and what it is responsible for. + +## The naive design and where it breaks + +Suppose you tried to run a multi-tenant Postgres platform without a gateway. Each database VM listens on its own port, DNS points customers at their VM, and the database's own authentication handles access. This works for one host and a handful of databases. It stops working along four separate axes as soon as the platform is real. + +**Routing.** A database lives on one host, but the customer should reach it from anywhere. If DNS resolves to the host nearest the client, that host is often not the one holding the database. Someone has to know where the database is right now, and that knowledge changes when databases are created, restored, moved, or when a host is drained for maintenance. + +**Isolation.** Hundreds or thousands of customer databases share a host. Exposing each VM's Postgres port directly means every VM is a separate attack surface, every VM does its own TLS termination, and a misconfiguration on one VM is a public-facing mistake. The platform also loses the ability to reason about who is connecting to what. + +**Credentials.** The connection string above carries an API key, not the Postgres password of the underlying database. Keeping those two things separate is deliberate: a customer can create and revoke keys per environment without the database's own credentials ever leaving the platform. That separation needs a component that can accept one credential from the client and present another to the database. + +**Abuse and operations.** Postgres has a hard cap on concurrent connections, and one tenant's connection storm should not take down its neighbors on the same host. A rolling upgrade should not cut every session on a host at once. When something breaks, the operator needs to know whether the failure was the platform's fault or the client's. None of these concerns can be handled by the database process itself. + +Each of these problems can be solved in isolation. The interesting decision is where to solve them together. + +## The gateway as a deliberate boundary + +Prisma runs one gateway process on every database host. Every client-facing connection to a Prisma Postgres database enters through it, whether the client speaks the raw Postgres wire protocol over TCP, connects through the pooled hostname, or uses the serverless driver over HTTP or WebSockets. The three paths differ in what happens after the gateway accepts the connection, and later posts cover those differences. What they share is the boundary. + +For a direct TCP connection, the gateway's work on each new session looks like this: + +1. Accept the TCP connection and negotiate TLS. The client's `SSLRequest` is handled here, not by the database VM. +2. Read the Postgres startup message and ask the client for its credentials. The password field carries the API key. +3. Resolve the tenant. The gateway looks up the key's hash and the database's location and connection parameters from a configuration store that is replicated to every host. +4. Open a connection to the database VM and authenticate to it with the platform-held credentials, using Postgres's own SCRAM-SHA-256 exchange. The client never sees those credentials. +5. Tell the client it is authenticated, then stream bytes in both directions for the rest of the session, inspecting only a few message types along the way. + +A conceptual view of the pieces involved: + +```mermaid +%% Conceptual view: every client path enters one gateway process on a database host. +%% Sources: ignite docs/metal/technology/ppg/architecture.md, pgbouncer/connection-flow.md, +%% connection-modes-comparison.md, rqlite.md; pdp-cloudflare ppg-proxy.ts (usage push). +flowchart TB + subgraph clients["Clients"] + direction LR + tcp["Postgres client
db.prisma.io:5432"] + pooled["Postgres client
pooled.db.prisma.io"] + sls["Serverless function
@prisma/ppg over HTTPS or WSS"] + end + + dns["BunnyDNS: resolves to the
lowest-latency host"] + + subgraph host["Database host (bare metal)"] + direction TB + gw["PPg gateway (ppg-proxy)
TLS, API-key check, tenant routing,
throttling, metering, selective streaming"] + pgb["Tenant PgBouncer VM
transaction mode, on demand"] + db[("Tenant database VM
PostgreSQL in a unikernel microVM")] + rq[("rqlite node
keys, encrypted connection strings,
holds, pool config")] + end + + cp["Control plane (Cloudflare Workers)
API keys and holds; fallback validation;
usage intake"] + + tcp --> dns + pooled --> dns + sls --> dns + dns --> gw + gw -- "direct" --> db + gw -- "pooled" --> pgb --> db + gw -. "cached lookup" .-> rq + gw -. "fallback validation, usage reports" .-> cp + cp -. "replicates tenant config" .-> rq +``` + +_Conceptual view of the connection path. Every client path enters the gateway on a database host; the gateway resolves the tenant from a locally replicated configuration store and connects to the database VM directly or through the tenant's pooler. Not every service is shown._ + +Two things about this layout matter for the rest of the series. First, the gateway and the database VM are on the same physical host in the common case, so the hop from gateway to database is local. The network cost a client pays is the round trip to the nearest gateway; when DNS lands a client on a host that does not hold its database, the gateway connects onward to the host that does. Second, the documented way to reach a database VM is through this ingress. Customers do not get host access, and the gateway's own administrative and metrics endpoints sit behind a separate, token-gated port that is not reachable directly from the internet. A gateway is what makes it possible to treat "the database is reachable only through a controlled path" as a design property rather than a hope. + +## What the gateway gives you + +It is tempting to describe a proxy by its mechanics. The more useful description is what each responsibility does for the person on either side of it. + +### Authentication without sharing database credentials + +The credential a client presents is a tenant identifier plus an API key. The gateway validates the key against a stored hash and, on success, authenticates to the database using credentials that are stored encrypted in the platform's configuration store and decrypted only inside the gateway. Environment-scoped keys can be added and revoked independently, and revoking one does not require touching the database's own role or password. + +There is a cost to this indirection, and it is worth being precise about it. The gateway caches lookup results for a short window so that a burst of connections from one tenant does not turn into a burst of lookups. A revoked key can therefore keep working for a bounded number of seconds until that cache entry expires. That is a conscious tradeoff between revocation latency and lookup load, not an accident. + +### Tenant routing that survives change + +Because the gateway resolves the database location on every new connection, databases can move without customers changing anything. The routing data lives in a small replicated SQLite cluster, [rqlite](https://rqlite.io/), with a node on every host, which means a gateway can answer "where is this database and how do I authenticate to it" with a local read most of the time. The section on hard parts below returns to what that costs. + +### Usage visibility and fair accounting + +Every connection through the gateway is metered: how many queries it executed, how many bytes went in and out, how long the handshake took, how long the session lived, and which errors occurred. That data feeds three things that are easy to conflate: + +- **Your usage.** Prisma Postgres bills on database operations and storage. The operation count comes from the gateway's per-connection query metering. Storage is measured inside the database VM, not at the gateway, and backups flow from the VM to object storage on a separate path that never touches the gateway. +- **Fair resource accounting.** The same counters tell the platform which tenant is generating load on a host, which is what makes the throttling described below possible. +- **Operational safety.** Error counts are classified at the gateway as either the client's problem (a bad password, a malformed startup message, a SQL error) or the platform's problem (an unreachable upstream, a protocol error mid-stream). Only the second kind counts against the service's availability objective. + +### Throttling that protects neighbors + +The gateway enforces limits in two phases. After authentication, each tenant gets a bounded number of concurrent connections on a host; exceeding it returns the standard Postgres "too many connections" error (`53300`) with a retry hint rather than a dropped socket. Before TLS, the gateway watches its own handshake latency and total connection count and, when the host is under stress, sheds a fraction of new connections outright. Between those two, per-tenant limits shrink when the host is degraded, and a tenant that keeps generating connection-limit errors is automatically slowed down further. + +Operators can also place holds on a tenant, full or partial, from the control plane, and on the TCP path the gateway re-checks them on every query rather than only at connect time. (The serverless path checked holds only at connect time until the redesign in Post 4, which closes that gap.) The distinction the team draws here is a useful one for anyone designing limits: throttling that enforces a plan limit is expected behavior, but throttling that fires because the platform itself is overloaded counts as an availability failure, because it means the platform should have had more capacity. + +### One place to observe + +Because every connection crosses the gateway, it is also where the service-level objectives are defined and measured: connection success rate, execution success rate, handshake latency, and the fraction of time the host spends outside its normal throttle mode, evaluated per region as well as fleet-wide. A gateway that only relayed bytes could report throughput. A gateway that understands the protocol can tell you that a session failed after the client had already started a transaction, which is the failure that actually hurts. + +## The parts that are hard + +Colocating these responsibilities is the easy decision. Three consequences of it are hard, and they shape the rest of this series. + +**Distributing the routing data.** The gateway needs the answer to "where is this database" on every host, within a bounded delay of any change, without adding a network round trip to every connection. The current answer is the replicated store plus short-lived per-host caches with request coalescing, so that a thousand simultaneous connections from one tenant produce one lookup rather than a thousand. Every cache is a tradeoff between freshness and load, and the revocation window above is one visible consequence. + +**The protocol is message-oriented, and the libraries want to buffer.** The [Postgres wire protocol](https://www.postgresql.org/docs/current/protocol-overview.html) is a stream of typed, length-prefixed messages. A gateway that needs to count queries, catch errors, and capture the key needed to cancel a running query has to look inside some messages. The common Go libraries read whole messages into memory before handing them over, which is fine for a small `ErrorResponse` and unacceptable for a multi-gigabyte `COPY`. Prisma's gateway therefore does its own framing: it reads the five-byte header, decides whether the message is one of the few it needs to inspect, and otherwise streams the body straight through without allocating. The proxy's own test suite includes a case that pushes a gigabyte of `COPY` data through the gateway and asserts that memory barely moves. Post 3 and Post 4 are largely about what it takes to keep that property while adding features. + +**Buffering and noisy neighbors.** Anything the gateway holds in memory on behalf of one connection is memory another tenant on the same host cannot use. Horizontal scaling hides this problem, since more instances mean each one holds less, but it does not remove it. The team has learned this from operating an earlier managed proxy: scaling a connection pool horizontally caused user-visible slowness, and the replacement they have designed scales vertically alongside each database VM instead. The safest buffer is the one you never allocate, which is why the streaming discipline above is treated as a constraint rather than an optimization. + +> **Sidebar: observability is load too.** A gateway that sees every connection is also a gateway that can emit a log line and a trace span for every connection, and at fleet scale that volume becomes a workload in its own right. Two documented facts from the proxy's own history make the point. Its logger writes through an asynchronous ring buffer that drops lines when the sink cannot keep up, and it counts what it dropped, because blocking the data path on log I/O would be worse. And one cleanup commit removed duplicate tracing spans that a stack of instrumentation wrappers had been emitting for the same logical operation, cutting several million spans a day. Neither is a heroic story. Both are the kind of thing a gateway forces you to get right. + +## Takeaway + +A multi-tenant database service needs a controlled connection boundary, and that boundary has to authenticate, route, meter, throttle, and observe every session, not just forward it. Putting those responsibilities in one process per host is a deliberate choice: it gives the platform one place to enforce policy and one place to measure, at the cost of making that process the component that must stream efficiently, cache carefully, and never become the bottleneck it exists to prevent. + +The gateway establishes the boundary. It does not, by itself, solve the problem that brings most people to a managed Postgres service in the first place: what happens when a burst of short-lived compute instances all want a connection at once. That is the subject of the next post. diff --git a/apps/blog/content/blog/why-serverless-apps-are-hard-on-postgres-connections/index.mdx b/apps/blog/content/blog/why-serverless-apps-are-hard-on-postgres-connections/index.mdx new file mode 100644 index 0000000000..b95ac24ea6 --- /dev/null +++ b/apps/blog/content/blog/why-serverless-apps-are-hard-on-postgres-connections/index.mdx @@ -0,0 +1,154 @@ +--- +title: "Why Serverless Apps Are Hard on Postgres Connections" +slug: "why-serverless-apps-are-hard-on-postgres-connections" +date: "2026-09-16" +authors: + - "Ankur Datta" +metaTitle: "Why Serverless Apps Are Hard on Postgres Connections" +metaDescription: "Short-lived compute turns concurrency into open Postgres connections. How transaction pooling manages a finite set of backend connections in Prisma Postgres, what it costs, and what it cannot fix." +excerpt: "Connection lifecycle pressure, and what pooling can and cannot fix." +heroImagePath: "/why-serverless-apps-are-hard-on-postgres-connections/imgs/hero.svg" +heroImageAlt: "Cover: a grid of twenty small circles representing client sessions funnels into a highlighted pooler tile labeled transaction mode, which fans out to three backend connection tiles, under the headline Many clients, few connections." +metaImagePath: "/why-serverless-apps-are-hard-on-postgres-connections/imgs/meta.png" +tags: + - "prisma-postgres" + - "education" + - "platform" +series: prisma-postgres-connectivity +seriesIndex: 2 +--- + +_This is the second post in a four-part series on how Prisma Postgres handles database connectivity. The [first post](/why-prisma-postgres-needs-a-gateway) explained the gateway that every connection passes through. This one is about what happens on the other side of it when the clients are short-lived._ + +After reading this post, you'll understand why bursts of short-lived compute put pressure on Postgres connections, and exactly which part of that problem connection pooling addresses. + +Here is the scenario. You deploy an API handler to a serverless platform. It opens a Postgres connection, runs two or three queries, and returns. The platform is generous with concurrency: a traffic spike of a few hundred simultaneous requests is handled by starting a few hundred instances of your handler. Each instance dutifully opens its own database connection. On a long-running server this would be one process with a pool of, say, ten connections serving all of that traffic. On the serverless platform it is a few hundred connection attempts arriving at once, most of them for a single query, and the database's answer to the ones past its limit is a hard error: + +```text +FATAL: too many connections for role "app" +``` + +Nothing about the queries was expensive. The database was not CPU-bound. It was connection-bound, and that distinction is the whole subject of this post. + +## Why a Postgres connection is not a cheap thing + +Postgres uses a process-per-connection model: the postmaster forks a new backend process for every client session, and that backend holds the session's state for as long as the client stays connected. The [PostgreSQL documentation](https://www.postgresql.org/docs/current/connect-estab.html) describes this directly, and it explains three consequences that matter here. + +First, establishing a session costs more than a TCP handshake. The client and server exchange a startup message, negotiate authentication (SCRAM-SHA-256 by default on modern setups), and the server sets up a backend with its own memory. Over TLS, and across a network, that is several sequential round trips before the first query byte is processed. + +Second, every open session consumes server memory and a slot in a fixed table. The `max_connections` setting is chosen at server start, and the server sizes shared resources based on it, so it is not something a managed service can simply raise on demand. On Prisma Postgres the limit is set per plan, and the application role is allowed only a fraction of it, with the remainder reserved for migrations and platform operations. The [connection pooling documentation](https://www.prisma.io/docs/postgres/database/connection-pooling) lists the current per-plan numbers. + +Third, and least obvious: a connection that is open but idle is still a backend process. Serverless platforms keep instances warm between invocations precisely so the next request is fast, and a warm instance that opened a connection keeps holding it. Which brings us to the shape of the traffic. + +## What "short-lived compute" actually does to connections + +It is tempting to say that every serverless invocation starts a new process and opens a new connection. That is not what the major platforms do, and the real behavior is what makes the problem hard to reason about. + +On AWS Lambda, an execution environment is frozen after an invocation and thawed for the next one, so a database connection declared outside the handler is reused across warm invocations. AWS's own guidance is to check whether a connection exists before creating one. But Lambda provisions a separate execution environment for every concurrent request, and it terminates environments every few hours even for continuously invoked functions. Concurrency therefore maps almost directly to open connections, and a burst of two hundred concurrent requests is two hundred environments, each with its own connection, whether or not any single environment is reused later. + +Vercel's Fluid Compute changes the shape again: multiple concurrent invocations can share one function instance, so a pool declared in module scope can serve several requests at once, and Vercel documents a helper that closes idle connections before an instance suspends. Cloudflare Workers can open outbound TCP sockets with the `connect()` API, but a socket cannot be created in global scope and shared across requests, and Cloudflare's own recommendation for Postgres is to go through a pooling layer. + +The common thread is not "no reuse". It is that the application does not control the lifetime of the thing holding the connection. Instances come and go on the platform's schedule, concurrency fans out into instances rather than into threads, and idle instances hold idle connections you cannot see. A pool inside one instance helps that instance and does nothing across instances. The connection budget has to be managed somewhere the platform's scaling cannot outrun. + +## What pooling does, precisely + +A connection pooler is a process that sits between clients and the database, holds a fixed set of authenticated backend connections open, and lends them to client sessions. Clients see a Postgres server. The database sees a small, stable number of sessions. The pooler's job is to decide when a backend connection can be handed from one client to another, and that decision is the entire design space. + +[PgBouncer](https://www.pgbouncer.org/), the pooler Prisma Postgres uses today, offers three answers, which its [feature documentation](https://www.pgbouncer.org/features.html) defines as follows: + +| Mode | When a backend connection returns to the pool | What a client can rely on | +| --- | --- | --- | +| Session | When the client disconnects | Everything: `SET`, prepared statements, temp tables, `LISTEN`, advisory locks all persist for the session | +| Transaction | When the current transaction ends | Only what lives inside a transaction. Session-level state is lost between transactions | +| Statement | When each statement finishes | Single statements only. Multi-statement transactions are rejected | + +Session mode barely pools at all: it saves the backend setup cost when a client reconnects, but a thousand connected clients still need a thousand backends. Statement mode pools aggressively but breaks transactions, which rules it out for most applications. Transaction mode is the common choice because a transaction is the smallest unit that still lets an application reason about atomicity, and because most application sessions spend most of their time between transactions. + +Here is what reuse looks like in transaction mode with three clients and one backend connection: + +```mermaid +sequenceDiagram +%% Transaction-mode reuse: three client sessions share one backend connection. +%% The reuse decision is driven by the ReadyForQuery status byte ('I' idle, 'T' in transaction). +%% Sources: PgBouncer features.html (pool modes); PostgreSQL protocol-message-formats.html (ReadyForQuery). + participant A as Client A + participant B as Client B + participant C as Client C + participant P as PgBouncer (transaction mode) + participant S as Backend connection + + A->>P: BEGIN + P->>S: BEGIN (backend lent to A) + S-->>P: ReadyForQuery (T) + P-->>A: ReadyForQuery (T) + B->>P: SELECT ... (autocommit) + Note over P: B waits: the only backend is lent to A + A->>P: UPDATE ... + P->>S: UPDATE ... + S-->>P: ReadyForQuery (T) + P-->>A: ReadyForQuery (T) + A->>P: COMMIT + P->>S: COMMIT + S-->>P: ReadyForQuery (I) + Note over P: status I: transaction closed,
backend returns to the pool + P-->>A: ReadyForQuery (I) + P->>S: SELECT ... (backend lent to B) + S-->>P: rows, ReadyForQuery (I) + P-->>B: rows, ReadyForQuery (I) + C->>P: INSERT ... (autocommit) + P->>S: INSERT ... (backend lent to C) + S-->>P: ReadyForQuery (I) + P-->>C: ReadyForQuery (I) +``` + +_Transaction-mode reuse with three clients and one backend connection. The pooler hands the backend to the next client only after Postgres reports the transaction closed in the status byte of its ReadyForQuery message. Simplified: a real pool holds more than one backend._ + +Client A holds the backend from `BEGIN` to `COMMIT`. The moment Postgres reports the transaction closed, the pooler can hand the same backend to client B's autocommit statement, and then to client C. Three application sessions, one backend, and none of them waited on connection setup to the database. The reason the pooler can do this safely is a detail of the wire protocol: after every query cycle Postgres sends a `ReadyForQuery` message whose status byte says whether the session is idle, inside a transaction, or inside a failed transaction. Transaction pooling is built on reading that one byte. Post 4 comes back to it. + +The cost of this arrangement is equally precise. Anything a client sets on the session outside a transaction lands on whichever backend it happened to be holding, and the next transaction may get a different one. That is why the Prisma Postgres documentation says session state such as `SET` commands, prepared statements, advisory locks, and temporary tables are lost after each transaction boundary on pooled connections, and directs workloads that need them to a direct connection. (PgBouncer can track protocol-level prepared statements across backends when configured to, but that does not change the general rule: assume nothing survives the transaction.) + +## How Prisma Postgres pools today + +On Prisma Postgres, pooling is opt-in by hostname. The direct connection string points at `db.prisma.io`; the pooled one points at `pooled.db.prisma.io`. Both arrive at the gateway from Post 1. For a pooled connection the gateway routes to a PgBouncer instance that belongs to that one tenant, running in transaction mode, in front of the tenant's database VM: + +```text +Application โ†’ gateway โ†’ tenant's PgBouncer (transaction mode) โ†’ database VM +``` + +Two properties of that arrangement are worth knowing because they shape its behavior. + +**The pooler is per tenant and starts on demand.** Each PgBouncer runs as its own microVM on the same host as the database, provisioned the first time a pooled connection arrives and frozen after a few seconds of inactivity, the same scale-to-zero mechanism the database VMs use. That isolates one tenant's pool from another's, and it means the first pooled connection to an idle database pays a provisioning or wake cost that later ones do not. The pool's minimum size is zero for the same reason: a pool that eagerly reopens backends would keep waking its database. + +**The limits are plan-sized.** PgBouncer has two independent knobs: how many clients may connect to it and how many backend connections it may hold. Prisma sizes both per plan, and the backend pool is smaller than the database's `max_connections`, so the pooler can never itself exhaust the database. The documentation publishes the client-side limits per plan, the 10-minute cap on a pooled query, and the 60-minute idle timeout on pooled sessions. + +The gateway itself holds no pool. It opens a fresh connection to PgBouncer for each client session and streams. That is a deliberate scoping: the gateway's job in this arrangement is routing and policy, and PgBouncer's is reuse. It also has a consequence for latency, which the next section is about. + +## What pooling fixes, and what it does not + +It is worth stating the non-goals plainly, because "connection pooling" is often described as if it made queries faster. + +**Pooling does not change query execution time.** A pooled `SELECT` runs the same plan on the same backend as a direct one. If a query is slow inside Postgres, a pooler cannot help. + +**Pooling does not reduce the client's connection setup cost on this path.** Count the sequential round trips a client pays before its first query byte: one for TCP, one for TLS, and two for the Postgres startup and password exchange with the gateway. Those happen at the full client-to-gateway latency in both modes, because the gateway terminates the client's session either way. What changes is the leg behind the gateway: with PgBouncer warm and a backend idle in its pool, the pooler answers the startup without opening a new database session, whereas a direct connection performs a fresh handshake and SCRAM exchange with the database. That leg is local to the host, so the saving is real but small relative to the client-facing round trips. If your client is far from the nearest region, pooling will not make connection setup feel fast; it will make it stop failing. + +**Pooling adds a hop.** Every pooled query crosses one more process. On the same host that is a small cost, but it is not zero, and a workload with one long-lived, well-behaved connection gains nothing from it. + +**Pooling can add waiting.** When every backend in the pool is busy, the next transaction queues inside PgBouncer instead of failing. That is the intended behavior under a burst, and it is better than a connection error, but it means end-to-end request latency can rise under load even though each query executes just as fast as before. When you measure a pooled workload, separate the time a query spent executing in Postgres from the time the request spent waiting for a backend. The second number is the one pooling controls. + +**Pooling reshapes the connection limit rather than lifting it.** The database still has its `max_connections`. What pooling changes is how many application sessions can share it. The [documentation](https://www.prisma.io/docs/postgres/database/connection-pooling) makes the same point: pooling does not increase the database connection limit, it makes better use of it. + +> **Sidebar: when the platform fights the pooler.** Running PgBouncer inside a VM that freezes when idle produced a failure mode worth learning from. PgBouncer keeps its backend connections open across client sessions, and it uses the monotonic clock to decide when an idle backend is old enough to be checked or closed. A frozen VM's monotonic clock stops. Meanwhile, the host's ingress closed the frozen pooler's long-idle connections to the database after a couple of hours, and because the pooler was the side that had opened those connections, it was not woken to see them close. On the next thaw, PgBouncer's timers reported almost no time elapsed, it handed a dead backend to a client, and the client saw `server conn crashed?` (SQLSTATE `08P01`). The mitigation is PgBouncer's own health check with no delay: before reusing a backend, send the empty query string, which Postgres answers with `EmptyQueryResponse` without touching the planner or the statistics views. The platform vendor has said it will wake both ends of a closed connection in a future fix. Until then, the lesson is that a pooler's idea of "idle for a while" has to survive the platform's idea of "asleep". + +## Where Accelerate fits, and where it is going + +Readers who have used Prisma for a while know an older answer to this problem. Prisma's Data Proxy, introduced in [Early Access in 2021](https://www.prisma.io/blog/prisma-data-proxy-xb16ba0p21), ran the Prisma query engine and a connection pool on Prisma's side, so that a serverless function could reach the database without holding a Postgres connection of its own. It [became Accelerate in 2023](https://www.prisma.io/blog/announcing-accelerate-usrvpi6sfkv4), which added a global cache. Accelerate is a different design from the pooler described above: the client speaks Prisma's query protocol over HTTPS to an edge worker, which forwards to a query engine instance near the database, and that engine holds a small pool of persistent connections to the database. The pool lives in the engine, not in front of the database. + +That design has served a lot of traffic, and it taught the team things the next post relies on. It is also being wound down: Prisma's [documentation](https://www.prisma.io/docs/accelerate) states that hosted Accelerate connections will be retired on December 1, 2026, that Prisma Postgres includes connection pooling, and that query caching is not carried over. The two paths forward are the pooled TCP hostname above and the serverless driver that Post 3 covers. + +One more thing is in progress and belongs in the roadmap column rather than the current one. The gateway's redesign, which Post 4 describes, includes an embedded transaction pool inside the gateway process itself, so that the pooler is no longer a separate microVM. That code exists and is tested in the new codebase, but its design is still under review and it is not what serves production pooled traffic at the time of writing. + +## Takeaway + +A connection pooler manages access to a finite set of backend connections. That is the whole job, and it is a valuable one when the number of things wanting a connection is decided by a platform's scaling behavior rather than by you. To judge whether pooling helps a workload, separate two costs that get conflated: the cost of establishing and holding connections, which pooling addresses, and the time queries spend executing in Postgres, which it does not. If the first number dominates, pool. If the second does, look at the queries. + +Pooling assumes one thing throughout: that the client can open a Postgres connection at all. Some runtimes cannot, or can only do so awkwardly, and even where they can, a request-scoped function is a poor fit for a protocol designed around long-lived sessions. That is the transport problem, and the next post is about the API Prisma built to solve it. diff --git a/apps/blog/proposals/ppg-proxy-series.md b/apps/blog/proposals/ppg-proxy-series.md new file mode 100644 index 0000000000..b58e469660 --- /dev/null +++ b/apps/blog/proposals/ppg-proxy-series.md @@ -0,0 +1,143 @@ +# ppg-proxy blog series (proposal) + +A four-part series on how we manage Prisma Postgres connectivity: what the ppg-proxy is, the problems it solves, how it evolved, the challenges we hit, and the revamp we did as a result. + +## Series framing + +**Promise:** How Prisma Postgres builds a secure, serverless-friendly connectivity layer for Postgres, and the engineering behind it. + +**Audience:** senior backend and platform engineers, plus database/protocol-curious readers. We keep the depth. Every post opens on a user-visible problem before going low-level. + +**Goal / definition of success:** build technical trust in Prisma Postgres connectivity and show the engineering rigor behind it. Secondary: it doubles as a recruiting signal for infra engineers. The structure below serves that goal. + +**Repeated structure per post** (makes each post easier to write and to follow): + +1. The problem a developer or operator actually hits +2. Why the obvious solution breaks down +3. Prisma's approach +4. Tradeoffs and lessons learned +5. What this unlocks next + +Each post opens with a one-line "After reading, you'll understand X" and closes with a concrete takeaway, not just a "what's next". + +**War stories** (logging-to-stdout, noisy neighbor, protocol rewrites, fakepg) appear as sidebars that support the reader-facing point. They are not the spine. + +**Jargon** (ingress, framing, flow control, terminal adapters, clean/busy/dirty connections, transaction-boundary detection, pgproto3, sub-message streaming) is introduced progressively and defined at first use, not assumed. + +--- + +# Post 1 - Why Prisma Postgres needs a gateway + +_Subtitle: the connectivity, security, and isolation problems a multi-tenant Postgres platform must solve._ + +**After reading, you'll understand** what sits between your app and a Prisma Postgres database, and why "a thin proxy" is the wrong mental model. + +1. The problem: connecting to a database in a multi-tenant, multi-region platform + - What Prisma Postgres is and where it runs (brief; we covered microVMs/unikernels before) + - Why "just expose Postgres on a port" does not survive contact with multi-tenancy, isolation, credential rotation, and abuse +2. The gateway mental model (establish this early, before internals) + - ppg-proxy as a single control point: routing and tenant isolation, auth boundary, metering point, observability boundary. State plainly that it is all of these, and why co-locating them is deliberate. + - Security first: the gateway lets us cordon the Postgres hosts off from public traffic entirely. That is a structural security and maintenance win, not a side effect. +3. What the gateway gives you (framed as reader value) + - Auth: API-key based, rotation without touching database credentials + - Usage visibility and fair resource accounting (queries, ingress/egress, backup traffic), framed as fairness, abuse protection, and operational safety rather than vendor billing + - Unified throttling, observability, access logging +4. The hard parts (short teaser for the rest of the series) + - Distributing the routing data: choices and tradeoffs + - The Postgres wire protocol is message-oriented, and the open-source library landscape is uneven + - Buffering and the noisy-neighbor problem: horizontal scaling hides it, it does not solve it + - Why we committed to end-to-end low-level streaming while still inspecting selected messages (error reporting, query counting) + - Sidebar: our logging-to-stdout adventure, a short war story on why observability at a gateway is harder than it looks + +**Key takeaway:** the gateway is what makes secure, isolated, meterable Postgres access possible. The rest of the series is how we make it fast and serverless-friendly. + +**What's next:** we still need a serverless-friendly entry point and per-tenant pooling. + +--- + +# Post 2 - Why serverless apps are hard on Postgres connections + +_Subtitle: connection lifecycle pressure, and what pooling can and cannot fix._ + +**After reading, you'll understand** why ephemeral compute breaks the Postgres connection model, and exactly which part of that problem pooling solves. + +1. The problem: ephemeral compute meets a stateful connection model (keep tight, tie every point to DB connectivity) + - Short-lived processes and connection storms + - Persistent connection and transaction model vs throwaway compute (Lambda-style) vs a long-lived VM/EC2 + - Sometimes no native TCP/TLS client connectivity at all (foreshadow Post 3, do not resolve it here) +2. Why the obvious fix (a fresh connection per invocation) breaks down + - Connection create/teardown cost on the database + - Approaches to bridge the gap: dedicated pooler, HTTP/WS wrapper, or pooler-as-a-service (Accelerate does both, with limits) +3. Prisma's approach: pooling + - What pooling is and its actual goal: relieve connection lifecycle pressure on the database + - Pooling levels (statement, transaction, connection) and how each reuses connections (query boundaries, transaction detection) + - How Prisma Postgres does it today (per-db pgbouncer) and its limits alongside the ppg-proxy +4. Tradeoffs and non-goals + - Pooling does not make queries faster, and can add latency + - It helps connection lifecycle, not query performance + - The cases where a pooler actively slows you down +5. What this unlocks / what is still missing + - Pooling fixes lifecycle pressure, not transport. Not every environment can open a TCP/TLS socket. + +**Explicit boundary with Post 3:** Post 2 is about connection lifecycle pressure. Post 3 is about transport and protocol limitations. Keeping these separate stops the two posts from overlapping. + +**Key takeaway:** pooling is the right tool for connection lifecycle pressure and the wrong tool for most of what people expect it to fix. + +**What's next:** the transport problem, reaching Postgres where TCP/TLS is not available. + +--- + +# Post 3 - Designing a streaming API for serverless Postgres + +_Subtitle: an HTTP/WS access path that keeps Postgres semantics without buffering everything._ + +**After reading, you'll understand** why request/response HTTP breaks down for a database, and what a streaming-first serverless Postgres API looks like. + +1. The problem: no TCP/TLS, but you still need real Postgres semantics + - Where we left off in Post 2: the transport gap + - Industry approaches: a bare pg-protocol wrapper vs a bespoke HTTP/WS API, and their tradeoffs +2. Why request/response breaks down (lead with the hook: streaming) + - The driver is the easy part; the API contract is the hard part + - Neon's serverless driver as a technical case study: a mechanics-first deep dive into its protocol (neutral, no editorializing) + - Lessons from operating query engines and managed proxies at scale (Data Proxy, Accelerate): the noisy-neighbor problem, and why buffering forces caps on query duration and body size. Framed as our own hard-won lessons, not as products being broken. +3. Prisma's approach: streaming as a first-class citizen + - The end-to-end streaming chain: ingress, encode/decode, framing and flow control, database session and sub-message streaming + - What streaming-first unlocks: no artificial duration or body-size caps, predictable behavior under load +4. The client side (kept, but contained to the API/driver contract) + - Streaming as first-class but optional on the client: CollectableIterator + - Free wins from the model: query batching and pipelining vs traditional request/response, and how it blends into the driver API +5. What this unlocks next + - A driver for every language, with a JDBC sketch. Positioned as a forward-looking close, not a structural pillar. + +**Key takeaway:** streaming-first is what lets a serverless HTTP/WS path behave like a real Postgres connection instead of a capped RPC. + +**What's next:** two access paths (raw TCP and serverless) still run as separate stacks. That is the revamp. + +--- + +# Post 4 - Unifying TCP and serverless connectivity + +_Subtitle: one protocol pipeline, two terminal adapters, and embedded pooling._ + +**After reading, you'll understand** how we collapsed two parallel connectivity stacks into one streaming pipeline, and why that unlocks better pooling. + +**Lead with the conceptual takeaway:** connection lifecycle and protocol handling are separate problems. Everything else in this post follows from that one idea. + +1. Before: two stacks that grew apart + - The raw pg-protocol TCP proxy and the serverless HTTP/WS API evolved on separate timelines and EA/GA phases + - The cost: duplicated or missing building blocks, inconsistent lifecycle logic, harder pooling + - Pooling as the forcing function: pgbouncer was a good quick win, but embedding pooling properly needs one centralized protocol handler (clean/busy/dirty connection state, transaction tracking) +2. The problem to solve: a boundary contract between lifecycle and protocol, without giving up the streaming promise +3. After: a unified pipeline (the architecture) + - Step 1: one cohesive raw pg-protocol library with first-class streaming, replacing a mixed pgproto3 and bespoke utility set + - Step 2: the core pg-protocol pipeline + - Step 3: serverless API and TCP terminal adapters on top of the same pipeline + - Sidebar: fakepg, and why deterministic pre-baked message sequences make protocol bugs testable. A concrete example after the architecture is clear, not a detour before it. +4. The payoff (explicit before/after benefit) + - Better pooling, easier testing, consistent streaming, less duplication, room for future features + - Lessons learned about re-architecting under fast-paced evolution (one honest line on tooling; not a structural pillar, since that framing ages badly) +5. What's next + - Pooling as a two-facet system: protocol sensing plus connection factory + - The rollout plan: query detection first, then transaction-boundary tracking, then unsupported-statement detection last + +**Key takeaway:** separating lifecycle from protocol turned two fragile stacks into one testable, streaming pipeline that pooling can finally sit inside. diff --git a/apps/blog/public/designing-a-streaming-api-for-serverless-postgres/imgs/hero.svg b/apps/blog/public/designing-a-streaming-api-for-serverless-postgres/imgs/hero.svg new file mode 100644 index 0000000000..10cac33a60 --- /dev/null +++ b/apps/blog/public/designing-a-streaming-api-for-serverless-postgres/imgs/hero.svg @@ -0,0 +1,62 @@ + + Designing a streaming API for serverless Postgres + Data moving incrementally through a channel with controlled flow. Prisma blog cover, 2026 brand: light paper surface, spectral prism wash, Postgres yellow accent. + + + + + + + + + + + + + + + + Prisma Postgres connectivity ยท Part 3 + Stream rows,not results + + +db +client + + + + + + +DataRow ยท one field at a time + +a slow client slows the sender + + +bounded memory + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/blog/public/designing-a-streaming-api-for-serverless-postgres/imgs/meta.png b/apps/blog/public/designing-a-streaming-api-for-serverless-postgres/imgs/meta.png new file mode 100644 index 0000000000..82e07835f2 Binary files /dev/null and b/apps/blog/public/designing-a-streaming-api-for-serverless-postgres/imgs/meta.png differ diff --git a/apps/blog/public/unifying-tcp-and-serverless-connectivity-in-prisma-postgres/imgs/hero.svg b/apps/blog/public/unifying-tcp-and-serverless-connectivity-in-prisma-postgres/imgs/hero.svg new file mode 100644 index 0000000000..f5e78f5418 --- /dev/null +++ b/apps/blog/public/unifying-tcp-and-serverless-connectivity-in-prisma-postgres/imgs/hero.svg @@ -0,0 +1,58 @@ + + Unifying TCP and serverless connectivity in Prisma Postgres + Two access paths meeting one shared protocol pipeline, with lifecycle handling kept apart. Prisma blog cover, 2026 brand: light paper surface, spectral prism wash, Postgres yellow accent. + + + + + + + + + + + + + + + + Prisma Postgres connectivity ยท Part 4 + Two paths,one pipeline + + + +lifecycle: accept ยท tls ยท auth ยท resolve ยท teardown +tcp +http / ws + + +pipelineshared handlers + + +one protocol vocabulary + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/blog/public/unifying-tcp-and-serverless-connectivity-in-prisma-postgres/imgs/meta.png b/apps/blog/public/unifying-tcp-and-serverless-connectivity-in-prisma-postgres/imgs/meta.png new file mode 100644 index 0000000000..eb2851d91c Binary files /dev/null and b/apps/blog/public/unifying-tcp-and-serverless-connectivity-in-prisma-postgres/imgs/meta.png differ diff --git a/apps/blog/public/why-prisma-postgres-needs-a-gateway/imgs/hero.svg b/apps/blog/public/why-prisma-postgres-needs-a-gateway/imgs/hero.svg new file mode 100644 index 0000000000..03e10b0959 --- /dev/null +++ b/apps/blog/public/why-prisma-postgres-needs-a-gateway/imgs/hero.svg @@ -0,0 +1,68 @@ + + Why Prisma Postgres needs a gateway + A controlled gateway boundary organising client access to isolated database destinations. Prisma blog cover, 2026 brand: light paper surface, spectral prism wash, Postgres yellow accent. + + + + + + + + + + + + + + + + Prisma Postgres connectivity ยท Part 1 + One gateway,every connection + + +psql / pg driver + +pooled client + +serverless fn + +gateway +auth ยท route +meter ยท throttle + + + + + + + + + + +isolated VMs + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/blog/public/why-prisma-postgres-needs-a-gateway/imgs/meta.png b/apps/blog/public/why-prisma-postgres-needs-a-gateway/imgs/meta.png new file mode 100644 index 0000000000..c2a4c8ed42 Binary files /dev/null and b/apps/blog/public/why-prisma-postgres-needs-a-gateway/imgs/meta.png differ diff --git a/apps/blog/public/why-serverless-apps-are-hard-on-postgres-connections/imgs/hero.svg b/apps/blog/public/why-serverless-apps-are-hard-on-postgres-connections/imgs/hero.svg new file mode 100644 index 0000000000..22f5ef5470 --- /dev/null +++ b/apps/blog/public/why-serverless-apps-are-hard-on-postgres-connections/imgs/hero.svg @@ -0,0 +1,78 @@ + + Why serverless apps are hard on Postgres connections + A burst of application connections reusing a smaller set of backend connections. Prisma blog cover, 2026 brand: light paper surface, spectral prism wash, Postgres yellow accent. + + + + + + + + + + + + + + + + Prisma Postgres connectivity ยท Part 2 + Many clients,few connections + + + + + + + + + + + + + + + + + + + + + + +20 client sessions + +poolertransaction mode + +conn + +conn + +conn +3 backends + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/blog/public/why-serverless-apps-are-hard-on-postgres-connections/imgs/meta.png b/apps/blog/public/why-serverless-apps-are-hard-on-postgres-connections/imgs/meta.png new file mode 100644 index 0000000000..ee1791abc8 Binary files /dev/null and b/apps/blog/public/why-serverless-apps-are-hard-on-postgres-connections/imgs/meta.png differ diff --git a/apps/blog/src/lib/series-registry.ts b/apps/blog/src/lib/series-registry.ts index 5c6c694fe3..e2381b7225 100644 --- a/apps/blog/src/lib/series-registry.ts +++ b/apps/blog/src/lib/series-registry.ts @@ -34,6 +34,15 @@ export const seriesRegistry = { featured: true, relatedSeries: ["agentic-engineering", "prisma-compute"], }, + "prisma-postgres-connectivity": { + title: "Inside Prisma Postgres connectivity", + description: + "How Prisma Postgres manages secure, serverless-friendly database connectivity: the gateway every connection crosses, connection pooling, the streaming serverless API, and the shared protocol pipeline behind both paths.", + featured: false, + docsUrl: "https://www.prisma.io/docs/postgres", + docsLabel: "Read the Prisma Postgres docs", + relatedSeries: ["prisma-compute", "prisma-8"], + }, "rust-to-typescript-migration-journey": { title: "Prisma ORM: The Complete Rust-to-TypeScript Migration Journey", description: