SaaS API Rate Limiting
A complete example for a project management tool like Linear or Asana with tiered rate limits based on subscription plans.
Scenario
- Free tier: 100 API requests/hour
- Pro tier: 5,000 API requests/hour
- Enterprise: Unlimited
Implementation
src/middleware/rate-limit.ts
import { hitlimit } from '@joint-ops/hitlimit'
import { redisStore } from '@joint-ops/hitlimit/stores/redis'
// Tier configuration from your pricing page
const TIER_LIMITS = {
free: { limit: 100, window: '1h' },
pro: { limit: 5000, window: '1h' },
enterprise: { limit: Infinity }
} as const
export const apiRateLimit = hitlimit({
store: redisStore({
url: process.env.REDIS_URL,
keyPrefix: 'ratelimit:api:'
}),
tiers: TIER_LIMITS,
// Get tier from authenticated user
tier: (req) => {
const user = req.user // From auth middleware
if (!user) return 'free'
return user.subscription?.tier || 'free'
},
// Rate limit by workspace, not individual user
key: (req) => {
const user = req.user
if (!user) return req.ip
return `workspace:${user.workspaceId}`
},
// Custom response matching your API style
response: (info) => ({
error: {
code: 'RATE_LIMITED',
message: 'API rate limit exceeded',
details: {
limit: info.limit,
remaining: info.remaining,
resetAt: new Date(info.resetAt).toISOString(),
upgradeUrl: info.limit < 5000
? 'https://yourapp.com/pricing'
: undefined
}
}
}),
// Fail open for non-critical endpoints
onStoreError: (error, req) => {
console.error('Rate limit store error:', error)
// Critical endpoints fail closed
if (req.path.includes('/billing') || req.path.includes('/admin')) {
return 'deny'
}
// Non-critical fail open
return 'allow'
},
// Skip rate limiting for webhooks and health checks
skip: (req) => {
if (req.path === '/health') return true
if (req.path.startsWith('/webhooks/')) return true
if (req.headers['x-internal-service'] === process.env.INTERNAL_SECRET) {
return true
}
return false
}
}) Stricter Limits for Expensive Operations
Search Rate Limit
// Stricter limit for expensive operations
export const searchRateLimit = hitlimit({
store: redisStore({ url: process.env.REDIS_URL }),
limit: 30,
window: '1m',
key: (req) => req.user?.id || req.ip,
response: () => ({
error: {
code: 'SEARCH_RATE_LIMITED',
message: 'Too many search requests. Please wait a moment.'
}
})
}) Export Rate Limit
// Very strict limit for exports (expensive operation)
export const exportRateLimit = hitlimit({
store: redisStore({ url: process.env.REDIS_URL }),
limit: 5,
window: '1h',
key: (req) => req.user?.workspaceId || req.ip
}) Usage in Routes
src/routes/api.ts
import { Router } from 'express'
import { apiRateLimit, searchRateLimit, exportRateLimit } from '../middleware/rate-limit'
const router = Router()
// Apply base API rate limit to all routes
router.use(apiRateLimit)
// Standard CRUD operations
router.get('/projects', projectController.list)
router.post('/projects', projectController.create)
router.get('/projects/:id', projectController.get)
router.patch('/projects/:id', projectController.update)
router.delete('/projects/:id', projectController.delete)
// Search has stricter limits (expensive operation)
router.get('/search', searchRateLimit, searchController.search)
// Export has very strict limits
router.post('/export', exportRateLimit, exportController.export)
export default router NestJS Version
src/rate-limit/rate-limit.module.ts
import { Module, Global } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { HitLimitModule } from '@joint-ops/hitlimit/nest'
import { redisStore } from '@joint-ops/hitlimit/stores/redis'
@Global()
@Module({
imports: [
HitLimitModule.registerAsync({
useFactory: (config: ConfigService) => ({
store: redisStore({ url: config.get('REDIS_URL') }),
tiers: {
free: { limit: 100, window: '1h' },
pro: { limit: 5000, window: '1h' },
enterprise: { limit: Infinity }
},
tier: (req) => req.user?.subscription?.tier || 'free',
key: (req) => req.user?.workspaceId || req.ip
}),
inject: [ConfigService]
})
]
})
export class RateLimitModule {} src/projects/projects.controller.ts
import { Controller, Get, Post, UseGuards } from '@nestjs/common'
import { HitLimitGuard, HitLimit } from '@joint-ops/hitlimit/nest'
@Controller('projects')
@UseGuards(HitLimitGuard) // Apply global rate limit
export class ProjectsController {
@Get()
list() {
return this.projectsService.findAll()
}
@Get('search')
@HitLimit({ limit: 30, window: '1m' }) // Stricter for search
search(@Query('q') query: string) {
return this.projectsService.search(query)
}
@Post('export')
@HitLimit({ limit: 5, window: '1h' }) // Very strict for exports
export(@Body() dto: ExportDto) {
return this.exportService.export(dto)
}
} Why These Limits?
- 100/hour for free tier: Enough for basic integrations, but encourages upgrade for heavy use
- 5,000/hour for pro: ~83 req/min - sufficient for most SaaS integrations
- Per-workspace: Prevents one power user from exhausting team's quota
- Search at 30/min: Search is expensive; prevents abuse while allowing normal use
- Export at 5/hour: Exports are very expensive; prevents bulk data scraping