API Design for Frontend Performance
API Design for Frontend Performance
The API Waterfall Nobody Optimized
The e-commerce inventory dashboard loads data from 7 REST endpoints:
GET /api/products- Product catalog (42KB response)GET /api/inventory/levels- Stock levels per product (18KB)GET /api/inventory/alerts- Low stock alerts (4KB)GET /api/orders/recent- Last 50 orders (28KB)GET /api/orders/metrics- Revenue and order count aggregates (2KB)GET /api/shipping/pending- Pending shipments (12KB)GET /api/analytics/conversion- Conversion funnel data (8KB)
These requests execute sequentially in the React component tree. The products component mounts and fetches products. Once products render, the inventory component mounts and fetches stock levels. The orders component mounts independently but waits for its own fetch before rendering order data.
The waterfall:
t=0ms: Products fetch starts
t=320ms: Products response received (42KB, 320ms)
t=340ms: Inventory fetch starts (depends on products rendering)
t=580ms: Inventory response received (18KB, 240ms)
t=340ms: Orders fetch starts (independent)
t=600ms: Orders response received (28KB, 260ms)
t=600ms: Shipping fetch starts (depends on orders)
t=780ms: Shipping response received (12KB, 180ms)
t=340ms: Metrics fetch starts (independent)
t=440ms: Metrics response received (2KB, 100ms)
t=340ms: Alerts fetch starts (independent)
t=460ms: Alerts response received (4KB, 120ms)
t=340ms: Conversion fetch starts (independent)
t=520ms: Conversion response received (8KB, 180ms)
Total time to full dashboard render: 780ms (limited by the sequential products → inventory → shipping chain). Total data transferred: 114KB across 7 requests. Total round trips: 7 (each request pays TCP overhead even with HTTP/2 multiplexing, because the application layer serializes them).
The problem is not the individual response sizes. The problem is the number of round trips and the sequential dependencies between them.
Aggregation Endpoints
A single aggregation endpoint serves all dashboard data in one request:
// SLOW: 7 separate fetches with sequential dependencies
async function loadDashboard(): Promise<DashboardData> {
const products = await fetch("/api/products").then((r) => r.json());
const [inventory, orders, metrics, alerts, conversion] = await Promise.all([
fetch("/api/inventory/levels").then((r) => r.json()),
fetch("/api/orders/recent").then((r) => r.json()),
fetch("/api/orders/metrics").then((r) => r.json()),
fetch("/api/inventory/alerts").then((r) => r.json()),
fetch("/api/analytics/conversion").then((r) => r.json()),
]);
const shipping = await fetch("/api/shipping/pending").then((r) => r.json());
return { products, inventory, orders, metrics, alerts, conversion, shipping };
}
// FAST: Single aggregation endpoint
async function loadDashboard(): Promise<DashboardData> {
const response = await fetch("/api/dashboard");
return response.json();
}
The server-side aggregation endpoint:
// Server: Express aggregation endpoint
import type { Request, Response } from "express";
interface DashboardResponse {
products: Product[];
inventory: InventoryLevel[];
alerts: Alert[];
recentOrders: Order[];
metrics: OrderMetrics;
pendingShipments: Shipment[];
conversionData: ConversionFunnel;
}
async function getDashboard(req: Request, res: Response): Promise<void> {
// Parallel server-side fetches (internal service calls, ~1ms latency)
const [products, inventory, alerts, orders, metrics, shipping, conversion] =
await Promise.all([
productService.getAll(),
inventoryService.getLevels(),
inventoryService.getAlerts(),
orderService.getRecent(50),
orderService.getMetrics(),
shippingService.getPending(),
analyticsService.getConversion(),
]);
const response: DashboardResponse = {
products,
inventory,
alerts,
recentOrders: orders,
metrics,
pendingShipments: shipping,
conversionData: conversion,
};
res.json(response);
}
Server-side service calls run over localhost or an internal network with <1ms latency. The parallel server-side calls complete in the time of the slowest individual call (~20ms for a database query) rather than the sum of sequential client-side round trips.
Results:
| Metric | 7 REST calls | 1 Aggregation call | Delta |
|---|---|---|---|
| Total round trips | 7 | 1 | -6 |
| Time to full data | 780ms | 320ms | -460ms |
| Total transfer | 114 kB | 98 kB* | -16 kB |
| LCP (dashboard) | 1,800ms | 1,020ms | -780ms |
*The aggregation response is smaller than the sum of individual responses because HTTP response headers (200-400 bytes each) are sent once instead of seven times, and there is less JSON envelope overhead.
GraphQL: The Selective Win
The dashboard’s aggregation endpoint solves the waterfall. But it has a rigid structure: every dashboard load fetches all seven datasets, even when the user has collapsed the orders panel and does not need order data.
GraphQL allows the client to request exactly the fields it needs:
# Only the data visible in the current viewport
query DashboardVisible {
products(first: 20) {
id
name
price
thumbnailUrl
}
inventoryLevels {
productId
quantity
warehouseId
}
alerts(severity: HIGH) {
productId
message
}
}
This query returns 38KB instead of the REST aggregation’s 98KB. The 60KB difference comes from excluding order data, shipping data, and conversion analytics that the user has not expanded.
The GraphQL server:
// GraphQL schema and resolvers (simplified)
import { makeExecutableSchema } from "@graphql-tools/schema";
const typeDefs = `
type Product {
id: ID!
name: String!
price: Float!
thumbnailUrl: String!
category: String!
}
type InventoryLevel {
productId: ID!
quantity: Int!
warehouseId: String!
}
type Alert {
productId: ID!
message: String!
severity: AlertSeverity!
}
enum AlertSeverity {
LOW
MEDIUM
HIGH
}
type OrderMetrics {
totalOrders: Int!
totalRevenue: Float!
averageOrderValue: Float!
}
type Query {
products(first: Int): [Product!]!
inventoryLevels: [InventoryLevel!]!
alerts(severity: AlertSeverity): [Alert!]!
recentOrders(limit: Int): [Order!]!
orderMetrics: OrderMetrics!
pendingShipments: [Shipment!]!
conversionData: ConversionFunnel!
}
`;
const resolvers = {
Query: {
products: async (_: unknown, args: { first?: number }) => {
return productService.getAll(args.first);
},
inventoryLevels: async () => {
return inventoryService.getLevels();
},
alerts: async (_: unknown, args: { severity?: string }) => {
return inventoryService.getAlerts(args.severity);
},
// ... other resolvers
},
};
The TypeScript client with generated types:
// Generated by graphql-codegen
import type { TypedDocumentNode } from "@graphql-typed-document-node/core";
interface DashboardVisibleQuery {
products: Array<{
id: string;
name: string;
price: number;
thumbnailUrl: string;
}>;
inventoryLevels: Array<{
productId: string;
quantity: number;
warehouseId: string;
}>;
alerts: Array<{
productId: string;
message: string;
}>;
}
const DASHBOARD_QUERY: TypedDocumentNode<DashboardVisibleQuery> = gql`
query DashboardVisible {
products(first: 20) {
id
name
price
thumbnailUrl
}
inventoryLevels {
productId
quantity
warehouseId
}
alerts(severity: HIGH) {
productId
message
}
}
`;
async function loadVisibleDashboard(): Promise<DashboardVisibleQuery> {
const response = await fetch("/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: print(DASHBOARD_QUERY) }),
});
const { data } = await response.json();
return data;
}
Persisted Queries for CDN Cacheability
GraphQL’s default transport is POST with the query in the request body. POST requests are not cached by CDNs. This eliminates one of REST’s strongest performance advantages.
Persisted queries solve this. At build time, each GraphQL query is hashed. The client sends the hash instead of the full query text. The server maps the hash to the pre-registered query.
// Build time: generate query hashes
import { createHash } from "crypto";
function persistQuery(query: string): string {
return createHash("sha256").update(query).digest("hex");
}
// Client sends:
// GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123..."}}
// Server maps hash → query and executes
With GET requests and query hashes, CDN caching works:
GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123..."}}
Cache-Control: public, max-age=60, stale-while-revalidate=300
The CDN caches each unique query hash as a separate entry. The dashboard query with its specific field selection is cached as one entry. A different query (with orders expanded) is a different hash and a different cache entry.
The Decision Rule
GraphQL is the correct choice when:
- The client needs different subsets of a data graph for different views, and the payload size difference between the full graph and the needed subset exceeds 40% of the full response.
- The client makes 4 or more REST calls that could be consolidated into a single GraphQL query.
- The application has mobile clients where bandwidth conservation is critical.
GraphQL is not the correct choice when:
- The API serves a single client with predictable data access patterns. A REST aggregation endpoint serves the same purpose with less infrastructure.
- The API is public and heavily cached. REST GET requests with CDN caching outperform GraphQL POST requests in cache hit rate.
- The team does not have GraphQL operational experience. The schema, resolvers, dataloaders, and query complexity limiting are real operational costs.
For the e-commerce platform: the dashboard uses GraphQL (variable data needs, 60% payload reduction on common views). The product listing pages use REST (predictable data shape, CDN-cacheable, public). The checkout API uses REST (fixed request/response shapes, no benefit from field selection).
The CI performance gate from Chapter 2 validates both approaches. GraphQL responses are measured by payload size in the Lighthouse resource audit. REST aggregation endpoints are measured by request count and total transfer size.