From June to the start of September, the most frequent error in the production backend of Shelter Sense was PrismaClientKnownRequestError. Sentry counted 2,556 of them in ninety days. Read the messages and 1,453 of those say, in Prisma's own words, that Accelerate could not do its job: "Accelerate experienced an error communicating with your Query Engine", "Accelerate was not able to connect to your database", "The rate limit for this environment has been exceeded."

Accelerate is Prisma's hosted connection pooler and query cache. It sits between your application and your database on Prisma's edge network, and for a year it had been the only way our three Nest APIs on Fly.io reached their Prisma Postgres databases. None of those errors named a query of ours that was wrong. They named a proxy that could not answer.

On the night of 2 September we moved all three databases to Fly Managed Postgres, in the same region as the APIs, on a private network, with no proxy in between. This post is the whole story: what Accelerate was doing for us, the five distinct ways it hurt, the two rounds of mitigation we shipped before deciding to leave, the migration itself, the bugs the migration flushed out of our own code, and what Sentry says now.

None of the 1,453 Accelerate errors named a query of ours that was wrong. They named a proxy that could not answer.

Mark Wilkins

01

What Accelerate did for us

Every Shelter Sense request travels a long way. The UI is a RedwoodSDK app on a Cloudflare Worker. It calls a NestJS API on Fly.io in Toronto through one endpoint, POST /api/internal/app-service, which we call "the bridge". The API talks to Postgres through Prisma. Until this week, "talks to Postgres" meant an HTTPS call to accelerate.prisma-data.net, which forwarded the query to a Prisma Postgres database in AWS us-east-1.

Accelerate was doing three jobs in that chain:

JobHow we used it
Connection poolingThe API never held a Postgres connection. Every query was an HTTP request to the edge proxy.
Query cachecacheStrategy with a 60 s TTL and a 120 s stale-while-revalidate window, tagged per tenant and invalidated by our own libs/nest-cache-revalidation code after writes.
Transaction budgetInteractive transactions had a 15-second cap that our code encoded as ACCELERATE_INTERACTIVE_TRANSACTION_OPTIONS.

Three apps depended on it, and only one of them was busy:

ServiceFly appMonthly Prisma operations (August)Data
Shelter Sense APIshelter-sense7.5 million57 tables, 1.7 million rows, 72 MiB
Auth Serverwilk-soft-auth-server3.5 million13 tables, 81,068 rows, 5 MiB
You Power Project APIyou-power-project-web85 thousand51 tables, 4,390 rows, 0.3 MiB

For a one-developer company this was a good trade at the time. I did not have to run Postgres, think about connection limits, or build a cache. The cost was the round trip: Toronto to a Cloudflare edge, to us-east-1, and back, for every query, including the tiny ones.

02

Five ways it failed

The errors clustered into a handful of shapes. Here is the breakdown of the PrismaClientKnownRequestError events by the query that happened to be in flight and the message Accelerate returned.

Query in flightAccelerate's messageEvents (90 days)
tenant.findUniqueerror communicating with your Query Engine427
tenantUser.findUniqueerror communicating with your Query Engine241
user.findUniqueerror communicating with your Query Engine162
user.findUniquenot able to connect to your database: Internal Server Error152
tenant.findUniquenot able to connect to your database: Internal Server Error108
tenantUser.findFirsterror communicating with your Query Engine106
tenantUser.findUniquerate limit for this environment has been exceeded61
animal.findFirsterror communicating with your Query Engine50
tenant.findUniquerate limit for this environment has been exceeded41
animal.findManyerror communicating with your Query Engine26

Look at the left column. Those are the smallest reads in the codebase: look up a tenant by slug, look up a membership by primary key. They are not slow queries and they are not big queries. They were simply what was in flight when the proxy fell over.

Ninety days of PrismaClientKnownRequestError in Shelter Sense production, four-hour buckets. The June spike is one afternoon. Sample count 2,582.

1. Error 1102: the proxy's own worker being killed. Digging into fifty sampled events in August, every "error communicating with your Query Engine" carried the same Cloudflare diagnostic underneath: zone: accelerate.prisma-data.net, worker_exceeded_resources, error 1102. That is Cloudflare terminating Accelerate's Worker for exceeding its resource limits. Not our worker, not our query, not a documented Accelerate limit we were near (we never once hit the 5 MiB response-size limit). The kills came in bursts: the worst was 22 in 14 minutes on 9 July, striking whatever happened to be in flight. Cloudflare's embedded advice on a 1102 is "do not retry", which is advice for the worker's owner. For a bystander whose findUnique was collateral, retrying is exactly right.

2. "Not able to connect to your database." 260 events where Accelerate could not reach Prisma Postgres at all, reported to us as an Internal Server Error or a bare error code: 1016. We had no visibility into that hop and nothing to do about it.

3. Rate limiting we did not provision for. 102 events of "the rate limit for this environment has been exceeded" from a plan that lists no per-environment rate limit anywhere we could find.

4. Stale reads after writes. The cache's stale-while-revalidate window meant that for up to 120 seconds after a write, a read that missed our in-process cache could be served the pre-write row by Accelerate. Normally our own cache masked this. Then we found a code path where a user's self-heal on sign-up emitted an unscoped cache tag, which our invalidation layer treated as "flush everything", which pushed every following load straight through to Accelerate, whose cache happily served rows from before the write. The symptom was a tenant setting that saved correctly and then reverted on the next page load. Twice in the full test suite, never in isolation. That one took a while.

5. The sunset. On 28 August, Prisma's documentation team merged a rewrite of its migration guides whose description says it exists "to support the Accelerate sunset": removal of the extension, the cache API and accelerateUrl. No date, and Accelerate is still sold, but the prisma+postgres://…api_key= path all three of our apps used is on its way out on Prisma's side as well.

The same errors by month. The July drop is our first round of fixes. August is what was left. September 3 is the first morning on Fly Managed Postgres.

03

What we tried first

The June number is 1,734 events in 25 days. Nobody moves a database over one bad month, and the first two rounds of work were about our side of the chain.

Round one, July: stop sending so many queries. The rwsdk split (UI on Cloudflare, API-only Nest on Fly) shipped on 9 and 10 July, and about 95% of the 1102 volume predates it. That was partly luck and partly the split removing a lot of server-rendering traffic from the Fly side.

Round two, August: find our own burst source. The sweep in August found the multiplier. Every List Mode card made its own bridge call for its detail, and every bridge call re-proved the caller's membership and two permissions with live queries, because the permission context was deliberately never cached. A shelter with 72 cats on one page was 72 round trips to Fly and about 216 tiny authentication queries per tablet, per load. Those tiny queries were exactly the victims in the table above. Two pull requests fixed that class:

ChangeEffect
One roster read per page, shared by the summary table and the cards, with a spec that fails if any per-animal component owns a bridge read72 bridge calls per page became 1
A request-scoped membership memo, so the bridge proof and every permission check share one tenantUser read per requestroughly 3 auth queries per call became 1
Retries with backoff on every small hot read, with 1102 kept on the transient list on purposevictims recover instead of failing the page
The offline-sync snapshot endpoint, the one Prisma surface with no retry, wrapped with a 30-second per-attempt deadlinethe two "whole page 500" issues stopped
Cache tags narrowed so no write can flush the global read cachethe stale-setting bug went away
Lazy per-card editors, one bootstrap call for List Modefewer queries per interaction

That work was worth doing regardless of the database, and it is why August has 212 events rather than 1,734. But 212 events is still 212 times a shelter volunteer saw a spinner or a "try again", for reasons that were structural:

  • The median bridge call was 149 ms and the 95th percentile 682 ms, because every one of them crossed to us-east-1 through an edge proxy and back, usually several times.
  • Retries can hide a flaky proxy from users; they cannot make it not flaky, and every retry is latency.
  • The staleness window was a property of Accelerate's cache, not ours. We could avoid triggering it; we could not remove it.
  • And the platform was being sunset.

04

The decision

The obvious option was to keep Prisma Postgres and drop only Accelerate: Prisma now exposes a direct TCP connection string with its own pooler. That removes the proxy but keeps the round trip to us-east-1, and it keeps us on the platform whose direction had just been announced. The other option was to put the database where the API is.

Fly Managed Postgres is exactly that. One cluster in Toronto, reachable only on Fly's private IPv6 network, with a PgBouncer host for the apps and a direct host for migrations. The facts that mattered, verified against the real cluster before committing:

QuestionAnswer
Read replicas?No. Every plan is one primary plus a standby that only takes over on failure. It is high availability, not read scaling. We never had read replicas on Accelerate either, so nothing was lost, but "with replicas" was in the original brief and had to come out.
Multiple databases on one cluster?Yes, cheap, one command each. Users and roles are cluster-wide, which is fine for a one-developer organisation.
Postgres version17.11 on Debian. Prisma Postgres was 17.2 on Alpine. That difference turned out to matter (section 06).
Import pathA plain pg_dump and pg_restore through fly mpg proxy. The Fly user is schema_admin, not superuser: it cannot CREATE DATABASE or CREATE SUBSCRIPTION over SQL, so logical replication was never an option and databases are created with flyctl.
CostPrisma Business was $129 a month. Fly's Starter plan with HA, backups and 10 GB is about $75.

Two design decisions followed directly from "no live replication":

  1. The move would be a snapshot: freeze writes, copy, deploy, unfreeze. That means a maintenance window per app, so the tooling had to make the window as short and as boring as possible.
  2. Because a snapshot can be wrong, the copy script had to be idempotent: run it once and Fly becomes identical to Prisma; run it again later and it becomes identical to Prisma now. Not a superset, identical.

05

One rule: the URL decides

The code change was smaller than I feared, because it could be one rule applied everywhere. Prisma 7's @prisma/adapter-pg lets the client talk to a real Postgres server through the pg driver, and the Accelerate client is what we already had. Which one to build is decided by the scheme of DATABASE_URL and nothing else:

// libs/universal-nest-prisma-module/src/lib/prisma-transport.util.ts
export function resolvePrismaTransport(
  environment: NodeJS.ProcessEnv,
): PrismaTransport {
  const databaseUrl = environment.DATABASE_URL?.trim();
  if (!databaseUrl) {
    throw new Error('DATABASE_URL is not set. …');
  }
  if (isAccelerateDatabaseUrl(databaseUrl)) {
    return { mode: 'accelerate', accelerateUrl: databaseUrl };
  }
  if (isStandardPostgresUrl(databaseUrl)) {
    return { mode: 'direct-postgres', databaseUrl };
  }
  throw new Error(
    'DATABASE_URL must start with prisma+postgres:// or prisma:// … or postgresql:// or postgres:// …',
  );
}

prisma+postgres:// builds the Accelerate client exactly as before. postgresql:// builds an adapter client with the pool limits Fly asks for (five connections per machine, five-second connect timeout, connections recycled every ten minutes). Anything else refuses to boot and names the two accepted schemes. There is no mode variable to keep in sync with the URL, which means:

  • Deploying the new code changed nothing on its own. DATABASE_URL still carried the Accelerate string, and the code recognised it.
  • The cut-over per app was one secret change.
  • Rollback was the same secret changed back.

Every Prisma client in the workspace, in three Nest apps, six seed scripts, the production data patches and the Playwright fixture helpers, was moved onto that rule, with a CommonJS twin for plain Node scripts and one spec that drives both implementations through the same inputs. The cache extension sends cacheStrategy only when the client actually has $accelerate, so on Postgres the reads are simply fresh. prisma migrate deploy uses a second DIRECT_DATABASE_URL pointing at the cluster's direct host, and ignores it while on Accelerate.

The collation surprise. The first full test run against a local postgres:17 container failed one spec: List Mode's sort order. Prisma Postgres is built on Alpine, and Alpine's C library has no real locale collation, so ORDER BY name had always sorted in byte order: Daisy, Zeus, datapointDog, capitals first. Fly's Debian build (and the local container) sort in en_US.utf8 for real: Daisy, datapointDog, Zeus. Every text ORDER BY in all three apps changes at cut-over. We took it as the improvement it is and put "Lists sort the way people read" in the release notes; the spec now models the real collation with Intl.Collator('en-US', { ignorePunctuation: true }), verified against the container on 44 awkward names.

06

A sync script you can run twice

tools/scripts/db-sync/sync-prisma-to-fly.mjs does one thing per target database:

  1. pg_dump --format=custom --schema=public --no-owner --no-acl from Prisma's unpooled host (the pooled host has a ten-minute query cap; the dump of Shelter Sense takes longer than that in the daytime). Prisma's own internal schema and extension never leave.
  2. Filter the dump's table of contents so it does not try to create public, which Fly already owns.
  3. Wipe everything in the Fly database's public schema, in dependency order, skipping the monitoring extension Fly installs there and anything the Fly user does not own.
  4. pg_restore --single-transaction --exit-on-error, then ANALYZE.
  5. Fingerprint both sides (row counts, sequence values, enums, indexes, views, functions, the _prisma_migrations table) and fail on any difference.

Wiping first is what makes the result identical rather than a superset: pg_restore --clean only drops objects that are also in the dump, so a table deleted upstream would otherwise survive forever on Fly. The script was proven on a throwaway local cluster as a non-superuser that does not own public, then on the real cluster: a dry run over every database, then the three development databases synced twice to prove that wipe-and-restore converges on an already-populated target.

TargetTablesRowsDumpRestore (night of the cut-over)
shelter_sense571,701,70372.5 MiB, 39 s at night, 275 s in the daytime393 s
auth_server1381,0685.3 MiB, 14 s29 s
you_power_project514,3900.3 MiB, 13 s35 s

One thing it does not do is anything clever. Because prisma migrate deploy runs as each app's release command, the migration history moves across with the data and the release command becomes a no-op. The ORM never knew it changed databases.

07

What the tests found

The end-to-end suite runs 258 Playwright tests across four isolated API-plus-worker stacks. Moving those stacks from a hosted development database to a local Postgres container was part of this work, and it started failing one spec per run, a different one each time. Card interactions in List Mode would suddenly find their card reset to a different tab mid-test.

A Playwright trace pinned the mechanism down to the millisecond. A spec on stack 2 created an animal in the shared database. On stack 4, a spec saved a comment, which re-rendered the whole page, and the new payload carried 43 animals where the page had loaded 42. The new animal sorted into position 12, and every card below it, 31 of them, was unmounted and remounted with fresh React ids: tab back to the default, half-typed comment gone.

The cause was in our code, and it had been hiding behind Accelerate's cache. React Flight streams all but the first card as a lazy reference that has no key until the streamed row resolves, and React.Children.toArray keys such children by array index. So an animal inserted above a card changed every later card's key, and React destroyed and re-created them. On Accelerate, the stale-while-revalidate cache usually served the pre-insert list for a while, so a neighbour's new animal rarely appeared mid-interaction. On a database that answers with the truth every time, it appeared immediately. That is a real user losing their form when a colleague adds an animal, and it was fixed the same day: the list keeps the key the server gave each card and never runs streamed children through React.Children. A unit test renders Flight-style lazy children and asserts an insertion above a card keeps that card's state; an end-to-end test has a second browser add an animal while the first is typing a comment.

Image gallery, slide 1 of 2

Shelter Sense List Mode summary table listing dogs Bailey, Bentley, Buddy, Charlie and Cooper with bio-security, location, status and behaviour colour columnsThe summary table sorted the way people read, on the new collation.1 / 2

The second finding was the test infrastructure itself. Four stacks sharing one database was the source of the cross-talk, so each stack now gets its own: the database preparation step re-creates shelter_sense_e2e_1 to _4 as CREATE DATABASE … TEMPLATE copies of the seeded base before every run, about a second each, and every fixture helper resolves its database from the Playwright project it runs in. The full gate, which runs Prisma generation, lint, 3,124 unit tests, typecheck, the deploy build and the Playwright suite, went like this:

Gate runResultWhat was red
1, 2reda typecheck error and the collation spec, both fixed
3255 passed, 1 flaky, 1 failedan offline spec whose predicate hung on the fallback page, and a quick-edit flake
4256 passed, 1 flakya comment spec: the remount, caught on trace
5254 passed, 2 flaky, 1 failedthe offline replay spec and an assessment spec
6256 passed, 1 flaky, 1 failedfirst run with the fix and per-stack databases: all four stacks clean, one offline helper bug left
7258 passed, 0 flakynothing
8258 passed, 0 flakynothing

Runs 7 and 8 are the first two consecutive zero-flake gates this suite has ever produced. That was not the goal of the database migration. It is a nice thing to get from it.

08

Cut-over night, honestly

Each app got the same five steps: stage the two Fly secrets so they apply on the next deploy, scale the app to zero (there is no maintenance mode; off is the write freeze), run the sync, deploy the release, verify. Smallest app first.

AppFreezeBack upNotes
Auth Server00:2900:3581,068 rows in 29 s. Textbook.
You Power Project00:4302:12The deploy was refused by a guard I had forgotten about, and my rollback made it worse. See below.
Shelter Sense02:1502:281.7 million rows restored in 6.5 minutes, then one deploy.

The You Power Project window deserves the detail, because the lesson is transferable. The deploy failed inside the build, in a guard that refuses to build a RedwoodSDK release from a linked git worktree: rwsdk stamps client-component identity with absolute paths, and a release built anywhere but the main checkout breaks module lookup for every open tab. Correct guard, and the build was running from a worktree.

The rollback should have been trivial: scale the old release back to one machine. It was not, because Fly applies staged secrets to any machine it creates, not only to deploys. The scale-up created a machine, that machine got the new postgresql:// URL, and the old Accelerate-only image did the only thing it could:

InvalidDatasourceError: Error validating `accelerateUrl`:
the URL must start with the protocol `prisma://` or `prisma+postgres://`

It exited at boot, the proxy restarted it on the next request, and it exited again, for as long as it took me to read the log properly. It looked exactly like an Accelerate outage. It was the opposite. The way back up was a fresh clone of main in a scratch directory (a real checkout, so every guard runs and passes) and an API-only deploy from there, which brought the app up on Fly Postgres at 02:12. The runbook now says, in bold, that after staging secrets the only rollback is the un-staged Accelerate string, never a scale-up, and that you write that string down before you stage anything, because Fly will not show it back to you.

Total user-facing downtime across the three apps: about six minutes, ninety minutes, and thirteen minutes, all of it overnight in Toronto. The ninety is on me, not on the plan.

09

The difference

The comparison below is Sentry's span data for the Shelter Sense API: the seven days before the cut-over on Accelerate against the first three hours on Fly Managed Postgres. The honest caveat is that three hours of overnight traffic is not seven days of shelter working hours. The shape of the work is the same (the same bridge endpoint, the same queries per interaction); the volume and the cache warmth are not. I will update this table after a full week.

Milliseconds, from Sentry spans. The bridge call is what every List Mode click waits on.
MeasureAccelerate, 7 daysFly Postgres, first 3 hoursChange
Database span p5010.0 ms6.0 ms−40%
Database span p9595.9 ms59.0 ms−38%
Database span p99276.7 ms148.9 ms−46%
Database span mean22.4 ms14.1 ms−37%
Bridge call p50148.6 ms23.3 ms−84%
Bridge call p95681.5 ms255.4 ms−63%
Bridge call mean241.0 ms58.6 ms−76%
Spans in the window474,00038,160

The per-query numbers moved less than I expected, because Accelerate's cache was genuinely serving a lot of reads without touching Postgres. The bridge numbers moved more than I expected, because a bridge call is several queries in sequence and each one used to pay the edge-and-back tax. A List Mode click that waited 150 ms now waits 25.

The error count is the number I actually cared about:

WindowAccelerate-class errorsDistinct issues
June (from the 5th)1,73462
July63267
August21250
1–2 September, still on Accelerate53
First six hours on Fly Managed Postgres00

And the bill: $129 a month became about $75, for a database in the same room as the API.

What we gave up is worth naming. We no longer have Accelerate's query cache; the in-process read cache in the API does that job, and it was always the layer that did most of the work. We no longer have a hosted pooler; PgBouncer on the cluster plus a five-connection pool per machine replaces it. We never had read replicas, and we still do not. And we now own a Postgres, in the sense that Fly runs it and we are responsible for watching it.

10

Takeaways

What I would tell someone at the start of this

  • Put the database next to the thing that queries it. Every hop you add is a place the answer can fail to come back, and a proxy you do not run is a dependency you cannot debug.

  • When the top error names a service rather than a query, count it by month before deciding anything. June to August told the whole story; any single week would have lied.

  • Fix your own burst sources first. Retries hid the proxy from users; removing 71 of 72 bridge calls per page was the change that cut the volume by 88%.

  • Caches hide bugs. The card-remount bug had shipped months earlier and stayed invisible behind a stale-while-revalidate window. A database that tells the truth immediately found it in a day.

  • Make the transport a property of the URL. One rule, applied everywhere, meant the deploy changed nothing until a secret did, and the rollback was the same secret.

  • A migration script you can run twice is worth the extra afternoon. Wipe first, restore in one transaction, fingerprint both sides, and rehearse it on databases you can afford to lose.

  • Rehearse the rollback, not just the deploy. Staged secrets apply to created machines. I found that out at 00:47 with a client app down.

Shelter Sense runs on this now

Shelter Sense is enrichment tracking, sign-in and sign-out, and offline-first list mode for animal shelters. It is faster this week than last week, and the reason is in this post.