We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide21 min read

Upstash - Complete Guide to Serverless Data Platform

Upstash provides Redis, queues, and vector search billed per request over HTTP. Pricing, limits, rate limiting, and common mistakes.

Upstash - Complete Guide to Serverless Data Platform

Why HTTP instead of TCP matters here

A classic Redis client opens a TCP connection and keeps it open. In an application running on your own server that is an advantage, since the connection is established once and reused across many requests. In a function invoked on demand, that model falls apart.

The reason is simple: every invocation may land on a fresh instance, so every one opens its own connection. A thousand concurrent invocations produce a thousand connections, and a Redis server has a cap and starts refusing more. On top of that, some runtimes, particularly those running close to the user, do not permit opening TCP sockets at all.

HTTP access sidesteps both problems. There is no connection state to exhaust, and a request can be sent from any environment capable of making an ordinary network call. The price is that a single operation carries more overhead than over TCP, since HTTP headers and TLS negotiation are added. At one command per request that is unnoticeable; at fifty in a loop it is not, which is exactly why batching commands matters more here than in classic Redis.

What happened to Kafka

This is the most important correction against older material about this platform, including an earlier version of this text. Upstash used to offer a Kafka service, and it is gone. The deprecation period began on 11 September 2024, ran for six months, and the service was shut down on 11 March 2025.

The company's stated reason was that rather than maintaining Kafka it preferred to focus resources on QStash and on its durable workflow mechanism. The direction is coherent: Kafka is a tool for high throughput streams, usually operated by a platform team, while Upstash targets an application developer who needs a queue and does not want to administer a broker.

If you encounter a tutorial showing this company's Kafka client package, it is out of date. The current product set covers Redis, vector search, QStash, workflows, full text search, and a sandbox environment.

What is Upstash?

Upstash is a serverless data platform offering Redis, QStash, and vector search with a pay-per-request model. Unlike traditional solutions where you pay for a running server 24/7, with Upstash you only pay for actual usage. This makes Upstash ideal for serverless applications and edge computing, where resources are allocated dynamically.

Founded in 2020, Upstash quickly gained popularity among developers building applications on Vercel, Cloudflare Workers, AWS Lambda, and other serverless platforms. The key advantage is a global edge network with replicas in 30+ locations, ensuring ultra-low latencies worldwide.

The platform currently offers:

  • Upstash Redis, a Redis compatible cache reachable over HTTP
  • QStash, a message queue with retries and scheduling
  • Vector, search by vector similarity
  • Workflow, long running processes that survive a function restart

The billing model and when it pays off

The difference against a classic Redis instance is not the unit price but the shape of the cost curve, and that is what decides whether this service suits you.

A server billed by time costs the same at three in the morning with no traffic as it does at peak. Billing by command gives you zero at zero traffic and rises linearly with use. For a side project, an internal application, or a product before its first customers, that is the difference between a dozen dollars a month and nothing.

The curves cross at a point worth knowing. At twenty cents per hundred thousand commands, ten million commands a month costs twenty dollars. That is roughly what a small always on instance costs elsewhere, and it will not break a sweat at that volume. Above that threshold per command billing starts losing, and a fixed monthly plan, available within the same service, makes more sense.

The practical rule: take per command billing for irregular or small traffic and a fixed plan for predictable, large traffic. Switching between them requires no data migration, so it is not a permanent decision.

One more line item is easy to forget when estimating. You pay separately for storage, twenty five cents per gigabyte, and for egress above the allowance included in your plan. With a cache holding small values those items are negligible; with large objects in it they stop being so.

Upstash against the alternatives

FeatureUpstashRedis self hostedHosted RedisVercel KV
Billingper command or fixedper serverper hourper usage
HTTP accessyes, nativenousually notyes
Works at the edgeyesnonoyes
Cost at zero trafficzerofullfullzero
Administrationnonefulllimitednone
Portabilityhigh, compatible APIhighesthighlow

Read that table through where your application runs. In an edge runtime or in on demand functions, only HTTP based options are genuinely available. On your own server or in a long lived container, classic Redis over TCP will be faster and cheaper, because the connection is established once.

Upstash Redis

Installation

Code
Bash
# SDK for JavaScript/TypeScript
npm install @upstash/redis

# SDK for Python
pip install upstash-redis

Configuration

TSlib/redis.ts
TypeScript
// lib/redis.ts
import { Redis } from '@upstash/redis'

// Option 1: With environment variables
export const redis = Redis.fromEnv()

// Option 2: With explicit config
export const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})

// Option 3: For Edge Runtime
export const redis = new Redis({
  url: 'https://xxx.upstash.io',
  token: 'AXxxxx',
  automaticDeserialization: true, // Automatic JSON parse
})
.env.local
ENV
# .env.local
UPSTASH_REDIS_REST_URL=https://xxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=AXxxxx

Basic operations

Code
TypeScript
import { redis } from '@/lib/redis'

// STRING operations
await redis.set('user:1:name', 'John Doe')
const name = await redis.get('user:1:name') // "John Doe"

// SET with TTL (expiration)
await redis.set('session:abc123', { userId: 1 }, { ex: 3600 }) // 1 hour

// SET with conditions
await redis.set('lock:resource', 'locked', { nx: true }) // Only if it does not exist
await redis.set('config:version', '2.0', { xx: true }) // Only if it already exists

// GET with typing
interface User {
  id: number
  name: string
  email: string
}
const user = await redis.get<User>('user:1') // Typed!

// MGET/MSET - multiple keys
await redis.mset({
  'key1': 'value1',
  'key2': 'value2',
  'key3': 'value3',
})
const values = await redis.mget('key1', 'key2', 'key3')

// Increment/Decrement
await redis.incr('page:views')
await redis.incrby('user:1:points', 10)
await redis.decr('inventory:item:123')

// Append
await redis.append('log:today', '\n2024-01-15: User logged in')

// String length
const length = await redis.strlen('user:1:bio')

Hash operations

Code
TypeScript
// HSET - set hash fields
await redis.hset('user:1', {
  name: 'John Doe',
  email: 'john@example.com',
  age: 30,
  verified: true,
})

// HGET - get a single field
const email = await redis.hget('user:1', 'email')

// HMGET - get multiple fields
const [name, email] = await redis.hmget('user:1', 'name', 'email')

// HGETALL - get the entire hash
const user = await redis.hgetall<User>('user:1')

// HINCRBY - increment a numeric value
await redis.hincrby('user:1', 'age', 1)

// HDEL - delete a field
await redis.hdel('user:1', 'temporary_field')

// HEXISTS - check if a field exists
const hasEmail = await redis.hexists('user:1', 'email')

// HKEYS/HVALS - keys and values
const keys = await redis.hkeys('user:1')
const values = await redis.hvals('user:1')

List operations

Code
TypeScript
// LPUSH/RPUSH - add elements
await redis.lpush('queue:tasks', 'task1', 'task2')
await redis.rpush('queue:tasks', 'task3')

// LPOP/RPOP - retrieve and remove an element
const task = await redis.lpop('queue:tasks')
const lastTask = await redis.rpop('queue:tasks')

// LRANGE - get a range
const tasks = await redis.lrange('queue:tasks', 0, -1) // All elements
const firstFive = await redis.lrange('queue:tasks', 0, 4)

// LLEN - list length
const queueLength = await redis.llen('queue:tasks')

// LINDEX - element at a given index
const secondTask = await redis.lindex('queue:tasks', 1)

// LSET - set element at a given index
await redis.lset('queue:tasks', 0, 'updated_task')

// LTRIM - trim the list
await redis.ltrim('notifications', 0, 99) // Keep the last 100

Set operations

Code
TypeScript
// SADD - add elements
await redis.sadd('tags:post:1', 'javascript', 'react', 'typescript')

// SMEMBERS - all elements
const tags = await redis.smembers('tags:post:1')

// SISMEMBER - check if an element belongs to the set
const hasTag = await redis.sismember('tags:post:1', 'react')

// SCARD - number of elements
const tagCount = await redis.scard('tags:post:1')

// SREM - remove an element
await redis.srem('tags:post:1', 'deprecated')

// SINTER - intersection of sets
const commonTags = await redis.sinter('tags:post:1', 'tags:post:2')

// SUNION - union of sets
const allTags = await redis.sunion('tags:post:1', 'tags:post:2')

// SDIFF - difference of sets
const uniqueTags = await redis.sdiff('tags:post:1', 'tags:post:2')

Sorted Set operations

Code
TypeScript
// ZADD - add with score
await redis.zadd('leaderboard', {
  score: 100,
  member: 'player1',
})
await redis.zadd('leaderboard',
  { score: 150, member: 'player2' },
  { score: 80, member: 'player3' }
)

// ZRANGE - get a range (ascending)
const topPlayers = await redis.zrange('leaderboard', 0, 9, {
  rev: true, // Descending (highest first)
  withScores: true,
})

// ZSCORE - get the score of a member
const score = await redis.zscore('leaderboard', 'player1')

// ZRANK - position in the ranking
const rank = await redis.zrank('leaderboard', 'player1')
const rankFromTop = await redis.zrevrank('leaderboard', 'player1')

// ZINCRBY - increment score
await redis.zincrby('leaderboard', 10, 'player1')

// ZRANGEBYSCORE - range by score
const playersAbove100 = await redis.zrangebyscore('leaderboard', 100, '+inf')

// ZREMRANGEBYRANK - remove a range
await redis.zremrangebyrank('leaderboard', 0, 9) // Remove the weakest 10

JSON operations (RedisJSON)

Code
TypeScript
import { redis } from '@/lib/redis'

// Save a JSON object
await redis.json.set('product:1', '$', {
  name: 'MacBook Pro',
  price: 2499,
  specs: {
    cpu: 'M3 Pro',
    ram: '18GB',
    storage: '512GB',
  },
  tags: ['laptop', 'apple', 'pro'],
})

// Get the entire object
const product = await redis.json.get('product:1')

// Get a specific path
const price = await redis.json.get('product:1', '$.price')
const cpu = await redis.json.get('product:1', '$.specs.cpu')

// Update a nested field
await redis.json.set('product:1', '$.price', 2299)
await redis.json.set('product:1', '$.specs.ram', '36GB')

// JSON array operations
await redis.json.arrappend('product:1', '$.tags', 'new-tag')
await redis.json.arrlen('product:1', '$.tags')

// Numeric operations
await redis.json.numincrby('product:1', '$.price', -100) // Discount

Pipelining and Transactions

Code
TypeScript
// Pipeline - multiple operations in a single request
const pipeline = redis.pipeline()

pipeline.set('key1', 'value1')
pipeline.set('key2', 'value2')
pipeline.incr('counter')
pipeline.hset('user:1', { lastActive: Date.now() })

const results = await pipeline.exec()
// results = [['OK', null], ['OK', null], [1, null], [1, null]]

// Multi/Exec - atomic transaction
const tx = redis.multi()

tx.decrby('account:A:balance', 100)
tx.incrby('account:B:balance', 100)

await tx.exec() // Atomic fund transfer

Rate limiting

Upstash offers a dedicated library for rate limiting:

Code
Bash
npm install @upstash/ratelimit

Basic rate limiting

Code
TypeScript
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

// Create a rate limiter
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
  analytics: true, // Enable analytics dashboard
  prefix: '@upstash/ratelimit',
})

// Middleware for Next.js
export async function middleware(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1'

  const { success, limit, remaining, reset } = await ratelimit.limit(ip)

  if (!success) {
    return new NextResponse('Too Many Requests', {
      status: 429,
      headers: {
        'X-RateLimit-Limit': limit.toString(),
        'X-RateLimit-Remaining': remaining.toString(),
        'X-RateLimit-Reset': reset.toString(),
        'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
      },
    })
  }

  return NextResponse.next()
}

Different rate limiting strategies

Code
TypeScript
// Fixed Window - the simplest
const fixedWindow = new Ratelimit({
  redis,
  limiter: Ratelimit.fixedWindow(100, '1 h'), // 100/hour
})

// Sliding Window - more fair
const slidingWindow = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '10 s'),
})

// Token Bucket - burst-friendly
const tokenBucket = new Ratelimit({
  redis,
  limiter: Ratelimit.tokenBucket(10, '1 s', 30), // 10 tokens/sec, max 30
})

// Cached - more efficient for high traffic
const cached = new Ratelimit({
  redis,
  limiter: Ratelimit.cachedFixedWindow(100, '10 s'),
  ephemeralCache: new Map(), // In-memory cache
})

Rate limiting per user/API key

Code
TypeScript
// Rate limit per authenticated user
export async function POST(request: Request) {
  const session = await getSession()
  const identifier = session?.user?.id ?? 'anonymous'

  const { success, remaining } = await ratelimit.limit(identifier)

  if (!success) {
    return Response.json(
      { error: 'Rate limit exceeded' },
      { status: 429 }
    )
  }

  // Add info to response headers
  const response = await handleRequest(request)
  response.headers.set('X-RateLimit-Remaining', remaining.toString())

  return response
}

// Different limits for different tiers
const limits = {
  free: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(100, '1 d'),
  }),
  pro: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(10000, '1 d'),
  }),
  enterprise: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(1000000, '1 d'),
  }),
}

export async function POST(request: Request) {
  const user = await getUser()
  const limiter = limits[user.tier] || limits.free

  const { success } = await limiter.limit(user.id)
  // ...
}

QStash - Message Queue

QStash is a serverless message queue with HTTP delivery, ideal for background jobs and scheduled tasks.

Code
Bash
npm install @upstash/qstash

Basic usage

Code
TypeScript
import { Client } from '@upstash/qstash'

const qstash = new Client({
  token: process.env.QSTASH_TOKEN!,
})

// Send a message to an endpoint
await qstash.publishJSON({
  url: 'https://my-app.com/api/process',
  body: {
    userId: '123',
    action: 'send-email',
    data: { template: 'welcome' },
  },
})

// With delay
await qstash.publishJSON({
  url: 'https://my-app.com/api/reminder',
  body: { userId: '123' },
  delay: '10m', // 10 minutes
})

// With retry
await qstash.publishJSON({
  url: 'https://my-app.com/api/webhook',
  body: { event: 'order.created' },
  retries: 5,
  callback: 'https://my-app.com/api/callback',
  failureCallback: 'https://my-app.com/api/failure',
})

Scheduled messages (Cron)

Code
TypeScript
// One-time scheduled message
await qstash.publishJSON({
  url: 'https://my-app.com/api/report',
  body: { type: 'weekly' },
  notBefore: Math.floor(Date.now() / 1000) + 86400, // In 24 hours
})

// Recurring schedule (cron)
const schedule = await qstash.schedules.create({
  destination: 'https://my-app.com/api/daily-report',
  cron: '0 9 * * *', // Every day at 9:00 UTC
  body: JSON.stringify({ type: 'daily' }),
})

// List schedules
const schedules = await qstash.schedules.list()

// Delete a schedule
await qstash.schedules.delete(schedule.scheduleId)

Receiver - message verification

TSapp/api/process/route.ts
TypeScript
// app/api/process/route.ts
import { Receiver } from '@upstash/qstash'
import { NextResponse } from 'next/server'

const receiver = new Receiver({
  currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY!,
  nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!,
})

export async function POST(request: Request) {
  const signature = request.headers.get('upstash-signature')!
  const body = await request.text()

  // Verify that the message comes from QStash
  const isValid = await receiver.verify({
    signature,
    body,
    url: process.env.VERCEL_URL + '/api/process',
  })

  if (!isValid) {
    return new NextResponse('Invalid signature', { status: 401 })
  }

  const data = JSON.parse(body)

  // Process the message
  await processMessage(data)

  return new NextResponse('OK')
}

Batch operations

Code
TypeScript
// Send multiple messages at once
const messages = users.map(user => ({
  url: 'https://my-app.com/api/notify',
  body: JSON.stringify({ userId: user.id }),
}))

await qstash.batchJSON(messages)

// Send to multiple endpoints
await qstash.publishJSON({
  url: [
    'https://my-app.com/api/analytics',
    'https://my-app.com/api/logging',
    'https://webhook.site/xxx',
  ],
  body: { event: 'user.signup', userId: '123' },
})

Topics (Pub/Sub)

Code
TypeScript
// Create a topic
await qstash.topics.create({ name: 'user-events' })

// Add an endpoint to the topic
await qstash.topics.addEndpoint({
  topic: 'user-events',
  endpoint: 'https://service-a.com/webhook',
})
await qstash.topics.addEndpoint({
  topic: 'user-events',
  endpoint: 'https://service-b.com/webhook',
})

// Publish to the topic - all subscribers will receive the message
await qstash.publishJSON({
  topic: 'user-events',
  body: { event: 'user.created', userId: '123' },
})

Integrations

Next.js Edge Functions

TSapp/api/cache/route.ts
TypeScript
// app/api/cache/route.ts
import { Redis } from '@upstash/redis'

export const runtime = 'edge' // Runs on Edge

const redis = Redis.fromEnv()

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const key = searchParams.get('key')

  if (!key) {
    return Response.json({ error: 'Key required' }, { status: 400 })
  }

  const cached = await redis.get(key)

  if (cached) {
    return Response.json({ data: cached, source: 'cache' })
  }

  // Fetch fresh data
  const data = await fetchFreshData(key)
  await redis.set(key, data, { ex: 3600 })

  return Response.json({ data, source: 'fresh' })
}

Vercel KV (powered by Upstash)

Code
TypeScript
// Vercel KV is built on top of Upstash Redis
import { kv } from '@vercel/kv'

// Identical API
await kv.set('key', 'value')
const value = await kv.get('key')
await kv.hset('hash', { field: 'value' })

Cloudflare Workers

TSworker.ts
TypeScript
// worker.ts
import { Redis } from '@upstash/redis/cloudflare'

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const redis = new Redis({
      url: env.UPSTASH_REDIS_REST_URL,
      token: env.UPSTASH_REDIS_REST_TOKEN,
    })

    const visits = await redis.incr('page:visits')

    return new Response(`This page has been visited ${visits} times`)
  },
}

Session Management

TSlib/session.ts
TypeScript
// lib/session.ts
import { Redis } from '@upstash/redis'
import { cookies } from 'next/headers'
import { nanoid } from 'nanoid'

const redis = Redis.fromEnv()

interface Session {
  userId: string
  email: string
  createdAt: number
}

export async function createSession(userId: string, email: string) {
  const sessionId = nanoid(32)
  const session: Session = {
    userId,
    email,
    createdAt: Date.now(),
  }

  await redis.set(`session:${sessionId}`, session, {
    ex: 60 * 60 * 24 * 7, // 7 days
  })

  const cookieStore = await cookies()
  cookieStore.set('session', sessionId, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7,
  })

  return session
}

export async function getSession(): Promise<Session | null> {
  const cookieStore = await cookies()
  const sessionId = cookieStore.get('session')?.value

  if (!sessionId) return null

  const session = await redis.get<Session>(`session:${sessionId}`)

  if (!session) return null

  // Refresh TTL on access
  await redis.expire(`session:${sessionId}`, 60 * 60 * 24 * 7)

  return session
}

export async function deleteSession() {
  const cookieStore = await cookies()
  const sessionId = cookieStore.get('session')?.value

  if (sessionId) {
    await redis.del(`session:${sessionId}`)
    cookieStore.delete('session')
  }
}

Caching with the SWR pattern

TSlib/cache.ts
TypeScript
// lib/cache.ts
import { Redis } from '@upstash/redis'

const redis = Redis.fromEnv()

interface CacheOptions {
  staleTime?: number // Time after which data is considered "stale"
  maxAge?: number    // Maximum time to live
}

export async function cachedFetch<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: CacheOptions = {}
): Promise<{ data: T; stale: boolean }> {
  const { staleTime = 60, maxAge = 3600 } = options

  // Check cache
  const cached = await redis.get<{
    data: T
    timestamp: number
  }>(key)

  const now = Date.now()

  if (cached) {
    const age = (now - cached.timestamp) / 1000

    if (age < staleTime) {
      // Fresh - return from cache
      return { data: cached.data, stale: false }
    }

    if (age < maxAge) {
      // Stale - return from cache, refresh in the background
      refreshInBackground(key, fetcher, maxAge)
      return { data: cached.data, stale: true }
    }
  }

  // No cache or expired - fetch fresh
  const data = await fetcher()
  await redis.set(key, { data, timestamp: now }, { ex: maxAge })

  return { data, stale: false }
}

async function refreshInBackground<T>(
  key: string,
  fetcher: () => Promise<T>,
  maxAge: number
) {
  try {
    const data = await fetcher()
    await redis.set(key, { data, timestamp: Date.now() }, { ex: maxAge })
  } catch (error) {
    console.error('Background refresh failed:', error)
  }
}

Pricing

PlanPriceCommandsData sizeDatabases
Free0 USD500K per month256 MB1
Pay as you go0.20 USD per 100K commandsunlimitedup to 100 GBup to 100
Fixed, from10 USD per monthunlimited250 MBplan dependent
Fixed, higher20, 100, 200, 400, 800, 1500 USDunlimited1 GB to 500 GBplan dependent

Two further items are billed separately. Storage costs 0.25 dollars per gigabyte, with the first gigabyte free. Egress is free up to two hundred gigabytes a month and three cents per gigabyte above that. Read replicas in other regions and the production feature bundle carry additional charges.

That last item deserves a figure, because it concerns precisely what is being sold here. Read replicas are priced from five to seven hundred and fifty dollars a month per region, depending on plan. A global edge network is therefore not something that arrives with the database but a decision to make for each region separately. Across three continents that line can exceed the cost of the commands themselves, so price it before treating reads close to the user as settled.

One change worth knowing when reading older tutorials: the former limit of ten thousand commands per day was replaced in March 2025 by a monthly allowance of five hundred thousand. In practice that means a sudden traffic spike on one day no longer cuts off access for the rest of it.

The queue and vector search bill at their own rates, per hundred thousand messages and per hundred thousand queries respectively, and each has its own free tier. When estimating a budget, count them separately, since the allowances do not pool.

FAQ - frequently asked questions

Is Upstash Redis fully compatible with Redis?

Yes, Upstash supports most Redis commands. The main difference is the REST API instead of TCP, which is actually an advantage for Edge/Serverless environments. Some blocking commands (BLPOP, BRPOP) are not supported due to the HTTP-based protocol.

How does global edge work?

Upstash replicates data to 30+ locations around the world. Each request is routed to the nearest replica. Writes go to the primary and are propagated to replicas. Read latency is typically <10ms.

Is data durable?

Yes, Upstash uses durable storage. Data is replicated and backed up. Unlike classic in-memory Redis, data survives restarts.

When to use QStash vs Redis?

  • QStash - For background jobs, webhooks, scheduled tasks. It has built-in retry, delivery confirmation, and scheduling.
  • Redis - For cache, sessions, rate limiting, real-time data. Synchronous operations.

Does Upstash work with Cloudflare Workers?

Yes, this is one of the main use cases. HTTP access works in environments that do not permit opening TCP sockets, edge functions among them.

Can I still use Kafka on Upstash?

No. The service was shut down on 11 March 2025 after a six month transition period. The company directed users to QStash and to its durable workflow mechanism. Tutorials showing this company's Kafka client package are out of date.

Rate limiting, the most common use

Judging by the projects where this service turns up, it most often arrives not as a cache but as a counter for limiting request rates. The reason is practical: you need state shared across function instances, and this is the smallest piece of state imaginable.

The differences between algorithms are worth understanding, because the choice has visible consequences. A fixed window is simplest and cheapest but lets double traffic through at the boundary between two windows, since a user can exhaust the allowance at the end of one and take a full allowance at the start of the next. A sliding window removes that at the cost of more operations. A token bucket permits brief bursts above the limit, which better matches how people actually behave when clicking faster than usual.

One practical note concerns the key you count against. An IP address is tempting, but in corporate networks and on mobile carriers hundreds of people share one, so the limit hits the innocent. Where users are signed in, count against their identifier and reserve the address for routes available without signing in.

A second note concerns failure behaviour, particularly on API routes in Next.js. If the service does not respond, your code has to know what to do: let the request through or reject it. Letting it through is safer for users and risky under abuse; rejecting is the reverse. The library's default is worth checking and deliberately confirming rather than discovering during your first outage.

Common mistakes

The first is issuing many commands in a loop. Over HTTP each command is a separate network request carrying full overhead, so a loop over a hundred keys means a hundred calls and latency measured in seconds. Batching commands solves it in one call and matters more here than in classic Redis.

The second is caching large objects. Billing includes egress, so reading a half megabyte document on every request raises the bill faster than command count does. For large content, cache an identifier or a hash and fetch the content itself from object storage.

The third is omitting expiry on keys. An entry without a deadline lives forever and occupies paid space, and with a cache you can almost always say how long the data stays valid. Setting expiry at write time is cheaper than cleaning up later.

The fourth is relying on blocking commands. Operations that wait for an element to appear make no sense over HTTP and are unavailable. If you are building a queue on them, you need QStash or another mechanism rather than a Redis list.

The fifth is assuming immediate consistency across regions. A write goes to the primary and reaches replicas after a delay, so a read from another region right after a write can return a stale value. For counters and rate limiting that matters and has to be accounted for in the design.

Current rates are on the Upstash pricing page, and the Kafka deprecation notice is on the company blog.