APIs are targets. Every second, bots probe your endpoints. They hammer your free tier, scrape your data, drain your database quota, or just cause chaos. An API without rate limiting is like a storefront without a lock. Velocity X uses a three-layer approach: Cloudflare Workers at the edge (cheap, fast, blocks by IP), Supabase Postgres tables for per-user app-level limits (flexible, auditable), and Upstash Redis only when you need shared state across regions. Most SaaS products need only layers one and two. If you're running a global API with sub-millisecond requirements, that's when Redis enters. Show you exactly how to build all three.
Why Rate Limiting Matters
Rate limiting enforces a quota per client — maximum 100 API calls per hour per user, or 1000 requests per minute from a single IP. Without it, a single bot can exhaust your database, trigger expensive third-party API calls (Stripe, Twilio, OpenAI), or crash your app. With it, you protect your infrastructure, ensure fair access, and monetize via tiers (free tier: 10 requests/min, pro: 1000/min). Rate limiting also catches bugs — if your app is looping and making 10K requests instead of 10, rate limits fire an alarm instead of a surprise Stripe bill.
Three Approaches: Edge, App, Shared State
Layer 1: Edge (Cloudflare, Netlify). Rate limit by IP address before requests hit your origin. Cheapest and fastest. Blocks obvious DDoS and scrapers. Can't distinguish between a user's phone and their laptop (both same IP). Good for public endpoints.
Layer 2: App (Supabase Postgres, simple table). Rate limit per authenticated user or API key. Knows who is calling. Can set different limits per tier (free vs pro). Slightly slower (one Postgres query per request) but accurate. Best for SaaS APIs where users are known.
Layer 3: Shared State (Upstash Redis or Redis). Global rate limiter across multiple servers/regions. Requires an external service. Overkill for most products. Use it when edge + app layers aren't enough and you have the scale to justify it.
Leaky Bucket vs Sliding Window
Two main algorithms. Leaky bucket: imagine a bucket with a small hole at the bottom. Requests fill the bucket; they leak out at a constant rate. Once the bucket overflows, you're rate limited. Simple, fair, allows bursts up to the bucket size. Downside: one client can monopolize the bucket. Sliding window: track requests in a rolling time window (last 60 seconds). Once you hit the limit, wait for the oldest request to fall out of the window. More accurate but requires more state tracking. For API limits, sliding window is the web standard. Show sliding window; it's what users expect.
Layer 1: Cloudflare Workers
If your API is on Netlify with Edge Functions, Cloudflare Workers sits in front. Deploy a rate-limiting middleware once:
// cloudflare-worker.js (deploy to Cloudflare)
export default {
async fetch(request) {
const ip = request.headers.get('cf-connecting-ip');
const url = new URL(request.url);
const key = `ratelimit:${ip}:${url.pathname}`;
// Store rate-limit state in Cloudflare KV
const limit = 100; // requests per minute
const window = 60 * 1000; // 1 minute in ms
const now = Date.now();
const data = await RATE_LIMIT_KV.get(key, 'json') || { count: 0, reset: now + window };
if (now > data.reset) {
data.count = 0;
data.reset = now + window;
}
data.count++;
if (data.count > limit) {
return new Response('Rate limit exceeded', {
status: 429,
headers: {
'Retry-After': Math.ceil((data.reset - now) / 1000)
}
});
}
await RATE_LIMIT_KV.put(key, JSON.stringify(data));
// Pass through to origin
return fetch(request);
}
};
Deploy this worker and route your API domain through it. Every IP gets 100 requests per minute. Simple, distributed, no database query needed.
Layer 2: Supabase Postgres — Per-User App-Level Limit
For authenticated users, store rate-limit state in a Postgres table:
-- Rate limit tracker
create table if not exists rate_limits (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
endpoint text not null, -- '/api/export', '/api/search', etc
request_count int default 0,
window_reset_at timestamp default now(),
created_at timestamp default now(),
unique(user_id, endpoint)
);
create index idx_rate_limits_user on rate_limits(user_id);
create index idx_rate_limits_reset on rate_limits(window_reset_at);
On every request, check and increment:
// In your Netlify Function or API route
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY);
export async function rateLimitCheck(userId, endpoint, limit = 100) {
const now = new Date();
const windowMinutes = 60; // sliding window: 1 hour
// Upsert: increment count, reset if window expired
const { data, error } = await supabase
.from('rate_limits')
.upsert(
{
user_id: userId,
endpoint,
request_count: 1,
window_reset_at: new Date(now.getTime() + windowMinutes * 60 * 1000)
},
{ onConflict: 'user_id,endpoint' }
)
.select()
.single();
if (error) throw error;
// Check if window expired; if so, reset
if (new Date(data.window_reset_at) < now) {
await supabase
.from('rate_limits')
.update({ request_count: 1, window_reset_at: new Date(now.getTime() + windowMinutes * 60 * 1000) })
.eq('id', data.id);
return { allowed: true, remaining: limit - 1 };
}
// Increment
await supabase
.from('rate_limits')
.update({ request_count: data.request_count + 1 })
.eq('id', data.id);
const remaining = Math.max(0, limit - data.request_count);
return {
allowed: data.request_count < limit,
remaining,
resetAt: new Date(data.window_reset_at)
};
}
Call this on every protected API endpoint. Return a 429 (Too Many Requests) response if allowed is false. Include `Remaining` and `Reset` headers so clients know when they're throttled.
Layer 3: Upstash Redis — Global Shared State
For APIs with traffic across multiple regions or sub-second requirements, Upstash is serverless Redis as an HTTP API. No infrastructure, scales automatically, global edge cache:
// supabase/functions/rate-limit-check/index.ts
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
const UPSTASH_REDIS_URL = Deno.env.get('UPSTASH_REDIS_URL');
const UPSTASH_REDIS_TOKEN = Deno.env.get('UPSTASH_REDIS_TOKEN');
async function redisCommand(cmd) {
const res = await fetch(`${UPSTASH_REDIS_URL}/exec`, {
method: 'POST',
headers: {
Authorization: `Bearer ${UPSTASH_REDIS_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify([cmd])
});
const data = await res.json();
return data[0];
}
serve(async (req) => {
const { userId, endpoint, limit } = await req.json();
const key = `ratelimit:${userId}:${endpoint}`;
const now = Date.now();
const windowMs = 3600000; // 1 hour
// Use Redis sorted set for sliding window
// Add current request with timestamp as score
await redisCommand(['zadd', key, now, now.toString()]);
// Remove old requests outside the window
await redisCommand(['zremrangebyscore', key, '-inf', now - windowMs]);
// Count remaining requests
const count = await redisCommand(['zcard', key]);
if (count > limit) {
return new Response(JSON.stringify({ allowed: false, remaining: 0 }), {
status: 429,
headers: { 'Content-Type': 'application/json' }
});
}
// Set expiry
await redisCommand(['expire', key, Math.ceil(windowMs / 1000)]);
return new Response(JSON.stringify({ allowed: true, remaining: limit - count }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
});
Upstash handles all the Redis complexity. You just POST a request per API call. Cost is a few cents per million requests. Use this if Postgres layer isn't hitting your SLA.
Six FAQs
Should I rate limit by user ID or API key?
Both. User ID for authenticated users. API key for public/service accounts. Create an `api_keys` table with a `user_id` foreign key, then rate limit by the key's owner. One key = one limit bucket.
How do I handle burst traffic (e.g., someone exports a report)?
Leaky bucket with a larger bucket size. Allow 100 requests per minute, but permit up to 200 in the bucket at once (user can frontload 100 extra). Once the bucket drains, they're back to 100/min. Token bucket is the fancier name for this.
Can I give certain users higher limits (e.g., pro tier gets 10x)?
Yes. Query the user's subscription tier on rate-limit check. Apply `limit * tier.multiplier`. Store this in a `subscriptions` table and join on every request. Tiny overhead.
What if a user is rate limited and angry?
Log the event in a `rate_limit_events` table. If you see a pattern (same user, same endpoint, every day), upgrade them or investigate their workflow. Maybe their integration is looping and needs fixing.
How do I test rate limiting locally?
Use a curl loop: `for i in {1..150}; do curl https://api.example.com/endpoint; done` and check response codes. For Postgres layer, drop and recreate the `rate_limits` table between tests. Upstash: clear the Redis cache with `FLUSHDB`.
Does rate limiting slow my API?
Cloudflare Workers: no, it's at the edge. Postgres layer: one query per request (~1–5ms), negligible. Upstash: one HTTP POST (~20–50ms), slightly higher. Optimize by caching the rate limit check in your function for the same user across multiple endpoints.
The Verdict
Every API needs rate limiting. Start with Cloudflare Workers or your CDN's native rate-limit rules (free, fast, protects against obvious abuse). Layer on Supabase Postgres for per-user limits (flexible, auditable, ties to your business model). Escalate to Upstash Redis only if Postgres can't keep up with your request volume or you need true global consistency. For Velocity SaaS, Cloudflare + Postgres is the default. The three layers stack — no conflict. A user might hit Cloudflare's IP limit, then Postgres's per-user limit, then Upstash's global limit, but in practice they hit the app layer and back off. Keep rate-limit response times under 50ms; any slower and you've defeated the purpose. For more on API security and database-driven SaaS patterns, see Velocity X infrastructure. And if you're queuing work behind your API, check Job Queues in Postgres.