Skip to main content

Command Palette

Search for a command to run...

The API I Wish I Never Built: Design Regrets

Learn: The API I Wish I Never Built: Design Regrets

Updated
7 min readView as Markdown
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

The API I Wish I Never Built: Design Regrets

The 3 AM Wake-Up Call

My phone buzzed at 3:17 AM. Then again at 3:19. By 3:23, I had twelve Slack messages, all saying the same thing: "The API is down. Again."

I rolled out of bed, opened my laptop, and stared at the monitoring dashboard. 47,000 requests queued. Database connections maxed out. Redis screaming. And there, in the error logs, was the endpoint I'd built eighteen months ago—the one I'd been so proud of.

POST /api/v1/users/batch-update

That single endpoint was bringing down our entire platform. And it was entirely my fault.

The Mistake That Kept On Giving

Let me take you back to where it all went wrong. We needed a way for clients to update multiple user records at once. Simple enough, right? I was under pressure, the deadline was tight, and I made what seemed like pragmatic decisions.

Here's what I built:

// The API endpoint that haunted my dreams
app.post('/api/v1/users/batch-update', async (req, res) => {
  const { users } = req.body; // Array of user objects

  // Mistake #1: No size limit
  for (const user of users) {
    // Mistake #2: Sequential processing
    await db.users.update(user.id, {
      name: user.name,
      email: user.email,
      preferences: user.preferences, // Mistake #3: Nested object without validation
      metadata: user.metadata // Mistake #4: Arbitrary data accepted
    });

    // Mistake #5: Synchronous cache invalidation
    await cache.delete(`user:${user.id}`);

    // Mistake #6: Webhook calls in the request cycle
    await notifyWebhooks(user.id, 'user.updated');
  }

  res.json({ success: true, updated: users.length });
});

It worked beautifully in testing. It worked fine in the first few months of production. Then our biggest client decided to sync their entire user database—250,000 records—in a single request.

The Seven Deadly Sins of API Design

Sin #1: Unbounded Input

I never set a limit on the array size. Why would I? "Let the clients decide what they need," I thought.

The Reality: One client sent 250,000 users. The request timeout was 30 seconds. The math didn't math.

What I Should Have Done:

app.post('/api/v1/users/batch-update', async (req, res) => {
  const { users } = req.body;

  // Set clear boundaries
  if (!Array.isArray(users) || users.length === 0) {
    return res.status(400).json({ 
      error: 'users must be a non-empty array' 
    });
  }

  if (users.length > 100) {
    return res.status(400).json({ 
      error: 'Maximum 100 users per request. Use pagination for larger batches.',
      max_allowed: 100,
      received: users.length
    });
  }

  // ... rest of the logic
});

Sin #2: Synchronous Processing

I processed everything sequentially in the request-response cycle. Each database write, each cache invalidation, each webhook—all blocking the next operation.

What I Should Have Done:

app.post('/api/v1/users/batch-update', async (req, res) => {
  const { users } = req.body;

  // Validate and create a job
  const jobId = uuidv4();

  await jobQueue.add('batch-user-update', {
    jobId,
    users,
    requestedBy: req.user.id,
    requestedAt: new Date()
  });

  // Return immediately
  res.status(202).json({
    jobId,
    status: 'processing',
    statusUrl: `/api/v1/jobs/${jobId}`,
    message: 'Batch update queued. Check status at the provided URL.'
  });
});

// Separate worker process
async function processBatchUpdate(job) {
  const { jobId, users } = job.data;

  for (const user of users) {
    try {
      await db.users.update(user.id, user);
      await updateJobProgress(jobId, 'success', user.id);
    } catch (error) {
      await updateJobProgress(jobId, 'failed', user.id, error);
    }
  }
}

Sin #3: No Input Validation

I accepted nested objects without validation. preferences could be anything. metadata could be a 10MB JSON blob. I trusted the clients way too much.

What I Should Have Done:

const Joi = require('joi');

const userUpdateSchema = Joi.object({
  id: Joi.string().uuid().required(),
  name: Joi.string().max(100).optional(),
  email: Joi.string().email().optional(),
  preferences: Joi.object({
    theme: Joi.string().valid('light', 'dark'),
    notifications: Joi.boolean(),
    language: Joi.string().length(2)
  }).optional(),
  metadata: Joi.object().max(10).optional() // Max 10 keys
});

const batchSchema = Joi.object({
  users: Joi.array().items(userUpdateSchema).min(1).max(100).required()
});

app.post('/api/v1/users/batch-update', async (req, res) => {
  const { error, value } = batchSchema.validate(req.body);

  if (error) {
    return res.status(400).json({
      error: 'Validation failed',
      details: error.details.map(d => d.message)
    });
  }

  // Process validated data
});

Sin #4: Mixing Concerns

I mixed immediate operations (database writes) with side effects (webhooks, notifications) in the same transaction. When webhooks were slow, everything was slow.

What I Should Have Done:

// Separate immediate operations from side effects
async function updateUsers(users) {
  // Immediate: Database updates only
  const results = await db.transaction(async (trx) => {
    return Promise.all(
      users.map(user => 
        trx.users.update(user.id, user)
      )
    );
  });

  // Deferred: Side effects via event bus
  for (const user of users) {
    await eventBus.publish('user.updated', {
      userId: user.id,
      timestamp: Date.now()
    });
  }

  return results;
}

// Separate service handles webhooks
eventBus.subscribe('user.updated', async (event) => {
  await notifyWebhooks(event.userId, 'user.updated');
});

Sin #5: No Idempotency

If a request failed halfway through, retrying it would create duplicate updates or inconsistent state. No idempotency keys, no deduplication.

What I Should Have Done:

app.post('/api/v1/users/batch-update', async (req, res) => {
  const idempotencyKey = req.headers['idempotency-key'];

  if (!idempotencyKey) {
    return res.status(400).json({
      error: 'Idempotency-Key header required'
    });
  }

  // Check if we've seen this request before
  const cached = await cache.get(`idempotency:${idempotencyKey}`);
  if (cached) {
    return res.status(200).json(cached);
  }

  // Process the request
  const result = await processBatchUpdate(req.body.users);

  // Cache the result for 24 hours
  await cache.set(
    `idempotency:${idempotencyKey}`, 
    result, 
    { ttl: 86400 }
  );

  res.status(200).json(result);
});

Sin #6: Poor Error Handling

When something failed, the entire batch failed. No partial success reporting, no way to know which records succeeded and which didn't.

What I Should Have Done:

async function processBatchUpdate(users) {
  const results = {
    successful: [],
    failed: [],
    total: users.length
  };

  for (const user of users) {
    try {
      await db.users.update(user.id, user);
      results.successful.push({
        id: user.id,
        status: 'updated'
      });
    } catch (error) {
      results.failed.push({
        id: user.id,
        error: error.message,
        code: error.code
      });
    }
  }

  return {
    ...results,
    successRate: results.successful.length / results.total
  };
}

Sin #7: No Rate Limiting

Any client could hammer this endpoint as much as they wanted. No rate limits, no throttling, no backpressure.

What I Should Have Done:

const rateLimit = require('express-rate-limit');

const batchUpdateLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 10, // 10 requests per window
  message: {
    error: 'Too many batch update requests',
    retryAfter: '15 minutes'
  },
  standardHeaders: true,
  legacyHeaders: false,
});

app.post('/api/v1/users/batch-update', 
  batchUpdateLimiter,
  async (req, res) => {
    // ... handler
  }
);

The Redesign

After that 3 AM incident, I spent two weeks redesigning the API. Here's what the new version looks like:

// v2: The API I wish I'd built the first time
app.post('/api/v2/users/batch-update', 
  authenticate,
  rateLimit({ windowMs: 900000, max: 10 }),
  async (req, res) => {
    // 1. Validate input
    const { error, value } = batchSchema.validate(req.body);
    if (error) {
      return res.status(400).json({
        error: 'Validation failed',
        details: error.details
      });
    }

    // 2. Check idempotency
    const idempotencyKey = req.headers['idempotency-key'];
    if (!idempotencyKey) {
      return res.status(400).json({
        error: 'Idempotency-Key header required'
      });
    }

    const cached = await cache.get(`idempotency:${idempotencyKey}`);
    if (cached) {
      return res.status(200).json(cached);
    }

    // 3. Create async job
    const jobId = uuidv4();
    await jobQueue.add('batch-user-update', {
      jobId,
      users: value.users,
      requestedBy: req.user.id,
      idempotencyKey
    }, {
      attempts: 3,
      backoff: { type: 'exponential', delay: 2000 }
    });

    // 4. Return immediately
    const response = {
      jobId,
      status: 'queued',
      statusUrl: `/api/v2/jobs/${jobId}`,
      estimatedCompletion: new Date(Date.now() + value.users.length * 100)
    };

    await cache.set(`idempotency:${idempotencyKey}`, response, { ttl: 86400 });

    res.status(202).json(response);
  }
);

// Status check endpoint
app.get('/api/v2/jobs/:jobId', authenticate, async (req, res) => {
  const job = await jobQueue.getJob(req.params.jobId);

  if (!job) {
    return res.status(404).json({ error: 'Job not found' });
  }

  const state = await job.getState();
  const progress = job.progress();

  res.json({
    jobId: job.id,
    status: state,
    progress: progress,
    result: state === 'completed' ? job.returnvalue : null,
    error: state === 'failed' ? job.failedReason : null
  });
});

The Lessons That Stuck

1. Async is your friend for batch operations. Never process large batches synchronously. Return a job ID immediately and let workers handle the heavy lifting.

2. Boundaries are not optional. Set limits on everything: array sizes, string lengths, nesting depth, request rates. Your future self will thank you.

3. Validate everything. Trust no one, not even your own frontend. Schema validation isn't paranoia—it's professionalism.

4. Idempotency is not a nice-to-have. In distributed systems, requests will be retried. Make sure retries are safe.

5. Separate concerns ruthlessly. Database writes, cache invalidation, webhooks, notifications—these should never block each other.

6. Partial success is success. Batch operations should report granular results. All-or-nothing is rarely the right choice.

7. Rate limiting is self-defense. Protect your API from both malicious actors and well-meaning clients who don't know better.

The Takeaway

That 3 AM wake-up call cost us three hours of downtime, angry customers, and a week of emergency fixes. But it taught me something invaluable: the best time to think about API design is before you write the first line of code. The second best time is right now.

If you're building an API today, especially one that handles batch operations, learn from my mistakes. Add those validations. Implement that rate limiting. Make it async. Your 3 AM self will thank you.

And if you've already built that problematic API? It's never too late to version it properly. v2 exists for a reason.

What's the API design decision you regret most? I'd love to hear your war stories in the comments.