import DataLoader from "dataloader";
import { Pool } from "pg";
export interface User { id: number; name: string; }
export interface Post { id: number; user_id: number; title: string; }
export function createLoaders(db: Pool) {
const userById = new DataLoader<number, User | null>(async (ids) => {
const { rows } = await db.query<User>(
"SELECT id, name FROM users WHERE id = ANY($1)",
[ids as number[]]
);
const byId = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => byId.get(id) ?? null);
});
const postsByUser = new DataLoader<number, Post[]>(async (userIds) => {
const { rows } = await db.query<Post>(
"SELECT id, user_id, title FROM posts WHERE user_id = ANY($1) ORDER BY id",
[userIds as number[]]
);
const grouped = new Map<number, Post[]>();
for (const row of rows) {
const bucket = grouped.get(row.user_id) ?? [];
bucket.push(row);
grouped.set(row.user_id, bucket);
}
// length + order must match the input keys
return userIds.map((id) => grouped.get(id) ?? []);
});
return { userById, postsByUser };
}
export type Loaders = ReturnType<typeof createLoaders>;
import { Pool } from "pg";
import { createLoaders, Loaders } from "./loaders";
const db = new Pool({ connectionString: process.env.DATABASE_URL });
export interface GraphQLContext {
db: Pool;
loaders: Loaders;
}
// Called once per request so the loader cache never crosses requests.
export function buildContext(): GraphQLContext {
return {
db,
loaders: createLoaders(db),
};
}
import { GraphQLContext } from "./context";
import { User, Post } from "./loaders";
export const resolvers = {
Query: {
users: async (_root: unknown, _args: unknown, ctx: GraphQLContext): Promise<User[]> => {
const { rows } = await ctx.db.query<User>("SELECT id, name FROM users LIMIT 50");
return rows;
},
user: (_root: unknown, args: { id: number }, ctx: GraphQLContext): Promise<User | null> => {
return ctx.loaders.userById.load(args.id);
},
},
User: {
// one batched query for all users in the list, not one per user
posts: (user: User, _args: unknown, ctx: GraphQLContext): Promise<Post[]> => {
return ctx.loaders.postsByUser.load(user.id);
},
},
Post: {
author: (post: Post, _args: unknown, ctx: GraphQLContext): Promise<User | null> => {
return ctx.loaders.userById.load(post.user_id);
},
},
};
A classic GraphQL performance trap is the N+1 problem: resolving a list of User objects and then resolving each user's posts field fires one query per user. The DataLoader pattern fixes this by deferring individual loads within a single tick of the event loop, collecting all the keys, and dispatching one batched query. This snippet shows how the pattern is wired end to end across three collaborating files.
In loaders.ts, each DataLoader is given a batch function that receives an array of keys and must return an array of results in the exact same order. postsByUserLoader groups a single SELECT ... WHERE user_id = ANY($1) result back into per-user buckets, ensuring that even users with no posts get an empty array — DataLoader requires the output length to match the input length, so any missing key must resolve to a value, not be dropped. userByIdLoader demonstrates the more common case of mapping unique rows keyed by id, with a Map lookup so the ordering guarantee is trivially satisfied and absent ids resolve to null.
The critical detail lives in context.ts: createLoaders() is invoked once per HTTP request. Loaders cache by key, so building them per-request scopes that cache to a single operation and avoids serving stale data across requests. Sharing a loader globally would leak one user's cached rows into another user's response, which is why the loaders are attached to the per-request context object rather than constructed as module singletons.
In resolvers.ts, the User.posts resolver simply calls context.loaders.postsByUser.load(user.id). Each call returns a promise immediately without touching the database; DataLoader coalesces every .load() made in the same tick into one batch call. So resolving fifty users triggers exactly one posts query instead of fifty.
The trade-offs are worth noting: the batch function must preserve order and length, the request-scoped cache means writes during a request won't be reflected unless .clear() is called, and DataLoader only batches within a synchronous tick, so awaiting between loads defeats the coalescing. Used correctly it turns quadratic query counts into a small constant.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = false
Bullet.bullet_logger = true
Bullet.console = true
N+1 query detection with Bullet gem
Share this code
Here's the card — post it anywhere.