API Performance Fundamentals
API performance directly impacts the user experience because most modern web applications fetch data from APIs after the initial page load. Slow APIs cause delayed content rendering, spinner-heavy interfaces, and poor INP scores. Optimizing APIs means reducing response time, minimizing payload size, and enabling efficient data fetching patterns.
API Performance Metrics
- Response time (p50, p95, p99): Target p95 under 200ms for user-facing APIs
- Payload size: Minimize data transfer with field selection and compression
- Request count: Reduce waterfalls with batching and denormalization
- Error rate: Failed requests waste time and often trigger retries
- Time to First Byte: How quickly the server begins sending the response
Response Compression
// Enable Brotli and gzip compression for API responses
// next.config.js
module.exports = {
compress: true, // Enables gzip by default in Next.js
};
// For custom API routes with optimal compression
import { NextRequest, NextResponse } from 'next/server';
import { gzip, brotliCompress } from 'zlib';
import { promisify } from 'util';
const gzipAsync = promisify(gzip);
const brotliAsync = promisify(brotliCompress);
export async function GET(request: NextRequest) {
const data = await getExpensiveData();
const json = JSON.stringify(data);
const acceptEncoding = request.headers.get('accept-encoding') || '';
if (acceptEncoding.includes('br')) {
const compressed = await brotliAsync(Buffer.from(json));
return new Response(compressed, {
headers: {
'Content-Type': 'application/json',
'Content-Encoding': 'br',
'Cache-Control': 'public, s-maxage=60',
},
});
}
return NextResponse.json(data);
}
// Middleware for automatic response compression timing
export async function middleware(request: NextRequest) {
const start = Date.now();
const response = NextResponse.next();
const duration = Date.now() - start;
response.headers.set('Server-Timing', `api;dur=${duration}`);
return response;
}Efficient Data Fetching Patterns
// 1. Field selection — only return requested fields
// app/api/products/route.ts
export async function GET(request: NextRequest) {
const fields = request.nextUrl.searchParams.get('fields')?.split(',');
const select = fields ? Object.fromEntries(
fields.map(f => [f.trim(), true])
) : undefined;
const products = await prisma.product.findMany({
select: select || { id: true, name: true, price: true, slug: true },
take: 20,
});
return NextResponse.json(products);
}
// 2. Request batching — combine multiple requests into one
// app/api/batch/route.ts
interface BatchRequest {
id: string;
method: string;
path: string;
body?: any;
}
export async function POST(request: NextRequest) {
const { requests }: { requests: BatchRequest[] } = await request.json();
const results = await Promise.all(
requests.map(async (req) => {
try {
const url = new URL(req.path, request.nextUrl.origin);
const response = await fetch(url, {
method: req.method,
body: req.body ? JSON.stringify(req.body) : undefined,
headers: { 'Content-Type': 'application/json' },
});
const data = await response.json();
return { id: req.id, status: response.status, data };
} catch (error) {
return { id: req.id, status: 500, error: 'Internal error' };
}
})
);
return NextResponse.json({ results });
}
// 3. DataLoader pattern for N+1 prevention in GraphQL/API
class DataLoader<K, V> {
private batch: Map<K, { resolve: (v: V) => void; reject: (e: Error) => void }> = new Map();
private timer: NodeJS.Timeout | null = null;
constructor(private batchFn: (keys: K[]) => Promise<Map<K, V>>) {}
async load(key: K): Promise<V> {
return new Promise((resolve, reject) => {
this.batch.set(key, { resolve, reject });
if (!this.timer) {
this.timer = setTimeout(() => this.executeBatch(), 0);
}
});
}
private async executeBatch() {
this.timer = null;
const batch = new Map(this.batch);
this.batch.clear();
try {
const results = await this.batchFn(Array.from(batch.keys()));
for (const [key, { resolve }] of batch) {
resolve(results.get(key)!);
}
} catch (error) {
for (const [, { reject }] of batch) {
reject(error as Error);
}
}
}
}API Response Time Optimization
// Parallel data fetching — avoid sequential waterfalls
export async function GET(request: NextRequest) {
const userId = request.nextUrl.searchParams.get('userId')!;
// BAD: Sequential — total time = sum of all requests
// const user = await getUser(userId);
// const orders = await getOrders(userId);
// const recommendations = await getRecommendations(userId);
// GOOD: Parallel — total time = max of all requests
const [user, orders, recommendations] = await Promise.all([
getUser(userId),
getOrders(userId),
getRecommendations(userId),
]);
return NextResponse.json({ user, orders, recommendations });
}
// Streaming API responses for large datasets
export async function GET(request: NextRequest) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const cursor = prisma.product.findMany({
take: 1000,
orderBy: { id: 'asc' },
});
controller.enqueue(encoder.encode('['));
let first = true;
for (const product of await cursor) {
if (!first) controller.enqueue(encoder.encode(','));
controller.enqueue(encoder.encode(JSON.stringify(product)));
first = false;
}
controller.enqueue(encoder.encode(']'));
controller.close();
},
});
return new Response(stream, {
headers: { 'Content-Type': 'application/json' },
});
}API Performance Checklist
- Compress responses: Enable Brotli/gzip for JSON responses
- Return only needed fields: Support field selection in your API
- Fetch in parallel: Use Promise.all for independent data fetches
- Batch requests: Combine multiple client requests into a single API call
- Use cursor pagination: Efficient pagination for large datasets
- Set caching headers: Use appropriate Cache-Control for each endpoint