Rate limiting and circuit breakers are often talked about in isolation, but their real power emerges when you combine them, creating a system that’s not just resilient, but also predictable under load.

Imagine a busy e-commerce API. Customers are browsing, adding to carts, and checking out. A sudden surge in traffic, perhaps from a viral marketing campaign or a bot attack, hits the GET /products endpoint.

Without any controls, this surge could overwhelm the backend product service. It might start returning errors, become sluggish, or even crash.

Here’s how a combined approach prevents this:

Rate Limiting: The First Line of Defense

Rate limiting prevents any single client or group of clients from consuming an excessive amount of resources. It’s like having a bouncer at the door of a popular club, letting in only a certain number of people per minute.

Example Scenario: We want to limit requests to /products to 100 per minute per user IP.

Configuration (Conceptual - using a common library like express-rate-limit in Node.js):

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

const app = express();

const productLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 100, // Limit each IP to 100 requests per `windowMs`
  message: 'Too many product requests from this IP, please try again after a minute',
  standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
  legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});

app.get('/products', productLimiter, (req, res) => {
  // Logic to fetch products
  res.send('List of products...');
});

app.listen(3000, () => console.log('Server started on port 3000'));

How it works: When a client makes a request, the rate limiter checks a counter associated with their IP address (or API key, or user ID, depending on the configuration). If the count exceeds max within the windowMs, the request is immediately rejected with a 429 Too Many Requests status code. This prevents the individual client from overwhelming the system.

Circuit Breaker: The System-Wide Guardian

While rate limiting protects against abusive clients, a circuit breaker protects the backend service from being overwhelmed by legitimate traffic that it can’t handle. It’s like a safety switch that trips if too many appliances in a house draw too much power simultaneously, preventing a blackout.

Example Scenario: The product service itself is experiencing intermittent failures or extreme latency. We want to stop sending requests to it if it fails more than 50 times in a minute, for at least 30 seconds.

Configuration (Conceptual - using a library like opossum in Node.js):

const circuitBreaker = require('opossum');
const axios = require('axios');

const productService = async (productId) => {
  // This is the function that calls the actual product service
  const response = await axios.get(`http://product-service.internal/products/${productId}`);
  return response.data;
};

const options = {
  errorThresholdPercentage: 50, // When 50% of requests fail, trip the circuit
  resetTimeout: 30000, // After 30 seconds, try to close the circuit again
  timeout: 5000, // If a request takes longer than 5 seconds, treat it as a failure
};

const circuit = circuitBreaker(productService, options);

// When the circuit opens, we want to execute a fallback
circuit.on('open', () => {
  console.warn('Product service circuit breaker opened!');
});

// When the circuit closes, we want to log that
circuit.on('close', () => {
  console.info('Product service circuit breaker closed.');
});

// When the circuit is half-open, we want to log that
circuit.on('halfOpen', () => {
  console.info('Product service circuit breaker is half-open.');
});

// Example of using the circuit breaker
async function getProduct(id) {
  try {
    const product = await circuit.fire(id);
    return product;
  } catch (err) {
    console.error('Failed to get product:', err.message);
    // This is where you might return cached data or a default response
    return { id: id, name: 'Unknown Product', error: true };
  }
}

// Example usage:
getProduct(123).then(data => console.log(data));

How it works: The circuit breaker wraps the call to the actual product service. It monitors the success/failure rate of these calls.

  • Closed: In the "closed" state, requests are allowed through. Failures are counted.
  • Open: If the failure rate exceeds errorThresholdPercentage within a rolling window, or if too many requests time out (timeout), the circuit "opens." All subsequent requests to the wrapped function are immediately rejected with an error, without even attempting to call the actual service. This gives the failing service time to recover.
  • Half-Open: After the resetTimeout, the circuit enters a "half-open" state. It allows a single request through. If that request succeeds, the circuit closes. If it fails, the circuit opens again.

The Synergy: Combining Them

Now, imagine that viral marketing campaign hits.

  1. Rate Limiter Kicks In: The productLimiter on the /products endpoint starts rejecting requests from individual IPs that exceed 100 per minute. This prevents any single user or bot from hogging resources.
  2. Backend Starts Struggling: Despite rate limiting, the sheer volume of legitimate requests is still too much for the product service. It starts responding slowly, and some requests time out.
  3. Circuit Breaker Trips: The circuit breaker, monitoring these timeouts and failures, sees the error rate climb. When it hits 50% of the last N requests (e.g., 50 failures in the last 100 requests), the circuit breaker trips and opens.
  4. System Protection: Now, any request that reaches the circuit breaker (even if it wasn’t rejected by the rate limiter) is immediately failed. The system stops hammering the struggling product service.
  5. Graceful Degradation: The catch block in getProduct is triggered. Instead of waiting for a slow or failed product service, it can immediately return a cached response, a default "product unavailable" message, or an error indicating the service is temporarily down. This provides a better user experience than a frozen page or a generic server error.
  6. Recovery: After 30 seconds, the circuit breaker enters the half-open state, allowing a test request. If the product service has recovered, the circuit closes, and normal operation resumes. If it’s still failing, the circuit re-opens, and the recovery process continues.

The key insight is that rate limiting is about clients and entry points, while circuit breaking is about services and their health. By layering them, you create a robust defense: rate limiting prevents clients from being the cause of overload, and circuit breaking prevents the system from crashing when a backend service becomes overloaded or unhealthy, regardless of the cause.

This combined approach ensures that even under extreme duress, your application remains available, albeit potentially with degraded functionality (like returning cached data), rather than failing completely.

The next logical step in resilience is implementing effective health checks that feed into the circuit breaker’s decision-making, allowing it to open before the error threshold is even met.

Want structured learning?

Take the full Rate-limiting course →