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.
01What 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:
| Job | How we used it |
|---|---|
| Connection pooling | The API never held a Postgres connection. Every query was an HTTP request to the edge proxy. |
| Query cache | cacheStrategy 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 budget | Interactive 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:
| Service | Fly app | Monthly Prisma operations (August) | Data |
|---|---|---|---|
| Shelter Sense API | shelter-sense | 7.5 million | 57 tables, 1.7 million rows, 72 MiB |
| Auth Server | wilk-soft-auth-server | 3.5 million | 13 tables, 81,068 rows, 5 MiB |
| You Power Project API | you-power-project-web | 85 thousand | 51 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.
02Five 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 flight | Accelerate's message | Events (90 days) |
|---|---|---|
tenant.findUnique | error communicating with your Query Engine | 427 |
tenantUser.findUnique | error communicating with your Query Engine | 241 |
user.findUnique | error communicating with your Query Engine | 162 |
user.findUnique | not able to connect to your database: Internal Server Error | 152 |
tenant.findUnique | not able to connect to your database: Internal Server Error | 108 |
tenantUser.findFirst | error communicating with your Query Engine | 106 |
tenantUser.findUnique | rate limit for this environment has been exceeded | 61 |
animal.findFirst | error communicating with your Query Engine | 50 |
tenant.findUnique | rate limit for this environment has been exceeded | 41 |
animal.findMany | error communicating with your Query Engine | 26 |
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.
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.
03What 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:
| Change | Effect |
|---|---|
| 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 read | 72 bridge calls per page became 1 |
A request-scoped membership memo, so the bridge proof and every permission check share one tenantUser read per request | roughly 3 auth queries per call became 1 |
| Retries with backoff on every small hot read, with 1102 kept on the transient list on purpose | victims 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 deadline | the two "whole page 500" issues stopped |
| Cache tags narrowed so no write can flush the global read cache | the stale-setting bug went away |
| Lazy per-card editors, one bootstrap call for List Mode | fewer 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.
04The 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:
| Question | Answer |
|---|---|
| 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 version | 17.11 on Debian. Prisma Postgres was 17.2 on Alpine. That difference turned out to matter (section 06). |
| Import path | A 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. |
| Cost | Prisma 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":
- 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.
- 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.
05One 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_URLstill 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.
06A sync script you can run twice
tools/scripts/db-sync/sync-prisma-to-fly.mjs does one thing per target database:
pg_dump --format=custom --schema=public --no-owner --no-aclfrom 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.- Filter the dump's table of contents so it does not try to create
public, which Fly already owns. - Wipe everything in the Fly database's
publicschema, in dependency order, skipping the monitoring extension Fly installs there and anything the Fly user does not own. pg_restore --single-transaction --exit-on-error, thenANALYZE.- Fingerprint both sides (row counts, sequence values, enums, indexes, views, functions, the
_prisma_migrationstable) 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.
| Target | Tables | Rows | Dump | Restore (night of the cut-over) |
|---|---|---|---|---|
shelter_sense | 57 | 1,701,703 | 72.5 MiB, 39 s at night, 275 s in the daytime | 393 s |
auth_server | 13 | 81,068 | 5.3 MiB, 14 s | 29 s |
you_power_project | 51 | 4,390 | 0.3 MiB, 13 s | 35 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.
07What 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
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 run | Result | What was red |
|---|---|---|
| 1, 2 | red | a typecheck error and the collation spec, both fixed |
| 3 | 255 passed, 1 flaky, 1 failed | an offline spec whose predicate hung on the fallback page, and a quick-edit flake |
| 4 | 256 passed, 1 flaky | a comment spec: the remount, caught on trace |
| 5 | 254 passed, 2 flaky, 1 failed | the offline replay spec and an assessment spec |
| 6 | 256 passed, 1 flaky, 1 failed | first run with the fix and per-stack databases: all four stacks clean, one offline helper bug left |
| 7 | 258 passed, 0 flaky | nothing |
| 8 | 258 passed, 0 flaky | nothing |
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.
08Cut-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.
| App | Freeze | Back up | Notes |
|---|---|---|---|
| Auth Server | 00:29 | 00:35 | 81,068 rows in 29 s. Textbook. |
| You Power Project | 00:43 | 02:12 | The deploy was refused by a guard I had forgotten about, and my rollback made it worse. See below. |
| Shelter Sense | 02:15 | 02:28 | 1.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.
09The 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.
| Measure | Accelerate, 7 days | Fly Postgres, first 3 hours | Change |
|---|---|---|---|
| Database span p50 | 10.0 ms | 6.0 ms | −40% |
| Database span p95 | 95.9 ms | 59.0 ms | −38% |
| Database span p99 | 276.7 ms | 148.9 ms | −46% |
| Database span mean | 22.4 ms | 14.1 ms | −37% |
| Bridge call p50 | 148.6 ms | 23.3 ms | −84% |
| Bridge call p95 | 681.5 ms | 255.4 ms | −63% |
| Bridge call mean | 241.0 ms | 58.6 ms | −76% |
| Spans in the window | 474,000 | 38,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:
| Window | Accelerate-class errors | Distinct issues |
|---|---|---|
| June (from the 5th) | 1,734 | 62 |
| July | 632 | 67 |
| August | 212 | 50 |
| 1–2 September, still on Accelerate | 5 | 3 |
| First six hours on Fly Managed Postgres | 0 | 0 |
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.
10Takeaways
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.




