API Design: Choosing Between REST, GraphQL, and gRPC
Compare REST, GraphQL, and gRPC APIs with performance benchmarks and use cases. Learn which API style fits your project based on real production experience.
Introduction
API design decisions have long-lasting consequences. The choice between REST, GraphQL, and gRPC affects everything from client development experience to system performance and maintainability.
After building APIs for healthcare systems, government platforms, and high-traffic e-commerce applications, I've developed a framework for making this decision based on real trade-offs rather than hype.
REST: The Established Standard
REST remains the most widely understood API paradigm. Its strengths lie in simplicity and cacheability.
When REST Excels
// REST shines for resource-oriented APIs with clear CRUD operations // GET /api/patients/123 // POST /api/patients // PUT /api/patients/123 // DELETE /api/patients/123 // Express.js REST implementation const router = express.Router(); router.get('/patients/:id', async (req, res) => { const patient = await patientService.findById(req.params.id); if (!patient) { return res.status(404).json({ error: 'Patient not found' }); } // ETags for caching const etag = generateETag(patient); if (req.headers['if-none-match'] === etag) { return res.status(304).end(); } res .set('ETag', etag) .set('Cache-Control', 'private, max-age=300') .json(patient); }); router.post('/patients', validateBody(createPatientSchema), async (req, res) => { const patient = await patientService.create(req.body); res.status(201) .location(`/api/patients/${patient.id}`) .json(patient); } );
REST Best Practices
// 1. Use proper HTTP methods and status codes // 2. Version your API // 3. Use HATEOAS for discoverability interface PatientResponse { id: string; name: string; dateOfBirth: string; _links: { self: { href: string }; appointments: { href: string }; medicalRecords: { href: string }; }; } // 4. Consistent error responses interface ApiError { status: number; code: string; message: string; details?: Record<string, string[]>; traceId: string; } // 5. Pagination with cursor-based approach for large datasets interface PaginatedResponse<T> { data: T[]; pagination: { cursor: string | null; hasMore: boolean; totalCount?: number; }; }
GraphQL: Flexible Queries
GraphQL solves the over-fetching and under-fetching problems of REST.
When GraphQL Excels
// GraphQL schema definition const typeDefs = gql` type Patient { id: ID! name: String! dateOfBirth: Date! appointments(first: Int, after: String): AppointmentConnection! medicalRecords: [MedicalRecord!]! primaryPhysician: Physician } type AppointmentConnection { edges: [AppointmentEdge!]! pageInfo: PageInfo! } type Query { patient(id: ID!): Patient patients( filter: PatientFilter first: Int after: String ): PatientConnection! } type Mutation { createPatient(input: CreatePatientInput!): Patient! updatePatient(id: ID!, input: UpdatePatientInput!): Patient! scheduleAppointment(input: ScheduleAppointmentInput!): Appointment! } `; // Resolvers with DataLoader for N+1 prevention const resolvers = { Query: { patient: async (_, { id }, { dataSources }) => { return dataSources.patientAPI.getPatient(id); }, }, Patient: { appointments: async (patient, { first, after }, { loaders }) => { return loaders.appointmentsByPatient.load({ patientId: patient.id, first, after, }); }, primaryPhysician: async (patient, _, { loaders }) => { if (!patient.primaryPhysicianId) return null; return loaders.physician.load(patient.primaryPhysicianId); }, }, }; // DataLoader to batch and cache requests const createLoaders = () => ({ physician: new DataLoader(async (ids) => { const physicians = await physicianService.findByIds(ids); return ids.map(id => physicians.find(p => p.id === id)); }), appointmentsByPatient: new DataLoader(async (keys) => { // Batch load appointments for multiple patients const patientIds = keys.map(k => k.patientId); const appointments = await appointmentService.findByPatientIds(patientIds); return keys.map(key => paginate( appointments.filter(a => a.patientId === key.patientId), key.first, key.after ) ); }), });
GraphQL Challenges and Solutions
// 1. Query complexity limiting const complexityPlugin = { requestDidStart: () => ({ didResolveOperation({ request, document }) { const complexity = calculateComplexity(document, { scalarCost: 1, objectCost: 2, listFactor: 10, }); if (complexity > 1000) { throw new Error(`Query too complex: ${complexity} > 1000`); } }, }), }; // 2. Rate limiting by query complexity const rateLimiter = new RateLimiter({ windowMs: 60000, max: (req) => { const complexity = req.queryComplexity || 1; return Math.floor(10000 / complexity); }, }); // 3. Persisted queries for security const persistedQueries = { 'abc123': 'query GetPatient($id: ID!) { patient(id: $id) { id name } }', 'def456': 'mutation CreatePatient($input: CreatePatientInput!) { ... }', }; // Only allow pre-approved queries in production if (process.env.NODE_ENV === 'production') { server.use((req, res, next) => { if (!persistedQueries[req.body.queryId]) { return res.status(400).json({ error: 'Query not allowed' }); } req.body.query = persistedQueries[req.body.queryId]; next(); }); }
gRPC: High-Performance Services
gRPC excels in service-to-service communication where performance matters.
When gRPC Excels
// patient_service.proto syntax = "proto3"; package healthcare.patient; service PatientService { // Unary RPC rpc GetPatient(GetPatientRequest) returns (Patient); // Server streaming - for large result sets rpc ListPatients(ListPatientsRequest) returns (stream Patient); // Client streaming - for batch uploads rpc ImportPatients(stream Patient) returns (ImportResult); // Bidirectional streaming - for real-time sync rpc SyncPatientUpdates(stream PatientUpdate) returns (stream PatientUpdate); } message Patient { string id = 1; string name = 2; google.protobuf.Timestamp date_of_birth = 3; repeated string allergies = 4; ContactInfo contact = 5; } message GetPatientRequest { string id = 1; } message ListPatientsRequest { int32 page_size = 1; string page_token = 2; PatientFilter filter = 3; }
// gRPC server implementation import * as grpc from '@grpc/grpc-js'; class PatientServiceImpl implements IPatientServiceServer { async getPatient( call: grpc.ServerUnaryCall<GetPatientRequest, Patient>, callback: grpc.sendUnaryData<Patient> ): Promise<void> { try { const patient = await this.repository.findById(call.request.id); if (!patient) { callback({ code: grpc.status.NOT_FOUND, message: 'Patient not found', }); return; } callback(null, this.toProto(patient)); } catch (error) { callback({ code: grpc.status.INTERNAL, message: error.message, }); } } // Server streaming for large datasets async listPatients( call: grpc.ServerWritableStream<ListPatientsRequest, Patient> ): Promise<void> { const cursor = this.repository.streamPatients(call.request.filter); for await (const patient of cursor) { const shouldContinue = call.write(this.toProto(patient)); if (!shouldContinue) { // Backpressure - wait for drain await new Promise(resolve => call.once('drain', resolve)); } } call.end(); } }
Making the Decision
Decision Framework
interface ApiRequirements { clientTypes: ('web' | 'mobile' | 'internal-service')[]; dataComplexity: 'simple' | 'nested' | 'highly-connected'; performanceNeeds: 'standard' | 'low-latency' | 'high-throughput'; cachingImportance: 'low' | 'medium' | 'high'; teamExperience: Record<'rest' | 'graphql' | 'grpc', 'low' | 'medium' | 'high'>; realTimeNeeds: boolean; } function recommendApiStyle(req: ApiRequirements): string[] { const recommendations: string[] = []; // REST is good default for simple, cacheable resources if (req.dataComplexity === 'simple' && req.cachingImportance === 'high') { recommendations.push('REST'); } // GraphQL for complex, nested data with multiple clients if (req.dataComplexity === 'highly-connected' && req.clientTypes.length > 1) { recommendations.push('GraphQL'); } // gRPC for internal services needing performance if (req.clientTypes.includes('internal-service') && (req.performanceNeeds === 'low-latency' || req.performanceNeeds === 'high-throughput')) { recommendations.push('gRPC'); } // Consider streaming needs if (req.realTimeNeeds) { recommendations.push('gRPC (streaming)', 'GraphQL Subscriptions'); } return recommendations; }
Hybrid Approaches
In complex systems, you might use multiple paradigms:
┌─────────────────────────────────────────────────────────────┐
│ Clients │
│ Web App Mobile App Partner APIs │
└──────┬─────────────────┬────────────────┬───────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ GraphQL │ │ GraphQL │ │ REST │
│ Gateway │ │ Gateway │ │ Gateway │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
└────────────┬────┴────────────────┘
│
▼
┌───────────────┐
│ API Gateway │
│ (Kong/etc) │
└───────┬───────┘
│
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ gRPC │ │ gRPC │ │ gRPC │
│ Patient │ │ Billing │ │ Schedule │
│ Service │ │ Service │ │ Service │
└──────────┘ └──────────┘ └──────────┘
Conclusion
There's no universally "best" API style. The right choice depends on:
- Client needs: Mobile/web clients often benefit from GraphQL's flexibility
- Performance requirements: gRPC for internal high-performance communication
- Caching needs: REST's HTTP caching is unmatched
- Team expertise: Don't underestimate the learning curve
- Ecosystem: Consider tooling, documentation, and community support
Start with REST if unsure—it's well-understood and works for most cases. Add GraphQL for complex client needs. Use gRPC for internal service communication where performance matters.
Related Articles
Software Architecture22 min read
Domain-Driven Design: Bounded Contexts in Practice
Learn how to implement bounded contexts in Domain-Driven Design. Practical guide covering context mapping, aggregates, domain events, and real examples from enterprise projects.
Software Architecture19 min read
Building Resilient Distributed Systems: Patterns for Fault Tolerance
Build resilient distributed systems with circuit breakers, retries, and timeouts. Production patterns for handling failures, cascading errors, and maintaining availability.
Backend Design20 min read
Database Design Patterns for Scale
Scale databases with sharding, replication, and partitioning. Covers PostgreSQL, MySQL, and MongoDB scaling patterns with real performance numbers from production systems.
Security Engineering18 min read
API Security Hardening: A Practitioner's Guide
Secure your APIs with rate limiting, input validation, and CORS configuration. Production-tested checklist covering authentication, encryption, and error handling.
Frontend Engineering16 min read
Advanced TypeScript Patterns for Large Codebases
Master advanced TypeScript patterns including generics, utility types, and type guards. Real examples from large-scale React and Node.js codebases.