← Back to Blog
GraphQL vs REST: When to Use Each

After building both GraphQL and REST APIs for the Catalyst PSA platform, I've learned that the "GraphQL vs REST" debate misses the point. They're tools for different jobs. The question isn't which is better—it's which fits your specific use case.

Here's what 27 years of API development and hands-on experience with both approaches taught me about when to use each.

REST for Project Management, GraphQL for Reporting

In Catalyst PSA, we used both in the same application:

  • REST APIs: CRUD operations on projects, tasks, time entries
  • GraphQL API: Complex reporting dashboard fetching data across multiple entities

This hybrid approach gave us the simplicity of REST where it made sense and the flexibility of GraphQL where we needed it.

Understanding the Fundamental Difference

REST: Resource-Oriented

REST organizes endpoints around resources:

GET    /api/projects
GET    /api/projects/123
POST   /api/projects
PUT    /api/projects/123
DELETE /api/projects/123

GET    /api/projects/123/tasks
GET    /api/projects/123/time-entries

Each endpoint returns a fixed structure. Want different fields? Create a new endpoint.

GraphQL: Query-Oriented

GraphQL has one endpoint and flexible queries:

query {
  project(id: "123") {
    id
    name
    client {
      name
      industry
    }
    tasks {
      id
      name
      status
    }
  }
}

Clients specify exactly what they need. Same endpoint, different data shapes.

When REST Wins

1. Simple CRUD Operations

For basic create/read/update/delete, REST is simpler:

// REST endpoint - straightforward
@Post('projects')
async createProject(@Body() dto: CreateProjectDTO) {
  return await this.projectService.create(dto);
}
 
@Get('projects/:id')
async getProject(@Param('id') id: string) {
  return await this.projectService.findById(id);
}

Compare to GraphQL:

// GraphQL - more boilerplate
@Resolver(() => Project)
export class ProjectResolver {
  @Mutation(() => Project)
  async createProject(@Args('input') input: CreateProjectInput) {
    return await this.projectService.create(input);
  }
 
  @Query(() => Project)
  async project(@Args('id') id: string) {
    return await this.projectService.findById(id);
  }
 
  // Need resolver for every field that requires loading
  @ResolveField(() => Client)
  async client(@Parent() project: Project) {
    return await this.clientService.findById(project.clientId);
  }
}

For CRUD, GraphQL adds complexity without benefit.

2. Caching

HTTP caching works beautifully with REST:

@Get('projects/:id')
@Header('Cache-Control', 'public, max-age=300')
async getProject(@Param('id') id: string) {
  return await this.projectService.findById(id);
}

CDNs, browsers, and proxies cache GET requests automatically. GraphQL typically uses POST, bypassing HTTP caches.

3. File Uploads

REST handles file uploads naturally:

@Post('projects/:id/attachments')
@UseInterceptors(FileInterceptor('file'))
async uploadAttachment(
  @Param('id') id: string,
  @UploadedFile() file: Express.Multer.File
) {
  return await this.attachmentService.create(id, file);
}

GraphQL requires multipart requests with special handling—doable but awkward.

4. Rate Limiting

REST endpoints are easy to rate limit:

@Get('projects')
@UseGuards(RateLimitGuard)
@RateLimit({ points: 100, duration: 60 })
async getProjects() {
  return await this.projectService.findAll();
}

With GraphQL, how do you rate limit? By operation complexity? Number of fields? It's more complex.

5. Monitoring and Debugging

REST endpoints show up clearly in logs:

GET /api/projects/123 - 200 - 45ms
POST /api/projects - 201 - 120ms

GraphQL logs all look the same:

POST /graphql - 200 - ???ms

You need to parse the query to know what actually happened.

When GraphQL Wins

1. Complex Data Fetching

Our reporting dashboard needed data from 10+ entities. With REST:

// Client makes 10+ requests
const project = await fetch('/api/projects/123');
const client = await fetch(`/api/clients/${project.clientId}`);
const tasks = await fetch('/api/projects/123/tasks');
const timeEntries = await fetch('/api/projects/123/time-entries');
const team = await fetch('/api/projects/123/team-members');
// ... 6 more requests
 
// Or create a special endpoint
const dashboard = await fetch('/api/projects/123/dashboard');
// But then you need different endpoints for different dashboards

With GraphQL:

query ProjectDashboard($id: ID!) {
  project(id: $id) {
    id
    name
    budget
    client {
      name
      industry
    }
    tasks {
      id
      name
      status
      assignee {
        name
        avatar
      }
    }
    timeEntries(last: 10) {
      hours
      description
      date
    }
    teamMembers {
      user {
        name
        role
      }
      allocation
    }
  }
}

One request, exactly the data needed. GraphQL shines here.

2. Mobile Apps with Limited Bandwidth

Mobile apps need to minimize requests. GraphQL lets them fetch exactly what the UI needs:

# Mobile list view - just names and status
query ProjectList {
  projects {
    id
    name
    status
  }
}
 
# Desktop detail view - full data
query ProjectDetail($id: ID!) {
  project(id: $id) {
    id
    name
    description
    status
    budget
    # ... all fields
  }
}

Same endpoint, different payloads for different clients.

3. Evolving APIs

Adding fields to GraphQL is backward-compatible:

type Project {
  id: ID!
  name: String!
  # Add new field - old queries still work
  estimatedHours: Int
}

REST versioning is harder:

GET /api/v1/projects - old clients
GET /api/v2/projects - new clients with estimatedHours

4. Nested Data Structures

When data is naturally hierarchical:

query OrgStructure {
  organization {
    departments {
      name
      teams {
        name
        members {
          name
          role
        }
      }
    }
  }
}

With REST, you'd need recursive requests or a huge single endpoint.

Performance Considerations

The N+1 Problem in GraphQL

GraphQL's flexibility creates performance pitfalls:

query {
  projects {
    id
    name
    client {  # N+1! Loads client for each project
      name
    }
  }
}

Without DataLoader, this makes N+1 database queries. Solution:

@Resolver(() => Project)
export class ProjectResolver {
  constructor(private dataLoader: DataLoaderService) {}
 
  @ResolveField(() => Client)
  async client(@Parent() project: Project) {
    // DataLoader batches and caches
    return await this.dataLoader.load('Client', project.clientId);
  }
}
 
// DataLoader implementation
export class DataLoaderService {
  private loaders = new Map();
 
  load(type: string, id: string) {
    if (!this.loaders.has(type)) {
      this.loaders.set(type, new DataLoader(async (ids) => {
        // Batch load all IDs at once
        return await this.loadMany(type, ids);
      }));
    }
 
    return this.loaders.get(type).load(id);
  }
}

REST doesn't have this problem—each endpoint is optimized explicitly.

Query Complexity

Malicious GraphQL queries can DOS your server:

query {
  projects {
    tasks {
      timeEntries {
        user {
          projects {
            tasks {
              # Infinite depth!
            }
          }
        }
      }
    }
  }
}

You need query complexity limits:

import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver } from '@nestjs/apollo';
 
@Module({
  imports: [
    GraphQLModule.forRoot({
      driver: ApolloDriver,
      validationRules: [
        depthLimit(5),
        createComplexityLimitRule(1000),
      ],
    }),
  ],
})
export class AppModule {}

REST endpoints are bounded by design.

Developer Experience

REST: Easier to Start

REST is intuitive. Any developer can understand:

GET /users/123

GraphQL has a learning curve. Schema definition language, resolvers, fragments, mutations vs queries—there's more to learn.

GraphQL: Better Tooling

Once you're up to speed, GraphQL tooling is excellent:

  • GraphQL Playground: Interactive API explorer
  • Schema introspection: Self-documenting
  • Type generation: Auto-generate TypeScript types from schema
// Auto-generated from GraphQL schema
export interface ProjectQuery {
  project: {
    id: string;
    name: string;
    client: {
      name: string;
    };
  };
}

REST requires manual documentation (OpenAPI) and type definitions.

Our Hybrid Approach in Catalyst PSA

We used both:

REST for:

  • User authentication (POST /auth/login)
  • CRUD operations (GET/POST/PUT/DELETE /projects)
  • File uploads (POST /attachments)
  • Webhooks (POST /webhooks/stripe)

GraphQL for:

  • Dashboards (complex aggregations)
  • Reporting (flexible queries)
  • Mobile API (minimize bandwidth)
  • Public API (let partners query flexibly)

Implementation:

@Module({
  imports: [
    // REST controllers
    ProjectModule,
    TaskModule,
    TimeEntryModule,
 
    // GraphQL API
    GraphQLModule.forRoot({
      driver: ApolloDriver,
      autoSchemaFile: true,
      playground: true,
    }),
  ],
})
export class AppModule {}

Both approaches coexist peacefully.

Migration Strategy

If you're migrating from REST to GraphQL (or vice versa), do it gradually:

Phase 1: Run Both

// Keep existing REST
@Get('projects')
async getProjects() { ... }
 
// Add GraphQL alongside
@Query(() => [Project])
async projects() { ... }

Phase 2: Migrate Clients Incrementally

// Old clients use REST
GET /api/projects
 
// New clients use GraphQL
POST /graphql
  query { projects { id name } }

Phase 3: Deprecate (Eventually)

Once all clients migrated, remove the old API. But there's no rush—both can coexist.

Decision Framework

Use REST when:

  • Building simple CRUD APIs
  • HTTP caching is important
  • Simplicity > flexibility
  • Uploading files
  • Public APIs with unpredictable usage

Use GraphQL when:

  • Fetching complex, nested data
  • Supporting multiple clients (mobile, web, desktop)
  • Bandwidth is limited
  • Rapid frontend iteration (UI changes don't require backend changes)
  • Schema evolution is important

Use both when:

  • You have both simple CRUD and complex queries
  • Different clients have different needs
  • You're migrating between approaches

Conclusion

After building both REST and GraphQL APIs in production, I've learned that it's not either/or. They solve different problems.

REST excels at simple, cacheable, resource-oriented APIs. GraphQL excels at complex data fetching with flexible clients.

In Catalyst PSA, using both gave us the best of both worlds: simple CRUD with REST, flexible reporting with GraphQL.

After 27 years of API development, I can say confidently: pick the right tool for the job, and don't be afraid to use both.

Share this article

Help others discover this content


Jason Cochran

Jason Cochran

Sofware Engineer | Cloud Consultant | Founder at Strataga

27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.