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
GraphQLREST APIAPI MigrationDeveloper ExperienceAPI Design

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.

The stats were terrifying:

  • 247 endpoints across 23 different services
  • Average 7 API calls to load a single dashboard view
  • 40% of mobile app crashes related to API timeouts
  • Developer onboarding time: 3-4 weeks just to understand the API structure
  • Support tickets: 60% were "How do I get data for X?" questions

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.

The GraphQL Evaluation: Not Just Following Trends

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):

| Criteria | Weight | REST Score | GraphQL Score | Weighted Impact | |----------|---------|------------|---------------|-----------------| | Developer Experience | 30% | 3/10 | 8/10 | GraphQL +1.5 | | Performance (Mobile) | 25% | 4/10 | 9/10 | GraphQL +1.25 | | Maintenance Complexity | 20% | 3/10 | 7/10 | GraphQL +0.8 | | Learning Curve | 15% | 8/10 | 5/10 | REST +0.45 | | Ecosystem Maturity | 10% | 9/10 | 6/10 | REST +0.3 | | Total | 100% | 4.7/10 | 7.4/10 | GraphQL 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:

| Endpoint | Usage (req/day) | Developer Complaints | Maintenance Issues | Priority | |----------|-----------------|---------------------|-------------------|----------| | /api/dashboard | 50K | High | High | 1 (Done Month 3) | | /api/projects | 30K | High | Medium | 2 | | /api/user/profile | 25K | Medium | High | 3 | | /api/search | 15K | High | Medium | 4 |

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)
  • Week 2: Hands-on schema design session
  • Week 3: Resolver implementation training
  • Week 4: Performance optimization techniques
  • Monthly: "GraphQL Office Hours" for questions

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 → 125ms average (63% improvement)
  • Mobile data usage: 2.3MB → 0.8MB per session (65% reduction)
  • Database queries per request: 8.7 → 3.2 average (63% 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 → 89/month (64% 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

  • [ ] 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

  • [ ] 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

  • [ ] Developer tooling - GraphQL Playground, documentation
  • [ ] Query complexity limits - Prevent expensive queries
  • [ ] Caching strategy - Normalized cache for GraphQL
  • [ ] Monitoring setup - GraphQL-specific analytics

Post-Migration

  • [ ] 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
Lead Developer Advocate at Freshworks