← Home

A Performance Team of One

This is about a strategy, not a trick. I was the only person working on performance at Sprinto while the company grew from small business customers to mid-market and above. Data sizes jumped, query patterns changed, the schema kept evolving, and teams shipping features came from different backgrounds. Not everyone had deep React or GraphQL experience, and nobody was slowing down.

So the real question was never “how do I make this page fast.” It was: how does one person fix performance across a product when you cannot optimize everything yourself, cannot ask teams to slow down, and cannot train everyone?

Here is how it actually went.

It started with the boring work

I did not start with clever infrastructure. I started with the floor: indexes that were never used (they cost every write and buy nothing), indexes that real query patterns needed but did not have, schema changes reviewed for query shape early, while changing them was still cheap.

Do not downplay this phase. It is boring and it is essential. You never know how much room you actually have until you clear every low hanging fruit, and the room turned out to be large: plenty of queries went from seconds to 10-50ms on index and query shape fixes alone. I ended up writing an internal handbook on hunting these wins systematically. (That handbook deserves to become its own article.)

The boring phase also taught me the limits. Fixing pages one at a time assumes bandwidth I did not have, and it decays: every new feature ships at the old default speed. One person cannot out-optimize an organization writing code at full speed.

The turn: change the defaults, not the people

If I could not fix every page, I could own the layer every page flows through. The plan became: build a small set of utilities that run behind the scenes and make the ordinary path fast. Engineers keep writing normal code. Performance happens to them.

Each utility exists because a stock tool had a sharp edge that engineers kept cutting themselves on.

A safer query hook. With plain useQuery, every engineer hand-rolls the same decisions: when to show a loader, when stale data is fine, what counts as an error. Multiply that by a growing team and half the product flashes loaders on data it already has. The wrapper serves the cached response instantly from an LRU cache and revalidates in the background, and returns an opinionated contract instead of raw states:

// before: every component reinvents this, often wrong
const { data, loading, error } = useQuery(PAGE_QUERY)
if (loading) return <Loader />   // flashes even when we have the data

// after: the hook decides, consistently, everywhere
const { data, shouldShowLoading, hasError } = useCacheAndNetworkQuery(PAGE_QUERY)
if (shouldShowLoading) return <Loader />   // only when there is truly nothing to show

Session-global data (org config, base page data) takes a fetchOnce flag and skips the network entirely while cached. Engineers stopped writing loading logic, which means they stopped writing wrong loading logic. Nobody using it thinks about caching. That is the point.

Background prefetching. A top-level component prefetches when it costs nothing: it waits for the shell data to resolve, then runs in requestIdleCallback, off the critical load path. It warms the navigation panels and page entry queries, dedupes in-flight requests, and can chain dependent queries off a first response.

The detail I like most: it adapts to each user, with one counter in the browser.

// every navigation bumps a counter
visits[url] = { hits: hits + 1, time: now }

// on the next session, warm that user's own top pages
prefetch(top(visits, 5))

A compliance manager who lives in the vendor pages and an engineer who lives in monitoring each get their own pages preloaded. No config, no server side profile.

A pagination hook with honest verbs. Cursor pagination through the stock client meant manual cache merges, and it mixed up three different user intentions. The hook names them: fetchMore moves pages (and cleans up cursors when you flip direction), refetch keeps your page but applies filters, resetQuery starts over. It also watches the cache only for the current variables, so a background update does not silently reset the table to page one. Every table in the product behaves the same way because they all speak the same three verbs.

Then below the frameworks

Some blocks live under the application layer, and those needed runtime surgery.

The biggest was inside graphql-js itself, the reference implementation everyone uses. Completing a large list response is one synchronous walk, and on our biggest orgs it held the event loop for 90-150ms per response. I forked the executor and swapped list completion for a chunked version. Same semantics, one addition: every 20 items it checks a clock, and past 50ms it yields to the event loop. This is the heart of it, trimmed to the mechanism:

const MAX_EVENTLOOP_BLOCK_TIME_IN_MS = 50;

const waitForNextEventCycle = () =>
  new Promise((resolve) => setImmediate(resolve));

async function completeListValueChunked(exeContext, returnType, fieldNodes, info, path, result) {
  const completedResults = [];
  const startTime = new Date().getTime();

  for (const [index, item] of Array.from(result).entries()) {
    const is20thElement = index % 20 === 0 && index > 0;
    const deltaFromStartTime = new Date().getTime() - startTime;

    if (is20thElement && deltaFromStartTime > MAX_EVENTLOOP_BLOCK_TIME_IN_MS) {
      console.warn('GraphQLExecutor::EventLoopBlock - Exceeded max execution time per cycle', {
        operationName: exeContext.operation.name?.value ?? 'Unknown',
        resolverName: info.fieldName ?? 'Unknown',
      });
      await waitForNextEventCycle();
    }

    // ...complete the item exactly as the original executor does
    completedResults.push(completeValue(exeContext, returnType.ofType, fieldNodes, info, addPath(path, index, undefined), item));
  }

  return completedResults;
}

Two details matter. The yield is setImmediate, a macrotask, because a resolved promise would drain before timers and still starve everything waiting. And the warn log names the operation and resolver, so every yield is also a report telling us which query is the next optimization target. Introspection queries stay on the original path.

Blocks dropped from 90-150ms to under 30ms. A year later the exact same budget-and-yield pattern solved event loop freezes in JSONata in our ingestion engine. Good patterns transfer.

Smaller cuts in the same spirit: request context builders moved to lazy initialization, so requests stop paying setup cost for things they never use, and hot paths reuse global objects instead of reallocating per request, which cut GC pressure.

Where it ended up

P95 went from 800-900ms to around 300ms. Event loop blocks went from 90-150ms to under 30ms. Many DB queries went from seconds to 10-50ms. With the cache and prefetch layers, the most visited pages load close to instantly.

All of this was built before Next.js 15 shipped its caching story and before TanStack Query was the obvious grab. The primitives (stale while revalidate, idle prefetch, request dedup) were always available. The work was packaging them so a growing team gets them without asking. Years later this layer is one of the most cohesive things in the product, precisely because nobody thinks about it.

What I actually learned