Data is the standard fix for GraphQL's N+1 query problem. Batch your database calls per request, cache within the request lifecycle, done. But once Data is in production, you're flying blind. Which s are actually called per request? Is your cache hit rate 15% or 60%? Should your batch size be 10 or 50? APM tools tell you resolver latency, but they don't understand Data batching. I built data-ai to answer those questions. Then I tested it for real by instrumenting 95 Data instances in Open Collective's GraphQL API. The problem: invisible batching Open Collective runs one of the largest open-source GraphQL APIs on the web. Their server/graphql/s/ directory contains 96 Data instances across 20 files — s for collectives, expenses, transactions, members, comments, orders, and more. Without instrumentation, none of these questions are answerable:
- Which s fire per request? You can guess from the schema, but you don't know for sure without tracing.
- Are batches efficient? A called 20 times in a request should ideally create 1 batch of 20 — not 20 batches of 1.
- What's the cache hit rate? Data's cache is per-request, but hit rate varies wildly depending on query shape.
- Is the batch size right? Too small = more round trips. Too big = slow batches. The default is often wrong. The tool: data-ai data-ai is a drop-in wrapper for the data package. Same API, zero config:
// before
import Data from 'data'
const user = new Data(batchLoadUsers)
// after
import { DataAI } from 'data-ai'
const user = new DataAI(batchLoadUsers, { name: 'user' })
Same load() /loadMany() /clear() /prime() API. Under the hood it tracks:
- Cache hit rate per (with visual bar in terminal)
- Avg and p95 latency per batch function
- Batch efficiency (rolling sparkline of batch sizes)
- Batch-size recommendations based on a configurable latency target It prints a live report to your terminal every 5 seconds:
▲ data-ai 14:23:01
──────────────────────────────────────────────────────
user
cache [████████████████░░░░░░░░] 64.2%
avg=12.4ms p95=18.1ms batched=47 avoided=86 savings=$0.0086
batch efficiency ▄▄█▄▅█▆▅██▄▆▇
recommendation ↑ increase 10 → 12
product
cache [████████░░░░░░░░░░░░░░░░] 34.1%
avg=8.7ms p95=14.3ms batched=31 avoided=42 savings=$0.0042
batch efficiency █▄▅▄██▄▅▆▄▅
recommendation ↓ decrease 10 → 8
──────────────────────────────────────────────────────
No API key required. No account. No data leaves your machine. It works in local-first mode — the terminal output is the product. An optional cloud dashboard exists for teams who want historical trends and alerts. The experiment: Open Collective's API I forked opencollective/opencollective-api and replaced 95 of 96 Data instances with DataAI , adding a descriptive name to each:
// before
new Data(async (ids) => { ... })
// after
new DataAI(async (ids: readonly number[]) => { ... }, { name: 'collective-by-id' })
The changes were mechanical — 20 files, 397 insertions, 379 deletions. You can see the full fork PR here. What I found server/graphql/s/index.ts is the hotspot — 43 inline Data instances in a single file (1,401 lines). This is where most collective, expense, and transaction s live. If you're going to instrument anything, start here. Named s make debugging 10x easier. Before, every was an anonymous new Data(fn) . After, each one has a name like collective-by-slug , expense-attached-files , or tier-total-donated . When the terminal report prints, you immediately know which is slow or under-batching. The readonly array pattern matters. DataAI tracks batch efficiency by counting keys per batch call. TypeScript's readonly number[] (vs number[] ) makes this explicit — the batch function receives an immutable snapshot of keys. One stayed vanilla. The buildForAssociation helper in helpers.ts is a generic utility that creates s dynamically — it's not a named, domain-specific . It's the right call to leave it as-is rather than add a generic name that doesn't tell you anything. How the recommendation engine works This is not ML. It's honest heuristics, and I want to be transparent about that. The BatchSizeOptimizer maintains a rolling window of batch latencies (default: last 20 batches). Every 5 batches, it checks:
- If avg latency < 70% of target → increase batch size by 20% (you have headroom)
- If avg latency > 130% of target OR p95 > 200% of target → decrease by 20% (you're over)
- Otherwise → hold (near-optimal) The default target is 50ms. If your batch function averages 12ms and your target is 50ms, the recommendation is: "you can safely batch more keys per call — increase from 10 to 12." That's a 20% reduction in round trips with zero risk. This is transparent. You can see exactly why each recommendation is made. You can configure the target latency, min/max batch size, and window size. No black box. A realistic example The SDK ships with a realistic ecommerce example — an Apollo Server with 5 DataAI s (users, products, categories, reviews, orders) and a load-test script that fires 5 different query patterns. Run it:
git clone https://github.com/currentlybuffering/data-ai
cd data-ai/src/examples/realistic-ecommerce
npm install
node index.ts
node load-test.ts
The terminal report shows all 5 s with live metrics. The orders (15-35ms simulated DB latency) consistently gets "increase batch size" recommendations. The category (3-7ms) holds steady. The reviews shows the most cache-hit variance because review queries overlap differently per request pattern. What this means for your GraphQL server If you're running Data in production: Add names to your s. Even if you don't use data-ai, naming your s makes debugging dramatically easier. Just add a name property to your Data options.Check your batch efficiency. Are you getting 1 batch of N keys, or N batches of 1 key? If resolvers call .load() late in the cycle (after awaits), Data can't batch them.Measure cache hit rate per query. A query that fetches the same user 5 times in one request should have 80% cache hit rate on the user . If it's 0%, something is wrong with your per-request cache lifecycle. Tune batch sizes to your actual latency. The default maxBatchSize in Data isInfinity . Most teams set it to something arbitrary (10, 50, 100) without measuring. Use your actual batch function latency to pick the right value. Try it
npx data-ai demo
No install, no account, no API key. The demo simulates a GraphQL server and prints live metrics to your terminal. For your own server:
npm install data-ai
Then swap Data → DataAI with a name option. That's it.
- Local mode: free forever, terminal metrics, no data leaves your machine
- Cloud dashboard: free during beta, historical trends + alerts
- SDK: MIT-licensed, on GitHub, on npm (1,400+ downloads/month) I'm the solo developer behind data-ai. Built it because I kept running into the same observability gap in GraphQL servers. Would love feedback from anyone running Data in production.