On this page

SaaS API Rate Limiting (Bun)

Using Node.js? See the Node.js SaaS Example for @joint-ops/hitlimit.

A complete example for a SaaS application with tiered rate limits based on subscription plans. Uses @joint-ops/hitlimit-bun with Redis for distributed state across multiple Bun instances.

Scenario

  • Free tier: 100 API requests/hour
  • Pro tier: 5,000 API requests/hour
  • Enterprise: Unlimited

Implementation

A Bun.serve implementation with tiered rate limiting, Redis-backed state, and custom JSON error responses:

server.ts
import { hitlimit } from '@joint-ops/hitlimit-bun'
import { redisStore } from '@joint-ops/hitlimit-bun/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

const apiLimiter = hitlimit({
  store: redisStore({ url: Bun.env.REDIS_URL }),

  tiers: TIER_LIMITS,

  // Get tier from auth header/token
  tier: (req) => {
    return getUserTier(req) || 'free'
  },

  // Rate limit by workspace, not individual user
  key: (req) => {
    const user = getUser(req)
    return user ? `workspace:${user.workspaceId}` : getIP(req)
  },

  // Custom response matching your API style
  response: (info) => ({
    error: 'RATE_LIMITED',
    message: `Rate limit exceeded. Try again in ${Math.ceil(info.resetIn / 60)} minutes.`,
    upgrade: 'https://example.com/pricing'
  })
})

Bun.serve({
  port: 3000,
  async fetch(req) {
    const url = new URL(req.url)

    // Skip rate limiting for health checks
    if (url.pathname === '/health') {
      return new Response('OK')
    }

    const { allowed, remaining, reset, limit } = await apiLimiter.check(req)

    const headers = {
      'X-RateLimit-Limit': String(limit),
      'X-RateLimit-Remaining': String(remaining),
      'X-RateLimit-Reset': String(reset),
      'Content-Type': 'application/json'
    }

    if (!allowed) {
      return Response.json(
        {
          error: 'RATE_LIMITED',
          message: 'Rate limit exceeded',
          upgrade: 'https://example.com/pricing'
        },
        { status: 429, headers: { ...headers, 'Retry-After': String(reset) } }
      )
    }

    // Route handling
    if (url.pathname.startsWith('/api/projects')) {
      return Response.json({ projects: [] }, { headers })
    }

    return Response.json({ status: 'ok' }, { headers })
  }
})

Elysia Version

The same tiered SaaS rate limiting implemented with the Elysia framework:

app.ts
import { Elysia } from 'elysia'
import { hitlimit } from '@joint-ops/hitlimit-bun/elysia'
import { redisStore } from '@joint-ops/hitlimit-bun/stores/redis'

const TIER_LIMITS = {
  free: { limit: 100, window: '1h' },
  pro: { limit: 5000, window: '1h' },
  enterprise: { limit: Infinity }
} as const

const app = new Elysia()
  .use(hitlimit({
    store: redisStore({ url: Bun.env.REDIS_URL }),
    tiers: TIER_LIMITS,
    tier: ({ request }) => {
      return getUserTier(request) || 'free'
    },
    key: ({ request }) => {
      const user = getUser(request)
      return user ? `workspace:${user.workspaceId}` : 'anonymous'
    },
    response: {
      error: 'RATE_LIMITED',
      message: 'Rate limit exceeded',
      upgrade: 'https://example.com/pricing'
    }
  }))
  .get('/api/projects', () => ({ projects: [] }))
  .post('/api/projects', ({ body }) => ({ created: true }))
  .get('/api/projects/:id', ({ params }) => ({ id: params.id }))
  .listen(3000)

Key Points

  • Per-workspace limits: Rate limits are shared across all users in a workspace, preventing one power user from exhausting the team's quota
  • Redis for distributed state: When running multiple Bun instances behind a load balancer, Redis ensures rate limit counters are shared across all nodes
  • Bun.env for configuration: Uses Bun.env.REDIS_URL instead of process.env for Bun-native environment variable access
  • 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
  • Infinity for enterprise: No rate limit enforced, but requests are still tracked for analytics
  • Custom JSON responses: Return structured error bodies that match your API convention, including an upgrade URL for monetization
  • Health check skip: Always bypass rate limiting for health check endpoints used by load balancers and uptime monitors