Skip to content
EK

Search

NayaOne · Senior Full-Stack Engineer · 2023–2024

Killing a GraphQL N+1 that threatened Tier 1 bank renewals

  • 80+ queries refactored
  • 250ms → 75ms (−70%)
  • 300+ daily users

Refactored 80+ GraphQL queries with raw SQL joins and batched cross-database resolvers, cutting response times 70% — 250ms to 75ms — for 300+ daily users.

GraphQL · PostgreSQL · Prisma · Node.js

Context

NayaOne runs an enterprise SaaS platform for 80+ financial institutions — Lloyds Banking Group, Barclays and other Tier 1 banks use it to evaluate fintech vendors inside FCA-regulated cloud environments. The dashboard those institutions log into every day was built on a GraphQL API over PostgreSQL.

Problem

Page loads had degraded to the point where Tier 1 clients raised performance during contract renewal conversations. Profiling showed the classic GraphQL failure mode at scale: nested resolvers firing one query per row. A single dashboard view could trigger hundreds of database round trips, and response times sat around 250ms on the hot paths — with worst cases far beyond that.

Constraints

  • The API surface was live and contractually relied on — no breaking schema changes.
  • Multi-tenant data isolation had to survive every rewrite; a fast query that leaks another tenant’s rows is worse than a slow one.
  • The work had to land incrementally alongside normal feature delivery, not as a big-bang rewrite.

My role

I owned the investigation and the fix end-to-end: profiling, prioritizing which resolvers mattered, rewriting the data access layer, and verifying the results against production traffic patterns.

Approach

I traced the worst dashboard views to their resolver chains and found 80+ queries worth rewriting. One architectural fact shaped the whole fix: the platform runs on two PostgreSQL databases — a shared one for platform-wide entities and a separate one per tenant — and the hottest relations crossed that boundary, where no ORM join exists.

The rewrite was two-layered. Aggregation-heavy paths, where the ORM generated pathological query plans, dropped to raw SQL joins. Cross-database relations got hand-rolled batching: fetch a page of results with one deep nested select, collect the related IDs across the whole page, resolve them with a single IN query against the other database, and stitch the results together in memory. Hundreds of per-row round trips collapsed into a handful of queries per request, and each rewritten resolver kept its GraphQL contract byte-identical — clients never knew anything changed.

Key decisions

  • Raw SQL where it counts, ORM everywhere else. Dropping to SQL for 80+ hot queries bought the performance without giving up Prisma’s safety on the long tail of CRUD paths.
  • Batch per page, not per row. The IN-list that resolves cross-database relations is built only from the current page of results, so it stays bounded no matter how deep the query goes — and tenant filters live inside the same query, so batching can never cross a tenant boundary.
  • Make expensive relations opt-in. The heaviest nested data is fetched only when a client explicitly asks for it — most requests never pay for fan-out they don’t render.
  • Ship optimized resolvers alongside the old ones. Each rewrite landed as a parallel resolver, clients migrated at their own pace, and the old paths were retired after — additive rollout instead of in-place rewrite of live contract surface.
  • Measure per resolver, not per page. Page-level timings hide which of thirty resolvers is the problem; per-resolver tracing made the priority list obvious.

Trade-offs

Raw SQL is a maintenance liability — it bypasses the ORM’s type safety and has to be reviewed harder. I limited it to queries where the measured win justified it and kept the rest on the ORM, accepting two data-access styles in one codebase as the price of the result.

I implemented page-level batching directly in the repository layer, keeping cross-database stitching and tenant filtering explicit. The trade-off was maintaining that batching code ourselves.

Result

Response times on the affected paths dropped 70%, from ~250ms to ~75ms, for 300+ daily enterprise users. The performance complaints disappeared from renewal conversations — which was the actual goal; the milliseconds were just the mechanism.

What I’d do differently

Add per-resolver latency budgets to CI early, before the slow paths accumulate. The N+1s didn’t appear overnight — they crept in one innocent resolver at a time, and a regression gate would have caught them years earlier.

Related case studies

Want to talk about a role or a system like these?