Optimizing GraphQL Resolvers: N+1, Authorization, and Testing

Imagine your API processes a request for a list of articles. Each article loads the author and comments. Without batching, this turns into (1 query for the list + N queries for authors + M queries for comments). With N=100 and M=50, you get 151 queries instead of 3. DataLoader reduces this to 3 quer

Development and maintenance of all types of websites:

Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:

Frequently Asked Questions

Latest works

  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1281
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1237
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    977
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    1027
  • image_website-sbh_0.webp
    Website development for SBH Partners
    1103
  • image_website-_0.webp
    Website development for Red Pear
    550

Imagine your API processes a request for a list of articles. Each article loads the author and comments. Without batching, this turns into (1 query for the list + N queries for authors + M queries for comments). With N=100 and M=50, you get 151 queries instead of 3. DataLoader reduces this to 3 queries — a 10x time savings. Under high load, infrastructure cost savings can be significant. In one media platform project, we cut response time from 4 seconds to 200 ms by applying DataLoader and pagination.

We've encountered projects where naive resolver implementations caused API response times to drop to 10 seconds. Each user request generated dozens of SQL queries — the N+1 problem in action. Field-level authorization was absent, leading to data leaks. Here's how we fix it: use DataLoader, contextual authorization, and tests. Our background includes more than 50 GraphQL projects, including high-load systems.

Why N+1 Optimization Is Critical

The N+1 problem is the primary enemy of GraphQL performance. When a resolver for a list of objects triggers a separate database query for each element, response time grows exponentially. DataLoader solves this but requires correct integration.

Technique Description Performance Complexity
DataLoader Batching + caching per request High Medium
Batch SQL (IN clause) Query with IN or JOIN High Low
Pagination + depth limiting Limit query depth Medium Low
Naive approach N+1 queries Low Low
Scenario Response time (100 articles) Number of queries
Naive 1500 ms 151
DataLoader 150 ms 3
Batch SQL 120 ms 3

DataLoader is 10-50x better than the naive approach in response time depending on nesting depth. Combining DataLoader with pagination yields even greater gains on high-load projects.

How DataLoader Solves the N+1 Problem

DataLoader is a utility for batching and caching requests. It groups multiple database calls into a single query, significantly reducing load. It's crucial to create a new DataLoader instance per request to avoid cross-user caching. DataLoader is a generic utility to be used as part of your application's data fetching layer to provide a simplified and consistent API over various backends and reduce requests to those backends via batching and caching. Learn more about DataLoader on the project page.

const resolvers = { Query: { user: async (parent, { id }, context) => { if (!context.user) throw new AuthenticationError('Not authenticated'); return context.db.users.findById(id); }, posts: async (parent, { limit, offset }, context) => { return context.db.posts.findAll({ limit, offset }); } }, User: { posts: async (parent, args, context) => { return context.loaders.postsByUserId.load(parent.id); } }, Post: { author: async (parent, args, context) => { return context.loaders.userById.load(parent.id); }, comments: async (parent, args, context) => { return context.loaders.commentsByPostId.load(parent.id); } } }; 

Why Create DataLoader Per Request?

Using a single DataLoader instance for all users can lead to data leakage: one user's cache might be served to another. Therefore, we create a new DataLoader in each request context, guaranteeing isolation.

Context and Dependency Injection

The request context is built per request: authentication, DataLoader creation, database passing.

import { ApolloServer } from '@apollo/server'; import { DataloaderRegistry } from './dataloaders'; const server = new ApolloServer({ typeDefs, resolvers }); app.use('/graphql', expressMiddleware(server, { context: async ({ req }) => { const token = req.headers.authorization?.replace('Bearer ', ''); const user = token ? await verifyToken(token) : null; const loaders = new DataloaderRegistry(db); return { user, db, loaders, req }; } })); 

Field-Level Authorization

Permission checks in each resolver using helper functions.

function requireAuth(context) { if (!context.user) { throw new GraphQLError('Not authenticated', { extensions: { code: 'UNAUTHENTICATED' } }); } } function requireRole(context, role) { requireAuth(context); if (!context.user.roles.includes(role)) { throw new GraphQLError('Forbidden', { extensions: { code: 'FORBIDDEN' } }); } } // Usage in mutation const resolvers = { Mutation: { deletePost: async (parent, { id }, context) => { requireAuth(context); const post = await context.db.posts.findById(id); if (!post) throw new UserInputError('Post not found'); if (post.author_id !== context.user.id && !context.user.roles.includes('admin')) { throw new GraphQLError('Cannot delete others\' posts', { extensions: { code: 'FORBIDDEN' } }); } await context.db.posts.delete(id); return { success: true }; } } }; 

How to Test Resolvers?

Tests protect against regressions when changing schema and business logic. We cover each resolver with unit tests, isolating the database and DataLoader using mocks. We also add integration tests to verify resolver collaboration.

describe('Post resolvers', () => { const mockDb = { posts: { findById: jest.fn(), delete: jest.fn() } }; const mockContext = (overrides = {}) => ({ user: { id: '1', roles: ['user'] }, db: mockDb, loaders: { userById: { load: jest.fn() } }, ...overrides }); it('deletePost: owner can delete', async () => { mockDb.posts.findById.mockResolvedValue({ id: '1', author_id: '1' }); mockDb.posts.delete.mockResolvedValue(true); const result = await resolvers.Mutation.deletePost(null, { id: '1' }, mockContext()); expect(result).toEqual({ success: true }); }); it('deletePost: non-owner gets FORBIDDEN', async () => { mockDb.posts.findById.mockResolvedValue({ id: '1', author_id: '99' }); await expect( resolvers.Mutation.deletePost(null, { id: '1' }, mockContext()) ).rejects.toMatchObject({ extensions: { code: 'FORBIDDEN' } }); }); }); 

Why Error Handling Matters

Errors must be informative but not reveal database details in production. Apollo Server allows configuring error formatting via formatError.

import { GraphQLError } from 'graphql'; import { ApolloServerErrorCode } from '@apollo/server/errors'; const resolvers = { Mutation: { createPost: async (parent, { input }, context) => { requireAuth(context); if (!input.title?.trim()) { throw new GraphQLError('Title is required', { extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } }); } try { return await context.db.posts.create({ ...input, author_id: context.user.id }); } catch (err) { console.error('DB error:', err); throw new GraphQLError('Internal server error', { extensions: { code: 'INTERNAL_SERVER_ERROR' } }); } } } }; // Formatting in production const server = new ApolloServer({ typeDefs, resolvers, formatError: (formattedError, error) => { if (process.env.NODE_ENV === 'production' && formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') { return { message: 'Internal server error' }; } return formattedError; } }); 

Our Process

  1. GraphQL schema analysis — identify types, relationships, bottlenecks.
  2. Resolver design considering access granularity.
  3. Implementation with DataLoader, authorization, and error handling.
  4. Test coverage (unit + integration).
  5. Documentation and deployment with CI/CD.

What's Included

  • Source code for resolvers with tests (Jest).
  • API documentation (GraphQL SDL + README).
  • CI/CD setup (GitHub Actions, Docker).
  • Team training on working with resolvers.

Timeline and Pricing

Development timeline — from 2 to 5 business days depending on schema complexity. Pricing is calculated individually. Get an engineer consultation — we'll analyze your GraphQL schema and propose the optimal solution. Order a resolver audit — it takes no more than an hour. Contact us for your project assessment.