Fix: Upstash Not Working — Redis Commands Failing, Rate Limiter Not Blocking, or QStash Messages Lost
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix Upstash issues — Redis REST client setup, rate limiting with @upstash/ratelimit, QStash message queues, Kafka topics, Vector search, and edge runtime integration.
The Problem
Upstash Redis commands return unexpected results:
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
const value = await redis.get('mykey');
// null — even after setting it moments agoOr the rate limiter doesn’t block requests:
import { Ratelimit } from '@upstash/ratelimit';
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '10 s'),
});
const result = await ratelimit.limit('user_123');
// result.success is always true — never blocksOr QStash messages aren’t delivered:
import { Client } from '@upstash/qstash';
const qstash = new Client({ token: process.env.QSTASH_TOKEN! });
await qstash.publishJSON({
url: 'https://myapp.com/api/webhook',
body: { event: 'user.created', userId: '123' },
});
// Message published but endpoint never receives itWhy This Happens
Upstash provides serverless Redis, QStash (message queue), and Vector (embeddings) via HTTP REST APIs. They’re designed for edge and serverless runtimes, where TCP-based clients can’t run.
The HTTP transport is the defining design choice and the source of most “this behaves differently than ioredis” reports. Every command is a stateless HTTPS request to an Upstash edge endpoint, which means no connection pool to exhaust, no reconnect logic to manage, and no MULTI/EXEC transactions (Upstash uses pipelined batches instead). It also means each command has a network round-trip cost — for high-throughput workloads, individual GET/SET calls become expensive and pipelining or batching matters more than it does with a long-lived TCP client.
Each Upstash service uses the same auth model but has different gotchas. Redis on Upstash speaks both the standard RESP protocol (for tools like redis-cli) and the REST protocol (for @upstash/redis), and confusing the two leads to “wrong endpoint” errors. QStash is one-way and asynchronous — publishing returns success immediately, but delivery happens later with retries, so silent failures only surface in the dashboard. Kafka uses long-poll HTTP for consumers, and @upstash/kafka clients on edge runtimes have lower throughput than dedicated Kafka clients because they can’t hold a persistent connection. Regional routing decisions affect latency for every service — pick the wrong primary region and reads from your application’s region pay an extra trans-continental round trip.
- Upstash Redis uses HTTP, not TCP — unlike traditional Redis clients that use TCP connections,
@upstash/redissends each command as an HTTP request. This means no connection management, but also different latency characteristics and potential issues with command pipelining. - Rate limiter state is in Redis —
@upstash/ratelimitstores counters in your Upstash Redis instance. If the Redis URL or token is wrong, the limiter can’t read/write counters and may default to allowing requests instead of blocking them. - QStash is asynchronous — when you publish a message, QStash accepts it immediately and delivers it later (with retries). If your endpoint returns a non-2xx status, QStash retries up to 3 times. If the endpoint URL is wrong or unreachable, messages are retried and eventually dead-lettered.
- Free tier has limits — Upstash Redis free tier allows 10,000 commands/day. QStash free tier has 500 messages/day. Exceeding these limits causes silent failures or rate limiting from Upstash itself.
Fix 1: Redis REST Client Setup
npm install @upstash/redis// Basic setup
import { Redis } from '@upstash/redis';
// Option 1: Explicit configuration
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!, // https://xyz.upstash.io
token: process.env.UPSTASH_REDIS_REST_TOKEN!, // AXxx...
});
// Option 2: Auto-detect from environment variables
// Reads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN automatically
const redis = Redis.fromEnv();
// String operations
await redis.set('user:123', JSON.stringify({ name: 'Alice', role: 'admin' }));
await redis.set('session:abc', 'user_123', { ex: 3600 }); // Expires in 1 hour
const user = await redis.get<{ name: string; role: string }>('user:123');
// user = { name: 'Alice', role: 'admin' } — automatically parsed from JSON
// Key operations
await redis.del('user:123');
const exists = await redis.exists('user:123'); // 0 or 1
const ttl = await redis.ttl('session:abc'); // Seconds remaining
// Hash operations
await redis.hset('user:456', { name: 'Bob', email: '[email protected]', role: 'user' });
const name = await redis.hget<string>('user:456', 'name'); // 'Bob'
const allFields = await redis.hgetall<Record<string, string>>('user:456');
// List operations (queue-like)
await redis.lpush('tasks', 'task1', 'task2', 'task3');
const task = await redis.rpop<string>('tasks'); // 'task1' (FIFO)
// Sorted set (leaderboard)
await redis.zadd('leaderboard', { score: 100, member: 'alice' });
await redis.zadd('leaderboard', { score: 85, member: 'bob' });
const top = await redis.zrange<string[]>('leaderboard', 0, 9, { rev: true });
// Pipeline — batch multiple commands in one HTTP request
const pipeline = redis.pipeline();
pipeline.set('key1', 'value1');
pipeline.set('key2', 'value2');
pipeline.get('key1');
pipeline.get('key2');
const results = await pipeline.exec();
// results = ['OK', 'OK', 'value1', 'value2']Fix 2: Rate Limiting
npm install @upstash/ratelimit @upstash/redisimport { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const redis = Redis.fromEnv();
// Sliding window — 10 requests per 10 seconds
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '10 s'),
analytics: true, // Track rate limit metrics in Redis
prefix: '@upstash/ratelimit',
});
// Alternative algorithms
// Fixed window — resets every interval
const fixed = new Ratelimit({
redis,
limiter: Ratelimit.fixedWindow(100, '1 m'), // 100 per minute
});
// Token bucket — smooth rate limiting
const tokenBucket = new Ratelimit({
redis,
limiter: Ratelimit.tokenBucket(5, '10 s', 20),
// 5 tokens added every 10s, max 20 tokens
});
// Usage in an API route
export async function POST(request: Request) {
// Use IP address or user ID as identifier
const ip = request.headers.get('x-forwarded-for') ?? 'anonymous';
const { success, limit, remaining, reset } = await ratelimit.limit(ip);
if (!success) {
return new Response('Too Many Requests', {
status: 429,
headers: {
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': String(remaining),
'X-RateLimit-Reset': String(reset),
'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
},
});
}
// Process request...
return new Response('OK');
}
// Next.js middleware rate limiting
// middleware.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
import { NextResponse, type NextRequest } from 'next/server';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(20, '60 s'),
});
export async function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api/')) {
const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? '127.0.0.1';
const { success } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
}
}
return NextResponse.next();
}Fix 3: QStash — Message Queues and Scheduled Jobs
npm install @upstash/qstash// Publish a message
import { Client } from '@upstash/qstash';
const qstash = new Client({ token: process.env.QSTASH_TOKEN! });
// Send a message to your API endpoint
await qstash.publishJSON({
url: 'https://myapp.com/api/process-order',
body: { orderId: '123', action: 'fulfill' },
retries: 3,
// Delay delivery by 30 seconds
delay: 30,
// Or schedule with cron
// cron: '0 9 * * *', // Every day at 9 AM
});
// Send to multiple destinations
await qstash.batchJSON([
{
destination: 'https://myapp.com/api/send-email',
body: { to: '[email protected]', template: 'welcome' },
},
{
destination: 'https://myapp.com/api/update-analytics',
body: { event: 'signup', userId: '123' },
},
]);// Receive and verify messages
// app/api/process-order/route.ts
import { verifySignatureAppRouter } from '@upstash/qstash/nextjs';
async function handler(request: Request) {
const body = await request.json();
// Process the message
await fulfillOrder(body.orderId);
// Return 200 to acknowledge — QStash won't retry
return new Response('OK');
}
// Wrap handler with signature verification — prevents unauthorized calls
export const POST = verifySignatureAppRouter(handler);
// For non-Next.js:
import { Receiver } from '@upstash/qstash';
const receiver = new Receiver({
currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY!,
nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!,
});
async function handleWebhook(request: Request) {
const body = await request.text();
const signature = request.headers.get('upstash-signature')!;
const isValid = await receiver.verify({ signature, body });
if (!isValid) return new Response('Unauthorized', { status: 401 });
// Process message...
}Fix 4: Caching Patterns
import { Redis } from '@upstash/redis';
const redis = Redis.fromEnv();
// Cache-aside pattern
async function getCachedUser(userId: string) {
// Try cache first
const cached = await redis.get<User>(`user:${userId}`);
if (cached) return cached;
// Cache miss — fetch from database
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
if (!user) return null;
// Store in cache with TTL
await redis.set(`user:${userId}`, JSON.stringify(user), { ex: 300 }); // 5 min
return user;
}
// Invalidate cache on update
async function updateUser(userId: string, data: Partial<User>) {
await db.update(users).set(data).where(eq(users.id, userId));
await redis.del(`user:${userId}`); // Invalidate cache
}
// Stale-while-revalidate pattern
async function getWithSWR<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 300,
staleTtl: number = 3600,
) {
const cached = await redis.get<{ data: T; timestamp: number }>(key);
if (cached) {
const age = (Date.now() - cached.timestamp) / 1000;
if (age < ttl) {
// Fresh — return immediately
return cached.data;
}
if (age < staleTtl) {
// Stale — return stale data and revalidate in background
fetcher().then(async (data) => {
await redis.set(key, JSON.stringify({ data, timestamp: Date.now() }), { ex: staleTtl });
});
return cached.data;
}
}
// Expired or missing — fetch fresh data
const data = await fetcher();
await redis.set(key, JSON.stringify({ data, timestamp: Date.now() }), { ex: staleTtl });
return data;
}Fix 5: Session Storage
import { Redis } from '@upstash/redis';
import { nanoid } from 'nanoid';
const redis = Redis.fromEnv();
const SESSION_TTL = 60 * 60 * 24 * 7; // 7 days
// Create session
async function createSession(userId: string) {
const sessionId = nanoid(32);
const session = {
userId,
createdAt: Date.now(),
lastActiveAt: Date.now(),
};
await redis.set(`session:${sessionId}`, JSON.stringify(session), {
ex: SESSION_TTL,
});
return sessionId;
}
// Get session
async function getSession(sessionId: string) {
const session = await redis.get<{
userId: string;
createdAt: number;
lastActiveAt: number;
}>(`session:${sessionId}`);
if (!session) return null;
// Refresh TTL on access
await redis.expire(`session:${sessionId}`, SESSION_TTL);
await redis.set(`session:${sessionId}`, JSON.stringify({
...session,
lastActiveAt: Date.now(),
}), { ex: SESSION_TTL });
return session;
}
// Delete session
async function deleteSession(sessionId: string) {
await redis.del(`session:${sessionId}`);
}
// Delete all sessions for a user (on password change)
async function deleteAllUserSessions(userId: string) {
// This requires tracking session IDs per user
const sessionIds = await redis.smembers<string[]>(`user_sessions:${userId}`);
if (sessionIds.length > 0) {
await redis.del(...sessionIds.map(id => `session:${id}`));
await redis.del(`user_sessions:${userId}`);
}
}Fix 6: Edge Runtime Usage
Upstash works everywhere — no TCP connections needed:
// Cloudflare Workers
export default {
async fetch(request: Request, env: Env) {
const redis = new Redis({
url: env.UPSTASH_REDIS_REST_URL,
token: env.UPSTASH_REDIS_REST_TOKEN,
});
const pageViews = await redis.incr('page-views');
return new Response(`Views: ${pageViews}`);
},
};
// Vercel Edge Middleware
import { Redis } from '@upstash/redis';
import { NextResponse, type NextRequest } from 'next/server';
const redis = Redis.fromEnv();
export async function middleware(request: NextRequest) {
// Feature flag from Redis
const maintenance = await redis.get<boolean>('feature:maintenance');
if (maintenance) {
return NextResponse.rewrite(new URL('/maintenance', request.url));
}
return NextResponse.next();
}
// Deno Deploy
import { Redis } from 'https://esm.sh/@upstash/redis';
const redis = new Redis({
url: Deno.env.get('UPSTASH_REDIS_REST_URL')!,
token: Deno.env.get('UPSTASH_REDIS_REST_TOKEN')!,
});Fix 7: Service Differences and Platform Compatibility
Upstash has four products under one auth umbrella, and each behaves differently across deployment platforms.
Redis vs Kafka vs QStash vs Vector:
- Redis — low-latency KV with full Redis command support, best for caching, sessions, rate limiting
- Kafka — log-structured streaming with topic partitions, best for event pipelines and audit logs
- QStash — fire-and-forget HTTP message queue, best for delayed jobs, cron schedules, webhook fan-out
- Vector — embedding storage with cosine similarity search, best for RAG and semantic search
People often pick Redis lists for queueing when QStash is the right choice — lists work but you have to build retry, dead-letter, and signature verification yourself. QStash handles all of that.
REST API vs Redis protocol:
Upstash Redis exposes two endpoints — https://<id>.upstash.io (REST) and rediss://default:<token>@<id>.upstash.io:6379 (RESP/TLS). The REST endpoint is the one @upstash/redis uses; the RESP endpoint is for ioredis, redis-cli, and other traditional clients. They speak to the same database, but you authenticate differently (Bearer token for REST, password in URL for RESP). Mixing them in the same project is fine — most teams use REST in serverless code and RESP in long-lived workers.
Edge runtime compatibility:
| Runtime | Redis | QStash | Kafka | Vector |
|---|---|---|---|---|
| Node.js | yes (REST or RESP) | yes | yes | yes |
| Bun | yes | yes | yes | yes |
| Cloudflare Workers | REST only | yes | REST only | yes |
| Vercel Edge | REST only | yes | REST only | yes |
| Deno Deploy | REST only | yes | REST only | yes |
| Vercel Functions (Node) | yes | yes | yes | yes |
| AWS Lambda | yes | yes | yes | yes |
The @upstash/redis package works on every runtime because it uses fetch. ioredis only works on runtimes that allow outbound TCP — that excludes Workers and Vercel Edge. If you need ioredis-compatible features on edge, switch to the REST client or accept that the feature won’t work there. For Workers-specific deployment debugging see Fix: Wrangler Not Working.
Regional routing:
Upstash Redis lets you pick a primary region and add read replicas in other regions. The REST API auto-routes to the nearest replica for reads, but writes always go to the primary. If your application runs in us-east-1 and your primary is in eu-west-1, every write pays trans-Atlantic latency. Check the dashboard for region settings and either move the primary or add a read replica close to your application.
Global database (multi-region) is available as a paid feature and pushes writes through a quorum protocol — latency is higher than single-region but read availability is global.
Connection limits:
Single-region Redis databases have per-tier connection limits even on REST (which doesn’t use connections in the traditional sense, but does rate-limit by IP). The free tier limits to 1,000 commands per second and 10,000 commands per day. The pay-as-you-go tier removes the daily cap but still has per-second limits. Hitting the limit returns HTTP 429 from the REST endpoint — the @upstash/redis client surfaces this as a thrown error, not a null return.
Hono integration patterns:
Hono and Upstash are a common pair for edge APIs. The client is the same as anywhere else, but Hono’s middleware system makes per-route rate limiting cleaner:
import { Hono } from 'hono';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis/cloudflare';
const app = new Hono<{ Bindings: { UPSTASH_REDIS_REST_URL: string; UPSTASH_REDIS_REST_TOKEN: string } }>();
app.use('/api/*', async (c, next) => {
const redis = new Redis({
url: c.env.UPSTASH_REDIS_REST_URL,
token: c.env.UPSTASH_REDIS_REST_TOKEN,
});
const limiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(10, '10 s') });
const ip = c.req.header('cf-connecting-ip') ?? 'anonymous';
const { success } = await limiter.limit(ip);
if (!success) return c.text('Rate limit exceeded', 429);
await next();
});For broader Hono routing and middleware debugging see Fix: Hono Not Working.
Cloudflare Workers AI alternative:
For embedding and inference workloads on Workers, Cloudflare’s built-in Workers AI is often a better fit than Upstash Vector because it runs colocated with your code. Use Upstash Vector when you need to share embeddings across multiple compute environments. See Fix: Cloudflare Workers AI Not Working for that comparison.
Comparison with self-hosted Redis:
If you’re moving from a self-hosted Redis to Upstash, the most common migration pain point is MULTI/EXEC. Upstash REST doesn’t support multi-command transactions in the traditional sense — use pipeline() for atomic batching, or move logic to Lua scripts via eval(). For lower-level Redis connection issues that don’t apply to Upstash REST but might apply if you’re using RESP, see Fix: Redis Connection Refused.
Still Not Working?
redis.get() returns null for a key you just set — check that the set completed without error. If using ex (expiration), a very short TTL could cause the key to expire before the get. Also verify both operations use the same Redis instance — UPSTASH_REDIS_REST_URL might point to different databases in different environments.
Rate limiter always allows requests — the limiter needs a working Redis connection to track counts. If the Redis URL or token is invalid, ratelimit.limit() may not throw but instead fail open (allow the request). Verify the connection with a simple await redis.ping() first. Also check the identifier — if every request uses a different identifier, each gets its own fresh window.
QStash message not delivered — check the QStash dashboard for delivery attempts and errors. Common issues: the endpoint URL must be publicly reachable (not localhost), the endpoint must return a 2xx status within 30 seconds, and the signature verification must pass. For local testing, use ngrok to expose your local server.
“Unauthorized” error from Redis — the REST token is scoped to a specific database. If you created a new database, you need the new token from the Upstash console. Tokens from environment variables might have extra whitespace — trim them.
HTTP 429 from @upstash/redis itself — you’ve hit the Upstash command-rate limit, not your application’s rate limit. The free tier caps daily commands, and even paid tiers have per-second limits. Reduce command volume by batching with pipeline(), caching reads in-memory inside the request, or upgrading the tier.
Cron-scheduled QStash messages stop firing — QStash cron jobs are tied to a specific endpoint URL. If the URL becomes unreachable for an extended period (4 hours of failures by default), the schedule is paused. Check the dashboard and re-enable. Common cause: the endpoint moved to a new domain without updating the schedule.
Edge runtime build complains about dns or net module — you’re importing ioredis or redis (the node-redis client) instead of @upstash/redis. Both traditional clients pull in Node-only modules that don’t exist on Workers, Vercel Edge, or Deno Deploy. Switch to @upstash/redis or use a build-time check to import different clients per runtime.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Neon Database Not Working — Connection Timeout, Branching Errors, or Serverless Driver Issues
How to fix Neon Postgres issues — connection string setup, serverless HTTP driver vs TCP, database branching, connection pooling, Drizzle and Prisma integration, and cold start optimization.
Fix: Turso Not Working — Connection Refused, Queries Returning Empty, or Embedded Replicas Not Syncing
How to fix Turso database issues — libsql client setup, connection URLs and auth tokens, embedded replicas for local-first apps, schema migrations, Drizzle ORM integration, and edge deployment.
Fix: Convex Not Working — Query Not Updating, Mutation Throwing Validation Error, or Action Timing Out
How to fix Convex backend issues — query/mutation/action patterns, schema validation, real-time reactivity, file storage, auth integration, and common TypeScript type errors.
Fix: Kysely Not Working — Type Errors on Queries, Migration Failing, or Generated Types Not Matching Schema
How to fix Kysely query builder issues — database interface definition, dialect setup, type-safe joins and subqueries, migration runner, kysely-codegen for generated types, and common TypeScript errors.