Realtime
NotifyKit supports real-time updates so inbox items and unread counts appear instantly in the client. Three adapters are available — pick based on your deployment.
Instant inbox updates
New items appear in the client the moment they're created — no polling, no manual refresh.
Live unread counts
Badge counts update instantly when items are read, archived, or created across tabs and devices.
Automatic reconnection
Built-in exponential backoff with gap-fill on reconnect. No events lost during network drops.
Tenant-scoped events
Events are isolated by identity and scope. Cross-tenant data never leaks through the event stream.
| Adapter | Best for | Trade-off |
|---|---|---|
| In-memory | Local dev, single-server deploys | Events don't cross process boundaries |
| PostgreSQL NOTIFY | Multi-instance with shared Postgres | 8KB payload limit, needs a dedicated connection |
| WebSocket | Custom transports, high connection counts | More setup — you manage the WS server |
In-memory adapter
For single-process deployments (one Next.js server, no horizontal scaling):
import { memoryRealtimeAdapter } from "@notifykitjs/core"
const notify = createNotifyKit({
// ...
realtime: memoryRealtimeAdapter(),
})Events are dispatched in-process. If you have multiple server instances, each only sees events from its own sends.
PostgreSQL NOTIFY adapter
For multi-process deployments sharing a Postgres database. Uses LISTEN/NOTIFY — no additional infrastructure needed.
npm install @notifykitjs/realtime-pgimport { pgRealtimeAdapter } from "@notifykitjs/realtime-pg"
import { Pool } from "pg"
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
// The adapter needs a dedicated connection for LISTEN
const listenClient = await pool.connect()
const realtime = pgRealtimeAdapter({
connection: {
listen: (channel, handler) => {
listenClient.on("notification", (msg) => {
if (msg.channel === channel) handler(msg.payload!)
})
return listenClient.query(`LISTEN ${channel}`)
},
unlisten: (channel) => listenClient.query(`UNLISTEN ${channel}`),
notify: (channel, payload) =>
pool.query("SELECT pg_notify($1, $2)", [channel, payload]),
},
heartbeatMs: 60_000, // detect dead connections
onError: (err) => console.error("realtime error:", err),
})
await realtime.start()
const notify = createNotifyKit({
// ...
realtime,
})inbox.refetch event that tells clients to re-fetch.WebSocket adapter
For custom transports or when you need fine-grained control over connections:
npm install @notifykitjs/realtime-wsimport { webSocketRealtimeAdapter } from "@notifykitjs/realtime-ws"
const realtime = webSocketRealtimeAdapter({
authenticate: async (request) => {
const token = new URL(request.url).searchParams.get("token")
const session = await verifyToken(token)
if (!session) return null
return {
recipientId: session.userId,
tenantId: session.orgId,
}
},
allowedOrigins: ["https://app.example.com"],
heartbeatMs: 30_000,
maxConnections: 10_000,
})Handling connections
server.on("upgrade", async (request, ws) => {
const identity = await realtime.handleUpgrade(request, ws)
if (!identity) {
ws.close(4001, "Unauthorized")
return
}
ws.on("message", (data) => realtime.handleMessage(ws, data.toString()))
ws.on("close", () => realtime.handleClose(ws))
})How it works
An inbox event occurs (create, read, archive, delete). The engine calls realtime.publish().
The adapter delivers the event to all listeners subscribed to that (recipientId, scope) pair.
The handler's GET /api/notifykit/inbox/stream route subscribes on behalf of the authenticated user and streams events to the browser.
useInbox() connects to the SSE endpoint and updates state as events arrive — no polling, no manual refresh.
Event types
| Event | Payload | React SDK action |
|---|---|---|
inbox.created | The new InboxItem | Prepends to items, increments unreadCount |
inbox.updated | The updated InboxItem | Replaces item in place, recalculates unreadCount |
inbox.deleted | { itemId } | Removes from items |
inbox.all_read | { count } | Sets all items' readAt, zeros unreadCount |
inbox.refetch | Empty | Re-fetches entire inbox from server |
Scaling path
Start simple and upgrade as your deployment grows:
Zero config. Works immediately. Upgrade when you add a second instance.
Events cross process boundaries via your existing Postgres. Upgrade when you hit the 8KB limit or need 10k+ connections.
Full control over connections, auth, and transport. You manage the WS server.
realtime option in createNotifyKit() is the only thing that changes — no client code, no schema migrations, no redeployment of the React app.When to upgrade
Watch for these signals — they tell you when your current adapter has hit its ceiling and what to move to:
| Signal | You're on | Move to | Why |
|---|---|---|---|
| Second server instance deployed | In-memory | PG NOTIFY | In-memory events are process-local — instance B never sees instance A's sends |
| Inbox items contain large HTML/JSON (>8KB) | PG NOTIFY | WebSocket | Postgres NOTIFY truncates at 8KB; WebSocket has no payload limit |
| More than ~5,000 concurrent SSE connections per instance | PG NOTIFY | WebSocket | Each PG listener shares one connection; WS gives per-client control and backpressure |
| Need binary frames, compression, or custom protocols | Any | WebSocket | SSE is text-only, one-direction; WS supports binary, bidirectional, and per-message deflate |
| Serverless / edge functions (no persistent process) | Any SSE-based | Polling fallback | SSE requires a long-lived process; serverless functions timeout after seconds |
active connections and event delivery lag before switching adapters.Connection lifecycle
SSE connections are long-lived and will inevitably break — network changes, token expiry, server deploys. Understanding the lifecycle helps you build reliable UIs:
Client opens SSE to /inbox/stream. Server authenticates via identify() and subscribes.
Events flow. Heartbeats keep the connection alive through proxies and load balancers.
Network drops, deploy restarts server, or token expires. Client detects via onerror.
Client reconnects and fetches full inbox to catch events missed during the gap.
| Event | React SDK behavior | Custom client action |
|---|---|---|
| Connection opens | realtimeStatus → "connected" | Hide any "reconnecting" UI |
| Connection drops | Auto-reconnects with exponential backoff (1s, 2s, 4s...) | Show subtle indicator; attempt reconnect after delay |
| 401 on reconnect | Stops retrying, sets realtimeStatus → "disconnected" | Token expired — redirect to login or refresh the token |
| Reconnect succeeds | Calls refresh() to re-fetch inbox (fills the gap) | Fetch /inbox to catch any missed events |
| Component unmounts | Closes the EventSource, cancels reconnect timers | Call eventSource.close() in cleanup |
function connectSSE(baseUrl, { onEvent, onStatusChange }) {
let retryDelay = 1000
let es = null
function connect() {
onStatusChange("connecting")
es = new EventSource(`${baseUrl}/inbox/stream`, { withCredentials: true })
es.onopen = () => {
retryDelay = 1000 // reset backoff
onStatusChange("connected")
}
es.onmessage = (e) => {
onEvent(JSON.parse(e.data))
}
es.onerror = () => {
es.close()
onStatusChange("disconnected")
// Exponential backoff: 1s, 2s, 4s, 8s... max 30s
setTimeout(connect, retryDelay)
retryDelay = Math.min(retryDelay * 2, 30_000)
}
}
connect()
return () => { es?.close() } // cleanup function
}useInbox(), reconnection, backoff, gap-filling, and cleanup are built in. You only need custom lifecycle management when building non-React clients or custom transports.Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
realtimeStatus stuck on "connecting" | SSE endpoint blocked by proxy, CDN, or buffering middleware | Disable response buffering for /api/notifykit/inbox/stream and confirm your function duration allows a useful connection lifetime. |
realtimeStatus is "disconnected" | No realtime adapter configured server-side | Add realtime: memoryRealtimeAdapter() to your createNotifyKit() config. |
| Events arrive on one server but not another | Using in-memory adapter across multiple instances | Switch to pgRealtimeAdapter or WebSocket — events must cross process boundaries. |
| SSE connects then drops after 30s | Load balancer idle timeout | The HTTP stream sends a heartbeat every 30 seconds; configure the load balancer idle timeout above that interval. |
| New inbox items don't appear until refresh | Send and SSE listener on different NotifyKit instances | Ensure both share the same adapter instance (same import path for the notify singleton). |
/inbox/stream request. If it's connected, you'll see heartbeat pings. Send a test notification and verify an inbox.created event appears in the stream.Performance tuning
SSE connections are cheap individually but add up at scale. Each open connection holds a file descriptor and a small memory allocation on the server. Here's how to plan for growth:
| Metric | Baseline | Warning sign | Action |
|---|---|---|---|
| Active connections | < 80% of your server's max FDs | > 90% — new connections will fail | Scale horizontally + move to pg/WS adapter |
| Heartbeat latency | Measure under normal traffic | Sustained regression from your baseline | Reduce work on the main thread, increase instances |
| Reconnect rate | Measure by client, region, and deploy | Sudden increase or reconnect loops | Check LB timeouts, deploy stability, heartbeat interval |
| Event delivery lag | Set from your product freshness requirement | Lag exceeds your own SLO | Check Postgres NOTIFY backlog or WS broadcast queue |
const client = createNotifyKitClient({
realtime: true,
onRealtimeError: (error) => {
metrics.increment("notifykit.realtime.errors")
reportError(error)
},
})SSE heartbeat behavior
The HTTP handler emits an SSE comment heartbeat every 30 seconds. That interval is fixed; configure proxy idle timeouts above 30 seconds. The separate PostgreSQL and WebSocket adapters expose their own heartbeatMs options for transport health checks.
| Layer | Setting | Guidance |
|---|---|---|
| NotifyKit HTTP SSE | Fixed at 30s | Set the proxy idle timeout comfortably above 30 seconds |
| PostgreSQL adapter | heartbeatMs | Detects a dead LISTEN connection; default is 60 seconds |
| WebSocket adapter | heartbeatMs | Controls ping/pong liveness checks; default is 30 seconds |
Polling fallback for serverless
When SSE isn't available or function duration limits make connections too short-lived, use interval-based polling as a drop-in replacement. The inbox stays fresh without a persistent connection.
| Approach | Freshness | Best for | Trade-off |
|---|---|---|---|
| SSE (default) | Event-driven; depends on network and adapter | Long-running servers and streaming-capable functions | Connection lifetime is bounded by hosting limits |
| Polling (interval) | Up to N seconds stale | Short-duration functions and constrained edge runtimes | More requests, slight delay |
| Hybrid | Event-driven when connected, polled on disconnect | Apps that deploy across mixed infra | Extra code, but best of both worlds |
Basic polling with useInbox
The React SDK's useInbox() accepts a pollInterval option. When set, it fetches the inbox on a timer instead of relying on SSE — no other code changes needed:
"use client"
import { useInbox, useUnreadCount } from "@notifykitjs/react"
function NotificationBell() {
// Poll every 10 seconds instead of SSE
const { items, markAsRead } = useInbox({ pollInterval: 10_000 })
const { unreadCount } = useUnreadCount({ pollInterval: 10_000 })
return (
<div>
<button>🔔 {unreadCount > 0 && <span>{unreadCount}</span>}</button>
<ul>
{items.map(item => (
<li key={item.id} onClick={() => markAsRead(item.id)}>
{item.title}
</li>
))}
</ul>
</div>
)
}Adaptive polling: fast after activity, slow when idle
Fixed polling wastes requests when nothing is happening and feels slow during active conversations. Use adaptive polling — poll fast right after a new item arrives, then slow down when idle:
"use client"
import { useInbox, useUnreadCount } from "@notifykitjs/react"
import { useState, useEffect, useCallback } from "react"
function useAdaptivePolling() {
const [interval, setInterval] = useState(30_000) // start slow (30s)
const onNewItems = useCallback(() => {
setInterval(5_000) // speed up on activity (5s)
}, [])
// Slow down after 2 minutes of no new items
useEffect(() => {
const slowDown = setTimeout(() => setInterval(30_000), 120_000)
return () => clearTimeout(slowDown)
}, [interval])
return { interval, onNewItems }
}
function NotificationBell() {
const { interval, onNewItems } = useAdaptivePolling()
const { items } = useInbox({
pollInterval: interval,
onNewItems, // called when new items appear
})
const { unreadCount } = useUnreadCount({ pollInterval: interval })
// ...render
}Environment-based: SSE in dev, polling in production
If you develop locally with a persistent server but deploy to serverless, switch the strategy based on the environment:
export const realtimeConfig = {
usePolling: process.env.NEXT_PUBLIC_USE_POLLING === "true",
pollInterval: 10_000,
}import { realtimeConfig } from "@/lib/realtime-config"
function NotificationBell() {
const inboxOptions = realtimeConfig.usePolling
? { pollInterval: realtimeConfig.pollInterval }
: {} // use SSE (default)
const { items } = useInbox(inboxOptions)
// ...
}# Set in your Vercel project settings (or hosting platform)
NEXT_PUBLIC_USE_POLLING=true| Poll interval | Requests/user/hour | Freshness | Good for |
|---|---|---|---|
| 5s | 720 | Near real-time | Chat-like apps, active collaboration — but consider upgrading to SSE |
| 10s | 360 | Acceptable | Most apps. Users notice a 10s delay but don't complain. |
| 30s | 120 | Noticeable | Low-traffic apps, dashboards checked periodically |
| 60s | 60 | Stale | Background tools, admin panels — users don't expect instant |
pollInterval option makes useInbox() call GET /api/notifykit/inbox on a timer. No server-side changes needed — the same handler that serves SSE also handles REST fetches. Mutations (markAsRead, archive) work identically in both modes.Verify your setup
Run through this checklist after configuring realtime. It takes 30 seconds and confirms the full path works end-to-end:
Open a terminal and hold the connection open. You should see a heartbeat within 30–60 seconds.
In a second terminal (or your app), call notify.send() targeting your test user.
The curl terminal should print an inbox.created event with the item payload.
# Terminal 1: connect to SSE (grab a session cookie from your browser)
curl -N -H "Cookie: session=..." http://localhost:3000/api/notifykit/inbox/stream
# Expected output (within 30s):
# : heartbeat
#
# After sending a notification:
# event: inbox.created
# data: {"type":"inbox.created","item":{"id":"inb_...","title":"..."}}| What you see | Meaning | If you don't see it |
|---|---|---|
| Connection hangs with no output | SSE connected but waiting for first event | Wait up to 30 seconds — a heartbeat comment should arrive |
: heartbeat appears | Connection is alive and the adapter is working | — |
event: inbox.created after send | Full pipeline works: send → adapter → SSE → client | Check that send and SSE share the same notify instance |
| 401 Unauthorized | identify() returned null for this request | Verify your cookie/token is valid — try GET /inbox first |
| Connection closes immediately | Response buffering or proxy timeout | Disable buffering for this route (see troubleshooting above) |
Testing realtime
Realtime events are easy to verify manually with curl, but you need automated assertions to prevent regressions. The memory adapter runs in-process — no WebSocket server, no Postgres — so events fire synchronously after send() resolves.
| What to test | Assertion | Catches |
|---|---|---|
| Event fires on send | Subscriber receives inbox.created with correct item | Broken adapter wiring, missing realtime config |
| Scoped delivery | Subscriber for org B does not receive org A's event | Cross-tenant event leaks in pub/sub layer |
| Mark-read propagates | Subscriber receives inbox.updated with readAt set | Mutation hooks not calling realtime.publish() |
| Bulk mark-all-read | Subscriber receives inbox.all_read with correct count | Bulk operation bypassing realtime publish |
| Delete propagates | Subscriber receives inbox.deleted with item ID | Delete path missing realtime call |
Pattern: subscribe before send
import { describe, it, expect, vi } from "vitest"
import { createNotifyKit, memoryAdapter, memoryRealtimeAdapter, fakeEmailProvider } from "@notifykitjs/core"
import { commentMentioned } from "./notifications"
describe("realtime events", () => {
function setup() {
const realtime = memoryRealtimeAdapter()
const notify = createNotifyKit({
notifications: [commentMentioned] as const,
database: memoryAdapter(),
providers: { email: fakeEmailProvider() },
realtime,
})
return { notify, realtime }
}
it("fires inbox.created when a notification is sent", async () => {
const { notify, realtime } = setup()
const received = vi.fn()
await notify.upsertRecipient({ id: "alice", email: "a@test.com" })
// Subscribe BEFORE sending — mimics an open SSE connection
realtime.subscribe("alice", {}, (event) => received(event))
await notify.send({
recipientId: "alice",
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postUrl: "/p/1" },
})
expect(received).toHaveBeenCalledTimes(1)
expect(received).toHaveBeenCalledWith(
expect.objectContaining({
type: "inbox.created",
payload: expect.objectContaining({ title: expect.any(String) }),
})
)
})
it("scopes events by tenant — no cross-org leaks", async () => {
const { notify, realtime } = setup()
const orgAEvents = vi.fn()
const orgBEvents = vi.fn()
await notify.upsertRecipient({ id: "alice", tenantId: "org_a", email: "a@test.com" })
await notify.upsertRecipient({ id: "alice", tenantId: "org_b", email: "a@test.com" })
realtime.subscribe("alice", { tenantId: "org_a" }, orgAEvents)
realtime.subscribe("alice", { tenantId: "org_b" }, orgBEvents)
await notify.send({
recipientId: "alice",
tenantId: "org_a",
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postUrl: "/p/1" },
})
expect(orgAEvents).toHaveBeenCalledTimes(1)
expect(orgBEvents).toHaveBeenCalledTimes(0) // ✓ isolated
})
it("fires inbox.updated on mark-read", async () => {
const { notify, realtime } = setup()
const received = vi.fn()
await notify.upsertRecipient({ id: "alice", email: "a@test.com" })
const result = await notify.send({
recipientId: "alice",
notificationId: "comment_mentioned",
payload: { actorName: "Rey", postUrl: "/p/1" },
})
realtime.subscribe("alice", {}, received)
await notify.inbox.markReadForRecipient(result.inboxItems[0].id, "alice")
expect(received).toHaveBeenCalledWith(
expect.objectContaining({
type: "inbox.updated",
payload: expect.objectContaining({ readAt: expect.any(Date) }),
})
)
})
})Testing reconnection gaps
The hardest realtime bug to catch: events missed during a disconnect window. Verify that a client re-fetching after reconnect sees items created during the gap:
it("gap-fill: items created while disconnected appear on re-fetch", async () => {
const { notify } = setup()
await notify.upsertRecipient({ id: "alice", email: "a@test.com" })
// Simulate: client was connected, then disconnected
// (no subscriber active during this send)
await notify.send({
recipientId: "alice",
notificationId: "comment_mentioned",
payload: { actorName: "Sam", postUrl: "/p/2" },
})
// Client reconnects and re-fetches inbox (gap-fill strategy)
const items = await notify.inbox.list("alice")
expect(items).toHaveLength(1)
expect(items[0].title).toContain("Sam")
})| Test level | Adapter | What it proves | External dependencies |
|---|---|---|---|
| Unit | Memory | Events fire, scoping works, mutations publish | None |
| Integration | PG NOTIFY / WebSocket | Events cross process boundaries, reconnection works | Postgres or WebSocket server |
| E2E | Full stack (browser + SSE) | React hook updates, unread badge decrements in real UI | Browser, application server, and adapter |