June 30, 2026·5 min read
Realtime portfolio updates without polling
How RTK Query and Ably keep public portfolio data fresh after an admin update - with zero polling and no custom WebSocket server.
Polling works. You fire a request every 10 seconds, you get fresh data eventually. But it has a hidden cost: you're hammering the server with the same question constantly, even when absolutely nothing has changed.
For a portfolio, that's wasted traffic. More importantly, there's always a lag - whatever your polling interval is, that's how stale your data can be.
Here's what a better architecture looks like.
POLLING PATTERN (naive approach)
│
├── Browser asks server: "Anything new?" ──── server: "No." (t=0s)
├── Browser asks server: "Anything new?" ──── server: "No." (t=10s)
├── Browser asks server: "Anything new?" ──── server: "No." (t=20s)
├── Admin updates data at t=25s
├── Browser asks server: "Anything new?" ──── server: "Yes!" (t=30s)
│
│ Result:
│ • 3 wasted requests
│ • Up to 10 seconds of stale data
│ • Scales poorly with more visitors
With 100 concurrent visitors polling every 10 seconds, that's 600 requests per minute - all hitting your API even when nothing changed.
Instead of asking "is there new data?", the server tells clients when something changes. Clients only fetch when they actually need to.
EVENT-DRIVEN PATTERN (what we built)
│
├── Browser connects to Ably channel (persistent WebSocket)
│ └── stays open, no requests, zero cost while idle
│
├── Admin saves a change
│ │
│ ▼
│ API route handles the mutation
│ │
│ ├── Writes to MongoDB ✓
│ │
│ └── Publishes a tiny event to Ably:
│ { type: "stories.updated" }
│ (not the full data - just a signal)
│
├── Ably broadcasts event to all connected browsers instantly
│ │
│ ▼
│ Each browser receives the event
│ │
│ └── RTK Query cache tag "Stories" gets invalidated
│ │
│ ▼
│ RTK Query refetches /api/stories automatically
│ │
│ ▼
│ UI updates with fresh data
│
│ Result:
│ • Zero wasted requests while idle
│ • Update propagates in ~100ms
│ • Same API routes, no duplication
The portfolio is built on:
- Next.js (App Router) for the frontend
- MongoDB as the database
- RTK Query for server state and caching
- Ably as the managed realtime layer
The key insight: Ably is not a replacement for the API. It's just a notification layer. The data still lives in MongoDB. The API still serves it. Ably just tells browsers when to ask for it again.
Server side - after a successful mutation, publish the event:
// app/api/stories/route.ts
import Ably from "ably";
export async function POST(req: Request) {
const data = await req.json();
// 1. Write to database
await db.collection("stories").insertOne(data);
// 2. Signal clients - just the event type, not the full payload
const ably = new Ably.Rest(process.env.ABLY_API_KEY!);
const channel = ably.channels.get("portfolio");
await channel.publish("stories.updated", { timestamp: Date.now() });
return Response.json({ ok: true });
}Client side - listen for the event and invalidate the cache:
// hooks/useRealtimeSync.ts
import Ably from "ably";
import { useEffect } from "react";
import { useDispatch } from "react-redux";
import { portfolioApi } from "@/store/portfolioApi";
export function useRealtimeSync() {
const dispatch = useDispatch();
useEffect(() => {
const ably = new Ably.Realtime({ authUrl: "/api/ably-token" });
const channel = ably.channels.get("portfolio");
channel.subscribe("stories.updated", () => {
// Tell RTK Query the cache is stale - it refetches automatically
dispatch(portfolioApi.util.invalidateTags(["Stories"]));
});
return () => {
channel.unsubscribe();
ably.close();
};
}, [dispatch]);
}RTK Query tag setup - so invalidation works correctly:
// store/portfolioApi.ts
export const portfolioApi = createApi({
reducerPath: "portfolioApi",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
tagTypes: ["Stories", "Settings"],
endpoints: (builder) => ({
getStories: builder.query<Story[], void>({
query: () => "/stories",
providesTags: ["Stories"], // ← this is what we invalidate
}),
}),
});When invalidateTags(["Stories"]) runs, RTK Query automatically refetches every active getStories query. Components using useGetStoriesQuery() re-render with fresh data. No manual fetch calls needed.
OPTION A: Custom WebSocket server
│
├── Need a persistent Node.js process
├── Vercel serverless functions = stateless, die after each request
├── Can't hold open WebSocket connections
├── Would need a separate always-on server (Railway, Fly.io, etc.)
└── Extra infra, extra cost, extra ops work
OPTION B: Managed realtime service (Ably)
│
├── Ably handles all persistent connections
├── Your Next.js app stays stateless and serverless
├── You just publish an event via REST API call
├── Ably distributes it to all connected clients
└── Free tier covers small portfolios easily
On Vercel, a managed service is the pragmatic choice. The app stays serverless, connections are Ably's problem, and you only pay if you scale beyond the free tier.
This is the one thing that will silently break your realtime connection if you get it wrong.
Ably tokens expire. When they do, the client requests a new one from your auth endpoint. The problem: if your endpoint generates a fresh random clientId each time, Ably sees it as a different client trying to use the same connection - and rejects it.
// ❌ Wrong - generates a new clientId every call
export async function GET() {
const ably = new Ably.Rest(process.env.ABLY_API_KEY!);
const token = await ably.auth.createTokenRequest({
clientId: crypto.randomUUID(), // different every time!
});
return Response.json(token);
}
// ✅ Correct - preserve the clientId from the original request
export async function GET(req: Request) {
const url = new URL(req.url);
const clientId = url.searchParams.get("clientId")
?? crypto.randomUUID(); // only generate on first request
const ably = new Ably.Rest(process.env.ABLY_API_KEY!);
const token = await ably.auth.createTokenRequest({ clientId });
return Response.json(token);
}The Ably client automatically passes its clientId on renewal requests. The server must echo it back.
Admin saves data
└── API writes to MongoDB
└── API publishes "stories.updated" to Ably (~1ms)
└── Ably delivers to all connected browsers (~50-100ms)
└── RTK Query invalidates cache
└── RTK Query refetches /api/stories
└── UI updates
Total time from save to visible: ~200ms
Requests while nothing is changing: 0
Small footprint. Fast. No polling. No custom server. One source of truth.