← Back to Blog
System DesignNext.jsServerlessArchitectureReal-time

How I Built a Serverless Real-Time Notification Engine with Next.js, Redis, and QStash

SY
Sumit Yadav
May 16, 20266 min read

The Problem: The "Timeout & Block"

If you are building a modern SaaS platform, notifications are rarely simple. When a user triggers a critical system event (like a deployment succeeding or a payment failing), your application needs to:

  1. Save the event to a database.
  2. Send an email via Resend or SendGrid.
  3. Send an SMS to the on-call developer via Twilio.
  4. Push a real-time alert to the active web dashboard.

If you put all of that into a single, synchronous Next.js API route, your app will break. Third-party APIs are slow. If Twilio takes 5 seconds to respond, your Next.js route hangs. If you deploy to Vercel, you'll hit the serverless function timeout limit, the database won't update, and the user's browser is left stuck loading.

The solution is an Event-Driven Fan-Out Architecture. Instead of doing the heavy lifting synchronously, you decouple the action from the delivery.


What I Built

StaffNotify — a production-ready, edge-deployed event distribution engine where you can:

  • Dispatch payloads that fan out across multiple communication channels (In-App, Email, SMS).
  • Process third-party deliveries in the background using serverless message queues.
  • Stream live updates directly to a React frontend without page reloads.

Built with Next.js 16 (App Router), Upstash Redis, Upstash QStash, and native Server-Sent Events (SSE). Let me walk you through the architecture.


The Fan-Out Pipeline — End to End

Here is the complete data flow. It splits into a Dispatch Pipeline (background workers) and a Streaming Pipeline (frontend UI).


Step 1: The Dispatch Gate

When an event fires, our goal is to get out of the main execution thread as fast as possible.

import { NextResponse } from "next/server";
import { redis } from "@/lib/redis";
import { Client } from "@upstash/qstash";
import { nanoid } from "nanoid";

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

export async function POST(req: Request) {
  const { title, message, channels } = await req.json();
  const notification = { id: nanoid(), title, message, read: false, createdAt: Date.now() };

  // 1. Edge State Persistence: O(log N) sorted sets
  await redis.zadd("notifications:feed", {
    score: Date.now(),
    member: JSON.stringify(notification),
  });

  // 2. Fan-out via QStash
  if (channels.includes("email")) {
    await qstash.publishJSON({
      url: \`\${process.env.NEXT_PUBLIC_APP_URL}/api/worker/email\`,
      body: { notification },
    });
  }

  // 3. Return immediately (sub-50ms)
  return NextResponse.json({ success: true });
}

By pushing the payload to QStash, we offload the retry logic, dead-letter queuing, and external API latency to a dedicated message broker. The user gets a lightning-fast response.


Step 2: The Background Workers

QStash takes the payload and fires it right back at our Next.js application, targeting specific background worker routes. Because the Next.js 16 App Router enforces strict folder-based routing, we map these to src/app/api/worker/email/route.ts.

import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const body = await req.json();

  // Simulate heavy third-party email dispatch (e.g., Resend)
  await new Promise((resolve) => setTimeout(resolve, 1000));

  // QStash reads this 200 OK and marks the queue task as successful
  return NextResponse.json({ success: true, delivered: true });
}

Step 3: Serverless Real-Time (The Tricky Part)

The biggest challenge in serverless environments like Vercel is keeping real-time connections alive. Standard WebSockets (ws://) demand persistent TCP connections, which serverless functions cannot maintain.

The solution? Server-Sent Events (SSE) combined with Cursor-Based Polling.

Instead of trying to hold open a Redis Pub/Sub TCP pipe (which fails in a stateless Upstash HTTP environment), the client opens a native EventSource stream. The server polls the Redis Sorted Set (ZSET) for new items based on a timestamp cursor:

import { NextRequest } from "next/server";
import { redis } from "@/lib/redis";

export const dynamic = "force-dynamic";

export async function GET(req: NextRequest) {
  const encoder = new TextEncoder();

  const customReadable = new ReadableStream({
    async start(controller) {
      let lastCheck = Date.now();

      // Poll Upstash periodically for brand new entries
      const interval = setInterval(async () => {
        // Fetch any nodes scored strictly higher than our last execution
        const newItems = await redis.zrange(
          "notifications:feed",
          lastCheck + 1,
          "+inf",
          {
            byScore: true,
          },
        );

        if (newItems && newItems.length > 0) {
          for (const item of newItems) {
            controller.enqueue(encoder.encode(`data: \${item}\n\n`));

            // Advance the cursor
            const itemTime =
              typeof item === "string"
                ? JSON.parse(item).createdAt
                : item.createdAt;
            if (itemTime > lastCheck) lastCheck = itemTime;
          }
        }
        // Keep-alive heartbeat to prevent proxy timeouts
        controller.enqueue(encoder.encode(": heartbeat\n\n"));
      }, 3000);

      req.signal.addEventListener("abort", () => clearInterval(interval));
    },
  });

  return new Response(customReadable, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no",
    },
  });
}

On the frontend, consuming this stream is beautifully simple:

const eventSource = new EventSource("/api/ws");

eventSource.onmessage = (event) => {
  const newNotification = JSON.parse(event.data);
  setNotifications((prev) => [newNotification, ...prev]);
};

Architecture Decisions

Why Upstash Redis over Standard Redis?

Traditional Redis requires maintaining connection pools. If you deploy a Next.js app to Vercel, every visitor spins up a new serverless instance. You will exhaust a standard Redis connection pool in seconds.

Upstash communicates entirely over stateless HTTP/REST. It scales infinitely with serverless architecture without ever crashing due to connection limits.

Why Server-Sent Events (SSE) over WebSockets?

Notifications are inherently a one-way street (Server -> Client). WebSockets are overkill. SSE works natively over standard HTTPS, passes easily through Vercel's reverse proxies, and the browser's EventSource API handles background reconnections automatically if the user's internet drops.


What I Learned

1. The Localhost Webhook Trap: You cannot test cloud webhooks (QStash) locally by giving them a localhost:3000 URL. It triggers an SSRF loopback security block. You must use an ngrok tunnel and dynamically pass the ngrok URL to QStash via an environment variable (NEXT_PUBLIC_APP_URL).

2. Upstash does not support .duplicate(). Because Upstash is HTTP-based, standard Node.js Redis Pub/Sub patterns (which fork TCP connections) don't work. Cursor-based polling against a ZSET is the correct serverless pattern.

3. Vercel caches GET routes aggressively. If you don't add export const dynamic = "force-dynamic"; to your history fetch routes, Vercel will freeze your Redis state at build time and serve stale data to your users.


The Verdict

Ten years ago, building this required renting dedicated AWS EC2 instances to run background workers (like Celery) and heavy WebSocket servers (like Socket.io) that cost hundreds of dollars a month just to keep idle connections open.

By using Next.js 16 + Upstash + QStash, you get a 100% serverless version of this architecture. It scales to zero when nobody is using it, but can instantly handle tens of thousands of concurrent webhook dispatches the moment a system event triggers.



Try the live demo →

View the source code →

← More ArticlesConnect on LinkedIn →