Our REST API had 15 endpoints. The frontend needed data from 4 of them to render a single page. That meant 4 HTTP requests, 4 round trips, and a slow user experience. I migrated to GraphQL over 3 weeks. Here is exactly how I did it — and the one mistake that almost took down production.
Why GraphQL? The Real Reason
It wasn’t about being trendy. It was about performance. Our product page made 6 REST calls:
GET /api/products/123— product detailsGET /api/products/123/reviews— reviewsGET /api/products/123/related— related productsGET /api/inventory/123— stock statusGET /api/pricing/123— current priceGET /api/users/me— user preferences
Total time: ~800ms on a good day. With GraphQL, this became 1 request: ~200ms.
Step 1: Define the Schema (Day 1-2)
I started with the schema. Not the code — the schema. This is the contract between frontend and backend.
type Product {
id: ID!
name: String!
description: String
price: Float!
inventory: Int!
reviews: [Review!]!
relatedProducts: [Product!]!
}
type Review {
id: ID!
rating: Int!
comment: String
author: String!
createdAt: String!
}
type Query {
product(id: ID!): Product
products(limit: Int, offset: Int): [Product!]!
}
Key decision: I made reviews and relatedProducts fields on Product rather than separate queries. This is the GraphQL advantage — the client asks for exactly what it needs.
Step 2: Build the Resolvers (Day 3-7)
Each field in the schema needs a resolver. I used graphql-yoga with Prisma:
const resolvers = {
Query: {
product: async (_, { id }, { prisma }) => {
return prisma.product.findUnique({ where: { id } });
},
products: async (_, { limit, offset }, { prisma }) => {
return prisma.product.findMany({
take: limit,
skip: offset,
});
},
},
Product: {
reviews: async (parent, _, { prisma }) => {
return prisma.review.findMany({
where: { productId: parent.id },
});
},
relatedProducts: async (parent, _, { prisma }) => {
return prisma.product.findMany({
where: {
category: parent.category,
id: { not: parent.id },
},
take: 4,
});
},
},
};
The N+1 problem hit me immediately. For 10 products, GraphQL would make 10 separate queries for reviews. The fix: DataLoader.
import DataLoader from 'dataloader';
const reviewLoader = new DataLoader(async (productIds) => {
const reviews = await prisma.review.findMany({
where: { productId: { in: productIds } },
});
return productIds.map(id =>
reviews.filter(r => r.productId === id)
);
});
Now 10 products = 1 query for reviews instead of 10.
Step 3: The Strangler Pattern (Day 8-14)
I didn’t replace REST all at once. I used the strangler pattern: put a proxy in front of the old API and gradually route traffic to GraphQL.
// Middleware: route GraphQL requests
app.use('/api/graphql', graphqlHandler);
// Keep REST endpoints working
app.use('/api/products', restProductHandler);
app.use('/api/reviews', restReviewHandler);
// Gradually update frontend to use /api/graphql
Both APIs ran simultaneously. The frontend could use either. This meant zero downtime.
The Mistake That Almost Broke Production
On day 10, I deployed a resolver that didn’t have a depth limit. A user sent this query:
query {
products {
relatedProducts {
relatedProducts {
relatedProducts {
relatedProducts {
... infinite loop
}
}
}
}
}
}
The server tried to resolve 4^10 = 1,048,576 products. CPU hit 100%. The site went down for 3 minutes.
The fix: depth limiting and query cost analysis.
import depthLimit from 'graphql-depth-limit';
const server = new YogaServer({
validationRules: [depthLimit(5)],
});
Now any query deeper than 5 levels is rejected immediately.
Step 4: Update the Frontend (Day 15-18)
With Apollo Client on the frontend, the migration was straightforward:
// Before (REST)
const [product, setProduct] = useState(null);
const [reviews, setReviews] = useState([]);
useEffect(() => {
fetch(`/api/products/${id}`).then(r => r.json()).then(setProduct);
fetch(`/api/products/${id}/reviews`).then(r => r.json()).then(setReviews);
}, [id]);
// After (GraphQL)
const { data, loading } = useQuery(PRODUCT_QUERY, {
variables: { id },
});
const PRODUCT_QUERY = gql`
query Product($id: ID!) {
product(id: $id) {
name
price
inventory
reviews {
rating
comment
author
}
relatedProducts {
name
price
}
}
}
`;
One query instead of two. And if I need more data later, I just add fields — no new endpoint needed.
Results After 3 Months
| Metric | Before | After |
|---|---|---|
| Avg page load time | 800ms | 200ms |
| API endpoints | 15 | 1 (with flexible queries) |
| Frontend network requests | 4-6 per page | 1 per page |
| Backend CPU usage | 45% | 35% |
| Developer velocity | Slow (new endpoints needed) | Fast (just add fields) |
Should You Migrate?
Yes if:
- Your frontend makes multiple REST calls per page
- You have over-fetching (returning more data than needed)
- You have under-fetching (needing multiple calls for related data)
No if:
- Your REST API is simple (under 5 endpoints)
- You have caching working well with CDN
- Your team has no GraphQL experience and no time to learn
One Last Tip
Use GraphQL Playground or Apollo Studio during development. Being able to test queries interactively is a game-changer. It’s like having documentation that you can actually run.