---
title: 'API Rate Limiting with Upstash Redis: 2026 Node Tutorial'
description: 'Add production rate limiting to a Node.js API with Upstash Redis sliding windows: reusable Hono middleware, 429 + Retry-After headers, multi-tier limits.'
url: https://nerdleveltech.com/api-rate-limiting-upstash-redis-sliding-window-tutorial
language: en
date: '2026-05-24'
category: backend
tags:
  - rate limiting
  - upstash
  - redis
  - hono
  - typescript
  - api security
  - node.js
  - backend
markdown_url: https://nerdleveltech.com/api-rate-limiting-upstash-redis-sliding-window-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/api-rate-limiting-upstash-redis-sliding-window-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# API Rate Limiting with Upstash Redis: 2026 Node Tutorial

API rate limiting caps how many requests each caller can make in a time window, shielding your service from floods, scrapers, and runaway bills. This tutorial builds production-grade rate limiting for a Node.js and Hono API using Upstash Redis sliding windows, in about 30 minutes.

## TL;DR

This hands-on guide adds real rate limiting to a Node.js API instead of hoping nobody abuses it. You will create an Upstash Redis database, build a sliding-window limiter with `@upstash/ratelimit`, identify callers by a *trusted* IP (the leftmost `X-Forwarded-For` value is a security trap), package everything as reusable Hono middleware, return spec-correct `429` responses with `Retry-After` and rate-limit headers, give anonymous, free, and paid callers different limits, and harden the whole thing against floods. It uses Node.js 24 LTS, `hono` 4.12.22, and `@upstash/ratelimit` 2.0.8[^ratelimit-npm]. Every file is copy-paste runnable and was type-checked against the published packages on 24 May 2026.

## What you'll learn

- Create an Upstash Redis database and connect it to a Node.js service
- Scaffold a typed Hono API worth protecting
- Build a sliding-window rate limiter with `@upstash/ratelimit` — and why a sliding window beats a fixed window
- Identify callers by a trusted client IP, and why the leftmost `X-Forwarded-For` value is spoofable
- Resolve a caller's tier so anonymous, free, and paid users get different limits
- Return spec-correct `429 Too Many Requests` responses with `Retry-After` and rate-limit headers
- Package rate limiting as reusable, typed Hono middleware
- Harden against floods with an ephemeral cache, a fail-open timeout, and deny lists

## Prerequisites

- **Node.js 24 LTS** or newer. Node.js 24 is the current Active LTS line, supported until April 2028[^node-lts]. Check with `node --version`.
- **An Upstash account.** The free tier is enough for this tutorial and small production workloads.
- Working knowledge of **TypeScript** and HTTP request/response basics.
- A terminal with `curl` for the verification step.

The stack: [Hono](https://nerdleveltech.com/deploy-bun-hono-fly-io-production-tutorial) 4.12.22 as the web framework, `@hono/node-server` 2.0.4 as the Node adapter, and `@upstash/ratelimit` 2.0.8 with `@upstash/redis` 1.38.0 for the limiter.

## Step 1 — Create an Upstash Redis database

Rate limiting needs a fast, shared counter store. A sliding-window limiter increments a counter on every request, so the store must handle low-latency reads and writes and expire keys automatically — exactly what Redis is built for. Upstash gives you a serverless Redis accessed over HTTP, which means the same limiter code runs on a long-lived Node server, a container, or an edge function without connection-pool juggling.

Sign in at [console.upstash.com](https://console.upstash.com), open the Redis section, and click **Create Database**. Pick a name, choose a primary region close to where your API runs, and create it. On the database page, find the **REST API** section and copy two values: `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`.

The free tier currently includes 256 MB of storage, 500,000 commands per month, and one database per account; beyond that, pay-as-you-go billing is 0.20 USD per 100,000 commands, as of May 2026[^upstash-pricing]. A sliding-window check costs three to five Redis commands depending on state, so budget accordingly — Step 8 cuts that cost sharply.

Create a project folder and store the credentials in a `.env` file:

```bash
mkdir rate-limited-api && cd rate-limited-api
cat > .env <<'EOF'
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-rest-token
PORT=3000
TRUSTED_PROXY_HOPS=0
EOF
echo ".env" > .gitignore
```

Never commit `.env` — the REST token is a full credential for your database. The `.gitignore` line above keeps it out of version control.

## Step 2 — Scaffold the Hono API

Now build a small API worth protecting. Initialize the project and install pinned dependencies:

```bash
npm init -y
npm pkg set type=module
npm install hono@4.12.22 @hono/node-server@2.0.4 \
  @upstash/ratelimit@2.0.8 @upstash/redis@1.38.0
npm install -D typescript@6.0.3 tsx@4.22.3 @types/node@25.9.1
```

Add a `tsconfig.json` configured for modern Node ES modules:

```json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "nodenext",
    "lib": ["ES2023"],
    "types": ["node"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}
```

`noUncheckedIndexedAccess` is on deliberately: rate-limiting code parses untrusted header strings, and having TypeScript force you to handle `undefined` from every array index is a real safety net.

Replace the `scripts` block in `package.json`:

```json
{
  "scripts": {
    "dev": "node --env-file=.env --import tsx src/index.ts",
    "build": "tsc",
    "start": "node --env-file=.env dist/index.js"
  }
}
```

Node 24 reads `.env` natively with `--env-file`, so there is no `dotenv` dependency. Create `src/env.ts` to validate the variables once at startup instead of discovering a missing token mid-request:

```ts
function required(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Missing required environment variable: ${name}`);
  }
  return value;
}

export const env = {
  UPSTASH_REDIS_REST_URL: required('UPSTASH_REDIS_REST_URL'),
  UPSTASH_REDIS_REST_TOKEN: required('UPSTASH_REDIS_REST_TOKEN'),
  PORT: Number(process.env.PORT ?? 3000),
  TRUSTED_PROXY_HOPS: Number(process.env.TRUSTED_PROXY_HOPS ?? 0),
};
```

Create a minimal API in `src/index.ts` — one liveness route and one endpoint to protect:

```ts
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { env } from './env.js';

const app = new Hono();

app.get('/health', (c) => c.json({ status: 'ok' }));

app.get('/api/quote', (c) =>
  c.json({ quote: 'Premature optimization is the root of all evil.' }),
);

serve({ fetch: app.fetch, port: env.PORT }, (info) => {
  console.log(`API listening on http://localhost:${info.port}`);
});
```

Run `npm run dev` and confirm `curl http://localhost:3000/api/quote` returns the JSON quote. Right now `/api/quote` will answer a million requests a second without complaint. That is the problem the rest of this tutorial fixes.

## Step 3 — Build a sliding-window rate limiter

Before wiring anything in, pick the algorithm. A **fixed window** counts requests in fixed clock slices — say, 00:00:00 to 00:00:10. The flaw is the boundary: a caller can send a full window of requests at 00:00:09 and another full window at 00:00:11, landing double the intended rate in two seconds[^algorithms].

A **sliding window** fixes this. It still divides time into slices, but it weights the *previous* slice by how much of it still overlaps the current moment. If you allow 10 requests per minute and you are 15 seconds into the current window with 4 requests in the previous window and 5 so far in this one, the limiter estimates `4 * ((60 - 15) / 60) + 5 = 8` and lets the request through[^algorithms]. The boundary burst is smoothed away, at the cost of also reading the previous window's counter on each check. For almost every API, that trade is worth it.

Create `src/ratelimit.ts`. It defines three tiers up front and builds one limiter per tier:

```ts
import { Ratelimit, type Duration } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
import { env } from './env.js';

const redis = new Redis({
  url: env.UPSTASH_REDIS_REST_URL,
  token: env.UPSTASH_REDIS_REST_TOKEN,
});

interface TierConfig {
  limit: number;
  window: Duration;
  windowSeconds: number;
}

// One source of truth for every tier's limits.
export const TIERS = {
  anon: { limit: 20, window: '10 s', windowSeconds: 10 },
  free: { limit: 100, window: '60 s', windowSeconds: 60 },
  paid: { limit: 1000, window: '60 s', windowSeconds: 60 },
} satisfies Record<string, TierConfig>;

export type Tier = keyof typeof TIERS;

function buildLimiter(tier: Tier): Ratelimit {
  const cfg = TIERS[tier];
  return new Ratelimit({
    redis,
    prefix: `rl:${tier}`,
    limiter: Ratelimit.slidingWindow(cfg.limit, cfg.window),
    analytics: true,
  });
}

export const limiters: Record<Tier, Ratelimit> = {
  anon: buildLimiter('anon'),
  free: buildLimiter('free'),
  paid: buildLimiter('paid'),
};
```

Three things matter here. The `prefix` namespaces each tier's keys in Redis so the limiters never collide. `Ratelimit.slidingWindow(limit, window)` takes a request count and a duration string like `'10 s'` or `'60 s'`. And `analytics: true` records every check so you can see allowed and blocked counts in the Upstash Console's Rate Limit dashboard — it adds one Redis command per call[^costs].

Using a limiter is a single call. `limiters.anon.limit(identifier)` returns a promise that resolves to an object you can act on:

```ts
const { success, limit, remaining, reset, reason } =
  await limiters.anon.limit('ip:203.0.113.7');
// success:   false if the caller exceeded the limit
// limit:     max requests in the window (20 for anon)
// remaining: requests left in the current window
// reset:     Unix timestamp in MILLISECONDS when the window resets
// reason:    'timeout' | 'cacheBlock' | 'denyList' | undefined
```

`reset` is a millisecond timestamp. For a sliding window it points at the start of the next window rather than an exact per-caller reset, which is close enough for a `Retry-After` value[^algorithms]. The `identifier` is whatever string you decide represents "one caller" — and choosing it correctly is its own problem.

## Step 4 — Identify callers without trusting spoofable headers

A rate limiter is only as good as its identifier. If every request maps to the same key, you have built a global throttle; if an attacker can pick their own key, you have built nothing.

For anonymous traffic the natural key is the client IP. The naive approach reads the leftmost value of the `X-Forwarded-For` header — and it is a genuine security hole. `X-Forwarded-For` is a comma-separated list that each proxy *appends* to. The leftmost entries are supplied by the client and completely unverified, so an attacker can send a different fake IP on every request and sail past a limiter that keys on them[^xff].

The only IP you can trust is the one your own infrastructure added: the TCP socket peer address, or — when you run behind reverse proxies you control — the entry those proxies appended, counted from the **right**[^xff]. Create `src/client-ip.ts`:

```ts
import type { Context } from 'hono';
import { getConnInfo } from '@hono/node-server/conninfo';
import { env } from './env.js';

/**
 * Resolve the client IP we can trust for rate limiting.
 *
 * The TCP socket peer address cannot be spoofed by the client. Behind
 * reverse proxies you control, those proxies append the real client IP
 * to X-Forwarded-For, so we count in from the RIGHT. The leftmost
 * entries are attacker-controlled and must never key a limiter.
 */
export function getClientIp(c: Context): string {
  const socketIp = getConnInfo(c).remote.address ?? 'unknown';

  if (env.TRUSTED_PROXY_HOPS === 0) {
    return socketIp;
  }

  const forwarded = c.req.header('x-forwarded-for');
  if (!forwarded) {
    return socketIp;
  }

  const chain = forwarded
    .split(',')
    .map((part) => part.trim())
    .filter(Boolean);

  // Each trusted proxy appends one entry, so the rightmost N entries are
  // from your own infrastructure. The real client IP is the leftmost of
  // those N — at index (length - N).
  const index = chain.length - env.TRUSTED_PROXY_HOPS;
  return chain[index] ?? socketIp;
}
```

`getConnInfo` comes from the Hono Node adapter and returns the real socket peer address[^conninfo]. When the app is directly internet-facing, set `TRUSTED_PROXY_HOPS=0` and use that address. When you add one proxy — an Nginx instance, a cloud load balancer — set it to `1`; for a CDN in front of a load balancer, set it to `2`. The value must match your real deployment exactly: set it too high and you start trusting attacker-supplied entries again.

## Step 5 — Resolve the caller's tier

Anonymous callers share an IP-based limit, but authenticated callers should be identified by their API key. Keying paid callers on their key instead of their IP also means one customer is never throttled because a noisy neighbour shares their office IP.

Create `src/tiers.ts` to map an API key to a tier:

```ts
import { type Tier } from './ratelimit.js';

// Demo key store. In production, look API keys up in your database
// (and cache the lookup) instead of hard-coding them.
const API_KEYS = new Map<string, Tier>([
  ['demo-free-key', 'free'],
  ['demo-paid-key', 'paid'],
]);

/** Map an incoming API key to a tier. No key, or an unknown key, is anonymous. */
export function resolveTier(apiKey: string | undefined): Tier {
  if (apiKey) {
    const tier = API_KEYS.get(apiKey);
    if (tier) return tier;
  }
  return 'anon';
}
```

The rule is deliberately strict: a missing key *or* an unrecognized key both fall through to `anon`. A caller cannot promote themselves to the `paid` tier by inventing a key, because an unknown key resolves to the lowest tier, not an error. In a real service, replace the hard-coded `Map` with a database lookup — and cache it, because this runs on every request.

## Step 6 — Build spec-correct 429 responses

When a caller is over the limit, the response matters as much as the rejection. The correct status is `429 Too Many Requests`[^429], and a well-behaved API tells the client how long to wait with a `Retry-After` header, whose value can be a count of seconds[^retry-after]. Clients and SDKs use it to back off instead of hammering you harder.

It also helps to advertise the limit on *every* response, not just rejections, so clients can self-throttle before they hit the wall. Two header conventions exist. The de-facto triple — `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` — is what GitHub and most APIs send today. The IETF is standardizing a replacement: `draft-ietf-httpapi-ratelimit-headers` defines structured `RateLimit` and `RateLimit-Policy` fields. It is still an Internet-Draft — the latest revision is dated May 2026 and it is not yet a finalized RFC[^ietf] — so the pragmatic move is to send both.

Create `src/rate-limit-headers.ts`:

```ts
import type { Context } from 'hono';
import { TIERS, type Tier } from './ratelimit.js';

/** The subset of an @upstash/ratelimit limit() response we need for headers. */
export interface RateLimitResult {
  success: boolean;
  limit: number;
  remaining: number;
  reset: number;
  reason?: 'timeout' | 'cacheBlock' | 'denyList';
}

/**
 * Attach rate-limit headers to the response and return the Retry-After
 * value (seconds until the window resets).
 */
export function applyRateLimitHeaders(
  c: Context,
  tier: Tier,
  result: RateLimitResult,
): number {
  const retryAfter = Math.max(0, Math.ceil((result.reset - Date.now()) / 1000));
  const safeRemaining = Math.max(0, result.remaining);
  const policy = TIERS[tier];

  // De-facto headers most HTTP clients and SDKs already understand.
  c.header('X-RateLimit-Limit', String(result.limit));
  c.header('X-RateLimit-Remaining', String(safeRemaining));
  c.header('X-RateLimit-Reset', String(Math.ceil(result.reset / 1000)));

  // Emerging IETF structured fields (draft-ietf-httpapi-ratelimit-headers).
  c.header('RateLimit-Policy', `"${tier}";q=${policy.limit};w=${policy.windowSeconds}`);
  c.header('RateLimit', `"${tier}";r=${safeRemaining};t=${retryAfter}`);

  return retryAfter;
}
```

`retryAfter` is computed from `reset` (a millisecond timestamp) minus the current time, floored at zero so clock skew never produces a negative wait. `X-RateLimit-Reset` is sent as Unix epoch seconds, matching GitHub's convention. The IETF `RateLimit-Policy` value `"anon";q=20;w=10` reads as "the anon policy allows a quota of 20 in a 10-second window," and `RateLimit` reports the live remaining quota and seconds left[^ietf].

## Step 7 — Wire it together as Hono middleware

Now assemble the pieces into one reusable middleware. Hono's factory helper, `createMiddleware`, produces middleware with correct TypeScript types, including any context variables it sets[^hono-factory].

Create `src/rate-limit-middleware.ts`:

```ts
import { createMiddleware } from 'hono/factory';
import { getClientIp } from './client-ip.js';
import { limiters, type Tier } from './ratelimit.js';
import { resolveTier } from './tiers.js';
import { applyRateLimitHeaders } from './rate-limit-headers.js';

export type AppEnv = {
  Variables: {
    rateLimit: { tier: Tier; limit: number; remaining: number };
  };
};

export function rateLimit() {
  return createMiddleware<AppEnv>(async (c, next) => {
    const apiKey = c.req.header('x-api-key');
    const tier = resolveTier(apiKey);
    const clientIp = getClientIp(c);

    // Anonymous callers are limited per IP; authenticated callers per key.
    const identifier = tier === 'anon' ? `ip:${clientIp}` : `key:${apiKey}`;

    const result = await limiters[tier].limit(identifier, {
      ip: clientIp,
      userAgent: c.req.header('user-agent'),
    });

    // Flush analytics + deny-list updates in the background. A long-lived
    // Node process stays alive, so there is no need to await this.
    result.pending.catch(() => {});

    const retryAfter = applyRateLimitHeaders(c, tier, result);

    if (!result.success) {
      if (result.reason === 'denyList') {
        return c.json(
          { error: 'forbidden', message: 'This request was blocked.' },
          403,
        );
      }
      c.header('Retry-After', String(retryAfter));
      return c.json(
        {
          error: 'too_many_requests',
          message: `Rate limit exceeded. Retry in ${retryAfter}s.`,
          retryAfter,
        },
        429,
      );
    }

    c.set('rateLimit', {
      tier,
      limit: result.limit,
      remaining: Math.max(0, result.remaining),
    });
    await next();
  });
}
```

A few details worth calling out. The `limit()` call passes `ip` and `userAgent` alongside the identifier; Step 8 uses those for deny-list checks. The `result.pending` promise carries the analytics and deny-list writes — on a serverless platform you must hand it to `waitUntil`, but on a long-lived Node server the process stays alive long enough for it to finish, so a fire-and-forget `.catch()` is correct and keeps the response fast[^methods]. A deny-list hit returns `403 Forbidden` rather than `429`, because that caller is not "too fast" — they are blocked.

Update `src/index.ts` to apply the middleware and read its result:

```ts
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { env } from './env.js';
import { rateLimit, type AppEnv } from './rate-limit-middleware.js';

const app = new Hono<AppEnv>();

// Liveness probe — deliberately NOT rate limited.
app.get('/health', (c) => c.json({ status: 'ok' }));

// Every route under /api is rate limited.
app.use('/api/*', rateLimit());

app.get('/api/quote', (c) => {
  const rl = c.get('rateLimit');
  return c.json({
    quote: 'Premature optimization is the root of all evil.',
    tier: rl.tier,
    remaining: rl.remaining,
  });
});

const server = serve({ fetch: app.fetch, port: env.PORT }, (info) => {
  console.log(`API listening on http://localhost:${info.port}`);
});

function shutdown(): void {
  server.close(() => process.exit(0));
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
```

`app.use('/api/*', rateLimit())` applies the limiter to every `/api` route while leaving `/health` untouched — a health probe that gets rate limited will eventually mark your service unhealthy under load. Because the app is typed with `AppEnv`, `c.get('rateLimit')` is fully typed inside the route.

## Step 8 — Harden against floods and abuse

The limiter works, but under a real flood it has two weaknesses: every malicious request still costs Redis commands, and a Redis hiccup could take your API down with it. Three `Ratelimit` constructor options close those gaps and add a deny list for known-bad clients. Update `buildLimiter` in `src/ratelimit.ts`:

```ts
// Add near the top of src/ratelimit.ts, at module scope:
const ephemeralCache = new Map<string, number>();

function buildLimiter(tier: Tier): Ratelimit {
  const cfg = TIERS[tier];
  return new Ratelimit({
    redis,
    prefix: `rl:${tier}`,
    limiter: Ratelimit.slidingWindow(cfg.limit, cfg.window),
    analytics: true,
    ephemeralCache, // block known-bad callers from memory, 0 Redis calls
    timeout: 1000, // fail open if Redis is slow or unreachable
    enableProtection: tier === 'anon', // deny-list checks for anon traffic
  });
}
```

**`ephemeralCache`** is an in-process `Map`. Once a caller is rate limited, the limiter remembers their reset time in memory and rejects every further request from them *without touching Redis at all*, reporting `reason: 'cacheBlock'`[^features]. During a flood from a handful of IPs, this collapses thousands of Redis commands to zero. Declare the `Map` at module scope so it survives across requests.

**`timeout: 1000`** makes the limiter *fail open*. If the Redis call does not resolve within 1000 ms, the request is allowed through instead of rejected[^features]. The default is 5000 ms. The reasoning: a rate limiter exists to protect your API, and it should never become the reason your API is down. A brief Redis outage degrades you to "unlimited" — annoying — rather than "offline."

**`enableProtection: true`** turns on deny lists[^denylist]. With it on, the `ip` and `userAgent` values you already pass to `limit()` are checked against a deny list you manage in the Upstash Console's Rate Limit dashboard, plus an Auto IP Deny List built from open-source malicious-IP feeds and refreshed daily around 02:00 UTC. A match returns `success: false` with `reason: 'denyList'` — which the Step 7 middleware already turns into a `403`. Deny-list checks add two Redis commands per call[^costs], so this tutorial enables them only for the anonymous tier, where abuse concentrates.

To block a specific IP by hand, open the Rate Limit dashboard, select your database, and add the value to the deny list. Matching is exact — there is no wildcard or CIDR support — so add individual addresses.

## Verification

Start the server with `npm run dev`. In a second terminal, confirm a single request succeeds and carries the headers:

```bash
curl -i http://localhost:3000/api/quote
```

You should see `HTTP/1.1 200 OK` plus `X-RateLimit-Limit: 20`, a decreasing `X-RateLimit-Remaining`, and the `RateLimit` / `RateLimit-Policy` fields.

Now exceed the anonymous limit of 20 requests per 10 seconds with a quick burst:

```bash
for i in $(seq 1 25); do
  curl -s -o /dev/null -w "%{http_code} " http://localhost:3000/api/quote
done
echo
```

The first 20 requests print `200` and the rest print `429` — the sliding window in action. Inspect a rejected response and confirm it carries `Retry-After`:

```bash
curl -i http://localhost:3000/api/quote
# HTTP/1.1 429 Too Many Requests
# retry-after: 7
# {"error":"too_many_requests","message":"Rate limit exceeded. Retry in 7s.","retryAfter":7}
```

Finally, prove the tiers are independent. The paid demo key has a 1000-request limit, so it sails through while anonymous traffic is still blocked:

```bash
curl -s -H "x-api-key: demo-paid-key" http://localhost:3000/api/quote
# {"quote":"Premature optimization is the root of all evil.","tier":"paid","remaining":999}
```

With `analytics: true`, the allowed and blocked counts from these runs appear in the Upstash Console's Rate Limit dashboard.

## Troubleshooting

**Every request is rejected immediately, even the first.** Your callers are probably all collapsing to one identifier. Log `identifier` at the top of the middleware: if it is the same string for different clients, `getConnInfo` is returning a single value (common when the app sits behind a proxy but `TRUSTED_PROXY_HOPS` is `0`, so every request shows the proxy's IP). Set `TRUSTED_PROXY_HOPS` to your real proxy count.

**Attackers are bypassing the limit entirely.** You are almost certainly keying on the leftmost `X-Forwarded-For` value somewhere. That entry is client-supplied; an attacker rotates it every request and never shares a key. Use the trusted IP from Step 4 — socket address or rightmost trusted hop — everywhere a caller is identified.

**`Error: Missing required environment variable: UPSTASH_REDIS_REST_URL`.** The `.env` file is not being loaded. Confirm the file sits in the project root and the `dev` script includes `--env-file=.env`. Running `tsx src/index.ts` directly skips it, because `tsx` does not load `.env` on its own.

**Requests succeed even though Redis credentials are wrong.** That is the `timeout` option working as designed. With an unreachable Redis, `limit()` times out after 1000 ms and fails open, so the request is allowed with `reason: 'timeout'`. Fix the `UPSTASH_REDIS_REST_URL` and token; if you would rather fail *closed*, check `result.reason === 'timeout'` in the middleware and reject.

**Analytics never appear in the dashboard.** Two usual causes. The dashboard's prefix selector must match the `prefix` you set on the limiter (`rl:anon`, `rl:free`, `rl:paid`) — analytics are stored per prefix. And on a serverless platform the `pending` promise must be awaited or handed to `waitUntil`, or the process ends before the analytics write completes; on a long-lived Node server this is not an issue.

## Next steps and further reading

You now have production-grade rate limiting: a sliding-window limiter, spoof-resistant caller identification, multi-tier limits, correct `429` responses, an ephemeral cache, a fail-open timeout, and deny lists. A few directions from here:

- **Per-route limits.** Apply a stricter limiter to expensive routes by creating another tier (for example a `search` limiter at 5 requests per 10 seconds) and mounting it with `app.use('/api/search', searchRateLimit())`.
- **Cost-weighted limits.** `limiters.paid.limit(id, { rate: batchSize })` subtracts more than one token for a heavier request — useful when a single call processes a batch[^features].
- **Cache the data behind the limiter.** Pair rate limiting with a caching layer so the requests that *do* get through are cheap; see [Redis caching patterns for scalable systems](https://nerdleveltech.com/redis-caching-patterns-the-complete-guide-for-scalable-systems).
- **Design the limits themselves.** For how to choose fair, cost-aware limits across an API surface, see [AI rate limiting: fairness, cost, and scale](https://nerdleveltech.com/ai-rate-limiting-managing-fairness-cost-and-scale-in-intelligent-systems) and [API versioning strategies](https://nerdleveltech.com/mastering-api-versioning-strategies-tradeoffs-and-best-practices).

---

## Related reads

- [The Modern Coder’s Toolkit: Python, JavaScript, Go & Rust](https://nerdleveltech.com/the-modern-coders-toolkit-python-javascript-go-rust)
- [Semantic Cache for LLM Calls with Redis Vector Sets (2026)](https://nerdleveltech.com/semantic-cache-llm-redis-vector-sets-nodejs-tutorial)


[^ratelimit-npm]: `@upstash/ratelimit` 2.0.8 and `@upstash/redis` 1.38.0, verified on the npm registry, 24 May 2026. https://www.npmjs.com/package/@upstash/ratelimit
[^node-lts]: Node.js Releases — Node.js 24 is the current Active LTS line, supported through April 2028. https://nodejs.org/en/about/previous-releases
[^upstash-pricing]: Upstash Redis Pricing & Limits, accessed 24 May 2026. https://upstash.com/docs/redis/overall/pricing
[^algorithms]: Ratelimiting Algorithms — fixed window, sliding window, and token bucket, Upstash Documentation. https://upstash.com/docs/redis/sdks/ratelimit-ts/algorithms
[^costs]: Costs — Redis command counts per algorithm, analytics, and deny lists, Upstash Documentation. https://upstash.com/docs/redis/sdks/ratelimit-ts/costs
[^xff]: "The perils of the 'real' client IP," adam-p, on why security-sensitive code must use the rightmost trusted `X-Forwarded-For` entry. https://adam-p.ca/blog/2022/03/x-forwarded-for/
[^conninfo]: ConnInfo Helper, Hono Documentation — `getConnInfo` for the Node.js adapter. https://hono.dev/docs/helpers/conninfo
[^429]: RFC 6585, Additional HTTP Status Codes — Section 4, "429 Too Many Requests." https://www.rfc-editor.org/rfc/rfc6585
[^retry-after]: RFC 9110, HTTP Semantics — Section 10.2.3, the `Retry-After` header field. https://www.rfc-editor.org/rfc/rfc9110#section-10.2.3
[^ietf]: "RateLimit header fields for HTTP," draft-ietf-httpapi-ratelimit-headers, IETF HTTPAPI Working Group, latest revision (draft-11) dated 23 May 2026 — still an Internet-Draft. https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/
[^features]: Features — caching, timeout, analytics, custom rates, Upstash Documentation. https://upstash.com/docs/redis/sdks/ratelimit-ts/features
[^methods]: Methods — the `limit()` response and the `pending` promise, Upstash Documentation. https://upstash.com/docs/redis/sdks/ratelimit-ts/methods
[^hono-factory]: Factory Helper — `createMiddleware`, Hono Documentation. https://hono.dev/docs/helpers/factory
[^denylist]: Traffic Protection — deny lists and the Auto IP Deny List, Upstash Documentation. https://upstash.com/docs/redis/sdks/ratelimit-ts/traffic-protection
