Skip to main content
Skip to main content
Back to Blog
API DesignFeatured

REST to GraphQL: The Migration That Saved Our API (And Our Sanity)

How we migrated from REST to GraphQL while supporting 50,000 developers. The strategies, gotchas, and lessons learned from a year-long API evolution.

Thakur Ganeshsingh
December 20, 2024
14 min read
Share this post:
REST to GraphQL: The Migration That Saved Our API (And Our Sanity)

This is Part 2 of "API Evolution Chronicles" - real stories from a decade of API transformations across multiple companies.

The Problem: REST API Hell#

By 2020, our REST API had become a monster.

REST API Hell: The Nightmare Numbers

247

Endpoints

Across 23 different services

7

API Calls per View

Average to load a single dashboard

40%

Mobile Crashes

Related to API timeouts

3-4 weeks

Onboarding Time

Just to understand API structure

60%

Support Tickets

Were 'How do I get data for X?' questions

2.3/5

Developer Satisfaction

Before the GraphQL migration

Our mobile team lead summed it up perfectly: "I spend more time figuring out which endpoints to call than actually building features."

Something had to change.

GraphQL was the hot new thing in 2020, but I've seen too many teams adopt technologies because they're trendy, not because they solve real problems.

Here's how we evaluated GraphQL against our specific pain points:

Our Core Problems vs. GraphQL Solutions#

Problem 1: Over-fetching and Under-fetching

  • REST: Mobile apps downloaded 10x more data than needed
  • GraphQL: Query exactly what you need

Problem 2: Multiple Round Trips

  • REST: Dashboard = 7 separate API calls
  • GraphQL: Dashboard = 1 query with nested relationships

Problem 3: API Documentation Complexity

  • REST: 247 endpoints to document and maintain
  • GraphQL: Self-documenting schema with introspection

Problem 4: Frontend-Backend Coupling

  • REST: Every UI change required backend endpoint modifications
  • GraphQL: Frontend queries evolve independently

Problem 5: Developer Onboarding

  • REST: Weeks to understand endpoint relationships
  • GraphQL: Explore entire API through GraphQL Playground in hours

The Decision Framework#

I created this evaluation matrix (you can adapt it for your tech decisions):

REST vs GraphQL: Our Evaluation

REST API
3/5

Our legacy implementation

Best for: Simple CRUD operations
✅ Pros:
  • Mature ecosystem
  • Team familiarity
  • Simple caching
❌ Cons:
  • 7 calls per view
  • 247 endpoints to maintain
  • Poor mobile performance
GraphQL
8/5

Modern data fetching

Best for: Complex data requirements, mobile apps
✅ Pros:
  • Single query
  • Self-documenting
  • Precise data fetching
❌ Cons:
  • Learning curve
  • Complex caching
  • N+1 query risks

Decision Matrix:

CriteriaWeightREST ScoreGraphQL ScoreWeighted Impact
Developer Experience30%3/108/10GraphQL +1.5
Performance (Mobile)25%4/109/10GraphQL +1.25
Maintenance Complexity20%3/107/10GraphQL +0.8
Learning Curve15%8/105/10REST +0.45
Ecosystem Maturity10%9/106/10REST +0.3
Total100%4.7/107.4/10GraphQL Wins

The decision: GraphQL for new development, gradual REST migration.

The Migration Strategy: The Strangler Fig Pattern#

Instead of a big-bang rewrite, we used the "Strangler Fig" pattern—gradually replacing REST endpoints with GraphQL while maintaining backward compatibility.

Phase 1: GraphQL Foundation (Months 1-3)#

Week 1-2: Schema Design

Graphql
type User {
  id: ID!
  email: String!
  profile: UserProfile!
  projects: [Project!]!
  activities(limit: Int = 10): [Activity!]!
}

type Project {
  id: ID!
  name: String!
  description: String
  owner: User!
  collaborators: [User!]!
  tasks(status: TaskStatus): [Task!]!
}

type Task {
  id: ID!
  title: String!
  status: TaskStatus!
  assignee: User
  project: Project!
  comments: [Comment!]!
}

Week 3-4: Resolver Implementation Started with our most-used data (User, Project, Task entities):

JavaScript
const resolvers = {
  Query: {
    user: async (_, { id }) => {
      // Calls existing REST services
      return await userService.findById(id);
    },
    projects: async (_, { userId }) => {
      return await projectService.findByUser(userId);
    },
  },
  User: {
    projects: async (user) => {
      return await projectService.findByUser(user.id);
    },
    activities: async (user, { limit }) => {
      return await activityService.findByUser(user.id, limit);
    },
  },
};

Month 2: Developer Tools Setup

  • GraphQL Playground for API exploration
  • Schema documentation auto-generation
  • Query complexity analysis and rate limiting
  • Caching strategy with DataLoader

Month 3: First GraphQL Endpoint Launched user dashboard endpoint—the perfect test case because:

  • High traffic (good performance testing)
  • Complex data relationships (showcases GraphQL benefits)
  • Used by all our client applications

Phase 2: Parallel Development (Months 4-8)#

The Rule: All new features use GraphQL, maintain REST for existing functionality.

Results after 4 months:

  • 47% of API traffic through GraphQL
  • API calls per mobile session dropped from 12 to 4
  • Dashboard load time improved by 65%
  • Developer satisfaction jumped from 5.2/10 to 7.8/10

Phase 3: Strategic Migration (Months 9-12)#

Identified REST endpoints to migrate based on:

  • Usage analytics (highest traffic first)
  • Developer pain points (most complained-about endpoints)
  • Maintenance overhead (endpoints with most bugs/changes)

Migration Priority Matrix:

EndpointUsage (req/day)Developer ComplaintsMaintenance IssuesPriority
/api/dashboard50KHighHigh1 (Done Month 3)
/api/projects30KHighMedium2
/api/user/profile25KMediumHigh3
/api/search15KHighMedium4

The Technical Implementation: Lessons Learned#

Lesson 1: Schema Design is Everything#

Wrong approach (our first attempt):

Graphql
# Mirroring REST endpoints in GraphQL
type Query {
  getUser(id: ID!): User
  getUserProjects(userId: ID!): [Project]
  getUserActivities(userId: ID!): [Activity]
}

Right approach (after refactoring):

Graphql
# Thinking in graphs, not endpoints
type Query {
  user(id: ID!): User
}

type User {
  projects: [Project!]!
  activities(limit: Int, after: String): ActivityConnection!
}

The difference: The second approach lets clients describe their data needs instead of making multiple calls.

Lesson 2: N+1 Problem is Real (And Solvable)#

The Problem:

Graphql
query {
  projects {
    name
    owner {
      name
      email
    }
  }
}

Without proper optimization, this query would:

  1. Fetch all projects (1 query)
  2. Fetch owner for each project (N queries)

Total: N+1 database queries for N projects.

The Solution: DataLoader

JavaScript
const userLoader = new DataLoader(async (userIds) => {
  const users = await User.findByIds(userIds);
  return userIds.map((id) => users.find((user) => user.id === id));
});

const resolvers = {
  Project: {
    owner: async (project) => {
      return await userLoader.load(project.ownerId);
    },
  },
};

Result: N+1 queries became 2 queries total.

Lesson 3: Query Complexity Management#

The Problem: Clients could write expensive queries:

Graphql
query ExpensiveQuery {
  users {
    projects {
      tasks {
        comments {
          author {
            projects {
              tasks {
                # This could go on forever...
              }
            }
          }
        }
      }
    }
  }
}

The Solution: Query Complexity Analysis

JavaScript
import costAnalysis from 'graphql-cost-analysis';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    costAnalysis({
      maximumCost: 1000,
      onComplete: (cost) => {
        console.log(`Query cost: ${cost}`);
      },
    }),
  ],
});

Schema with cost annotations:

Graphql
type User {
  projects: [Project!]! @cost(complexity: 10, multipliers: ["first"])
  activities: [Activity!]! @cost(complexity: 5, multipliers: ["limit"])
}

Lesson 4: Caching Strategy#

REST caching was simple: Cache by URL
GraphQL caching required more thought: Same endpoint, different queries

Our solution: Normalized cache with Apollo

JavaScript
const cache = new InMemoryCache({
  typePolicies: {
    User: {
      fields: {
        projects: {
          merge(existing = [], incoming) {
            return [...existing, ...incoming];
          },
        },
      },
    },
  },
});

The Migration Challenges (And How We Solved Them)#

Challenge 1: Team Learning Curve#

Problem: 23 developers needed to learn GraphQL while maintaining existing features.

Solution:

Week 1

GraphQL fundamentals workshop (4 hours)

Completed

Week 2

Hands-on schema design session

Completed

Week 3

Resolver implementation training

Completed

Week 4

Performance optimization techniques

Completed

Monthly

"GraphQL Office Hours" for ongoing questions

Completed

Result: 95% of developers comfortable with GraphQL within 6 weeks.

Challenge 2: Client Application Updates#

Problem: Mobile apps and web frontends needed GraphQL integration.

Solution:

  • Apollo Client for React applications
  • Apollo iOS for mobile apps
  • Code generation for type safety
  • Migration guides with side-by-side REST vs GraphQL examples

Example migration guide:

JavaScript
// OLD: REST approach
const loadDashboard = async () => {
  const user = await api.get('/user/me');
  const projects = await api.get(`/user/${user.id}/projects`);
  const activities = await api.get(`/user/${user.id}/activities`);
  return { user, projects, activities };
};

// NEW: GraphQL approach
const DASHBOARD_QUERY = gql`
  query Dashboard {
    me {
      name
      email
      projects {
        id
        name
        tasksCount
      }
      activities(limit: 10) {
        id
        type
        createdAt
      }
    }
  }
`;

const { data } = useQuery(DASHBOARD_QUERY);

Challenge 3: Monitoring and Debugging#

Problem: REST monitoring tools didn't work with GraphQL's single endpoint.

Solution:

  • Apollo Studio for query analytics and performance monitoring
  • Custom metrics for query complexity and error tracking
  • Query whitelisting in production for security
  • Schema versioning strategy for breaking changes

The Results: Numbers Don't Lie#

After 12 months of migration:

Performance Improvements#

  • API response time: 340ms → 204ms average (40% improvement)
  • Mobile data usage: 2.3MB → 0.8MB per session (65% reduction)
  • Database queries per request: 8.7 → 5.2 average (40% reduction)
  • Cache hit rate: 23% → 67% (normalized caching benefit)

Developer Experience Improvements#

  • API onboarding time: 3-4 weeks → 2-3 days
  • Support tickets: 247/month → 173/month (30% reduction)
  • Developer satisfaction: 5.2/10 → 8.4/10
  • Feature development time: 40% faster (fewer API calls to implement)

Business Impact#

  • Mobile app stability: 40% crash reduction
  • Developer adoption: 85% prefer GraphQL for new features
  • API documentation: 90% self-service (thanks to introspection)
  • Development velocity: 35% increase in shipped features

The Gotchas: What We Wish We'd Known#

1. File Uploads Are Still Painful#

GraphQL doesn't handle file uploads natively. We kept REST endpoints for file operations:

JavaScript
// Still using REST for this
POST /api/upload/avatar
Content-Type: multipart/form-data

// Then update via GraphQL
mutation UpdateUserAvatar($avatarUrl: String!) {
  updateUser(input: { avatarUrl: $avatarUrl }) {
    id
    avatarUrl
  }
}

2. Real-time Updates Need WebSocket/Subscriptions#

Graphql
subscription TaskUpdates($projectId: ID!) {
  taskUpdated(projectId: $projectId) {
    id
    title
    status
    assignee {
      name
    }
  }
}

Setting up GraphQL subscriptions added complexity, but real-time updates were worth it.

3. Query Optimization Requires Domain Knowledge#

Auto-generated resolvers are convenient but inefficient. We needed custom resolvers for complex queries:

JavaScript
// Bad: Auto-generated resolver (N+1 problem)
projects: (user) => Project.findByUserId(user.id);

// Good: Optimized resolver
projects: async (user, args, { userProjectsLoader }) => {
  return await userProjectsLoader.load(user.id);
};

4. Schema Evolution is Harder Than REST Versioning#

With REST, you could version endpoints independently. GraphQL schema changes affect the entire API surface:

Graphql
# Adding optional fields: Safe
type User {
  name: String!
  email: String!
  avatarUrl: String # Safe addition
}

# Removing fields: Breaking change
type User {
  name: String!
  # email removed - breaks existing queries
}

# Changing field types: Breaking change
type User {
  id: ID! # Was String!, now ID! - breaking
  name: String!
}

Solution: Deprecation warnings and gradual migration:

Graphql
type User {
  name: String!
  email: String! @deprecated(reason: "Use contactInfo.email")
  contactInfo: ContactInfo!
}

The Framework: REST to GraphQL Migration Checklist#

Pre-Migration Assessment

0 / 4 completed
API usage analytics

Identify high-traffic endpoints

Developer pain point survey

What hurts most?

Performance baseline

Measure current API performance

Team skill assessment

Who needs GraphQL training?

Migration Planning

0 / 4 completed
Schema design

Model relationships, not endpoints

Migration priority matrix

High impact, low risk first

Backward compatibility strategy

Keep REST during transition

Performance optimization plan

DataLoader, caching, complexity analysis

Implementation Phase

0 / 4 completed
Developer tooling

GraphQL Playground, documentation

Query complexity limits

Prevent expensive queries

Caching strategy

Normalized cache for GraphQL

Monitoring setup

GraphQL-specific analytics

Post-Migration

0 / 4 completed
Performance comparison

Measure improvements

Developer satisfaction survey

Validate experience improvements

Documentation updates

Migration guides and best practices

Team training

Advanced GraphQL techniques

When NOT to Migrate to GraphQL#

GraphQL isn't always the answer. Stick with REST when:

  • Simple CRUD operations with minimal relationships
  • Public APIs where you can't control client queries
  • Heavy file upload/download workflows
  • Team lacks GraphQL expertise and timeline is tight
  • Existing REST API works well and developer feedback is positive

The Personal Takeaway#

This migration taught me that API design is really UX design for developers. The technical implementation matters, but the developer experience transformation was the real success.

Before: Developers spent 60% of their time figuring out API calls
After: Developers spend 5% of their time on API integration, 95% building features

That ratio change was worth every hour of migration effort.


Next in the series: "API Design Patterns That Scale: From Startup to Enterprise"

Planning your own GraphQL migration? Share your challenges in the comments—I'll help you avoid our mistakes.

Want the complete migration playbook and code examples? Subscribe for detailed implementation guides.

Thakur Ganeshsingh
Thakur Ganeshsingh
Engineering Manager - Developer Relations at Freshworks