Back to Blog
Tutorial10 min read2026-07-31

Deploy an Edge Function for API Rate Limiting

Build a rate-limiting edge function with Node.js runtime that blocks abusive clients before they hit your backend. In-memory tracking, HTTP invocation, and sub-100ms responses.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Rate limiting is one of those features that sounds simple until you implement it: track requests per IP, block clients who exceed the limit, and reset counters periodically. Doing this in your main API server works until you realize abusive clients are still consuming CPU and network just to get rejected. Edge functions run the rate limit check before the request reaches your backend, dropping bad traffic as close to the user as possible.

PandaStack edge functions deploy as standalone HTTP endpoints with dedicated CPU and memory. This guide builds a rate-limiting function in Node.js (the only two runtimes are nodejs and python), shows how your API calls it before processing requests, and explains why in-memory state works for rate limiting even though functions scale to zero.

The rate-limiting logic

Edge functions on PandaStack are stateless HTTP handlers: receive a request, return a response, exit. No persistent disk, no long-running process, no database connection pooling. For rate limiting, you need state (request counts per IP), but hitting a database for every check defeats the purpose. The solution: in-memory storage that persists as long as the function instance is warm.

Create a directory for the function:

mkdir rate-limiter && cd rate-limiter

Create index.js:

const rateLimits = new Map()

const WINDOW_MS = 60 * 1000 // 1 minute
const MAX_REQUESTS = 100

function cleanupOldEntries() {
  const now = Date.now()
  for (const [ip, data] of rateLimits.entries()) {
    if (now - data.windowStart > WINDOW_MS) {
      rateLimits.delete(ip)
    }
  }
}

export default async function handler(req) {
  const ip = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || 'unknown'
  const now = Date.now()

  cleanupOldEntries()

  let clientData = rateLimits.get(ip)

  if (!clientData || now - clientData.windowStart > WINDOW_MS) {
    clientData = { count: 0, windowStart: now }
    rateLimits.set(ip, clientData)
  }

  clientData.count++

  if (clientData.count > MAX_REQUESTS) {
    return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), {
      status: 429,
      headers: {
        'Content-Type': 'application/json',
        'Retry-After': '60'
      }
    })
  }

  return new Response(JSON.stringify({
    allowed: true,
    remaining: MAX_REQUESTS - clientData.count
  }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' }
  })
}

The function:

  1. 1Extracts the client IP from X-Forwarded-For (set by PandaStack's ingress)
  2. 2Checks if the IP has an existing rate limit entry
  3. 3If the entry is older than 1 minute, resets it
  4. 4Increments the count and returns 429 if over the limit, 200 otherwise

The rateLimits Map persists across requests as long as the function instance stays warm (typically 5-15 minutes of inactivity before scale-to-zero). This is fine for rate limiting: if the instance scales down, the counts reset, which is more lenient than blocking forever.

Create package.json:

{
  "type": "module",
  "main": "index.js"
}

Edge functions on PandaStack don't require a build step for Node.js (Python is similar — just a .py file). The runtime loads the module and calls the exported handler.

Deploy the edge function via the CLI

Install the PandaStack CLI if you haven't:

npm install -g @pandastack/cli
panda login

From the rate-limiter/ directory:

panda functions create rate-limiter --runtime nodejs

The CLI zips the current directory and uploads it to PandaStack. The function deploys, and you get an invoke URL:

https://functions.pandastack.io/invoke/fn_abc123

Test it:

curl https://functions.pandastack.io/invoke/fn_abc123
# {"allowed":true,"remaining":99}

for i in {1..101}; do
  curl -s https://functions.pandastack.io/invoke/fn_abc123
done

The first 100 requests return {"allowed":true,...}. The 101st returns:

{"error":"Rate limit exceeded"}

The function blocked the request without touching your backend.

Integrate with your API

Your main API (Fastify, Express, Hono, whatever) calls the edge function before processing requests. In Express:

import express from 'express'
import fetch from 'node-fetch'

const app = express()

const RATE_LIMIT_FUNCTION_URL = process.env.RATE_LIMIT_FUNCTION_URL

app.use(async (req, res, next) => {
  const response = await fetch(RATE_LIMIT_FUNCTION_URL, {
    method: 'GET',
    headers: { 'X-Forwarded-For': req.ip }
  })

  if (response.status === 429) {
    return res.status(429).json({ error: 'Rate limit exceeded' })
  }

  const data = await response.json()
  res.setHeader('X-RateLimit-Remaining', data.remaining)
  next()
})

app.get('/api/data', (req, res) => {
  res.json({ message: 'Data retrieved' })
})

const port = process.env.PORT || 3000
app.listen(port, '0.0.0.0', () => {
  console.log(`API listening on port ${port}`)
})

Set RATE_LIMIT_FUNCTION_URL via the dashboard or API:

panda projects env your-api-name set RATE_LIMIT_FUNCTION_URL=https://functions.pandastack.io/invoke/fn_abc123

Now every request to /api/data checks the rate limit first. Abusive clients get blocked at the edge function (which has minimal CPU overhead), and your backend only processes legitimate traffic.

Deploy via the API (for CI)

Generate a PandaStack API token and create the function programmatically:

# Zip the function code
zip -r rate-limiter.zip index.js package.json

# Upload via API
curl -X POST https://api.pandastack.io/v1/functions \
  -H "Authorization: Bearer psk_live_your_token_here" \
  -F "name=rate-limiter" \
  -F "runtime=nodejs" \
  -F "code=@rate-limiter.zip"

The response includes the invoke URL. Store it as a GitHub Actions secret and use it in your API's environment variables.

Why in-memory state works here

Edge functions scale to zero when idle, which means the rateLimits Map disappears when the instance shuts down. For rate limiting, this is acceptable:

  • Lenient enforcement: If a client hits the limit and then the function scales down, the count resets when it scales back up. They get another 100 requests. This is fine — the goal is to stop sustained abuse, not to block forever.
  • Low latency: Checking a Map is sub-millisecond. A database query (even Redis) adds 5-20ms of latency.
  • No external dependencies: No Redis to provision, no connection pooling, no credentials to manage.

For stricter rate limiting (persistent across scale-downs), use PandaStack's KV store (managed Redis) instead of the in-memory Map. The function would read/write to Redis via KV_URL, and the counts would survive restarts.

Deploy an edge function with the KV store

Provision a KV store from the dashboard (Databases → KV Store → New KV). Link it to the function, and KV_URL, KV_REST_API_URL, and KV_REST_API_TOKEN are injected automatically.

Update index.js to use Redis:

import { createClient } from 'redis'

let redis

async function getRedisClient() {
  if (!redis) {
    redis = createClient({ url: process.env.KV_URL })
    await redis.connect()
  }
  return redis
}

export default async function handler(req) {
  const ip = req.headers.get('x-forwarded-for') || 'unknown'
  const now = Date.now()
  const key = `ratelimit:${ip}`

  const client = await getRedisClient()

  const data = await client.get(key)
  let count = data ? parseInt(data, 10) : 0

  if (count === 0) {
    await client.set(key, '1', { EX: 60 })
    count = 1
  } else {
    count = await client.incr(key)
  }

  if (count > 100) {
    return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), {
      status: 429,
      headers: { 'Content-Type': 'application/json', 'Retry-After': '60' }
    })
  }

  return new Response(JSON.stringify({ allowed: true, remaining: 100 - count }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' }
  })
}

Add the Redis client to package.json:

{
  "type": "module",
  "main": "index.js",
  "dependencies": {
    "redis": "^4.6.0"
  }
}

Redeploy:

panda functions update rate-limiter --runtime nodejs

Now the rate limit counts persist in Redis. If the function scales down and back up, the counts remain. This is stricter enforcement but adds Redis latency (5-10ms) to every request.

What breaks and how to fix it

Function always returns "unknown" for IP: Your API is calling the function without forwarding the client's IP. Pass the original X-Forwarded-For header when calling the edge function, or the function sees the API's IP instead of the client's.

Rate limit resets too quickly: The WINDOW_MS is too short, or the function is scaling down frequently. Either increase the window (e.g., 5 minutes instead of 1) or switch to Redis-backed state so counts survive scale-downs.

Function invocation times out: The function is doing blocking I/O (like a slow external API call). Edge functions have a 30-second timeout; if your handler doesn't respond by then, the invocation fails. For rate limiting, the handler should be sub-100ms.

Redis connection errors: The KV store isn't linked to the function, so KV_URL is unset. Go to the function's settings and link the KV store, or pass KV_URL as an environment variable manually.

Edge functions vs API middleware

Why deploy rate limiting as a separate edge function instead of middleware in your API?

AspectEdge functionAPI middleware
LatencyAdds one HTTP call (~10-20ms)No extra latency
DecouplingWorks with any backendTied to the API framework
Resource usageAbusive requests never hit the backendBackend still processes the request to reject it
ReusabilityOne function protects multiple APIsEach API implements its own

For a single API, middleware is simpler. For multiple services (a web API, a mobile API, a webhook receiver), one edge function protects all of them.

Next steps

You've deployed a rate-limiting edge function with Node.js runtime that blocks abusive clients before they reach your backend. The same pattern works for:

  • Authentication checks (validate a JWT before forwarding to the API)
  • IP allowlisting (only allow requests from specific networks)
  • Request transformation (rewrite headers or payloads before proxying to the backend)

PandaStack edge functions support nodejs and python runtimes only — no Go or Rust. For CPU-intensive work (cryptography, image processing), use a container app instead.

References

  • [PandaStack edge functions](https://docs.pandastack.io/functions)
  • [Rate limiting strategies](https://redis.io/glossary/rate-limiting/)
  • [PandaStack KV store guide](https://docs.pandastack.io/kv)
  • [Redis client documentation](https://github.com/redis/node-redis)

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also