Generated banner for Neon Realtime
Generated logo for Neon Realtime

Neon Realtime

NeonOfficialPublished by Neon

  • Skill
  • Tool integration
  • Data and AI
  • Apache-2.0
  • No issues found

From the author

Neon Realtime makes any SQL query live. Build realtime, reactive apps and agents on Lakebase Postgres with live SQL queries and reactivity primitives. Use when app UI or agent state must update live from database rows (dashboards, feeds, collaboration, multi-user or multi-device sync), for optimistic mutations, or to replace polling or other realtime sync systems. Triggers include "live query", "subscribe", "sync engine", "useLiveQuery", "TanStack DB", "optimistic updates", "@neon/realtime". For push that doesn't come from database rows (presence, typing, LLM streams), use neon-functions.
— the description by Neon

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

Neon Realtime enables developers to create reactive applications and agents by transforming static SQL queries into live, streaming data sources. By utilizing a combination of server-side query sealing and client-side reactivity primitives, it allows UI components and agent states to update automatically as database rows change. This approach eliminates the need for manual polling or complex custom synchronization logic, providing a type-safe, performant way to keep frontends and backend services in sync with the underlying Postgres database.

What it does

  • Converts standard SQL queries into live, reactive data streams.
  • Provides secure query sealing to prevent unauthorized data access or modification.
  • Supports optimistic UI updates to ensure immediate user feedback during data mutations.
  • Integrates with popular ORMs like Drizzle and Kysely for seamless development.
  • Offers React-specific hooks for binding live database state to UI components.
  • Enables efficient synchronization for dashboards, feeds, and multi-user collaboration.

When to use it

  • When building dashboards or feeds that require live data updates.
  • When implementing collaborative features that need multi-user or multi-device synchronization.
  • When you want to replace inefficient polling mechanisms with a push-based sync system.
  • When developing optimistic UI patterns to improve perceived application performance.
  • When building AI agents that need to react to real-time changes in database state.

Advantages

  • Reduces network overhead by replacing polling with WebSocket-based streaming.
  • Simplifies state management by keeping UI in sync with database transactions.
  • Enhances user experience through instant, optimistic UI updates.
  • Maintains security by authorizing and sealing queries on the backend.
  • Works with existing SQL knowledge and familiar ORM workflows.

Usage examples

“How do I make a list of messages in my chat app update in real-time?”
You define a sealed SQL query on your backend that selects messages for a specific channel, then use the useLiveQuery hook in your React frontend to subscribe to that query and automatically update the message list as new rows are inserted.
“How can I implement optimistic updates for a user action?”
You perform the write operation through your backend, return the transaction ID, and use the awaitTxId utility on the client to wait for the database confirmation while immediately updating the local UI state.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/neondatabase/neon-realtime/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip neon-realtime.zip -d .agents/skills

Documentation by Neon

FIRST: Use the parent neon skill for a Neon overview, getting started with Neon, Neon development best practices, and more.

If the neon skill is not installed, fetch it from https://neon.com/docs/ai/skills/neon/SKILL.md or install it with:

neon skills -s neon -y

Neon Realtime

Neon's Realtime makes any SQL query live. Subscribe in realtime to your Lakebase Postgres database. Develop apps and agents with end-to-end reactivity using type-safe live queries and optimistic mutations.

Realtime is new and in beta, so its APIs aren't in your training data. Start from the core pattern below, and read each section's reference file before writing that code.

How It Works

Realtime's layers compose: a live query engine inside Postgres, the Live SQL API (a WebSocket API), lower-level client SDKs (seal(), subscribe()), and reactivity primitives such as useLiveQuery.

  1. In trusted backend code, define a live query (any read-only SQL, including joins, aggregates, ordering and limits), seal it with NEON_REALTIME_SECRET and expose it from an authenticated endpoint or server function. The client can't read or change a sealed query's SQL or parameters.

  2. The client subscribes with the sealed query. It receives the full initial result, then each committed transaction's changes as one batch. Results stay consistent, but delivery of every change event isn't guaranteed.

  3. Writes go through your own API, which returns each write's txid for awaitTxId().

Core Pattern

The guides' chat app, with messages authorized through channel_members. The backend seals the query and returns each write's txid:

import { createRealtime, rawSql } from "@neon/realtime/server"
import { drizzleAdapter } from "@neon/realtime-drizzle"
import { and, desc, eq, getTableColumns, sql } from "drizzle-orm"
import { Hono } from "hono"
import { HTTPException } from "hono/http-exception"
import { requireUser } from "./auth"
import { db } from "./db"
import { channelMembers, messages, type Message } from "./schema"

export const realtime = createRealtime({ secret: process.env.NEON_REALTIME_SECRET!, db: "neondb", adapter: drizzleAdapter() })

// Each selected key must match its column name
export function messagesQuery(userId: string, channelId: number) {
  return db
    .select(getTableColumns(messages)).from(messages)
    .innerJoin(channelMembers, eq(channelMembers.channel_id, messages.channel_id))
    .where(and(eq(channelMembers.user_id, userId), eq(messages.channel_id, channelId)))
    .orderBy(desc(messages.created_at))
    .limit(50)
}

// Or raw SQL
const rawMessagesQuery = (userId: string, channelId: number) => rawSql<Message>(
    `SELECT messages.author_id, messages.body, messages.channel_id, messages.created_at, messages.id FROM messages
     INNER JOIN channel_members ON channel_members.channel_id = messages.channel_id WHERE channel_members.user_id = $1 AND messages.channel_id = $2 ORDER BY messages.created_at DESC LIMIT 50`,
    [userId, channelId]
  )

// The id of the transaction that made the change, read in the write's own RETURNING
const txid = sql<string>`pg_current_xact_id()::text`

const app = new Hono()
  .post("/api/v1/messages/live", async (c) => {
    const user = await requireUser(c)
    const { channelId } = await c.req.json<{ channelId: number }>() // validate input
    return c.json(await realtime.seal({ query: messagesQuery(user.id, channelId) }))
  })
  .post("/api/v1/messages", async (c) => {
    const user = await requireUser(c)
    const { id, channelId, body } = await c.req.json<{ id: string; channelId: number; body: string }>() // validate input
    // One statement. The membership row supplies channel_id and author_id, so only a member's message is inserted.
    // List every column in table order, alias each sql field, and cast untyped params (they resolve to text)
    const [sent] = await db.insert(messages).select(
      db.select({
          id: sql`${id}::uuid`.as("id"), channel_id: channelMembers.channel_id, author_id: channelMembers.user_id,
          body: sql`${body}`.as("body"), created_at: sql`now()`.as("created_at"),
        })
        .from(channelMembers)
        .where(and(eq(channelMembers.channel_id, channelId), eq(channelMembers.user_id, user.id)))
    ).returning({ txid })
    if (!sent) throw new HTTPException(403, { message: "Not a member of this channel" })
    return c.json({ kind: "committed", txid: sent.txid })
  })

The React frontend subscribes and sends optimistic messages:

import { createRealtimeClient, type SealedLiveQuery } from "@neon/realtime/client"
import { drizzleParsers } from "@neon/realtime-drizzle/client"
import { useLiveQuery } from "@neon/realtime-react"
import { startTransition, useCallback, useOptimistic } from "react"
import type { Message } from "../backend/schema"
import { fetchSealedMessages, sendMessage } from "./api"

// One client per app, because subscriptions share its connection. Wrap the app in <RealtimeProvider client={client}>
export const client = createRealtimeClient({ url: import.meta.env.VITE_NEON_REALTIME_URL, parsers: { ...drizzleParsers } })

const NO_MESSAGES: readonly Message[] = []
type Props = { sealedQuery: SealedLiveQuery<Message>; channelId: number; userId: string }

export function Messages({ sealedQuery, channelId, userId }: Props) {
  const refreshQuery = useCallback(() => fetchSealedMessages(channelId), [channelId])
  const { data, status, error, utils } = useLiveQuery(sealedQuery, { refreshQuery })
  // Once the sent message syncs, show only the synced row
  const [messages, addOptimistic] = useOptimistic(data ?? NO_MESSAGES, (current, message: Message) =>
    current.some(({ id }) => id === message.id) ? current : [message, ...current]
  )

  function send(body: string) {
    // The server sets author and timestamp but keeps this id
    const message = { id: crypto.randomUUID(), channel_id: channelId, author_id: userId, body, created_at: new Date() }
    startTransition(async () => {
      addOptimistic(message)
      try {
        const result = await sendMessage(message) // maps channel_id to channelId
        if (result.kind === "committed") await utils.awaitTxId(result.txid, 10_000)
      } catch { /* show "Couldn't confirm the change" */ }
    })
  }

  if (status === "error") return <p role="alert">Live updates stopped: {error.message}</p>
  if (data === undefined) return <p>Loading messages</p>
  return (
    <form action={(form) => send(String(form.get("body")))}>
      <input name="body" required />
      <ul>{messages.map((message) => <li key={message.id}>{message.body}</li>)}</ul>
    </form>
  )
}

Choose the Stack

A live query replaces fetching app data from an API or another sync system. Reuse existing query code, and use the highest layer that fits rather than wiring changes into app state by hand.

UseFor
drizzleAdapter() from @neon/realtime-drizzle, or kyselyAdapter() from @neon/realtime-kyselyQueries written in Drizzle or Kysely
rawSql from @neon/realtime/serverAny other ORM, or none
useLiveQuery from @neon/realtime-reactReact. Binds one sealed query to a component's state
realtimeCollectionOptions from @neon/realtime-tanstackMany views over the same data, or an app on TanStack DB. Oversync one broader query into a collection, then derive local live queries from it
client.subscribe() from @neon/realtime/clientOther frameworks
realtime.subscribe() from @neon/realtime/serverAgents and workers, in trusted backend code

Migrating from Electric: Realtime is a drop-in replacement for Electric's role in your stack. Live SQL queries replace shapes, and realtimeCollectionOptions() replaces electricCollectionOptions().

Setup

  • New app: neon bootstrap my-app --template realtime-react, then neon deploy in my-app.

  • Enable it on the linked branch with neon realtime enable --allowed-origin <app origin>, or declare preview: { realtime: true } in neon.ts (neon config add realtime) and run neon deploy. Child branches inherit it. It's unavailable on Claimable projects until claimed, or outside the supported regions.

  • Tables need a stable replica identity; a primary key is enough. Leave row-level security off on every table a live query reads: Realtime rejects those subscriptions with unsupported query: row security. For per-user data, authorize in the sealed query instead (see below).

  • Environment: neon env pull --service postgres --service realtime writes DATABASE_URL, NEON_REALTIME_URL and NEON_REALTIME_SECRET. Expose only the URL to the browser, under your framework's public prefix (like VITE_). url options take NEON_REALTIME_URL; db takes the database name from DATABASE_URL.

  • Install @neon/realtime and the packages chosen above. Use these SDKs, not the still-evolving WebSocket protocol.

Define and Seal Queries

Read references/queries.md before writing query or sealing code, including Kysely and server functions.

  • Read-only and deterministic. A raw query is a single SELECT with no ; and $1 placeholders. It can't call now() or gen_random_uuid(), so pass such values as parameters, wrapping every value except strings, numbers, booleans, bigints and null in a pgParam helper (pgParam.timestamptz(date)).

  • Authorize in the query. Check the session before sealing, and join through your membership tables so revoked access drops out. Any web framework auth works, including Managed Better Auth, but not RLS yet. See Authorization.

  • A sealed query is a bearer token. Keep it out of URLs, logs and persistent storage, and never fill its parameters from unchecked input.

  • Check a query before wiring it in: neon realtime subscribe "SELECT ..." prints the initial result, then each transaction's changes, until you stop it. With --output json, each change prints a full snapshot. Set debugMode on createRealtime for full database errors (development only).

  • Sealed queries expire. Pass refreshQuery to the bindings or call renew(), sealing the same query and parameters, or the subscription errors. New parameters need a new sealed query and subscription. In Next.js, renew from a Route Handler, because server actions run one at a time.

Subscribe

Read references/reactivity.md before writing React or TanStack DB code. Read references/subscribing.md before subscribing directly with client.subscribe(sealed) (getSnapshot, onChange, onStateChange), or from agents and workers, which add url to createRealtime and await realtime.subscribe(query) to seal in-process.

  • One database per client. The first sealed query binds the connection to its db.

  • Select a stable, unique key for every row and key rows by it, including TanStack DB's getKey: usually the primary key, the grouped columns for aggregates, or the combined keys for joins that repeat a row. Never use rowId, which every rows update invalidates.

  • error is final, even after renew(). Fetch a fresh sealed query and subscribe again, with a new client if the connection failed. Watch onStateChange on direct subscriptions; useLiveQuery() reports errors through status, not by throwing or suspending.

  • When the session changes, leave with a full page load. It drops the Realtime client, any TanStack DbClient and the previous user's rows. The hook retries a failed renewal every second, so stop renewing once one fails as signed out. Check on every renewal and write that the session's user is the one the page was loaded for, or a write from the old page lands in the new user's account. See When the session changes.

Optimistic Writes

Realtime has no write path, so writes keep your backend's usual auth. Read references/mutations.md before writing mutations, for each layer.

  • Return the txid from the write itself: .returning({ txid: sql<string>`pg_current_xact_id()::text` }), in one round trip on any Postgres driver. Write a batch in one statement, so there's one txid. Never open db.transaction() just to select pg_current_xact_id(): that's four round trips holding a pooled connection. When a write genuinely needs several statements, put the txid in the RETURNING of a write that always changes a row.

  • Always pass awaitTxId() a timeout: it keeps waiting through reconnects and renewals, and never settles if the txid doesn't reach the query.

  • A write that changes no rows has no txid, so skip awaitTxId() and let the optimistic state drop.

Server-Side Rendering

Read references/ssr.md before rendering live queries on the server, for Next.js and TanStack Start.

  • Create the client in a useState initializer rather than at module level, render initialData as live rows without a loading state, and never share a TanStack DbClient between requests.

  • Keep row shapes identical on both sides. JSON.stringify turns dates into strings, and int8 and numeric arrive as strings.

More Docs

TaskPage
Beta scope, regions, pricing, FAQ, other systemsOverview
Architecture and consistencyHow it works
A first live queryQuickstart
Every SDK exportTypeScript SDK reference
Full-stack apps with auth and SSRNext.js, TanStack
Live agent workflowsMastra
ORM adaptersDrizzle, Kysely

Files

references/subscribing.md

<!-- Generated by scripts/sync-skill-docs.mjs from website@docs/neon-auth-package-rename:bf3595986 (docs/realtime/guides/subscribing). Do not edit: change the docs and run npm run sync:docs. sha256:daef4cc4851b540a8fbc1fb5f2e9169fd4f8326fac759460fc3281a830c6f524 -->

Canonical page: https://neon.com/docs/realtime/guides/subscribing.md

Summary: Subscribe to live queries from trusted backend code or the browser. Fetch sealed queries from your API, handle every callback and renew sealed queries before they expire.

Subscribing to queries

Subscribe to live queries using the Realtime client.

Contents

  • Getting the query to subscribe to

    • Subscribe in trusted backend code

    • Fetch from an API endpoint

    • Fetch from a server function

  • Subscribe to the sealed query

  • Listen for changes

  • Renew the sealed query

  • Handle errors

  • Debug subscriptions

    • Log client diagnostics

    • Show full database errors

Once you've defined and sealed a query, you can subscribe to it using the low‑level Realtime client.

Important: Client-side reactivity

Manual subscriptions are a relatively low-level API. Most apps and agents can use the higher‑level reactivity primitives that manage the subscription for you.

Getting the query to subscribe to

Workers, trusted agents and other backend processes can subscribe to a query directly.

However, to subscribe to a live query in an untrusted environment, you'll need to expose a sealed query from your backend, either a standard API endpoint or using a server function (where SSR can embed the sealed query and the initial results).

Tip: Running example

The guide continues the running example introduced in the Defining queries guide.

Subscribe in trusted backend code

Add a url to createRealtime() and it returns a direct server, which seals queries with your secret and subscribes to them itself.

import { createRealtime } from "@neon/realtime/server"
import { drizzleAdapter } from "@neon/realtime-drizzle"

import { messagesQuery } from "./queries"

// The worker acts for one known user, so its parameters are trusted constants
const SUPPORT_BOT_ID = "support-bot"
const SUPPORT_CHANNEL_ID = 2

const realtime = createRealtime({
  url: process.env.NEON_REALTIME_URL!,
  secret: process.env.NEON_REALTIME_SECRET!,
  db: "neondb",
  adapter: drizzleAdapter()
})

const query = messagesQuery(SUPPORT_BOT_ID, SUPPORT_CHANNEL_ID)

const subscription = await realtime.subscribe(query)
subscription.onChange(console.log)

process.once("SIGTERM", () => realtime.close())

Warning: Untrusted parameters

Never fill a direct subscription's parameters from untrusted input.

NEON_REALTIME_URL is written by neon env pull --service realtime, or shown on the Realtime page in the Neon Console. Note the await: the direct server's subscribe() is async, because it seals the query locally before it subscribes. The browser client's subscribe() is synchronous. Otherwise it's the same subscription, with the same options and callbacks as the client's.

The direct server renews its own sealed queries and recovers from outages until you close the subscription or it moves to error. An error is final, so a long-running worker should watch for it with onStateChange and subscribe again. realtime.close() closes every subscription and the shared WebSocket. The direct server opens its WebSocket with the ws package, which you installed in setup, even on Node.js 22 and later.

Use direct subscriptions for workers, trusted agents and server-side fan-out.

Fetch from an API endpoint

Most queries depend on who's asking and what they're asking for.

If you've exposed your sealed query via an API endpoint, you can provide this context via the request data using your standard auth machinery, such as Neon's Managed Better Auth:

import { createAuthClient } from "@neon/auth"

export const authClient = createAuthClient(import.meta.env.VITE_NEON_AUTH_URL)

In our running example, we create a typed client for the API with Hono's hc. It takes its routes from AppType, so every call is checked against the backend:

import type { SealedLiveQuery } from "@neon/realtime/client"
import { hc } from "hono/client"

import type { Message } from "../backend/schema"
import type { AppType } from "../backend/server"
import { authClient } from "./auth"

export const server = hc<AppType>(window.location.origin, {
  headers: async (): Promise<Record<string, string>> => {
    const { data } = await authClient.getSession()
    const token = data?.session.token

    return token ? { Authorization: `Bearer ${token}` } : {}
  }
})

export async function fetchSealedMessages(channelId: number): Promise<SealedLiveQuery<Message>> {
  const res = await server.api.v1.messages.live.$post({ json: { channelId } })

  if (!res.ok) {
    throw new Error(`Could not fetch the sealed query (${res.status})`)
  }

  return (await res.json()) as SealedLiveQuery<Message>
}

Hono's client types the response body as JSON, so the Date in created_at shows up as a string. The as restores the row type that the Realtime client decodes at runtime.

getSession() returns the cached session until its JWT is about to expire, then fetches a new one, so each request carries a valid token. Managed Better Auth tokens only last about 15 minutes. Without a session there's no token, so the API answers 401.

window.location.origin assumes your dev server proxies /api to the API, as the example's Vite config does.

Fetch from a server function

If your framework supports server functions, you can expose the sealed query from one instead of an API endpoint. Client code calls a server function like a local async function, so it can take the place of fetchSealedMessages() above. With SSR, the page can also embed the sealed query and the initial query results on the server.

Server functions run on your app's origin, so they read the session from its cookie. See Exposing queries, Using server functions, the Server-side rendering guide and the TanStack example for more details.

Important: Next.js uses Route Handlers

In Next.js, server functions are server actions, which run one at a time, so renew through a Route Handler instead, as the Next.js example shows.

Subscribe to the sealed query

Create one client for the whole app, because every subscription shares its connection:

import { createRealtimeClient } from "@neon/realtime/client"
import { drizzleParsers } from "@neon/realtime-drizzle/client"

export const client = createRealtimeClient({
  url: import.meta.env.VITE_NEON_REALTIME_URL,
  parsers: { ...drizzleParsers }
})

The client opens its WebSocket lazily, on the first subscription, and multiplexes every subscription over that one connection. The first sealed query binds the connection to its database, so every subscription on a client must target the same db. The connection carries no user identity. Each subscription brings its own sealed query.

Now fetch the sealed query, subscribe to it and render every change:

import type { LiveQuerySnapshot, LiveQueryState } from "@neon/realtime/client"

import type { Message } from "../backend/schema"
import { fetchSealedMessages } from "./api"
import { client } from "./client"

const list = document.querySelector<HTMLUListElement>("#messages")!
const statusLine = document.querySelector<HTMLParagraphElement>("#status")!

// A real app takes the channel from its router
const channelId = 2

const sealedQuery = await fetchSealedMessages(channelId)
const subscription = client.subscribe(sealedQuery)

subscription.onChange(render)

window.addEventListener("beforeunload", () => {
  subscription.unsubscribe()
  client.close()
})

function render(snapshot: LiveQuerySnapshot<Message>) {
  statusLine.textContent = describe(snapshot)
  list.replaceChildren(...(snapshot.data ?? []).map(renderMessage))
}

function describe(state: LiveQueryState): string {
  switch (state.status) {
    case "connecting":
      return "Loading messages"
    case "live":
      return "Up to date"
    case "stale":
      return "Updating. Messages may be out of date."
    case "error":
      return `Stopped: ${state.error.message}`
    case "closed":
      return "Closed"
  }
}

function renderMessage(message: Message): HTMLLIElement {
  const item = document.createElement("li")

  item.textContent = `${message.created_at.toLocaleTimeString()} ${message.body}`

  return item
}

subscribe() returns a materialized subscription, which keeps the current rows for you. onChange gets a snapshot of the rows and the subscription's state whenever either changes, so the render reads data, status and error from one consistent value. data is undefined until the first result arrives.

Rows arrive decoded, so created_at is a Date, as Drizzle's Message type says. The client's default parsers decode these Postgres types:

Postgres typeArrives as
boolboolean
int2, int4, oid, float4, float8number
json, jsonbThe parsed JSON value
timestamptzDate
timestampDate in local time
dateDate at local midnight
byteaUint8Array
int8, numericstring, so their values stay exact
Everything else, such as text and uuidstring, as Postgres formats it

NULL arrives as null, and arrays of built-in types decode element by element.

The client above spreads drizzleParsers, so rows match Drizzle's types: date stays a string and timestamp is read as UTC.

When you're done with a subscription, call unsubscribe(). It moves the subscription to closed, so any listeners get a final closed update, and then stops all further callbacks. client.close() does the same for every subscription on the client, and closes the WebSocket.

Listen for changes

A subscription has three callbacks. Each one takes a listener and returns a function that removes it:

CallbackListener argumentsFires
onChangesnapshot: LiveQuerySnapshot<Row>After each state change, after each changes update while live, and when a new result is complete. Materialized subscriptions only.
onStateChangestate: LiveQueryStateWhen the status changes.
onUpdateupdate: RawLiveQueryUpdate<Row>With type: "rows" and rows, each { rowId, row }, when a complete, authoritative result replaces the previous one. With type: "changes", changes and batch: LiveQueryBatchInfo when committed transactions change the result. Each change is an upsert with a rowId and row, or a remove with only a rowId.

Listeners run in the order you add them, and one that throws doesn't stop the others. Each initial result and each committed transaction becomes visible atomically. Outside a listener, getSnapshot() and getState() return the current values.

The status tells you how far to trust the rows:

StatusMeaning
connectingWaiting for a result: when it starts, including with initialData from a server render, after a reconnect, and after renewing an expired sealed query. Rows you already have stay.
liveThe rows are authoritative and changes are streaming. It stays live while the server replaces the whole result.
staleThe sealed query expired and the subscription is waiting for a renewed one. The rows are usable but may be out of date. Render them normally.
errorAutomatic recovery has stopped. See handle errors.
closedClosed by unsubscribe() or client.close(). It doesn't reopen.

By default, subscriptions are materialized. The client keeps the rows and applies each update before onUpdate fires, so getSnapshot() in an update listener already includes it. If your code keeps its own rows, as TanStack DB does, pass { materialize: false } for a raw subscription. It has the same callbacks except onChange, and no getSnapshot(). Keep the rows in a map keyed by rowId, replace them on each rows update and apply each changes update's upserts and removes:

import type { LiveQueryState, RawLiveQueryUpdate } from "@neon/realtime/client"

import type { Message } from "../backend/schema"
import { fetchSealedMessages } from "./api"
import { client } from "./client"

const channelId = 2

// Keyed by rowId, which is only valid until the next `rows` update
const rows = new Map<string, Message>()

const subscription = client.subscribe(await fetchSealedMessages(channelId), { materialize: false })

subscription.onUpdate((update: RawLiveQueryUpdate<Message>) => {
  if (update.type === "rows") {
    rows.clear()
    for (const { rowId, row } of update.rows) {
      rows.set(rowId, row)
    }
    return
  }

  for (const change of update.changes) {
    if (change.type === "upsert") {
      rows.set(change.rowId, change.row)
    } else {
      rows.delete(change.rowId)
    }
  }

  console.log(`${rows.size} messages after transactions ${update.batch.txids.join(", ")}`)
})

subscription.onStateChange((state: LiveQueryState) => {
  console.log(state.status, state.error?.message ?? "")
})

A rowId is opaque and scoped to its subscription, and each rows update invalidates every earlier one. It isn't a primary key, so don't store it or match it against your own ids. A rows update can arrive at any time, for example after a reconnect, and it always replaces the whole result.

Each changes update also carries the ids of the Postgres transactions it covers, in update.batch.txids. The mutations guide uses them to confirm optimistic mutations.

Renew the sealed query

Sealed queries are short-lived, so a subscription that outlives its sealed query needs a fresh one. Fetch a replacement before expiresAt and pass it to renew(). This scheduler renews 10 seconds before expiry, retries a failed fetch after 1 second, reschedules from each new expiresAt and stops if renew() fails:

import type { RawLiveQuerySubscription, SealedLiveQuery } from "@neon/realtime/client"

// The same timing the React and TanStack DB bindings use
const RENEW_EARLY_MS = 10_000
const RETRY_MS = 1_000

export function renewBeforeExpiry<Row>(
  subscription: RawLiveQuerySubscription<Row>,
  sealedQuery: SealedLiveQuery<Row>,
  fetchSealedQuery: () => Promise<SealedLiveQuery<Row>>
): () => void {
  let timer: ReturnType<typeof setTimeout> | undefined
  let stopped = false

  function schedule(delay: number) {
    clearTimeout(timer)

    if (!stopped) {
      timer = setTimeout(renew, delay)
    }
  }

  function scheduleBeforeExpiry(expiresAt: number) {
    schedule(Math.max(0, expiresAt - Date.now() - RENEW_EARLY_MS))
  }

  async function renew() {
    let next: SealedLiveQuery<Row>

    try {
      next = await fetchSealedQuery()
    } catch {
      schedule(RETRY_MS)
      return
    }

    scheduleBeforeExpiry(next.expiresAt)

    // A rejected renewal means the subscription is in `error` or the query changed
    subscription.renew(next).catch(stop)
  }

  function stop() {
    stopped = true
    clearTimeout(timer)
  }

  scheduleBeforeExpiry(sealedQuery.expiresAt)

  return stop
}

Start it after subscribing, with a function that fetches the same query again, and stop it when you unsubscribe:

import type { LiveQuerySnapshot, LiveQueryState } from "@neon/realtime/client"

import type { Message } from "../backend/schema"
import { fetchSealedMessages } from "./api"
import { client } from "./client"
import { renewBeforeExpiry } from "./renewal"

const list = document.querySelector<HTMLUListElement>("#messages")!
const statusLine = document.querySelector<HTMLParagraphElement>("#status")!

// A real app takes the channel from its router
const channelId = 2

const sealedQuery = await fetchSealedMessages(channelId)
const subscription = client.subscribe(sealedQuery)
const stopRenewing = renewBeforeExpiry(subscription, sealedQuery, () => fetchSealedMessages(channelId))

subscription.onChange(render)

window.addEventListener("beforeunload", () => {
  stopRenewing()
  subscription.unsubscribe()
  client.close()
})

function render(snapshot: LiveQuerySnapshot<Message>) {
  statusLine.textContent = describe(snapshot)
  list.replaceChildren(...(snapshot.data ?? []).map(renderMessage))
}

function describe(state: LiveQueryState): string {
  switch (state.status) {
    case "connecting":
      return "Loading messages"
    case "live":
      return "Up to date"
    case "stale":
      return "Updating. Messages may be out of date."
    case "error":
      return `Stopped: ${state.error.message}`
    case "closed":
      return "Closed"
  }
}

function renderMessage(message: Message): HTMLLIElement {
  const item = document.createElement("li")

  item.textContent = `${message.created_at.toLocaleTimeString()} ${message.body}`

  return item
}

renew() replaces the sealed query for the same exact query, and it keeps the subscription object, its listeners and its rows:

  • Before expiry, the subscription stays live, with no new rows update.

  • After expiry, for example when a laptop wakes from sleep, the subscription is stale. renew() moves it to connecting while the client subscribes again behind the same object, and to live when the new result arrives.

If the replacement's queryFingerprint doesn't match, the promise that renew() returns rejects. A changed parameter, like a different channel, makes a different query, so subscribe to the new one and unsubscribe from the old.

You don't write this yourself when you use the bindings. The React and TanStack DB bindings take a refreshQuery function and renew with the same timing, 10 seconds before expiry with a 1-second retry. They're built on QueryRefreshController, which you can use if you're writing a binding for another framework.

Handle errors

When automatic recovery stops, the subscription moves to error, and its error is a LiveQueryError with:

  • code: a stable, machine-readable error code

  • message: a description of the failure

  • retryable: whether reconnecting or a fresh sealed query may recover it

  • sqlState: the Postgres SQLSTATE code, when debug mode is on and the database supplied one

  • cause: the underlying error, when there is one

A subscription never leaves error by itself, and renew() can't bring it back. Unsubscribe, show the error, and subscribe again with a fresh sealed query, for example when the user retries.

Errors stay with their own subscription. A row that fails to decode, for example, puts only its own subscription into error, and the others on the client carry on.

If the connection itself fails, for example because of a fatal protocol error, every subscription on the client moves to error at once and the client can't reconnect. Create a new client with createRealtimeClient() before you subscribe again.

Debug subscriptions

Log client diagnostics

The client is silent by default. To see what it's doing, pass a logLevel to createRealtimeClient(): "error", "warn", "info" or "debug". Each level includes the ones before it.

import { createRealtimeClient } from "@neon/realtime/client"
import { drizzleParsers } from "@neon/realtime-drizzle/client"

export const client = createRealtimeClient({
  url: import.meta.env.VITE_NEON_REALTIME_URL,
  parsers: { ...drizzleParsers },
  logLevel: import.meta.env.DEV ? "debug" : "warn"
})

Entries go to the console unless you pass a logger, which receives each entry instead. An entry has a level, a message, a timestamp and a stable event name, like connection_lost or subscription_failed. Match on event, not on message:

logger: (entry) => {
  if (entry.event === "subscription_failed") reportError(entry.error)
}

Entries never include sealed queries, SQL, parameters or rows. An entry's error can hold an error from the server or from your own code, so handle it like any other error you log.

The React and TanStack DB bindings log through the client you give them. A direct server takes the same logLevel and logger options in createRealtime({ url }).

Show full database errors

By default, subscription errors carry safe, generic messages. To see the database's own error while you develop, set debugMode on the server that seals your queries:

import { createRealtime } from "@neon/realtime/server"
import { drizzleAdapter } from "@neon/realtime-drizzle"

export const realtime = createRealtime({
  secret: process.env.NEON_REALTIME_SECRET!,
  db: "neondb",
  adapter: drizzleAdapter(),
  debugMode: process.env.NODE_ENV === "development"
})

Every query it seals then reports errors with the database's full message, plus its SQLSTATE code in sqlState when there is one. It's a backend option because it travels inside each sealed query.

Warning: Development only

Full error details can expose schema, table and column names to anyone who subscribes, including through any error message your UI shows. Never turn on debugMode in production.

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs network access

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
Neon
Publisher
Neon (Official)
Source
neondatabase/agent-skills · skills/neon-realtime · commit 2b0e2b6
Licence
Apache License 2.0 (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced
Also distributed in
neondatabase/agent-skills

AutoGineer is not affiliated with or endorsed by Neon or Neon.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download neon-realtime.zip