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.

AdapterBest forTrade-off
In-memoryLocal dev, single-server deploysEvents don't cross process boundaries
PostgreSQL NOTIFYMulti-instance with shared Postgres8KB payload limit, needs a dedicated connection
WebSocketCustom transports, high connection countsMore setup — you manage the WS server
Start with in-memory. It works out of the box with zero config. Upgrade to Postgres NOTIFY when you add a second server instance, or WebSocket when you need full control.

In-memory adapter

For single-process deployments (one Next.js server, no horizontal scaling):

lib/notifykit.ts
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-pg
lib/notifykit.ts
import { 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,
})
8KB limit. PostgreSQL NOTIFY payloads are limited to ~8KB. If an event exceeds this (e.g. a large inbox item body), the adapter automatically falls back to sending an 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-ws
lib/realtime.ts
import { 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.ts
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

1
Mutation

An inbox event occurs (create, read, archive, delete). The engine calls realtime.publish().

2
Adapter

The adapter delivers the event to all listeners subscribed to that (recipientId, scope) pair.

3
SSE stream

The handler's GET /api/notifykit/inbox/stream route subscribes on behalf of the authenticated user and streams events to the browser.

4
React

useInbox() connects to the SSE endpoint and updates state as events arrive — no polling, no manual refresh.

Event types

EventPayloadReact SDK action
inbox.createdThe new InboxItemPrepends to items, increments unreadCount
inbox.updatedThe updated InboxItemReplaces item in place, recalculates unreadCount
inbox.deleted{ itemId }Removes from items
inbox.all_read{ count }Sets all items' readAt, zeros unreadCount
inbox.refetchEmptyRe-fetches entire inbox from server

Scaling path

Start simple and upgrade as your deployment grows:

1
Single server → In-memory

Zero config. Works immediately. Upgrade when you add a second instance.

2
Multi-instance → PostgreSQL NOTIFY

Events cross process boundaries via your existing Postgres. Upgrade when you hit the 8KB limit or need 10k+ connections.

3
High scale → WebSocket

Full control over connections, auth, and transport. You manage the WS server.

Swapping adapters is a one-line change. The 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:

SignalYou're onMove toWhy
Second server instance deployedIn-memoryPG NOTIFYIn-memory events are process-local — instance B never sees instance A's sends
Inbox items contain large HTML/JSON (>8KB)PG NOTIFYWebSocketPostgres NOTIFY truncates at 8KB; WebSocket has no payload limit
More than ~5,000 concurrent SSE connections per instancePG NOTIFYWebSocketEach PG listener shares one connection; WS gives per-client control and backpressure
Need binary frames, compression, or custom protocolsAnyWebSocketSSE is text-only, one-direction; WS supports binary, bidirectional, and per-message deflate
Serverless / edge functions (no persistent process)Any SSE-basedPolling fallbackSSE requires a long-lived process; serverless functions timeout after seconds
Don't pre-optimize. Most apps run fine on PG NOTIFY up to thousands of concurrent users. The upgrade path exists for when you need it — not before. Measure 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:

1
Connect

Client opens SSE to /inbox/stream. Server authenticates via identify() and subscribes.

2
Stream

Events flow. Heartbeats keep the connection alive through proxies and load balancers.

3
Disconnect

Network drops, deploy restarts server, or token expires. Client detects via onerror.

4
Reconnect + re-fetch

Client reconnects and fetches full inbox to catch events missed during the gap.

EventReact SDK behaviorCustom client action
Connection opensrealtimeStatus → "connected"Hide any "reconnecting" UI
Connection dropsAuto-reconnects with exponential backoff (1s, 2s, 4s...)Show subtle indicator; attempt reconnect after delay
401 on reconnectStops retrying, sets realtimeStatus → "disconnected"Token expired — redirect to login or refresh the token
Reconnect succeedsCalls refresh() to re-fetch inbox (fills the gap)Fetch /inbox to catch any missed events
Component unmountsCloses the EventSource, cancels reconnect timersCall eventSource.close() in cleanup
lib/connect-sse.ts
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
}
The React SDK handles all of this. If you're using 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

SymptomCauseFix
realtimeStatus stuck on "connecting"SSE endpoint blocked by proxy, CDN, or buffering middlewareDisable 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-sideAdd realtime: memoryRealtimeAdapter() to your createNotifyKit() config.
Events arrive on one server but not anotherUsing in-memory adapter across multiple instancesSwitch to pgRealtimeAdapter or WebSocket — events must cross process boundaries.
SSE connects then drops after 30sLoad balancer idle timeoutThe HTTP stream sends a heartbeat every 30 seconds; configure the load balancer idle timeout above that interval.
New inbox items don't appear until refreshSend and SSE listener on different NotifyKit instancesEnsure both share the same adapter instance (same import path for the notify singleton).
Debug in DevTools. Open the Network tab, filter by "EventStream", and look for the /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:

MetricBaselineWarning signAction
Active connections< 80% of your server's max FDs> 90% — new connections will failScale horizontally + move to pg/WS adapter
Heartbeat latencyMeasure under normal trafficSustained regression from your baselineReduce work on the main thread, increase instances
Reconnect rateMeasure by client, region, and deploySudden increase or reconnect loopsCheck LB timeouts, deploy stability, heartbeat interval
Event delivery lagSet from your product freshness requirementLag exceeds your own SLOCheck Postgres NOTIFY backlog or WS broadcast queue
lib/notifykit-client.ts
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.

LayerSettingGuidance
NotifyKit HTTP SSEFixed at 30sSet the proxy idle timeout comfortably above 30 seconds
PostgreSQL adapterheartbeatMsDetects a dead LISTEN connection; default is 60 seconds
WebSocket adapterheartbeatMsControls ping/pong liveness checks; default is 30 seconds
Function duration still bounds a stream. Platforms including Vercel support streaming responses, but a connection ends when that invocation reaches its configured maximum duration. The client reconnects automatically; use polling if frequent reconnects are undesirable for your deployment.

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.

ApproachFreshnessBest forTrade-off
SSE (default)Event-driven; depends on network and adapterLong-running servers and streaming-capable functionsConnection lifetime is bounded by hosting limits
Polling (interval)Up to N seconds staleShort-duration functions and constrained edge runtimesMore requests, slight delay
HybridEvent-driven when connected, polled on disconnectApps that deploy across mixed infraExtra 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:

components/notification-bell.tsx
"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:

components/notification-bell.tsx
"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:

lib/realtime-config.ts
export const realtimeConfig = {
  usePolling: process.env.NEXT_PUBLIC_USE_POLLING === "true",
  pollInterval: 10_000,
}
components/notification-bell.tsx
import { realtimeConfig } from "@/lib/realtime-config"

function NotificationBell() {
  const inboxOptions = realtimeConfig.usePolling
    ? { pollInterval: realtimeConfig.pollInterval }
    : {} // use SSE (default)

  const { items } = useInbox(inboxOptions)
  // ...
}
.env.production
# Set in your Vercel project settings (or hosting platform)
NEXT_PUBLIC_USE_POLLING=true
Poll intervalRequests/user/hourFreshnessGood for
5s720Near real-timeChat-like apps, active collaboration — but consider upgrading to SSE
10s360AcceptableMost apps. Users notice a 10s delay but don't complain.
30s120NoticeableLow-traffic apps, dashboards checked periodically
60s60StaleBackground tools, admin panels — users don't expect instant
Start at 10 seconds. It's a good balance — fresh enough that users don't notice the delay, light enough that you won't overload your API. If you see performance issues, increase to 30s. If users complain about delays, decrease to 5s or upgrade your hosting to support SSE.
Polling still uses the same API. The 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:

1
Connect with curl

Open a terminal and hold the connection open. You should see a heartbeat within 30–60 seconds.

2
Send a test notification

In a second terminal (or your app), call notify.send() targeting your test user.

3
Confirm the event arrives

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 seeMeaningIf you don't see it
Connection hangs with no outputSSE connected but waiting for first eventWait up to 30 seconds — a heartbeat comment should arrive
: heartbeat appearsConnection is alive and the adapter is working
event: inbox.created after sendFull pipeline works: send → adapter → SSE → clientCheck that send and SSE share the same notify instance
401 Unauthorizedidentify() returned null for this requestVerify your cookie/token is valid — try GET /inbox first
Connection closes immediatelyResponse buffering or proxy timeoutDisable buffering for this route (see troubleshooting above)
This replaces guessing. If curl shows heartbeats and events but your React UI doesn't update, the issue is client-side (provider wiring, component mount). If curl shows nothing, the issue is server-side (adapter config, auth, proxy).

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 testAssertionCatches
Event fires on sendSubscriber receives inbox.created with correct itemBroken adapter wiring, missing realtime config
Scoped deliverySubscriber for org B does not receive org A's eventCross-tenant event leaks in pub/sub layer
Mark-read propagatesSubscriber receives inbox.updated with readAt setMutation hooks not calling realtime.publish()
Bulk mark-all-readSubscriber receives inbox.all_read with correct countBulk operation bypassing realtime publish
Delete propagatesSubscriber receives inbox.deleted with item IDDelete path missing realtime call

Pattern: subscribe before send

tests/realtime.test.ts
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")
})
Memory adapter makes tests instant. No WebSocket server to start, no Postgres LISTEN to set up, no timers to advance. Events fire synchronously in the same process — subscribe, send, assert. Use integration tests against Postgres NOTIFY only when you're specifically testing the adapter swap.
Test levelAdapterWhat it provesExternal dependencies
UnitMemoryEvents fire, scoping works, mutations publishNone
IntegrationPG NOTIFY / WebSocketEvents cross process boundaries, reconnection worksPostgres or WebSocket server
E2EFull stack (browser + SSE)React hook updates, unread badge decrements in real UIBrowser, application server, and adapter
Don't skip the scoping test. Cross-tenant event leaks are silent — the feature "works" in single-tenant testing but leaks data in production. Always test with two tenants subscribed simultaneously and verify isolation.