GraphQL with Apollo Server in Node.js
GraphQL API with Apollo Server in Node.js: schema definition, resolvers, queries, mutations, subscriptions, data loaders for N+1 prevention, and production deployment..
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
- ✓Basic Node.js and JavaScript knowledge. Familiarity with npm and Express.js is recommended.
Apollo Server is a production-ready GraphQL server for Node.js that implements the GraphQL specification. It provides schema definition via the graphql-tag or the newer graphql-js v17 schema builder,
Imagine you're at a buffet where you can ask for exactly the dishes you want, in the portions you want, instead of being served a fixed platter. GraphQL is that buffet: the client asks for specific data fields, and the server serves only that, no more, no less. Apollo Server is the chef who prepares those custom orders efficiently.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
A REST API endpoint returns user data with 20 fields, but your frontend only needs 3. The extra data slows down the page, wastes bandwidth, and increases backend load. GraphQL solves over-fetching and under-fetching by letting the client specify exactly which fields it needs. Apollo Server is the most popular GraphQL implementation for Node.js, used by major companies including Airbnb, Netflix, and Shopify. This article covers building a GraphQL API from scratch, designing schemas, writing resolvers, implementing DataLoader to prevent N+1 queries, and adding real-time subscriptions.
Why Apollo Server for GraphQL in Production
Apollo Server is the de facto standard for running GraphQL in Node.js production environments. It provides a unified interface for schema definition, resolver composition, and middleware integration. Unlike raw express-graphql, Apollo Server offers built-in support for persisted queries, response caching, error masking, and federation. In production, you need these features to handle traffic spikes, prevent accidental data leaks, and maintain observability. Apollo Server's plugin system allows you to inject logging, metrics, and custom error handling without cluttering your resolvers. For example, you can use the ApolloServerPluginUsageReporting to track field-level performance. The trade-off is a slightly heavier dependency, but the operational benefits far outweigh the cost. Always start with Apollo Server for any serious GraphQL API.
@apollo/server package. Avoid the deprecated apollo-server package which is no longer maintained.stop() method is critical in graceful shutdowns.Schema Design: Type Safety and Validation
Your GraphQL schema is the contract between client and server. In production, a poorly designed schema leads to breaking changes and client errors. Use the Schema Definition Language (SDL) to define types, inputs, and enums explicitly. Always use non-nullable fields (!) for required data to avoid unexpected nulls. For mutations, define input types to group arguments. Leverage custom scalars (e.g., DateTime, JSON) for consistency. Validate your schema at startup using graphql-constraint-directive or custom directives. For example, enforce string length limits on user inputs. Avoid exposing internal IDs; use opaque global IDs (e.g., base64-encoded) for relay-style pagination. Schema-first development forces you to think about the API contract before implementation, reducing rework.
graphql-constraint-directive to enforce input validation at the schema level, reducing resolver boilerplate.Resolvers: Composition and Data Fetching
Resolvers are the heart of your GraphQL server. Each field can have a resolver that fetches data from any source. In production, resolvers must be efficient and resilient. Use DataLoader to batch and cache database queries, preventing the N+1 problem. Structure resolvers to be thin: delegate complex logic to service layers. For example, a user resolver calls a UserService.getById which uses DataLoader. Always handle errors gracefully: return null for nullable fields and throw GraphQLError for errors you want to expose. Use resolver chains to compose data: parent resolvers pass context to child resolvers. Avoid deep nesting that causes multiple round trips. Implement field-level authorization using a schema directive or a middleware that checks permissions before resolving.
GraphQLError with custom extensions to control what is shown.Authentication and Authorization in Resolvers
Securing your GraphQL API is non-negotiable. Authentication verifies who the user is; authorization determines what they can do. In Apollo Server, use the context function to extract user info from request headers (e.g., JWT). Then, in resolvers, check permissions before returning data. For fine-grained control, implement a custom @auth directive that wraps resolvers. This keeps authorization logic declarative and testable. Avoid putting auth logic inside resolvers; it leads to duplication and security gaps. Use role-based access control (RBAC) or attribute-based access control (ABAC) depending on complexity. For public fields, allow unauthenticated access. Always validate token expiry and signature. In production, use a dedicated auth service (e.g., Auth0) and cache token validation results.
Error Handling and Error Masking
GraphQL errors can leak stack traces or internal details if not handled properly. Apollo Server provides a formatError function to mask errors before sending them to clients. In production, never expose stack traces. Use a custom error class that extends GraphQLError with a code extension for client-side error handling. For unexpected errors, log the full error server-side and return a generic 'Internal server error' to the client. Implement a plugin to capture error metrics (e.g., error rate by field). Use ApolloServerPluginInlineTrace for tracing. For partial errors (e.g., one resolver fails), GraphQL still returns partial data; ensure your client handles that gracefully.
Performance: Caching and Persisted Queries
GraphQL APIs can be expensive if not optimized. Use response caching at the HTTP level (e.g., CDN) for public queries. For private data, use Apollo's @cacheControl directive to set max-age and scope. Implement persisted queries (APQ) to reduce request size and allow caching of query strings. Apollo Server supports APQ out of the box. For complex queries, use query depth limiting and cost analysis to prevent abusive queries. Use DataLoader for batching as mentioned. Monitor query performance with Apollo Studio or custom plugins. In production, set reasonable timeouts per resolver to avoid hanging requests.
persistedQueries: true in Apollo Server options. This reduces request size and allows CDN caching.Subscriptions: Real-Time Data with WebSockets
GraphQL subscriptions enable real-time updates via WebSockets. Apollo Server integrates with graphql-ws for production-grade subscriptions. Subscriptions are useful for live notifications, chat, or data feeds. However, they add complexity: you need to manage WebSocket connections, handle reconnection, and secure subscription channels. Use a pub/sub system (e.g., Redis) to scale subscriptions across multiple server instances. Always authenticate subscription connections in the onConnect callback. Avoid sending sensitive data over subscriptions without encryption. In production, monitor WebSocket connections and implement rate limiting to prevent abuse.
Federation: Scaling with Microservices
As your API grows, a monolithic GraphQL schema becomes unmanageable. Apollo Federation allows you to split your schema across multiple services (subgraphs) and compose them into a single graph. Each subgraph defines its own types and resolvers, and the gateway (Apollo Router or @apollo/gateway) stitches them together. Federation is production-proven at scale. However, it introduces complexity: you need to manage entity references, resolve types across services, and handle partial failures. Use the @key directive to define entities. In production, use the Apollo Router (Rust-based) for better performance than the JavaScript gateway.
Testing: Unit, Integration, and End-to-End
Testing a GraphQL API requires multiple layers. Unit test resolvers in isolation by mocking data sources. Integration test the full GraphQL execution with a test server. Use @apollo/server's executeOperation for serverless testing. For end-to-end tests, run a real server and send queries via HTTP. Always test error paths: invalid inputs, authentication failures, and resolver errors. Use snapshot testing for schema changes. In production, run tests in CI and enforce coverage thresholds. Mock external services to avoid flaky tests. Use graphql-tools' mockServer for schema-based mocking.
@graphql-tools/mock to generate realistic mock data for integration tests without a real database.Monitoring and Observability
Production GraphQL APIs need monitoring beyond HTTP status codes. Use Apollo Studio for field-level tracing and error tracking. Implement custom plugins to log query complexity, resolver timing, and cache hit rates. Use OpenTelemetry for distributed tracing across microservices. Monitor WebSocket connections for subscriptions. Set up alerts for high error rates, slow queries, and schema changes. In production, log every query (with sensitive fields redacted) for debugging. Use structured logging (e.g., JSON) to integrate with log aggregation tools.
Deployment and CI/CD
Deploying a GraphQL server requires careful consideration of schema changes. Use schema registry (Apollo Studio) to detect breaking changes before deployment. In CI, run schema checks against the production schema. Use blue-green deployments to avoid downtime. Containerize your server with Docker and use orchestrators like Kubernetes. Set environment-specific configuration (e.g., database URLs, JWT secrets) via environment variables. Use health checks (/.well-known/apollo/server-health) for load balancers. In production, enable compression and set reasonable body size limits.
rover graph check in CI to detect breaking schema changes before merging. A breaking change can cause client errors.Security: Rate Limiting and Depth Limiting
GraphQL APIs are vulnerable to abusive queries that can overload your server. Implement query depth limiting to prevent deeply nested queries. Use query cost analysis to limit the complexity of each request. Apollo Server plugins like graphql-query-complexity can enforce cost limits. Rate limit by IP or user ID to prevent brute force attacks. Use graphql-rate-limit-directive for field-level rate limiting. Always validate input sizes and types. In production, set conservative limits and monitor for abuse. Use a Web Application Firewall (WAF) to block malicious requests.
DataLoader and N+1 Prevention
The N+1 problem is a common performance pitfall in GraphQL where resolving a list of entities triggers a separate database query for each item. DataLoader batches and caches requests within a single request cycle, reducing database round trips. Install DataLoader and create a loader per request context. For example, when fetching authors for a list of posts, a naive resolver calls findAuthor(post.authorId) for each post. With DataLoader, you define a batch function that loads all authors by their IDs in one query. DataLoader deduplicates and batches requests, then caches results for the duration of the request. Always create a new DataLoader instance per request to avoid stale data. Use DataLoader with any data source: SQL, REST, or microservices. This pattern is essential for production GraphQL APIs.
GraphQL Code Generator Setup
GraphQL Code Generator (graphql-codegen) automates TypeScript type generation from your GraphQL schema. It eliminates manual type definitions and ensures type safety across resolvers, hooks, and operations. Install @graphql-codegen/cli and configure codegen.yml to point to your schema and documents. Generate types for resolvers (Resolver type wrappers), React hooks (via @graphql-codegen/typescript-react-apollo), and even mock data. The generator reads your .graphql files and outputs TypeScript interfaces, enums, and operation types. For resolvers, use the generated Resolvers type to enforce correct return shapes. Integrate codegen into your build pipeline to regenerate types on schema changes. This reduces runtime errors and improves developer experience.
Depth Limiting and Cost Analysis
GraphQL queries can be deeply nested, leading to expensive database joins or excessive data fetching. Depth limiting restricts the maximum nesting level, while cost analysis assigns a cost to each field and rejects queries exceeding a budget. Use graphql-depth-limit to enforce a max depth (e.g., 7 levels). For cost analysis, libraries like graphql-query-cost or graphql-validation-complexity allow you to define per-field costs and a global limit. Implement these as Apollo Server plugins or validation rules. For example, set a default cost of 1 per field, but increase cost for expensive fields (e.g., avatar at 5). Reject queries with total cost > 1000. This prevents resource exhaustion and DoS attacks. Combine with query whitelisting for critical endpoints.
Apollo Connectors and REST Integration
Apollo Connectors (part of Apollo GraphOS) allow you to connect REST APIs to your GraphQL schema without writing resolvers. Define a connector in the schema using @connect directive. This is useful for integrating legacy REST endpoints. For example, connect a /users/:id REST endpoint to a User type. Apollo Server resolves the field by calling the REST API automatically. This reduces boilerplate and centralizes API logic. Connectors support headers, caching, and error handling. They are ideal for gradual GraphQL adoption. However, for complex transformations, custom resolvers are still needed. Use connectors for simple CRUD operations and custom resolvers for business logic.
File Uploads in GraphQL
GraphQL file uploads are handled via the multipart request specification. Use the graphql-upload package with Apollo Server. Define a scalar Upload in your schema. In resolvers, the Upload scalar provides a promise that resolves to a file stream. Process the stream (e.g., save to S3 or local disk). For production, stream directly to cloud storage to avoid memory issues. Validate file size and type before processing. Use middleware like express-graphql-upload for Express. Apollo Server 4 supports uploads natively with the @apollo/server package. Ensure your client sends multipart requests (e.g., using apollo-upload-client).
Cursor Pagination
Cursor pagination is the recommended pagination pattern for GraphQL APIs. Unlike offset pagination, it is stable and efficient for large datasets. Use a cursor (opaque string) to paginate through results. Implement Relay-style pagination with edges and pageInfo. In resolvers, fetch one extra item to determine hasNextPage. Encode the cursor (e.g., base64 of the ID). For example, to paginate posts, query posts after a cursor. Use database pagination (e.g., WHERE id > decodedCursor) for performance. Return edges with cursor and node. This pattern scales well and avoids skipping items when data changes.
Apollo Server Crash Due to Unbounded Recursive Resolvers
- Always enforce query depth and complexity limits in production GraphQL APIs.
- Default protections in Apollo Server are minimal; you must explicitly configure them.
- Monitor for stack overflow errors and set up alerts for process crashes.
| File | Command / Code | Purpose |
|---|---|---|
| server.js | const { ApolloServer } = require('@apollo/server'); | Why Apollo Server for GraphQL in Production |
| schema.graphql | scalar DateTime | Schema Design |
| resolvers | const { GraphQLError } = require('graphql'); | Resolvers |
| auth.js | const { ApolloServer } = require('@apollo/server'); | Authentication and Authorization in Resolvers |
| errorHandling.js | const { GraphQLError } = require('graphql'); | Error Handling and Error Masking |
| cacheControl.js | const { ApolloServer } = require('@apollo/server'); | Performance |
| subscriptions.js | const { ApolloServer } = require('@apollo/server'); | Subscriptions |
| subgraph.js | const { buildSubgraphSchema } = require('@apollo/subgraph'); | Federation |
| resolver.test.js | const { ApolloServer } = require('@apollo/server'); | Testing |
| monitoringPlugin.js | const plugin = { | Monitoring and Observability |
| Dockerfile | FROM node:18-alpine | Deployment and CI/CD |
| security.js | const { ApolloServer } = require('@apollo/server'); | Security |
| dataloader-example.js | const DataLoader = require('dataloader'); | DataLoader and N+1 Prevention |
| codegen.yml | schema: './src/schema.graphql' | GraphQL Code Generator Setup |
| depth-cost-limits.js | const depthLimit = require('graphql-depth-limit'); | Depth Limiting and Cost Analysis |
| schema.graphql | type User @connect( | Apollo Connectors and REST Integration |
| upload-resolver.js | const { GraphQLUpload } = require('graphql-upload'); | File Uploads in GraphQL |
| schema.graphql | type Query { | Cursor Pagination |
Key takeaways
Interview Questions on This Topic
What is the N+1 problem in GraphQL and how do you solve it with Apollo Server?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
That's Node.js. Mark it forged?
7 min read · try the examples if you haven't