Skip to content

Fix: Upstash Not Working — Redis Commands Failing, Rate Limiter Not Blocking, or QStash Messages Lost

FixDevs ·

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 ago

Or 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 blocks

Or 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 it

Why This Happens

Upstash provides serverless Redis, QStash (message queue), and Vector (embeddings) via HTTP REST APIs. They’re designed for edge and serverless runtimes:

  • Upstash Redis uses HTTP, not TCP — unlike traditional Redis clients that use TCP connections, @upstash/redis sends 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/ratelimit stores 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/redis
import { 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')!,
});

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.

For related backend issues, see Fix: Wrangler Not Working and Fix: Hono Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles