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-limiterCreate 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:
- 1Extracts the client IP from
X-Forwarded-For(set by PandaStack's ingress) - 2Checks if the IP has an existing rate limit entry
- 3If the entry is older than 1 minute, resets it
- 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 loginFrom the rate-limiter/ directory:
panda functions create rate-limiter --runtime nodejsThe 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_abc123Test 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
doneThe 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_abc123Now 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 nodejsNow 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?
| Aspect | Edge function | API middleware |
|---|---|---|
| Latency | Adds one HTTP call (~10-20ms) | No extra latency |
| Decoupling | Works with any backend | Tied to the API framework |
| Resource usage | Abusive requests never hit the backend | Backend still processes the request to reject it |
| Reusability | One function protects multiple APIs | Each 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)