Stop Writing APIs Like It's 2015

Published: (December 28, 2025 at 05:50 AM EST)
4 min read
Source: Dev.to

Source: Dev.to

Introduction

We’re in 2025, and many codebases still treat APIs as simple “endpoints that return JSON.” If your API design hasn’t evolved past basic CRUD routes, you’re sacrificing performance, scalability, and developer experience.

1. Stop Returning Everything by Default

The Problem

// 2015 mindset
app.get('/api/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  res.json(user); // Returns 47 fields no one asked for
});

Why It’s Bad

  • Kills mobile performance
  • Exposes internal data structures
  • Forces the frontend to filter
  • Makes caching nearly impossible

Modern Approach

app.get('/api/users/:id', async (req, res) => {
  const fields = req.query.fields?.split(',') || ['id', 'name', 'email'];
  const user = await db.users.findById(req.params.id, { select: fields });
  res.json(user);
});

Let the client request only what it needs. GraphQL taught us this years ago—REST can do it too.

2. Pagination Without Limits Is Criminal

The Problem

app.get('/api/products', async (req, res) => {
  const products = await db.products.find(); // All 50,000 rows
  res.json(products);
});

Why It’s Bad

  • One request can tank your database
  • No way to handle growth
  • Timeouts degrade user experience

Modern Approach (Cursor‑based Pagination)

app.get('/api/products', async (req, res) => {
  const limit = Math.min(parseInt(req.query.limit) || 20, 100);
  const cursor = req.query.cursor;

  const products = await db.products.find({
    where: cursor ? { id: { gt: cursor } } : {},
    limit: limit + 1
  });

  const hasNext = products.length > limit;
  const items = hasNext ? products.slice(0, -1) : products;

  res.json({
    data: items,
    cursor: hasNext ? items[items.length - 1].id : null
  });
});

Predictable load and infinite scroll that doesn’t destroy your server.

3. Error Responses Are Not an Afterthought

The Problem

app.post('/api/orders', async (req, res) => {
  try {
    const order = await createOrder(req.body);
    res.json(order);
  } catch (err) {
    res.status(500).json({ error: 'Something went wrong' });
  }
});

Frontend developers hate this vague response.

Modern Approach

app.post('/api/orders', async (req, res) => {
  try {
    const order = await createOrder(req.body);
    res.json({ data: order });
  } catch (err) {
    if (err.name === 'ValidationError') {
      return res.status(400).json({
        error: {
          code: 'VALIDATION_FAILED',
          message: 'Invalid order data',
          fields: err.details
        }
      });
    }

    if (err.name === 'InsufficientStock') {
      return res.status(409).json({
        error: {
          code: 'INSUFFICIENT_STOCK',
          message: 'Product out of stock',
          productId: err.productId
        }
      });
    }

    // Log actual error server‑side
    logger.error(err);
    res.status(500).json({
      error: {
        code: 'INTERNAL_ERROR',
        message: 'Failed to create order'
      }
    });
  }
});

Structured errors give actionable feedback, allowing the frontend to handle failures gracefully.

4. Rate Limiting Isn’t Optional Anymore

The Problem
No rate limiting leaves your API open to abuse, accidental runaway scripts, and costly spikes.

Modern Approach

import rateLimit from 'express-rate-limit';

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  handler: (req, res) => {
    res.status(429).json({
      error: {
        code: 'RATE_LIMIT_EXCEEDED',
        message: 'Too many requests',
        retryAfter: req.rateLimit.resetTime
      }
    });
  }
});

app.use('/api/', limiter);

Protect your infrastructure—this should be default in 2025.

5. Versioning From Day One

The Problem
app.get('/api/users', …); – What happens when the contract changes?

Modern Approach

app.get('/api/v1/users', …);   // Existing clients
app.get('/api/v2/users', …);   // New behavior

Versioning isn’t premature optimization; it respects your users and your future self.

6. Stop Ignoring HTTP Status Codes

The Problem

res.status(200).json({ error: 'User not found' }); // WRONG

Modern Approach – Use the appropriate codes:

  • 200 – Success
  • 201 – Created
  • 400 – Bad request (client error)
  • 401 – Unauthorized
  • 403 – Forbidden
  • 404 – Not found
  • 409 – Conflict (duplicate, constraint violation)
  • 422 – Unprocessable Entity (validation failed)
  • 429 – Rate limited
  • 500 – Server error (your fault)

Correct status codes enable clients, monitoring tools, and caching layers to work properly.

7. Caching Headers Are Free Performance

The Problem
Every request hits the database, even for data that hasn’t changed in weeks.

Modern Approach

app.get('/api/products/:id', async (req, res) => {
  const product = await db.products.findById(req.params.id);
  res.set({
    'Cache-Control': 'public, max-age=300', // 5 minutes
    'ETag': generateETag(product)
  });
  res.json(product);
});

CDNs, browsers, and proxies will handle the heavy lifting for you.

The Real Cost of Legacy API Patterns

  • A single unoptimized endpoint can cost $800/month in database reads.
  • Missing rate limits once caused an accidental DDoS from a buggy mobile app.
  • Poor error handling turned every bug report into “something went wrong.”

Modern API design isn’t about being trendy; it’s about building systems that scale, survive user mistakes, and don’t need a complete rewrite in two years.

What outdated API patterns are you still seeing in 2025? Drop them in the comments.

Back to Blog

Related posts

Read more »