Scaling GraphQL APIs with NestJS: Lessons from Real Projects
Practical patterns for keeping NestJS GraphQL APIs fast, modular, and predictable as product complexity and traffic grow.

The clinic management product was running fine at ten concurrent users. At fifty, the appointment list endpoint started hitting 800ms. At a hundred, it was timing out. The schema looked reasonable. The resolvers were clean. The problem was that each appointment in the list was triggering its own database query to fetch the attending doctor, then another for the patient, then another for the invoice status. A list of thirty appointments meant ninety-plus queries on every page load.
GraphQL makes it easy for frontends to ask for exactly what they need. It also makes it easy to accidentally build systems where "exactly what they need" translates to a waterfall of database round-trips. Here is what we do to stay ahead of that.
Design the schema around product language, not database tables
The schema is a contract with your frontend teams. If it maps one-to-one to your database tables, it will leak implementation details and resist change. If it maps to product concepts — what the user actually sees and does — it stays useful as the backend evolves.
An appointment in a clinic product is not just a row with a patientId and doctorId. It is a scheduling event with an attending physician, a patient profile, a current status, and a linked invoice. Model that in the schema:
type Appointment {
id: ID!
startsAt: DateTime!
status: AppointmentStatus!
doctor: Doctor!
patient: Patient!
invoice: Invoice
notes: String
}
When the database schema changes — maybe doctor data moves to a different microservice — the GraphQL type stays stable and only the resolver changes.
Use DataLoader for every relationship that lists resolve
The N+1 problem in GraphQL is structural, not a code quality issue. When a resolver for Doctor runs once per Appointment in a list, you always get N+1 queries. DataLoader solves this by batching calls that arrive in the same event loop tick.
In NestJS, make DataLoaders request-scoped so cached values from one user's request never leak to another:
// src/loaders/patient.loader.ts
@Injectable({ scope: Scope.REQUEST })
export class PatientLoader {
loader: DataLoader<string, Patient | null>;
constructor(private readonly prisma: PrismaService) {
this.loader = new DataLoader(async (ids: readonly string[]) => {
const patients = await this.prisma.patient.findMany({
where: { id: { in: [...ids] } },
});
const byId = new Map(patients.map((p) => [p.id, p]));
return ids.map((id) => byId.get(id) ?? null);
});
}
}
// In the resolver:
@ResolveField(() => Patient)
patient(
@Parent() appointment: Appointment,
@Context() ctx: { loaders: { patient: PatientLoader } },
) {
return ctx.loaders.patient.loader.load(appointment.patientId);
}
With DataLoader, fetching thirty appointments with their patients costs two queries: one for the appointments, one for all referenced patients. Without it, it costs thirty-one.
When DataLoader is not enough
DataLoader batches by ID. If your relationship needs a more complex query — "fetch the latest invoice for each appointment where status = 'unpaid'" — batching requires a slightly different approach: group by appointment IDs, run one query with WHERE appointmentId IN (...), then map results back.
Keep mutations thin — push logic into services
A mutation resolver should do four things: validate input, authorize the request, call an application service, and return a result. Business logic does not belong in resolvers.
@Mutation(() => AppointmentPayload)
async createAppointment(
@Args("input") input: CreateAppointmentInput,
@CurrentTenant() tenant: Tenant,
@CurrentUser() user: User,
) {
await this.authz.assertCanCreate(user, "Appointment");
return this.appointmentService.create({ tenantId: tenant.id, input });
}
This pattern matters more than it looks. When the same workflow needs to run from a cron job, a webhook, or an admin CLI, the service is already reusable. The resolver is just a transport layer.
Add query complexity and depth limits before external clients arrive
An unrestricted GraphQL endpoint can be asked for deeply nested data that causes exponential database load. This matters less for internal products with controlled frontends, but it matters immediately once external clients or a public API exists.
// In your NestJS GraphQL module config:
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
validationRules: [
depthLimit(7),
createComplexityLimitRule(1000, {
onCost: (cost) => logger.debug({ cost }, "query complexity"),
}),
],
}),
Set the limits based on real operation complexity. A too-low limit breaks legitimate queries; a too-high limit is useless. Start permissive and tighten once you have data on what real operations cost.
Persisted queries reduce surface area in production
For products with a controlled frontend (a React app you own), persisted queries remove the ability to send arbitrary GraphQL from the browser. The client registers allowed operations at build time; the server rejects anything else.
This is not a primary security control, but it meaningfully reduces the blast radius of a misconfigured authorization rule and makes your API surface much smaller to audit.
Make expensive operations visible
Average latency is the least useful metric in a GraphQL API. One cheap query and one expensive query both appear in the average.
Log per-resolver timing, operation name, selected fields, and database query count for every request. A structured log entry that looks like:
{
"operation": "ListAppointments",
"durationMs": 87,
"dbQueryCount": 3,
"tenantId": "t_abc123",
"selectedFields": ["id", "startsAt", "doctor", "patient"]
}
…tells you immediately when a new field is being resolved in a way that adds database load. Without field-level logging, you discover expensive resolvers when they hit users, not when they land in a PR.
Module organization: one NestJS module per domain area
A single AppModule that imports everything works until your schema passes a few hundred types. After that, circular injection errors and long cold-start times become painful.
Group resolvers, services, and loaders by product domain — appointments, patients, billing, auth — each in its own NestJS module with explicit imports. Shared infrastructure (Prisma, auth, caching) lives in a separate set of provider modules imported where needed.
This mirrors how the product is actually organized, which makes it easier to hand a module to a new engineer or to split services later if traffic demands it.
NestJS GraphQL scales well when the schema reflects product concepts, loaders eliminate N+1 access patterns, mutations stay thin, and expensive operations are visible before they surprise you in production.
For the data layer underneath this API, see Multi-Tenant SaaS with PostgreSQL and Prisma.