After wrapping up Parts 0-7 of Full Stack Open with two different implementations of the BlogApp (Redux vs React Query), I was ready for something new. Enter Part 8: GraphQL.
If you've been following the MERN stack journey in my previous articles, you know I'm all about diving deep and going beyond the minimum requirements. Part 8 was no exception - and honestly, GraphQL turned out to be both fascinating and frustrating in ways I didn't expect.
Why GraphQL?
Before jumping in, let's address the elephant in the room: REST APIs work perfectly fine for most applications. So why learn GraphQL?
The course materials make a compelling case: GraphQL solves the over-fetching and under-fetching problems inherent in REST. Instead of hitting multiple endpoints or getting back data you don't need, you ask for exactly what you want in a single request.
Sounds great in theory. But theory and practice are two very different beasts.
What Part 8 Covers
The project is a deliberately simple library-style application, built to explore GraphQL fundamentals - authors, books, genres, the usual suspects. Here's what the course teaches:
- GraphQL server with Apollo Server - schemas, resolvers, queries, mutations
- React frontend with Apollo Client - querying data, managing cache
- MongoDB integration - because GraphQL still needs to get data from somewhere
- Authentication with JWT - protecting mutations behind login
- Subscriptions - real-time updates via WebSocket
The course exercises took me about 20 hours. But here's where it gets interesting: I spent an additional 15+ hours implementing features that weren't required but felt necessary for a real-world application.
Let me explain why.
Beyond the Exercises: Where Things Got Real
1. Pagination - When the Cache Has a Mind of Its Own
Here’s where I made my first ‘creative’ decision: the course doesn’t cover pagination. Not content with adding it like a reasonable person, I decided to spice things up by implementing both approaches. Because why solve one problem when you can create two?
- Offset-based pagination for the Authors page (the traditional "page 1, 2, 3" approach)
- Cursor-based pagination for Books with infinite scroll (like social media feeds)
The offset approach was straightforward - nothing too surprising there. But cursor-based pagination with infinite scroll? That's where Apollo Client's cache became... interesting.
The problem: When you're loading more items incrementally (load 30 books, scroll down, load 30 more), Apollo needs to remember what you've already fetched and append new results. Easy, right? Not quite.
Apollo Client caches data in a normalized way - think of it like a smart database that stores each object once and references it elsewhere. When I added real-time subscriptions (so new books would appear instantly when added by other users), the cache got confused about how to merge the new data with the existing paginated list.
New books wouldn't appear. Or they'd appear twice. Or the cache would just forget chunks of data entirely.
Why this happens: Apollo provides mechanisms for merging paginated data, but when you combine custom merge strategies with real-time updates, you need to explicitly tell Apollo: "Hey, this new book reference goes here in the list, check if it already exists first, and update these specific query results."
The solution: Instead of letting Apollo's default behavior handle it, I had to manually update the cache whenever a new book arrived. This meant checking if the book already existed (to avoid duplicates), creating a proper reference to it (not embedding the whole object), and inserting it at the right position in the paginated list.
Lesson learned: Apollo's cache is incredibly powerful, but it requires you to think about data as references and relationships, not just objects. When in doubt, the Apollo DevTools cache inspector became my best friend - seeing the actual cache structure helped me understand what was going wrong.
2. Custom Scalars - Validation That Makes Sense
The course uses basic types: strings, numbers, booleans. But in a real application, you want more specific validation.
The idea: GraphQL lets you define custom data types (called "scalars") that validate input automatically. Instead of accepting any string for a username, you can create a Username type that only accepts alphanumeric characters between 3-15 characters long.
I added custom scalars for:
-
Year- ensures it's a valid 4-digit year (not 99999 or -500) -
Username- enforces formatting rules -
Password- sets length requirements before hashing -
Genre- normalizes to lowercase, prevents weird formatting
Why this matters: Validation happens at the GraphQL layer before your data even reaches the database. If someone tries to add a book published in year 3000, GraphQL rejects it immediately with a clear error message. Time travelers will need to use a different API. No need to write validation logic in multiple places - it's baked into the schema itself.
The challenge: GraphQL's built-in scalars are limited, so you have to define custom ones yourself. But once set up, they work everywhere automatically - queries, mutations, even in nested objects.
3. Error Handling - Making Sense of What Went Wrong
In REST APIs, you have HTTP status codes: 400 for bad request, 401 for unauthorized, 500 for server error. Simple.
In GraphQL, every request returns HTTP 200, even when things go wrong. Error details live inside the response body under an errors array.
The problem: Different types of errors (validation errors, authentication errors, database errors) all look similar to the frontend unless you explicitly categorize them.
My approach: Following the sacred DRY principle and my personal vendetta against scattered error handling, I created a central error formatting function on the server that:
- Catches database validation errors (like duplicate usernames)
- Transforms them into clear, user-friendly messages
- Adds proper error codes (like
BAD_USER_INPUTorUNAUTHENTICATED) - Hides internal server details from the client
On the frontend, I doubled down on this strategy with an error handling pipeline that:
- Intercepts all GraphQL errors globally
- Shows user-friendly toast notifications
- Automatically logs out users when authentication expires
- Retries failed requests for network issues (but not for bad input)
- Prevents duplicate error messages from spamming the user (a deceptively simple bullet point that cost me time wondering, “why is this toast appearing three times?!”)
Why this matters: Without proper error handling, users see cryptic messages like "E11000 duplicate key error" instead of "This username is already taken." Good error handling is the difference between a confusing app and a polished one.
4. The N+1 Query Problem - Efficiency at Scale
Here's a sneaky performance issue that GraphQL makes easy to create: the N+1 query problem.
Scenario: You want to show a list of 20 authors, each with their book count.
Naive approach:
- Query the database for 20 authors (1 query)
- For each author, query the database for their books (20 queries)
- Total: 21 database queries
If you have 100 authors, that's 101 queries. Yikes.
The solution: DataLoader
DataLoader is a utility that batches requests. Instead of making 20 separate "get books for this author" queries, it collects all the author IDs, makes ONE query to get book counts for all authors at once, then distributes the results back to the right resolvers.
Result: 21 queries become 2 queries. On large datasets, this is the difference between a snappy UI and a sluggish one.
Why GraphQL makes this tricky: Because GraphQL lets clients request nested data flexibly, it's easy to accidentally create these N+1 scenarios without realizing it. REST APIs force you to think about this upfront because you're designing specific endpoints.
5. Security - Protecting Your API
The course doesn't cover GraphQL security, but here's the thing: GraphQL's flexibility is also a potential attack vector.
Problems that don't exist in REST:
- Deeply nested queries: Someone could request books → authors → books → authors → books... 50 levels deep, crashing your server
- Expensive queries: A client could request every field on every object in your entire database in a single query
- Schema introspection: By default, anyone can query your GraphQL schema to see every available field and type - like having your API documentation publicly available to attackers
My security additions:
- Query depth limiting: Reject queries nested deeper than 5 levels
- Query cost analysis: Assign "costs" to different fields and reject queries that exceed a budget
- Introspection toggle: Disable schema introspection in production
Why this matters: These attacks are specific to GraphQL's flexibility. A REST API naturally limits what clients can request because each endpoint is fixed. In GraphQL, you have to enforce these limits explicitly.
6. Database Aggregations - When Abstractions Break
Here's a fun quirk I discovered: When using Mongoose (MongoDB library), there are two ways to query data:
- Regular queries - Mongoose handles everything nicely, transforms your data automatically
- Aggregation pipelines - You write raw MongoDB queries for complex operations
For the Authors page, I wanted to sort authors by book count. This required using aggregation pipelines because I needed to count related books and sort by that count.
The surprise: All the nice automatic data transformations Mongoose does (like converting MongoDB's _id to id via Mongoose’s toJSON transform) don't happen in aggregation pipelines. Suddenly, my GraphQL queries expected an id field, but the database was returning _id.
The fix: I had to manually specify the field transformation in the aggregation pipeline itself.
Lesson learned: Abstractions are great until they're not. Sometimes you need to understand what's happening under the hood, especially when performance optimizations take you outside the happy path.
GraphQL vs REST: My Honest Take
After spending 35+ hours with GraphQL, here's what I think:
GraphQL shines when:
- You have complex, interconnected data (think social networks, content platforms)
- Different clients (web, mobile, desktop) need different data subsets
- You want a strongly-typed contract between frontend and backend
- Real-time updates are important
REST is still better when:
- Your API is straightforward CRUD operations
- You want to leverage standard HTTP caching (GraphQL usually uses a single POST endpoint, which makes CDN and browser-level caching much harder)
- Simplicity matters more than flexibility
GraphQL isn't magic - it's a trade-off. You exchange REST's simplicity for flexibility and type safety, but you pay for it with increased complexity in caching, error handling, and security.
Time Breakdown
Here's how those 35 hours broke down:
| Task | Time |
|---|---|
| Course material (Parts 8a-8e) | 20h 3m |
| Custom scalars & validation | 1h 40m |
| Pagination (offset + cursor-based) | 7h 42m |
| Error handling & bug fixes | 4h 12m |
| Security (depth/cost limits) | 1h 15m |
| Total | 35h 17m |
That pagination time? Most of it was debugging cache issues and reading Apollo documentation to understand why things weren't working.
What's Next?
The Full Stack Open course continues with CI/CD, containers, and more. I’ll probably write about other parts in future articles.
Want to see the code? Check out my GitHub repository with the full implementation, including all the extra features I added.
Have you worked with GraphQL? What was your experience? Drop a comment — I’m curious how it played out for you.
Happy coding. 😉
Top comments (0)