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