AlmostShipped

Realtime credit updates

Upgrade from polling to realtime: Supabase, Pusher, or self-hosted SSE.

AlmostShipped ships Level-1 freshness out of the box: pull-based invalidation, window-focus refetching, and light polling. This is enough for most SaaS products and adds zero infrastructure cost.

When you need true realtime — sub-second balance updates the moment a deduct or grant happens server-side — this guide walks through three upgrade paths, in order of effort.

PathEffortMonthly costBest for
Supabase Realtime~1 hour$0 (free tier)You already use Supabase
Pusher~2 hours~$50/moAny database, want a managed service
Self-hosted SSE~6–8 hoursInfrastructure onlyNo external services, full control

All three patterns hook into the same place: a new useCreditsRealtime hook adjacent to the existing useCredits at apps/saas/modules/credits/hooks/use-credits.ts. You add a subscription that invalidates TanStack Query when relevant changes happen server-side. The rest of your app stays the same.

What AlmostShipped ships today

Without any changes, the credit system already feels reasonably fresh thanks to four mechanisms wired in CREDITS-REFRESH:

  1. Window-focus refetch — balance refreshes when the user returns to the tab
  2. Notification polling — bell counts refresh every 60 seconds
  3. Mutation invalidation — AI generators invalidate credits on success (onSuccess for mutations, onFinish for streams)
  4. Top-up retry loop — post-checkout, retry invalidation at 1s / 3s / 6s to catch the webhook arrival window

For a typical SaaS, this is enough. The rest of this guide is for products where realtime is a feature requirement, not a nice-to-have.

Path 1: Supabase Realtime

When to use: Your database is already on Supabase (managed or self-hosted). You want the cheapest, fastest upgrade.

Step 1 — Enable Realtime on the relevant tables

In Supabase Dashboard, go to Database → Replication and toggle on:

  • credit_transaction (so balance changes propagate)
  • notification (so new notifications propagate)

Or run this SQL:

alter publication supabase_realtime add table credit_transaction;
alter publication supabase_realtime add table notification;

Row Level Security policies are automatically enforced — users only receive events for rows they can read.

Step 2 — Create a shared realtime hook

Create apps/saas/modules/credits/hooks/use-credits-realtime.ts:

"use client";

import { useQueryClient } from "@tanstack/react-query";
import { createBrowserClient } from "@supabase/ssr"; // or your existing client
import { useEffect } from "react";

/**
 * Subscribes to Postgres changes on credit_transaction and notification
 * tables for the current user. Invalidates relevant TanStack Query keys
 * when changes arrive. Returns cleanup that unsubscribes on unmount.
 */
export function useCreditsRealtime(userId: string | undefined) {
	const queryClient = useQueryClient();

	useEffect(() => {
		if (!userId) return;

		const supabase = createBrowserClient(
			process.env.NEXT_PUBLIC_SUPABASE_URL!,
			process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
		);

		const channel = supabase
			.channel(`credits-${userId}`)
			.on(
				"postgres_changes",
				{
					event: "INSERT",
					schema: "public",
					table: "credit_transaction",
					filter: `userId=eq.${userId}`,
				},
				() => {
					queryClient.invalidateQueries({ queryKey: [["credits", "balance"]] });
					queryClient.invalidateQueries({ queryKey: [["credits", "transactions"]] });
				},
			)
			.on(
				"postgres_changes",
				{
					event: "INSERT",
					schema: "public",
					table: "notification",
					filter: `userId=eq.${userId}`,
				},
				() => {
					queryClient.invalidateQueries({ queryKey: [["notifications", "unreadCount"]] });
					queryClient.invalidateQueries({ queryKey: [["notifications", "list"]] });
				},
			)
			.subscribe();

		return () => {
			void channel.unsubscribe();
		};
	}, [userId, queryClient]);
}

The double-wrapped array key shape [["credits", "balance"]] is the verified @orpc/tanstack-query partial-prefix pattern from CREDITS-REFRESH — it matches both personal-scope and org-scope queries in a single invalidate call.

Step 3 — Wire it into your layout

In apps/saas/app/(authenticated)/(main)/layout.tsx (or wherever your authenticated session is available), call the hook once at the layout level so it stays mounted across page navigations:

"use client";

import { useSession } from "@auth/hooks/use-session";
import { useCreditsRealtime } from "@credits/hooks/use-credits-realtime";

export function RealtimeProvider({ children }: { children: React.ReactNode }) {
	const { user } = useSession();
	useCreditsRealtime(user?.id);
	return <>{children}</>;
}

That's it. Balance and notifications now update within ~100ms of any server-side change.

Cost at scale

Supabase Realtime free tier covers 200 concurrent connections and 2 million messages/month. For most SaaS products this is enough for hundreds of daily active users. Above that, the Pro plan is $25/mo with much higher limits.

Path 2: Pusher

When to use: Your database isn't on Supabase. You want a managed realtime service with predictable pricing.

Step 1 — Install dependencies

pnpm add pusher pusher-js

Step 2 — Configure Pusher

Add to your .env:

PUSHER_APP_ID=your_app_id
PUSHER_KEY=your_key
PUSHER_SECRET=your_secret
PUSHER_CLUSTER=us2
NEXT_PUBLIC_PUSHER_KEY=your_key
NEXT_PUBLIC_PUSHER_CLUSTER=us2

Create packages/payments/lib/realtime/pusher-server.ts:

import Pusher from "pusher";

export const pusher = new Pusher({
	appId: process.env.PUSHER_APP_ID!,
	key: process.env.PUSHER_KEY!,
	secret: process.env.PUSHER_SECRET!,
	cluster: process.env.PUSHER_CLUSTER!,
	useTLS: true,
});

Step 3 — Trigger events on credit changes

In packages/payments/lib/credits/balance.ts, find the deductCredits and addCredits functions. After each successful write, add a Pusher trigger:

import { pusher } from "../realtime/pusher-server";

export async function deductCredits(/* ... */) {
	// ... existing logic ...

	// After successful deduct:
	await pusher.trigger(`user-${entityId}`, "credits-updated", {
		balance: result.balanceAfter,
	});

	return result;
}

Do the same in addCredits (and any future helpers that mutate the ledger). Also wire into packages/payments/lib/credits/notifications.ts after notification creation. Use the scope (user-${entityId} or org-${entityId}) so org-scoped balances target the right channel.

Step 4 — Subscribe on the client

Create apps/saas/modules/credits/hooks/use-credits-realtime.ts:

"use client";

import { useQueryClient } from "@tanstack/react-query";
import PusherClient from "pusher-js";
import { useEffect } from "react";

export function useCreditsRealtime(userId: string | undefined) {
	const queryClient = useQueryClient();

	useEffect(() => {
		if (!userId) return;

		const pusher = new PusherClient(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
			cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
		});

		const channel = pusher.subscribe(`user-${userId}`);

		channel.bind("credits-updated", () => {
			queryClient.invalidateQueries({ queryKey: [["credits", "balance"]] });
			queryClient.invalidateQueries({ queryKey: [["credits", "transactions"]] });
		});

		channel.bind("notification-created", () => {
			queryClient.invalidateQueries({ queryKey: [["notifications", "unreadCount"]] });
			queryClient.invalidateQueries({ queryKey: [["notifications", "list"]] });
		});

		return () => {
			channel.unbind_all();
			pusher.unsubscribe(`user-${userId}`);
			pusher.disconnect();
		};
	}, [userId, queryClient]);
}

Wire into your layout as shown in Path 1, Step 3.

Cost at scale

Pusher Sandbox: free, 100 connections, 200k messages/day. Startup plan: $49/mo, 500 concurrent connections. Growing apps usually graduate to the $99 plan at around 2,000 concurrent users.

For most use cases, Ably is a drop-in alternative with similar pricing and a more generous free tier.

Path 3: Self-hosted SSE

When to use: You want zero external dependencies. You're comfortable running Redis. You have a host that supports long-lived HTTP connections (Vercel functions, Cloudflare Workers, Fly.io, Railway, your own VPS).

This path is the longest because you're building the infrastructure yourself. It pays back when you have many users and want to control costs precisely.

Step 1 — Add Redis pub/sub

Upstash Redis has a generous free tier and HTTP-based access that works on Vercel functions:

pnpm add ioredis

Add to .env:

REDIS_URL=redis://...

Step 2 — Create the SSE endpoint

Create apps/saas/app/api/credits/stream/route.ts:

import { auth } from "@repo/auth";
import Redis from "ioredis";
import { headers } from "next/headers";

export const runtime = "nodejs"; // SSE needs Node runtime, not Edge

export async function GET(request: Request) {
	const session = await auth.api.getSession({ headers: await headers() });
	if (!session?.user?.id) {
		return new Response("Unauthorized", { status: 401 });
	}

	const userId = session.user.id;
	const encoder = new TextEncoder();

	const stream = new ReadableStream({
		async start(controller) {
			// Subscribe to Redis channel for this user
			const subscriber = new Redis(process.env.REDIS_URL!);
			await subscriber.subscribe(`user-events:${userId}`);

			subscriber.on("message", (_channel, message) => {
				controller.enqueue(encoder.encode(`data: ${message}\n\n`));
			});

			// Heartbeat every 15s so proxies don't close the connection
			const heartbeat = setInterval(() => {
				controller.enqueue(encoder.encode(`: heartbeat\n\n`));
			}, 15_000);

			// Cleanup when client disconnects
			request.signal.addEventListener("abort", () => {
				clearInterval(heartbeat);
				void subscriber.unsubscribe();
				void subscriber.quit();
				controller.close();
			});
		},
	});

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

Step 3 — Publish events on credit changes

In packages/payments/lib/credits/balance.ts:

import Redis from "ioredis";

const publisher = new Redis(process.env.REDIS_URL!);

export async function deductCredits(/* ... */) {
	// ... existing logic ...

	await publisher.publish(
		`user-events:${entityId}`,
		JSON.stringify({ type: "credits-updated", balance: result.balanceAfter }),
	);

	return result;
}

Do the same in addCredits (and any future ledger-mutating helpers) and notification creation.

Step 4 — Subscribe via EventSource on the client

Create apps/saas/modules/credits/hooks/use-credits-realtime.ts:

"use client";

import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";

export function useCreditsRealtime(userId: string | undefined) {
	const queryClient = useQueryClient();

	useEffect(() => {
		if (!userId) return;

		const eventSource = new EventSource("/api/credits/stream");

		eventSource.onmessage = (event) => {
			const data = JSON.parse(event.data);

			if (data.type === "credits-updated") {
				queryClient.invalidateQueries({ queryKey: [["credits", "balance"]] });
				queryClient.invalidateQueries({ queryKey: [["credits", "transactions"]] });
			}

			if (data.type === "notification-created") {
				queryClient.invalidateQueries({ queryKey: [["notifications", "unreadCount"]] });
				queryClient.invalidateQueries({ queryKey: [["notifications", "list"]] });
			}
		};

		eventSource.onerror = () => {
			// EventSource auto-reconnects by default. Add custom logic if needed.
		};

		return () => {
			eventSource.close();
		};
	}, [userId, queryClient]);
}

Wire into your layout as shown in Path 1, Step 3.

Hosting caveats

  • Vercel: SSE works on Node runtime (not Edge) but has a 300-second max duration on Hobby and Pro plans. Heartbeats keep connections alive, but very long sessions will reconnect periodically. Enterprise plan removes the limit.
  • Cloudflare Workers: Supports SSE via the streaming API. No duration limit on the paid plan.
  • Self-hosted (Fly.io, Railway, VPS): No connection duration limits. Use a process manager (PM2, systemd) and ensure keep-alive is configured on any reverse proxy.

Cost at scale

Free if you self-host. With Upstash Redis free tier (10k commands/day), you can support ~50 concurrent users sending occasional events. The $10/mo plan handles thousands of concurrent users for typical SaaS event volumes.

Removing the polling once realtime is wired

Once you have any of the three paths running, you can reduce or remove the Level-1 polling for efficiency. Optional, since both layers can coexist safely.

In apps/saas/modules/dashboard/components/header/NotificationDropdown.tsx and apps/saas/modules/shared/components/NotificationCenter.tsx, remove refetchInterval: 60_000 from the useQuery config. Window-focus refetch can stay — it's effectively free and acts as a safety net if the realtime connection ever drops.

Choosing a path

If you're not sure which path to pick, this decision tree helps:

Are you on Supabase?
  ├─ YES → Path 1 (Supabase Realtime). ~1 hour, free.
  └─ NO
     ├─ Want managed service?
     │   ├─ YES → Path 2 (Pusher). ~2 hours, ~$50/mo.
     │   └─ NO  → Path 3 (Self-hosted SSE). ~6–8 hours, infra cost only.

Most AlmostShipped buyers will start with Level-1 (no changes needed) and upgrade to Path 1 or Path 2 when realtime becomes a product requirement. Path 3 is for teams optimizing infrastructure cost at scale.

On this page