GraphQL Killed My REST API (And I Helped)

I was a REST purist. Endpoints were my religion, HTTP verbs my commandments. Then GraphQL came along and made me an accomplice to REST's demise. This is my confession.
The Crime Scene
It was a Tuesday. Our mobile team had just filed their 47th ticket requesting a new endpoint. "We need user data with their last 3 orders, but only the order totals, and also the shipping status, but not the items unless they're digital products."
I stared at my screen. We already had:
GET /users/:idGET /users/:id/ordersGET /users/:id/orders?include=itemsGET /users/:id/orders/recentGET /users/:id/profile-with-orders(don't ask)
The mobile team wanted GET /users/:id/profile-with-recent-orders-totals-and-digital-items-shipping-status.
That's when I knew: REST had to go.
The Autopsy: What Killed REST?
Don't get me wrong - REST isn't actually dead. It's alive and well, powering most of the internet. But for our use case, it was dying a death of a thousand paper cuts.
Cut #1: The Endpoint Explosion
Year 1: 12 endpoints
Year 2: 47 endpoints
Year 3: 156 endpoints
Year 4: "We need a spreadsheet to track our endpoints"
Each new feature meant new endpoints. Each new client (web, iOS, Android, partner API) had different data needs. Our "RESTful" API had become a Frankenstein's monster of custom endpoints.
Cut #2: Over-fetching Was Killing Our Mobile Users
Our /users/:id endpoint returned everything:
{
"id": "123",
"email": "user@example.com",
"name": "Jane Doe",
"avatar": "...",
"bio": "A 2000 character biography that nobody asked for...",
"preferences": { /* 50 fields */ },
"metadata": { /* Another 30 fields */ },
"createdAt": "...",
"updatedAt": "...",
"lastLoginAt": "...",
"loginCount": 847,
"referralCode": "...",
// 40 more fields nobody wanted
}
Mobile users on spotty connections were downloading kilobytes of data just to display a name and avatar.
Cut #3: Under-fetching Was Killing Our Patience
To render a simple order confirmation page:
// Request 1
const user = await fetch('/users/123');
// Request 2
const order = await fetch('/orders/456');
// Request 3
const items = await fetch('/orders/456/items');
// Request 4
const shipping = await fetch('/orders/456/shipping');
// Request 5 (because we need the product images)
const products = await Promise.all(
items.map(item => fetch(`/products/${item.productId}`))
);
Five round trips minimum. On mobile, that's 5× the latency. Users were staring at loading spinners while our servers played ping-pong.
The Murder Weapon: GraphQL
I'd heard of GraphQL. I'd dismissed it as "Facebook's pet project" and "unnecessary complexity." Then I actually tried it.
query OrderConfirmation {
user(id: "123") {
name
avatar
}
order(id: "456") {
total
status
items {
quantity
product {
name
imageUrl
}
}
shipping {
carrier
trackingNumber
estimatedDelivery
}
}
}
One request. Only the fields we need. I felt like a mass murderer looking at my REST endpoints.
The Migration: A Step-by-Step Confession
We didn't kill REST overnight. It was a slow, methodical process.
Phase 1: The Facade (Months 1-2)
We put GraphQL in front of our existing REST API:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Clients │─────▶│ GraphQL │─────▶│ REST APIs │
│ │ │ Gateway │ │ (unchanged)│
└─────────────┘ └─────────────┘ └─────────────┘
@QueryMapping
public User user(@Argument String id) {
// Still calling REST under the hood
return restTemplate.getForObject(
"http://user-service/users/" + id,
User.class
);
}
Result: Clients got GraphQL's benefits immediately. Backend teams didn't have to change anything yet.
Phase 2: The Data Source Migration (Months 3-6)
Gradually, we moved resolvers to call databases directly:
@QueryMapping
public User user(@Argument String id) {
// Now going directly to the database
return userRepository.findById(id).orElse(null);
}
We tracked which REST endpoints were still being called:
┌────────────────────────────────────────────────────────────┐
│ REST Endpoint Usage Over Time │
├────────────────────────────────────────────────────────────┤
│ │
│ 100% │████████ │
│ │████████████ │
│ │████████████████ │
│ │████████████████████ │
│ │████████████████████████ │
│ 0% │████████████████████████████████ │
│ └──────────────────────────────────────────────── │
│ Month 1 Month 3 Month 5 Month 7 │
└────────────────────────────────────────────────────────────┘
Phase 3: The Funeral (Months 7-9)
One by one, we deprecated REST endpoints:
@Deprecated
@GetMapping("/users/{id}")
public User getUser(@PathVariable String id) {
log.warn("Deprecated endpoint called: GET /users/{}", id);
metricsService.incrementCounter("deprecated.endpoint.users");
return userService.findById(id);
}
We set up alerts when deprecated endpoints were called. We reached out to the culprits. We had awkward conversations.
Phase 4: The Gravestone (Month 10)
@GetMapping("/users/{id}")
public ResponseEntity<String> getUser(@PathVariable String id) {
return ResponseEntity
.status(HttpStatus.GONE)
.body("This endpoint has been murdered. " +
"Please use GraphQL: POST /graphql");
}
REST in peace, old friend.
The Evidence: Before and After
API Response Size
┌──────────────────────────────────────────┐
│ Average Response Size │
├──────────────────────────────────────────┤
│ │
│ REST: ████████████████████ 47 KB │
│ │
│ GraphQL: ████ 8 KB │
│ │
└──────────────────────────────────────────┘
Round Trips Per Page
| Page | REST | GraphQL |
|---|---|---|
| Home | 7 | 1 |
| Product | 4 | 1 |
| Checkout | 9 | 2 |
| Order History | 12 | 1 |
Mobile App Performance
┌──────────────────────────────────────────────────────────────┐
│ Time to Interactive (Mobile) │
├──────────────────────────────────────────────────────────────┤
│ │
│ Before (REST): │
│ ████████████████████████████████████████ 4.2s │
│ │
│ After (GraphQL): │
│ ██████████████ 1.4s │
│ │
└──────────────────────────────────────────────────────────────┘
Developer Happiness
┌──────────────────────────────────────────────────────────────┐
│ "How many custom endpoints did you create this month?" │
├──────────────────────────────────────────────────────────────┤
│ │
│ Jan (REST): ████████████████████ 23 │
│ Feb (REST): ██████████████████████████ 31 │
│ Mar (Hybrid): ████████████ 14 │
│ Apr (GraphQL): ██ 2 │
│ May (GraphQL): █ 1 │
│ Jun (GraphQL): 0 │
│ │
└──────────────────────────────────────────────────────────────┘
Zero custom endpoints in June. The mobile team stopped filing tickets. I started sleeping through the night.
The Confession: What I Got Wrong
I'll admit it - I made mistakes. Here's my confession:
Mistake #1: I Underestimated the N+1 Problem
My first GraphQL implementation was naive:
@SchemaMapping
public Author author(Book book) {
return authorRepository.findById(book.getAuthorId());
}
Query for 100 books = 101 database queries. Oops.
The fix: DataLoader. Always DataLoader.
@BatchMapping
public Map<Book, Author> author(List<Book> books) {
// 1 query instead of 100
Set<String> authorIds = books.stream()
.map(Book::getAuthorId)
.collect(Collectors.toSet());
Map<String, Author> authors = authorRepository
.findAllById(authorIds)
.stream()
.collect(Collectors.toMap(Author::getId, a -> a));
return books.stream()
.collect(Collectors.toMap(b -> b, b -> authors.get(b.getAuthorId())));
}
Mistake #2: I Forgot About Caching
REST had beautiful HTTP caching. Cache-Control: max-age=3600. CDNs loved it.
GraphQL? Everything's a POST request. CDNs cried.
The fix: Persisted queries and application-level caching.
@Cacheable("books")
@QueryMapping
public Book bookById(@Argument String id) {
return bookRepository.findById(id);
}
Mistake #3: I Let Queries Run Wild
Someone wrote this query:
query {
users {
orders {
items {
product {
reviews {
author {
orders {
items {
product {
# ... you get the idea
}
}
}
}
}
}
}
}
}
}
Our database wept.
The fix: Query complexity limits and depth limits.
@Bean
public Instrumentation maxQueryDepthInstrumentation() {
return new MaxQueryDepthInstrumentation(7);
}
@Bean
public Instrumentation maxQueryComplexityInstrumentation() {
return new MaxQueryComplexityInstrumentation(100);
}
The Verdict: Was It Worth It?
Let me answer with numbers:
| Metric | Before | After | Change |
|---|---|---|---|
| API Response Time (p50) | 340ms | 89ms | -74% |
| Mobile Data Usage | 2.3 MB/session | 0.6 MB/session | -74% |
| Backend Endpoints | 156 | 1 | -99% |
| New Endpoint Tickets | 12/month | 0/month | -100% |
| Developer Satisfaction | 3.2/5 | 4.6/5 | +44% |
The Lesson: When to Kill REST
Don't murder REST just because GraphQL is shiny. Kill it when:
✅ Multiple clients need different data shapes
- Mobile wants minimal data
- Web wants rich data
- Partners want specific subsets
✅ You're drowning in custom endpoints
- More than 50 endpoints
- Endpoints named like
getUserWithOrdersAndPreferencesButNotPaymentInfo
✅ Over-fetching is measurable and painful
- Mobile performance suffering
- Bandwidth costs increasing
✅ Under-fetching causes waterfall requests
- Pages making 5+ sequential API calls
- Time to interactive is suffering
The Alibi: When REST is Innocent
Keep REST alive when:
❌ Simple CRUD with predictable access patterns
- Admin panels
- Internal tools
❌ File uploads are common
- GraphQL handles files awkwardly
❌ HTTP caching is critical
- Public, cacheable data
- CDN-heavy architectures
❌ Your team doesn't want to learn GraphQL
- Buy-in matters
- Forced migrations fail
Closing Statement
Yes, I killed REST. But it was self-defense. Our API was attacking our mobile users, our developers, and our sanity.
GraphQL isn't perfect. It has its own sharp edges. But for our use case - multiple clients with diverse data needs - it was the right weapon.
If you're in the same situation, maybe it's time for you to become an accomplice too.
Just remember: always use DataLoader. Learn from my mistakes.
The author cannot be held legally responsible for any REST APIs harmed in the making of this blog post. All endpoints were deprecated humanely.