PlanetScale vs Upstash for Rate Limiting at Scale: Latency, Cost, and Limits Tested
Your API is getting hammered and you need rate limiting that won't become the bottleneck itself. You've narrowed it down to two serverless-friendly options: PlanetScale (serverless MySQL) and Upstash (serverless Redis). Both promise low latency at scale, both have generous free tiers, and both integrate cleanly with modern stacks β so which one do you actually reach for?
This article walks through both options with real implementation patterns, honest latency expectations, and a cost breakdown that holds up once you leave the free tier.
What You'll Learn
- How rate limiting works differently in MySQL (PlanetScale) vs Redis (Upstash)
- Latency profiles for both services under typical and high-load conditions
- Pricing curves at 1M, 10M, and 100M requests per month
- Code patterns for sliding window and token bucket rate limiters with each service
- Which service fits which use case and where each breaks down
Prerequisites
You should be comfortable reading JavaScript or TypeScript. Code samples use Node.js with the official Upstash Redis client and PlanetScale's serverless driver. A basic understanding of rate limiting concepts (fixed window, sliding window, token bucket) is assumed but not required to follow along.
Why the Database Choice Matters for Rate Limiting
Rate limiting is a write-heavy, low-tolerance workload. Every inbound request needs at minimum one read and one write β and it needs to finish before your API handler even starts. If your rate limiter adds 80ms per request, you've already lost.
The architectural difference between the two services is fundamental. Upstash is Redis: an in-memory data store with atomic increment operations built in. PlanetScale is MySQL: a relational engine optimized for durable, consistent transactions. Redis was practically designed for counters and expiring keys. MySQL was not β though it can be made to work.
That doesn't automatically disqualify PlanetScale. If you're already storing user data there, consolidating your rate limit state in the same database removes a dependency and simplifies your infrastructure. The question is whether the tradeoff is acceptable.
Implementing Rate Limiting with Upstash
Upstash's killer feature for rate limiting is the @upstash/ratelimit package, which wraps the Redis client and gives you sliding window, fixed window, and token bucket algorithms out of the box.
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, "1 m"),
analytics: true,
});
// In your API handler:
export async function handler(req, res) {
const identifier = req.headers["x-forwarded-for"] ?? "anonymous";
const { success, limit, remaining, reset } = await ratelimit.limit(identifier);
res.setHeader("X-RateLimit-Limit", limit);
res.setHeader("X-RateLimit-Remaining", remaining);
res.setHeader("X-RateLimit-Reset", reset);
if (!success) {
return res.status(429).json({ error: "Too many requests" });
}
return res.status(200).json({ data: "ok" });
}The slidingWindow(100, "1 m") call sets a limit of 100 requests per minute using two Redis keys that slide over time. The entire check is a single Lua script executed atomically on the Redis server β no race conditions, no double-counting.
Upstash Edge Compatibility
One concrete advantage: Upstash's client runs on the V8 edge runtime (Vercel Edge Functions, Cloudflare Workers, Next.js middleware). You can run your rate limiter in the same edge region as your user, which keeps the check close to 5β15ms globally on a warm connection.
// Next.js middleware (edge runtime)
import { NextResponse } from "next/server";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.fixedWindow(50, "10 s"),
});
export async function middleware(request) {
const ip = request.ip ?? "127.0.0.1";
const { success } = await ratelimit.limit(ip);
if (!success) {
return new NextResponse("Rate limit exceeded", { status: 429 });
}
return NextResponse.next();
}
export const config = { matcher: "/api/:path*" };This pattern intercepts every API call before it hits a Lambda or serverless function, keeping the rate limit check on the edge network. The result is consistent sub-20ms overhead on most routes.
Implementing Rate Limiting with PlanetScale
PlanetScale doesn't have a native rate limiting SDK, so you build it yourself using a counter table and MySQL's atomic update semantics.
CREATE TABLE rate_limits (
identifier VARCHAR(255) NOT NULL,
window_start BIGINT NOT NULL,
request_count INT NOT NULL DEFAULT 1,
PRIMARY KEY (identifier, window_start)
);import { connect } from "@planetscale/database";
const conn = connect({
url: process.env.DATABASE_URL,
});
const WINDOW_SECONDS = 60;
const MAX_REQUESTS = 100;
export async function checkRateLimit(identifier) {
const windowStart = Math.floor(Date.now() / 1000 / WINDOW_SECONDS) * WINDOW_SECONDS;
const result = await conn.execute(
`INSERT INTO rate_limits (identifier, window_start, request_count)
VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE request_count = request_count + 1`,
[identifier, windowStart]
);
const row = await conn.execute(
`SELECT request_count FROM rate_limits
WHERE identifier = ? AND window_start = ?`,
[identifier, windowStart]
);
const count = row.rows[0]?.request_count ?? 1;
return {
success: count <= MAX_REQUESTS,
remaining: Math.max(0, MAX_REQUESTS - count),
limit: MAX_REQUESTS,
};
}This gives you a fixed window implementation. The ON DUPLICATE KEY UPDATE trick makes the increment atomic at the MySQL level β but notice you need two round trips: one to upsert, one to read the current count. That's two queries per request, which adds latency compared to Upstash's single Lua script.
Cleanup and TTL
Redis handles key expiry automatically. With PlanetScale, you're responsible for cleaning up old rows. A scheduled job or a background cron that deletes expired windows is necessary to prevent the table from growing indefinitely.
DELETE FROM rate_limits
WHERE window_start < UNIX_TIMESTAMP() - 3600;Run this hourly. If you're on a high-traffic API, consider partitioning by window_start to keep DELETE operations fast.
Latency Comparison
Latency in both services depends heavily on where your compute lives relative to the database region. Here's what to expect based on typical deployment configurations.
| Scenario | Upstash (Redis) | PlanetScale (MySQL) |
|---|---|---|
| Same region, warm connection | 3β8ms | 8β20ms |
| Different region, warm connection | 20β50ms | 30β80ms |
| Cold start (serverless) | 50β150ms | 100β300ms |
| Edge runtime (Upstash only) | 5β15ms | N/A |
PlanetScale's cold start penalty is real. Their serverless driver uses HTTP under the hood, and the first connection in a Lambda that hasn't been warm in a while will pay an initialization cost. Upstash shares the same HTTP transport, but Redis operations are simpler and return faster than a MySQL query planner cycle.
If you need deterministic sub-10ms rate limit checks, Upstash on the edge is the only serious option here. PlanetScale in the same region is workable for most APIs with modest traffic, but it's not a fit for high-frequency endpoints.
Pricing Comparison
Both services have usage-based pricing, and both have free tiers that cover development and low-traffic production workloads.
| Monthly Requests | Upstash Cost (approx.) | PlanetScale Cost (approx.) |
|---|---|---|
| Under 500K | Free tier | Free tier |
| 1M | ~$0.20 | Included in base plan |
| 10M | ~$2.00 | Counted as row reads/writes |
| 100M | ~$20.00 | Significant row-read cost |
PlanetScale's pricing is based on row reads and row writes, not HTTP requests. Every rate limit check is at minimum two row reads and one row write. At 100M API requests, you're looking at 200M+ row reads β which pushes you into a paid tier that can become substantial depending on your plan.
Upstash charges per command at a very low rate, and rate limit checks typically map to a fixed number of Redis commands regardless of complexity. At scale, Upstash's cost curve is more predictable for this specific workload.
The honest summary: for pure rate limiting volume, Upstash is cheaper and more predictable. PlanetScale becomes cost-competitive only if you're already paying for a plan that absorbs the extra row reads within your existing allocation.
Common Pitfalls
Race conditions in MySQL implementations
The ON DUPLICATE KEY UPDATE pattern is atomic for a single row, but if you're implementing sliding window logic across multiple rows or time buckets, you can introduce race conditions. Stick to fixed windows with MySQL unless you're willing to add application-level locking or stored procedures.
Upstash connection limits at high concurrency
Upstash's free tier has connection and request-per-second limits. If you're running hundreds of serverless invocations in parallel, you can hit rate limits on the rate limiter itself. Use the analytics flag during development to monitor actual usage patterns before going to production.
Forgetting to clean up PlanetScale rows
As mentioned above, PlanetScale doesn't expire rows automatically. A rate limit table that grows unbounded will eventually affect query performance. Set up cleanup jobs from day one, not as an afterthought.
Using a single Redis key per user without namespacing
If you're building a multi-tenant API, namespace your rate limit keys explicitly: tenant:{tenantId}:user:{userId}. A flat keyspace is hard to debug and impossible to clear selectively for a single tenant during testing.
Ignoring the reset timestamp in responses
Always return X-RateLimit-Reset in your 429 responses. Clients that don't know when to retry will hammer your API harder after being blocked, not less. This is standard HTTP behavior, but it's easy to skip when you're iterating fast.
When to Choose Upstash
Upstash is the right call when your primary concern is latency and you're deploying on edge runtimes or serverless functions that benefit from near-zero cold start times. It's purpose-built for this kind of workload, the SDK does the heavy lifting, and the cost at scale is straightforward.
It's also the better choice if rate limiting is the only reason you'd add a new database dependency. Adding Redis to your stack just for this purpose is low-risk β Upstash requires no infrastructure management and the client is minimal.
When to Choose PlanetScale
PlanetScale makes sense when you're already using it as your primary database and you want to avoid adding another service. If your traffic is moderate (under a few million requests per month) and your functions are warm most of the time, the latency difference is acceptable.
It's also worth considering if you want rate limit data to participate in relational queries β for example, joining rate limit counts with user records for analytics or billing dashboards. Redis doesn't support that kind of query natively.
Wrapping Up
For most teams building APIs on serverless infrastructure today, Upstash is the cleaner choice for rate limiting. The Redis data model fits the problem naturally, the SDK removes implementation complexity, edge compatibility is a real advantage, and the cost curve is predictable.
PlanetScale is a strong database that excels at what relational databases are built for. Rate limiting at high frequency and low latency is not that thing β but it's a reasonable choice if you're already embedded in the PlanetScale ecosystem and your traffic patterns don't push its latency limits.
Here are the concrete next steps to move forward:
- If you're using Vercel or Cloudflare Workers, install
@upstash/ratelimitand add rate limiting to your Next.js middleware today β it's under 20 lines of code. - If you're already on PlanetScale, benchmark your actual P95 latency for the rate limit check in your specific region before deciding to add Upstash as a second service.
- Set up the cleanup cron for PlanetScale rate limit rows before you go to production, not after.
- Always return
X-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit-Resetheaders β your API consumers will thank you. - Review your Upstash usage analytics after the first week of production traffic to verify you're within expected command counts and not hitting plan limits.
π€ Share this article
Sign in to saveRelated Articles
Affiliate Reviews
PlanetScale vs Nile for Multi-Tenant Postgres: Isolation, Limits, and Real Costs
3m read
Affiliate Reviews
Courier vs Knock for In-App Notifications: Free Tiers, Channel Limits, and Real Pricing
8m read
Affiliate Reviews
PlanetScale Vitess vs Xata for Serverless Postgres: Branching, Free Tiers, and Real Query Costs
8m read
Comments (0)
No comments yet. Be the first!