๐Ÿณ IntuitiveFE
Login
โ† All concepts

GraphQL Schema & N+1 Problem

โฑ๏ธ ~4-minute bite ยท solve the sandbox to master

0%lesson
๐Ÿง’

5-Year-Old Metaphor

โ€” The physical, real-world picture. No jargon.

๐Ÿ“š N+1 = calling the librarian 10 times for 10 authors. DataLoader = giving the librarian a list and getting all 10 at once.

N+1 โ€” the naive resolver

You ask: "Give me 10 posts with author names." The resolver fetches 10 posts (1 query), then calls the librarian once per post: "Who wrote post 1? Post 2? Post 3?..." โ€” 10 separate trips to the shelf.

SELECT * FROM posts LIMIT 10; -- 1

SELECT * FROM users WHERE id=1; -- 2

SELECT * FROM users WHERE id=2; -- 3

... ร— 10 = 11 queries total

DataLoader โ€” the smart librarian

DataLoader waits for all 10 author requests to arrive in the same tick, then goes to the shelf once with a list of 10 IDs: "Get me all of these." One trip, same result.

SELECT * FROM posts LIMIT 10; -- 1

SELECT * FROM users

WHERE id IN (1,2,...,10); -- 2

2 queries total โ€” same data!

GraphQL vs REST โ€” the real comparison

ConcernRESTGraphQL
Over-fetchingAlways (fields fixed by server)Never (client specifies fields)
Under-fetchingMultiple endpoints (N+1 in REST too)One query, nested data
VersioningURL versions (/v1, /v2)@deprecated + schema evolution
Type safetyOpenAPI spec (optional)Schema is the type system
CachingURL-based (CDN-friendly)POST-based (no HTTP cache by default)
N+1 riskIn REST clients (waterfall fetching)In resolvers (requires DataLoader)
๐ŸŽ›๏ธ

Interactive Sandbox

โ€” Move something, see it react instantly.

Pattern

GraphQL schema

js
1# Query type โ€” reads
2type Query {
3 posts(first: Int, after: String): PostConnection!
4 post(id: ID!): Post
5 user(id: ID!): User
6}
7ย 
8# Mutation type โ€” writes
9type Mutation {
10 createPost(input: CreatePostInput!): Post!
11 updatePost(id: ID!, input: UpdatePostInput!): Post!
12 deletePost(id: ID!): Boolean!
13}
14ย 
15# Subscription type โ€” real-time
16type Subscription {
17 newPost: Post!
18 postUpdated(id: ID!): Post!
19}
20ย 
21# Types
22type Post {
23 id: ID!
24 title: String!
25 author: User! # nested resolver โ†’ N+1 risk
26 createdAt: DateTime!
27}
28ย 
29type User {
30 id: ID!
31 name: String!
32 email: String!
33 posts: [Post!]!
34}

Pipeline steps

Schema-first: SDL written first, resolvers implement it

Code-first: types in TS โ†’ SDL generated (Pothos, TypeGraphQL)

The schema is the contract. Breaking changes (removing fields, changing types) require versioning or field deprecation. Use @deprecated directive, never just delete fields.
Gotcha: Not limiting query depth allows deeply nested queries like { posts { author { posts { author { posts ... } } } } }. Without depth limiting, one query can fetch millions of rows. Use graphql-depth-limit or complexity analysis.
Insight: Schema-first keeps the API contract visible โ€” the SDL file is the documentation. Code-first keeps schema and resolvers in sync automatically โ€” TypeScript types are the truth. Both are valid; pick based on team preference.
Explored:๐Ÿ“๐Ÿ”ฅ๐Ÿ“ฆ๐Ÿ“„๐Ÿ“ก
๐ŸŽฏ

Challenge

Compare N+1 (before) vs DataLoader (after). Check the query count badge โ€” it should drop from 11 to 2.

Try it
๐ŸŽฏ

Why Should I Care?

โ€” The exact interview question + the bug it kills.

Interview questions

Q: How does DataLoader batch and cache?

js
1// DataLoader internals โ€” simplified
2class DataLoader<K, V> {
3 private queue: Array<{key: K; resolve: (v: V) => void}> = [];
4 private cache = new Map<K, Promise<V>>();
5ย 
6 load(key: K): Promise<V> {
7 if (this.cache.has(key)) return this.cache.get(key)!;
8ย 
9 const promise = new Promise<V>((resolve) => {
10 this.queue.push({ key, resolve });
11 });
12 this.cache.set(key, promise);
13ย 
14 // Schedule batch at end of current tick
15 if (this.queue.length === 1) {
16 Promise.resolve().then(() => this.flush());
17 }
18ย 
19 return promise;
20 }
21ย 
22 private async flush() {
23 const batch = this.queue.splice(0); // drain queue
24 const keys = batch.map(b => b.key);
25 const values = await this.batchFn(keys); // ONE DB call
26 batch.forEach((b, i) => b.resolve(values[i]));
27 }
28}

Q: Cursor-based vs offset-based pagination โ€” when to use each?

Offset: Simple SQL (LIMIT x OFFSET y). Works for static datasets. Breaks when items are inserted or deleted mid-page (duplicates/gaps). Performance degrades at large offsets (O(n) scan). Good for: admin tables, search results, any paginated list where real-time updates don't matter.

Cursor: Encodes the last-seen position (usually base64(id)). Stable under real-time updates. O(log n) via index seek. Can't jump to page N. Good for: social feeds, real-time lists, infinite scroll, anything that updates while the user reads.

Q: Schema-first vs code-first GraphQL โ€” tradeoffs?

Schema-first: SDL written in .graphql files. Resolvers generated or written separately. The SDL is the documentation and contract โ€” visible to all teams. Risk: schema and resolvers can drift. Tools: graphql-code-generator generates TypeScript types from SDL.

Code-first: Types defined in TypeScript (Pothos, TypeGraphQL, NestJS GraphQL). SDL generated automatically. Resolver and type always in sync โ€” TypeScript is the truth. Risk: SDL is generated output, less visible. Better for TypeScript-first teams.

Bug: not limiting query depth โ€” DoS vector

js
1# โœ— DANGER: deeply nested query fetches millions of rows
2query {
3 posts {
4 author {
5 posts {
6 author {
7 posts { # unbounded nesting
8 author { ... } # exponential DB load
9 }
10 }
11 }
12 }
13 }
14}
15ย 
16# โœ“ FIX: depth limiting
17import { createComplexityRule } from 'graphql-query-complexity';
18const server = new ApolloServer({
19 validationRules: [
20 depthLimit(5), // max nesting depth
21 createComplexityRule({ maximumComplexity: 1000 }),
22 ],
23});
๐Ÿ”ฌ

The Deep Dive

โ€” Spec refs, engine internals, the minutiae.

Persisted queries for security and caching

Persisted queries replace the full GraphQL query string with a hash. Clients send only the hash. Unknown hashes are rejected. Benefits: (1) prevents arbitrary queries from reaching production; (2) makes queries CDN-cacheable (GET with hash as query param).

js
1// Client sends hash instead of full query
2GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"abc123"}}
3ย 
4// Server: if hash unknown โ†’ client sends full query
5GET /graphql?query={posts{id,title}}&extensions={"persistedQuery":{"sha256Hash":"abc123"}}
6ย 
7// Server caches: hash โ†’ query string
8// Next request: just the hash โ€” no query body needed
9// CDN can cache GET requests! (impossible with POST queries)

Apollo vs Yoga vs Pothos

Apollo Server

Most widely used. Plugin ecosystem, Federation for microservices. Heavy โ€” 15MB bundle. Overkill for simple APIs.

Use for: Enterprise, microservice federation

GraphQL Yoga

Lightweight (~2MB), built on Fetch API, works in Edge runtime (Cloudflare Workers). Code-first optional. Great default.

Use for: Modern stacks, Edge runtime

Pothos (schema builder)

Code-first schema builder in TypeScript. Plugin for Prisma integration. Best TypeScript type safety. No SDL files.

Use for: TypeScript-first, Prisma users

GraphQL caching challenges

GraphQL's biggest caching weakness: queries are POST requests โ€” not cacheable by HTTP caches or CDNs by default. Solutions: (1) persisted queries via GET; (2) client-side normalized cache (Apollo Client, urql); (3) partial cache invalidation via cache tags.

js
1// Apollo Client: normalized in-memory cache
2// Objects cached by __typename + id
3const client = new ApolloClient({
4 cache: new InMemoryCache(),
5});
6ย 
7// Query fetches Post:1 and User:5
8// Cache stores { 'Post:1': {...}, 'User:5': {...} }
9// If mutation updates User:5, all queries that use User:5 re-render
10// Automatic cache invalidation for known entities
11ย 
12// Manual cache update after mutation:
13cache.writeQuery({
14 query: GET_POST,
15 data: { post: updatedPost },
16 variables: { id: '1' },
17});
๐ŸŽค

Interview Questions

โ€” Real questions from real interviews โ€” with answers.

Cursor pagination for the feed; subscriptions for new posts; DataLoader for all nested resolvers.

Resolver fires per field per parent; DataLoader batches all calls within one event loop tick.

Apply depth limits and complexity scoring; reject queries above thresholds at validation time.

Subscriptions for collaborative/multi-user events; SSE for one-way feeds; polling for infrequent status.

Apollo Federation: each team owns a subgraph; the gateway composes them into one unified schema.

Schema-first: SDL is the visible contract; code-first: TypeScript is the source of truth with no drift.

๐ŸŽฎ

Memory Game

โ€” Quick quiz โ€” lock the concept in long-term memory.
1/4

What is the Apollo Router used for in a federation architecture?