A

HTTP Best Practices for REST APIs

apiresthttpstatus-codespaginationversioningerror-handling

HTTP Best Practices for REST APIs

Building a REST API means working with HTTP, not against it. The protocol provides semantics for status codes, caching, content negotiation, and more—use them. This article covers the practical patterns that make APIs predictable, cacheable, and easy to debug.

HTTP Status Codes

Status codes tell clients what happened without parsing response bodies. Use them semantically.

2xx — Success

Code Meaning When to Use
200 OK Request succeeded GET, PUT, PATCH that returns data
201 Created Resource created POST that creates a new resource
202 Accepted Request accepted for processing Async operations (background jobs)
204 No Content Success, no body DELETE, or PUT/PATCH with no response body
// 200 OK - Successful GET or update
router.get('/users/:id', async (req, res) => {
  const user = await db.user.findUnique({ where: { id: req.params.id } });
  if (!user) return res.status(404).json({ error: 'User not found' });
  res.status(200).json({ data: user });
});

// 201 Created - New resource
router.post('/users', async (req, res) => {
  const user = await db.user.create({ data: req.body });

  // Include Location header pointing to new resource
  res.status(201)
    .header('Location', `/users/${user.id}`)
    .json({ data: user });
});

// 202 Accepted - Async job queued
router.post('/reports/generate', async (req, res) => {
  const job = await queue.add('generate-report', { userId: req.user.id });

  res.status(202).json({
    data: {
      jobId: job.id,
      status: 'pending',
      statusUrl: `/jobs/${job.id}`,
    },
  });
});

// 204 No Content - Successful delete
router.delete('/users/:id', async (req, res) => {
  await db.user.delete({ where: { id: req.params.id } });
  res.status(204).send();
});

3xx — Redirection

Code Meaning When to Use
301 Moved Permanently Resource moved forever Old URL permanently redirects
302 Found Temporary redirect Temporary redirect (maintains method)
304 Not Modified Cached version valid Response to conditional GET
307 Temporary Redirect Temporary, preserve method Redirect POST without changing to GET
308 Permanent Redirect Permanent, preserve method Permanent redirect for non-GET
// 301 - Permanent redirect (old API version)
router.get('/v1/users', (req, res) => {
  res.redirect(301, '/v2/users');
});

// 304 - Conditional GET with ETag
router.get('/posts/:id', async (req, res) => {
  const post = await db.post.findUnique({ where: { id: req.params.id } });
  const etag = generateETag(post);

  // Client sent If-None-Match header
  if (req.headers['if-none-match'] === etag) {
    return res.status(304).send();
  }

  res.setHeader('ETag', etag);
  res.json({ data: post });
});

4xx — Client Errors

Code Meaning When to Use
400 Bad Request Malformed request Invalid JSON, missing required fields
401 Unauthorized Not authenticated No token, expired token, invalid token
403 Forbidden Not authorized Valid auth, but no permission
404 Not Found Resource doesn't exist ID not in database
405 Method Not Allowed HTTP method not supported POST to read-only endpoint
409 Conflict Resource state conflict Duplicate email, concurrent edit conflict
410 Gone Resource permanently deleted Soft-deleted resource that won't return
422 Unprocessable Entity Semantic error Valid JSON but business logic fails
429 Too Many Requests Rate limited Slow down, include Retry-After
// 400 Bad Request - Invalid input
router.post('/users', async (req, res) => {
  const result = userSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Invalid request body',
        details: result.error.flatten().fieldErrors,
      },
    });
  }
  // ...
});

// 401 Unauthorized - Not authenticated
const authMiddleware = (req, res, next) => {
  const token = req.headers.authorization?.replace('Bearer ', '');

  if (!token) {
    return res.status(401).json({
      error: {
        code: 'UNAUTHORIZED',
        message: 'Authentication required',
      },
    });
  }

  try {
    req.user = verifyToken(token);
    next();
  } catch {
    return res.status(401).json({
      error: {
        code: 'INVALID_TOKEN',
        message: 'Token is invalid or expired',
      },
    });
  }
};

// 403 Forbidden - Authenticated but not authorized
router.delete('/posts/:id', async (req, res) => {
  const post = await db.post.findUnique({ where: { id: req.params.id } });

  if (!post) {
    return res.status(404).json({ error: { code: 'NOT_FOUND' } });
  }

  if (post.authorId !== req.user.id && req.user.role !== 'admin') {
    return res.status(403).json({
      error: {
        code: 'FORBIDDEN',
        message: 'You do not have permission to delete this post',
      },
    });
  }
  // ...
});

// 409 Conflict - Duplicate resource
router.post('/users', async (req, res) => {
  const existing = await db.user.findUnique({
    where: { email: req.body.email },
  });

  if (existing) {
    return res.status(409).json({
      error: {
        code: 'DUPLICATE_EMAIL',
        message: 'A user with this email already exists',
      },
    });
  }
  // ...
});

// 422 Unprocessable Entity - Business logic error
router.post('/orders', async (req, res) => {
  const product = await db.product.findUnique({ where: { id: req.body.productId } });

  if (product.stock < req.body.quantity) {
    return res.status(422).json({
      error: {
        code: 'INSUFFICIENT_STOCK',
        message: `Only ${product.stock} items available`,
        available: product.stock,
      },
    });
  }
  // ...
});

// 429 Rate Limited
router.use('/api', rateLimit({
  windowMs: 60 * 1000,
  max: 100,
  handler: (req, res) => {
    res.status(429)
      .header('Retry-After', '60')
      .json({
        error: {
          code: 'RATE_LIMITED',
          message: 'Too many requests, please try again later',
          retryAfter: 60,
        },
      });
  },
}));

5xx — Server Errors

Code Meaning When to Use
500 Internal Server Error Unexpected error Unhandled exception, bug
502 Bad Gateway Upstream failure Database down, service unavailable
503 Service Unavailable Temporarily down Maintenance, overloaded
504 Gateway Timeout Upstream timeout Slow dependency
// Global error handler - catch unhandled errors
app.use((err, req, res, next) => {
  console.error('Unhandled error:', err);

  // Don't leak internal details
  res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred',
      requestId: req.id, // For support correlation
    },
  });
});

// 503 during maintenance
if (process.env.MAINTENANCE_MODE === 'true') {
  app.use((req, res) => {
    res.status(503)
      .header('Retry-After', '3600')
      .json({
        error: {
          code: 'MAINTENANCE',
          message: 'Service temporarily unavailable for maintenance',
        },
      });
  });
}

Error Response Format

Use a consistent error structure across your entire API:

// src/types/api.ts
interface ApiError {
  error: {
    code: string;           // Machine-readable code (VALIDATION_ERROR)
    message: string;        // Human-readable message
    details?: unknown;      // Additional context (field errors, etc)
    requestId?: string;     // For support/debugging
  };
}

// Example error responses
// 400 Validation Error
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request body",
    "details": {
      "email": ["Invalid email format"],
      "name": ["Required"]
    },
    "requestId": "req_abc123"
  }
}

// 404 Not Found
{
  "error": {
    "code": "NOT_FOUND",
    "message": "User not found",
    "requestId": "req_abc123"
  }
}

// 500 Internal Error
{
  "error": {
    "code": "INTERNAL_ERROR",
    "message": "An unexpected error occurred",
    "requestId": "req_abc123"
  }
}

API Versioning

APIs evolve. Breaking changes happen. Versioning strategies help you migrate clients gracefully.

URI Versioning

Version is part of the URL path. Most explicit and widely used.

GET /v1/users
GET /v2/users
// src/routes/v1/users.ts
const v1Router = Router();
v1Router.get('/users', (req, res) => {
  // v1 returns flat user object
  res.json({ data: users });
});

// src/routes/v2/users.ts
const v2Router = Router();
v2Router.get('/users', (req, res) => {
  // v2 includes nested profile
  res.json({ data: usersWithProfiles });
});

// src/app.ts
app.use('/v1', v1Router);
app.use('/v2', v2Router);

Pros:

  • Obvious which version you're using
  • Easy to test in browser
  • Cache-friendly (different URLs)

Cons:

  • URL changes when version changes
  • Clients need to update all URLs

Header Versioning

Version in custom header or Accept header.

GET /users
Accept-Version: v1

# Or using content negotiation
GET /users
Accept: application/vnd.myapi.v1+json
const versionMiddleware = (req, res, next) => {
  const version = req.headers['accept-version'] || 'v2'; // Default to latest

  req.apiVersion = version;
  next();
};

router.get('/users', (req, res) => {
  if (req.apiVersion === 'v1') {
    return res.json({ data: v1Response });
  }

  res.json({ data: v2Response });
});

Pros:

  • URLs stay clean
  • Single endpoint serves multiple versions

Cons:

  • Can't test easily in browser
  • Caching more complex
  • Easy to forget the header

Query Parameter Versioning

GET /users?v=1
GET /users?version=2
router.get('/users', (req, res) => {
  const version = req.query.v || req.query.version || '2';

  if (version === '1') {
    return res.json({ data: v1Response });
  }

  res.json({ data: v2Response });
});

Pros:

  • Easy to switch versions
  • Works in browser

Cons:

  • Non-standard
  • Pollutes URL with meta-info
  • Optional parameter can be forgotten

Recommendation

Use URI versioning for public APIs. It's explicit, standard, and cache-friendly. The "ugly URL" argument is weak—clarity beats aesthetics for APIs.

# Production API
https://api.example.com/v1/users

# When v2 is ready, clients migrate at their pace
https://api.example.com/v2/users

# Eventually deprecate v1
https://api.example.com/v1/* → 410 Gone (with migration guide)

Pagination Patterns

Any endpoint that returns a list needs pagination. Choose based on your data access patterns.

Offset-Based Pagination

Traditional approach using limit and offset.

GET /posts?limit=20&offset=0   # Page 1
GET /posts?limit=20&offset=20  # Page 2
GET /posts?limit=20&offset=40  # Page 3
// src/routes/posts.ts
router.get('/posts', async (req, res) => {
  const limit = Math.min(Number(req.query.limit) || 20, 100);
  const offset = Number(req.query.offset) || 0;

  const [posts, total] = await Promise.all([
    db.post.findMany({
      take: limit,
      skip: offset,
      orderBy: { createdAt: 'desc' },
    }),
    db.post.count(),
  ]);

  res.json({
    data: posts,
    meta: {
      total,
      limit,
      offset,
      hasMore: offset + posts.length < total,
    },
  });
});

Pros:

  • Simple to implement
  • Can jump to any page
  • Easy to show "Page X of Y"

Cons:

  • Performance degrades with large offsets (OFFSET 10000 scans 10000 rows)
  • Inconsistent results if data changes between requests (items shift)

Cursor-Based Pagination

Uses an opaque cursor (usually the last item's ID) to fetch the next page.

GET /posts?limit=20                    # First page
GET /posts?limit=20&after=post_abc123  # Next page (after cursor)
// src/routes/posts.ts
router.get('/posts', async (req, res) => {
  const limit = Math.min(Number(req.query.limit) || 20, 100);
  const cursor = req.query.after;

  // Fetch one extra to check if there's more
  const posts = await db.post.findMany({
    take: limit + 1,
    cursor: cursor ? { id: cursor } : undefined,
    skip: cursor ? 1 : 0, // Skip the cursor item itself
    orderBy: { createdAt: 'desc' },
  });

  const hasMore = posts.length > limit;
  const data = hasMore ? posts.slice(0, -1) : posts;

  res.json({
    data,
    meta: {
      hasMore,
      nextCursor: hasMore ? data[data.length - 1].id : null,
    },
  });
});

Pros:

  • Consistent performance regardless of position
  • Stable results even if data changes
  • Works well with real-time data

Cons:

  • Can't jump to arbitrary page
  • Harder to show "Page X of Y"
  • Cursor must be stable (ID-based, not position-based)

Keyset Pagination

Similar to cursor, but uses actual column values instead of opaque cursors. More flexible for sorting.

GET /posts?limit=20
GET /posts?limit=20&after_date=2024-01-15&after_id=abc123
// Keyset pagination with compound sort
router.get('/posts', async (req, res) => {
  const limit = Math.min(Number(req.query.limit) || 20, 100);
  const afterDate = req.query.after_date;
  const afterId = req.query.after_id;

  let whereClause = {};

  if (afterDate && afterId) {
    // Posts older than cursor, or same date but lower ID
    whereClause = {
      OR: [
        { createdAt: { lt: new Date(afterDate) } },
        {
          AND: [
            { createdAt: new Date(afterDate) },
            { id: { lt: afterId } },
          ],
        },
      ],
    };
  }

  const posts = await db.post.findMany({
    where: whereClause,
    take: limit + 1,
    orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
  });

  const hasMore = posts.length > limit;
  const data = hasMore ? posts.slice(0, -1) : posts;
  const lastItem = data[data.length - 1];

  res.json({
    data,
    meta: {
      hasMore,
      nextCursor: hasMore
        ? { after_date: lastItem.createdAt.toISOString(), after_id: lastItem.id }
        : null,
    },
  });
});

Pagination Recommendation

Use Case Recommendation
Admin dashboards Offset (need page numbers, datasets are manageable)
Infinite scroll Cursor (consistent, performant)
Large datasets Cursor or keyset (offset will timeout)
Sorted by multiple columns Keyset (cursor can encode sort state)

Caching

HTTP caching reduces server load and improves response times. Use it for read-heavy endpoints.

Cache-Control Header

// Immutable resource (versioned asset)
router.get('/assets/:hash', (req, res) => {
  res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
  res.sendFile(assetPath);
});

// Public cacheable for 5 minutes
router.get('/posts', async (req, res) => {
  res.setHeader('Cache-Control', 'public, max-age=300');
  res.json({ data: await getPosts() });
});

// Private (user-specific), cacheable for 1 minute
router.get('/profile', authMiddleware, async (req, res) => {
  res.setHeader('Cache-Control', 'private, max-age=60');
  res.json({ data: await getProfile(req.user.id) });
});

// No cache (sensitive data)
router.get('/account/balance', authMiddleware, async (req, res) => {
  res.setHeader('Cache-Control', 'no-store');
  res.json({ data: await getBalance(req.user.id) });
});

ETag for Conditional Requests

ETags let clients ask "has this changed?"

import { createHash } from 'crypto';

const generateETag = (data: unknown): string => {
  const hash = createHash('md5')
    .update(JSON.stringify(data))
    .digest('hex');
  return `"${hash}"`;
};

router.get('/posts/:id', async (req, res) => {
  const post = await db.post.findUnique({ where: { id: req.params.id } });

  if (!post) {
    return res.status(404).json({ error: { code: 'NOT_FOUND' } });
  }

  const etag = generateETag(post);

  // Check if client has current version
  if (req.headers['if-none-match'] === etag) {
    return res.status(304).send();
  }

  res.setHeader('ETag', etag);
  res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
  res.json({ data: post });
});

Vary Header

Tell caches that response varies based on certain request headers:

// Response varies by authorization (different users get different data)
res.setHeader('Vary', 'Authorization');

// Response varies by accept-encoding (gzip vs plain)
res.setHeader('Vary', 'Accept-Encoding');

// Multiple vary headers
res.setHeader('Vary', 'Authorization, Accept-Language');

Content Negotiation

Support multiple response formats using the Accept header.

router.get('/posts/:id', async (req, res) => {
  const post = await db.post.findUnique({ where: { id: req.params.id } });

  if (!post) {
    return res.status(404).json({ error: { code: 'NOT_FOUND' } });
  }

  const accept = req.headers.accept || 'application/json';

  if (accept.includes('application/xml')) {
    res.type('application/xml');
    return res.send(toXML(post));
  }

  if (accept.includes('text/csv')) {
    res.type('text/csv');
    return res.send(toCSV([post]));
  }

  // Default to JSON
  res.json({ data: post });
});

Idempotency

Idempotent operations can be safely retried. GET, PUT, DELETE are idempotent by design. POST typically isn't—creating a resource twice creates duplicates.

Idempotency Keys

For non-idempotent operations, accept a client-generated key:

// src/middleware/idempotency.ts
const idempotencyCache = new Map<string, { status: number; body: unknown }>();

export const idempotencyMiddleware = async (req, res, next) => {
  const key = req.headers['idempotency-key'];

  if (!key) {
    return next();
  }

  // Check if we've seen this request before
  const cached = idempotencyCache.get(key);

  if (cached) {
    return res.status(cached.status).json(cached.body);
  }

  // Capture the response
  const originalJson = res.json.bind(res);

  res.json = (body) => {
    idempotencyCache.set(key, { status: res.statusCode, body });

    // Expire after 24 hours
    setTimeout(() => idempotencyCache.delete(key), 24 * 60 * 60 * 1000);

    return originalJson(body);
  };

  next();
};

// Usage
router.post('/payments', idempotencyMiddleware, async (req, res) => {
  const payment = await processPayment(req.body);
  res.status(201).json({ data: payment });
});

Client usage:

// Client generates unique key per logical operation
const response = await fetch('/api/payments', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(),
  },
  body: JSON.stringify({ amount: 1000 }),
});

// Safe to retry on network error - same key returns same response

Common Gotchas

1. Using 200 for Everything

Don't return 200 OK with an error body. Status codes exist for a reason.

// BAD
res.status(200).json({ success: false, error: 'Not found' });

// GOOD
res.status(404).json({ error: { code: 'NOT_FOUND', message: 'User not found' } });

2. Exposing Internal Errors

Never expose stack traces or internal details to clients.

// BAD
res.status(500).json({ error: err.stack });

// GOOD
console.error('Internal error:', err);
res.status(500).json({
  error: {
    code: 'INTERNAL_ERROR',
    message: 'An unexpected error occurred',
    requestId: req.id,
  },
});

3. Ignoring Accept Headers

If a client asks for XML and you only support JSON, return 406 Not Acceptable.

if (req.headers.accept === 'application/xml') {
  return res.status(406).json({
    error: {
      code: 'NOT_ACCEPTABLE',
      message: 'XML format not supported. Use application/json.',
    },
  });
}

4. Large Payloads Without Limits

Always limit request body size:

app.use(express.json({ limit: '1mb' }));

// Or per-route for file uploads
router.post('/upload', express.json({ limit: '10mb' }), uploadHandler);

See Also

Last updated: March 23, 2026