Build a rate limiter with the connectionless Upstash Redis client and the @upstash/ratelimit library.
In the previous lesson you made every decision and wrote no code.
The database is provisioned through the Upstash integration, the two environment variables are validated in env.ts, and @upstash/ratelimit is in your package.json.
What you have is an empty lib/rate-limit.ts.
This lesson fills it in, and it comes down to three things.
What your app calls: the client, the limiter, and how they fit together.
Which algorithm runs by default, and when you would reach for another.
And what limit(key) returns, so a route handler can turn that verdict and its numbers into an HTTP response.
If you’ve used a database client like pg for Postgres or ioredis for Redis, you expect a connection pool: something you size, that opens connections at startup and closes them on shutdown, and that can hit a “too many connections” ceiling under load.
None of that exists with Upstash.
The reason is the transport.
Upstash Redis is reached over HTTP, not a long-lived TCP socket, so every operation is its own HTTPS request, like a fetch call.
The “client” isn’t a pool of sockets with a lifecycle; it’s a small object holding a URL and a token that knows how to make those requests.
We call this connectionless .
Here’s the entire client setup.
lib/rate-limit.ts
import { Redis } from'@upstash/redis';
const redis = Redis.fromEnv();
Redis.fromEnv() reads the two variables straight from the environment.
You could write new Redis({ url, token }) and pass them explicitly, but env.ts is already the single place those values are validated, so passing them again here would just duplicate that binding.
Because there’s no socket to keep alive, this redis object is safe to share across every request the server handles: no pool to exhaust, no connect() to await, no end() to remember on shutdown.
It’s also why the same code runs unchanged in an edge runtime where raw TCP isn’t available, since there’s no socket to be unavailable.
The diagram below puts the two side by side.
Notice what’s missing on the Upstash side: the cell where the pool would be.
Pooled TCP clientpg, ioredis
App
→borrow / return socket
Connection poolN sockets · open / idle / close
→persistent TCP
Redis server
Connectionless@upstash/redis
App
→
fetch(url, token)
no poolnothing to keep alive
Redis serverHTTP
Pooled clients manage N sockets and their lifecycle; the connectionless client makes one HTTPS request per operation, with nothing to keep alive in between.
Your client can talk to Redis, but a client only runs commands.
Turning “run commands against Redis” into “tell me whether this key is over its budget” is the job of the second package.
The two packages sit a layer apart.
@upstash/redis is the client you just built, the thing that makes the HTTP calls.
@upstash/ratelimit is a small library that uses that client to implement a limiter, and it exposes essentially one method: limit(key).
import { Ratelimit } from'@upstash/ratelimit';
The library owns the counter math: incrementing, expiring, and deciding whether you’re over the line.
It sets the TTL on each key so a window expires on its own.
It keeps a small in-memory cache so a single hot server doesn’t hammer Redis on every call.
And it can write per-key analytics for you.
The part that’s easy to undervalue is how it counts.
The check (“are you under your budget?”) and the increment (“count this request”) have to happen together, as one indivisible step.
They have to be atomic .
Otherwise two requests arriving at the same instant could both read the same count, both decide they’re fine, and both pass, sailing past a limit of one.
The library guarantees atomicity by shipping its logic as a Lua script that Redis runs in a single step.
You write the configuration; the library writes the Lua.
From here on, everything you do is a configuration decision.
You’re not implementing a limiter; you’re telling a finished one how to behave.
This is the file you came to write: one limiter for the sign-in endpoint, declared at module scope.
Every field in the config answers a question: which algorithm, where the keys live, whether to keep analytics on.
Take them one at a time.
import { Ratelimit } from'@upstash/ratelimit';
import { Redis } from'@upstash/redis';
const redis = Redis.fromEnv();
export const signInLimiter = newRatelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '1 m'),
prefix: 'rl:signin',
analytics: true,
});
The shared client from the last section, built once at module scope. Every limiter in this file reuses this same redis; you don’t build one per limiter.
import { Ratelimit } from'@upstash/ratelimit';
import { Redis } from'@upstash/redis';
const redis = Redis.fromEnv();
export const signInLimiter = newRatelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '1 m'),
prefix: 'rl:signin',
analytics: true,
});
A named export at module scope, which matters more than it looks. The rule of thumb is one limiter per abusable intent: sign-in gets its own, and sign-up and reset will get theirs.
import { Ratelimit } from'@upstash/ratelimit';
import { Redis } from'@upstash/redis';
const redis = Redis.fromEnv();
export const signInLimiter = newRatelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '1 m'),
prefix: 'rl:signin',
analytics: true,
});
Hand the limiter the shared client. This is the only link between the two packages; the limiter does all its Redis work through it.
import { Ratelimit } from'@upstash/ratelimit';
import { Redis } from'@upstash/redis';
const redis = Redis.fromEnv();
export const signInLimiter = newRatelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '1 m'),
prefix: 'rl:signin',
analytics: true,
});
The algorithm and the budget in one call: 10 requests per rolling one-minute window. The window is a duration string the library parses, like '1 m', '10 s', or '1 h'.
import { Ratelimit } from'@upstash/ratelimit';
import { Redis } from'@upstash/redis';
const redis = Redis.fromEnv();
export const signInLimiter = newRatelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '1 m'),
prefix: 'rl:signin',
analytics: true,
});
Namespaces every Redis key this limiter writes, so rl:signin counters never collide with rl:signup. Give each limiter its own prefix; share one and two limiters silently corrupt each other’s counts. The key you pass to limit() does not include the prefix; the library prepends it.
import { Ratelimit } from'@upstash/ratelimit';
import { Redis } from'@upstash/redis';
const redis = Redis.fromEnv();
export const signInLimiter = newRatelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '1 m'),
prefix: 'rl:signin',
analytics: true,
});
Records a per-key timeline in the Upstash dashboard: rate over time, busiest keys, reject rate. Keep it on for the auth surface so an incident is reviewable after the fact. It costs one extra Redis write per call, but that write is non-blocking, as the return-shape section will show.
1 / 1
One field you won’t see here but should recognize is timeout.
The constructor accepts it, it defaults to 5000ms, and it bounds how long limit() waits on Redis before giving up.
That’s the knob behind the fail-mode question: what happens when Redis is slow or down.
The policy for handling it is the next lesson’s job; for now, just know the field exists.
There’s no return-type annotation here, and that’s deliberate.
signInLimiter is a const value, not an exported function: the convention is to annotate exported functions, and TypeScript infers this one correctly on its own.
This is the one mistake in this lesson that passes every test you run in development and then quietly costs you in production.
The library keeps a small in-process cache of keys it’s seen recently, the ephemeralCache, on by default.
While a serverless instance stays hot between requests, a repeat limit() call for an already-blocked key can be answered from that in-memory cache instead of making another round-trip to Redis, so a burst against one hot key stays cheap.
That cache only helps if the limiter object survives between requests, so declare the limiter once, at module scope.
On a hot invocation , the same signInLimiter is reused with its cache still warm.
Declare it inside the handler and you build a brand-new limiter, with an empty cache, on every request, so every call pays a fresh Redis round-trip.
The in-handler version isn’t wrong: the counts in Redis stay correct and the limiter still works, it’s just slower and pricier on every call.
In development, where you fire a few requests by hand against a process that’s always hot, you’d never notice.
It surfaces only under real traffic, as latency and cost.
Built once when the module loads. Every hot invocation reuses this instance and its warm cache, so a blocked hot key is answered from memory with no Redis round-trip.
A fresh limiter, with a cold, empty cache, on every request. The counts stay correct, so it passes in dev. Under load it means a Redis round-trip on every call: slower and pricier, for nothing.
You can turn the cache off with ephemeralCache: false, but the auth surface keeps it on.
It’s a performance optimization for bursty keys, not a correctness lever: your counts are right either way.
Three rate-limit algorithms, and which to default to
The limiter field picks the algorithm.
Three are worth choosing between, and the short answer is to use sliding window unless you have a reason not to.
Here is why each one behaves the way it does.
Sliding window, Ratelimit.slidingWindow(limit, window), weights your count across the current window and the previous one, so the budget glides forward in time instead of resetting on a hard clock boundary.
That gives the smoothest cap: the counter never snaps back to zero and lets a flood of requests through.
It’s the chapter’s default and what the auth surface uses.
Token bucket, Ratelimit.tokenBucket(refillRate, interval, maxTokens), is a bucket of maxTokens tokens that refills refillRate tokens every interval; each request spends one.
A full bucket lets a client fire a quick burst, then throttles them down to the steady refill rate.
Reach for it when bursting is legitimate and you mainly want to cap sustained usage, like an endpoint that calls an LLM where a short burst is fine but ongoing spend is not.
Fixed window, Ratelimit.fixedWindow(limit, window), keeps one counter per clock-aligned window and resets it on the boundary.
It’s the cheapest and simplest of the three, with one known weakness: a client can spend its full budget at the end of one window and again at the start of the next, briefly pushing through nearly twice the limit.
That’s the “thundering minute” at the boundary.
Reach for it when a little boundary slop buys you fewer Redis operations, or for coarse limits where exactness doesn’t matter.
Fixed windowcounter resets on a hard boundary
window 1window 2
~2× the budget can slip through here
Sliding windowbudget weighted across windows
budget glides; no boundary spike
one request
time →
Fixed window resets on a hard boundary, so a burst can straddle it. Sliding window weights across windows, so the limit holds smoothly.
Two more are worth recognizing.
Ratelimit.cachedFixedWindow(...) is a fixed window that answers from the in-memory cache first and reconciles with Redis afterward, trading exactness for the lowest latency.
Per call, limit(key, { rate: n }) spends n tokens instead of one, handy when a single request represents n units of work, like a batch.
Neither is the auth choice, since sign-in spends exactly one token per attempt.
Match each workload to the algorithm an experienced engineer would reach for.
Drag each item into the bucket it belongs to, then press Check.
Five fields come back, each with a job, and the response is just those fields copied onto the wire.
Nothing here is hand-computed.
Watch where each one lands.
The boolean the handler branches on: true means the request is under budget, false means respond 429 Too Many Requests. This is the verdict; the rest are the numbers.
limit is the budget you configured (10); remaining is what’s left in the current window. Both go straight onto their headers with no math, just String(...).
reset is a Unix timestamp in milliseconds, the instant the window rolls over. The header wants delta-seconds , not an absolute timestamp, so Math.ceil((reset - Date.now()) / 1000) bridges the two: subtract now, divide to seconds, round up. Ship the raw ms value and you tell the client to wait tens of thousands of years, the most common rate-limit-header bug there is.
A Promise that flushes the analytics write, a second Redis call you don’t want the user waiting on. Schedule it with after() from next/server, the post-response scheduler from the background-work chapter, so the write happens after the response ships. The Upstash docs reach for ctx.waitUntil(result.pending); waitUntil is the raw primitive after() is built on, so recognize it but use after().
1 / 1
A denial also carries a reason ('timeout', 'cacheBlock', 'denyList') explaining why it was blocked.
The auth surface doesn’t branch on it; it’s there for diagnostics.
Trace each field below to where it lands, and notice that pending peels off to the side, never touching the response.
limit() result
successboolean
limitnumber
remainingnumber
resetms epoch
pendingPromise
branch200 vs 429
convert
ceil((reset − now) / 1000)
after(…)analytics write · off the response
HTTP response
200 OK / 429 Too Many
RateLimit-Limit
RateLimit-Remaining
RateLimit-Reset
Retry-Afteron 429
Each header is a returned field, copied or converted. The only math is reset, ms timestamp to delta-seconds; pending goes to the background.
Those headers aren’t decoration; they’re a contract.
A well-behaved HTTP client reads them, and so do load tests, so treat them as part of the limiter’s public interface.
There are four.
RateLimit-Limit is the budget, RateLimit-Remaining is what’s left, and RateLimit-Reset carries the delta-seconds until the window resets.
On a 429 you add a fourth, Retry-After , also in delta-seconds; when both it and RateLimit-Reset are present, Retry-After wins.
Write these headers on every response, not only on the 429s.
A thoughtful client reads RateLimit-Remaining on a successful 200 and slows itself before it gets throttled, so headers everywhere let it avoid failing at all, while headers only on rejections tell it only that it has already failed.
The project at the end of this unit verifies the limiter by reading exactly these headers.
Build them from the limit() result, never hand-counted:
lib/rate-limit.ts
type LimitResult =Awaited<ReturnType<typeof signInLimiter.limit>>;
This is a sketch, not the finished contract: the production version adds Retry-After on a rejection and pairs the 429 with a user-safe body, both next lesson’s work.
The principle to carry forward is that the headers are a pure function of the limiter’s result.
The key argument is the identity the limiter counts under.
Get it right and the limit means what you intend; get it subtly wrong and it silently stops working.
This section names the identity shapes the auth surface uses; the next lesson applies them to sign-in.
The key does not include the prefix.
The limiter prepends its own prefix, so you pass the bare identity, user@example.com, not rl:signin:user@example.com.
The auth surface counts under three kinds of identity:
Per-IP keys count on the client’s IP, read from the x-forwarded-for header (Vercel sets it; the first entry is the original client).
Per-email keys count on the user’s email.
Per-user keys count on the authenticated user id.
The full IP-parsing helper comes next lesson; here, notice the shape.
Now the rule that makes per-email keys work: normalize the email exactly once, at a shared boundary helper.User@example.com and user@example.com are the same mailbox but different strings, so if one code path lowercases before calling limit() and another doesn’t, the two land in separate counters and the per-email cap is bypassed.
One shared helper leaves no second path to disagree; the next lesson introduces lib/keys.ts for exactly this.
A few more guardrails.
Keep keys lowercased and length-bounded, and never put a secret in a key, since keys are written to Redis and shown in the analytics dashboard.
The normalization you use for the key must match the normalization you use for the database lookup, or the limiter counts a different identifier than the one you look up.
Next lesson, you’ll pass 'ip:' + ip and 'email:' + email to the samesignInLimiter: two namespaces under one budget config.
A quick gut-check on the normalize-once rule.
Mark each statement True or False.
If your sign-in handler lowercases the email on one path but passes it raw on another, an attacker can bypass the per-email limit just by varying the capitalization of the address.
True. User@x.com and user@x.com are the same mailbox but different strings, so they land in different Redis counters. Normalizing once at a shared boundary helper removes the second path that could disagree.
A limiter looks like a new expense: another network call, another bill line.
In practice each cost is small against what an auth endpoint already pays.
A limit() call is one Upstash request, or near zero when the in-memory cache answers it, which it does for the blocked hot keys you most want to be cheap.
The analytics write adds one more, but pending keeps it off the user’s response.
Budget one to two operations per limited request.
The free tier runs to tens of thousands of commands per day, enough to limit sign-in, sign-up, and password reset on a small SaaS.
Pricing tiers drift, so treat that as an order of magnitude and check Upstash’s current limits.
Same-region Upstash adds roughly 5–15ms at the p50 and 25–40ms at the p99 ; cross-region pushes that to 50–100ms, which is why the previous lesson said to co-locate the database with your Vercel region.
Put it in proportion: an auth endpoint already pays for a database round-trip plus a deliberately slow password hash that takes tens of milliseconds.
The limiter is a small slice of that budget, and it runs before the hash, capping how often you pay for it at all.
Two more capabilities exist in the library.
You won’t use either on the auth surface, but recognize the names so you know what’s available when a situation calls for them.
Deny lists
Ratelimit.deny() hard-blocks specific identifiers, such as a known abuse IP or a sanctioned range, with no Redis round-trip. Reach for it when an out-of-band abuse signal needs an immediate, unconditional block. The course’s auth surface leans on the limiter plus Better Auth’s existing security primitives instead, so this is recognition, not a build step.
Multi-region replication
MultiRegionRatelimit reads from the nearest replica and syncs counts across regions via CRDTs . The trade-off is eventual consistency: a hot key hit in two regions at once can briefly exceed its budget. Reach for it only when your Vercel deploy is genuinely multi-region.