Implementing Optimistic UI Updates with useDbSync Polling in Agent-Native

Agent-Native enables optimistic UI updates by combining a shared SSE/polling transport in useDbSync with TanStack Query's cache management, allowing immediate local state mutations while preventing stale poll responses from overwriting fresh data through version cursors and query cancellation.

Agent-Native is an open-source framework for building real-time applications with synchronized state. When implementing optimistic UI updates with useDbSync polling, developers can apply local changes immediately while relying on a robust transport layer to reconcile server state without visual flicker or regression.

How useDbSync Manages Real-Time Synchronization

The useDbSync hook in packages/core/src/client/use-db-sync.ts creates a unified transport layer that merges Server-Sent Events (SSE) with intelligent polling to keep client UI synchronized with the server-side database.

Singleton Transport Architecture

At the module level, useDbSync maintains a transportRegistry that stores one SyncTransport instance per unique <pollUrl>\0<sseUrl> pair (source). This singleton pattern ensures that all hook instances in the same browser tab reuse a single SSE connection and poll loop, regardless of how many components invoke the hook.

Each call to useDbSync registers a subscriber identified by a unique Symbol. The transport aggregates subscriber preferences—including poll interval, tab-hidden behavior, and fallback intervals—to compute an effectiveInterval based on the fastest caller's requirements (source).

Version Cursor and Event Delivery

The transport tracks a global versionRef that advances with every batch of events from the server. This cursor is sent as the ?since= query parameter during poll requests, ensuring only new events are returned (source).

When data arrives via SSE (the fast path to /_agent-native/events) or fallback polling (/_agent-native/poll), the transport normalizes the payload through normalizeEventPayload and fans out events to all subscribers via their onEvents callbacks (source). If SSE is unavailable or the tab is hidden, the transport automatically falls back to polling, capped by the effectiveInterval and protected by a POLL_ABORT_MIN_MS timeout to prevent hanging requests (source).

Per-Subscriber Freshness Guarantees

Each subscriber maintains its own subscriberVersion cursor. When onEvents executes, it discards any events older than the subscriber's current version, preventing stale poll responses from overwriting newer UI state (source).

The Optimistic UI Pattern

Implementing optimistic updates in Agent-Native requires orchestrating local cache mutations with the transport's event stream to prevent race conditions.

Step 1: Cancel In-Flight Polls

Before writing locally, cancel any ongoing queries to stop in-flight polls that could return stale data and revert the optimistic UI. In templates/slides/app/hooks/use-sidebar-collapsed.ts, the pattern calls queryClient.cancelQueries({ queryKey }) immediately before mutation (source).

Step 2: Mutate Local Cache

Update the TanStack Query cache optimistically using queryClient.setQueryData, and persist the value to best-effort storage like localStorage for fast reloads (source).

Step 3: Send Remote Mutation

Fire the REST request with keepalive: true so it survives page unloads. Include the X-Request-Source header set to the tab's TAB_ID (generated in templates/slides/app/lib/tab-id.ts) so the server can tag events with the originating tab (source).

Step 4: Handle Failures Gracefully

If the request fails, invalidate the query to trigger a refetch of the authoritative state. The useDbSync transport will then re-emit the latest events, correcting the UI (source).

Step 5: Filter Echo Events

Pass the ignoreSource option to useDbSync to filter out events tagged with the current tab's TAB_ID. This prevents the hook from processing its own writes twice (source).

Complete Implementation Example

The following implementation from templates/slides/app/hooks/use-sidebar-collapsed.ts demonstrates the complete optimistic UI pattern:

import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { TAB_ID } from "@/lib/tab-id";

const KEY = "sidebarCollapsed";
const URL = `/_agent-native/application-state/${KEY}`;
const QUERY_KEY = ["app-state", KEY] as const;

export function useSidebarCollapsed() {
  const qc = useQueryClient();

  const { data } = useQuery<boolean>({
    queryKey: QUERY_KEY,
    queryFn: async () => {
      const res = await fetch(URL);
      const { collapsed } = await res.json();
      return Boolean(collapsed);
    },
    staleTime: 0,
  });

  const setCollapsed = useCallback(
    async (next: boolean | ((prev: boolean) => boolean)) => {
      // 1. Stop any in-flight poll that could revert our optimistic UI.
      await qc.cancelQueries({ queryKey: QUERY_KEY });

      // 2. Compute the new value and update the cache optimistically.
      const prev = qc.getQueryData<boolean>(QUERY_KEY) ?? false;
      const nextVal = typeof next === "function" ? next(prev) : next;
      qc.setQueryData(QUERY_KEY, nextVal);

      // 3. Fire the remote mutation, tagging the request with the tab ID.
      fetch(URL, {
        method: "PUT",
        keepalive: true,
        headers: {
          "Content-Type": "application/json",
          "X-Request-Source": TAB_ID,
        },
        body: JSON.stringify({ collapsed: nextVal }),
      }).catch(() => {
        // 4. If it fails, let the poll/useDbSync pull the authoritative state.
        qc.invalidateQueries({ queryKey: QUERY_KEY });
      });
    },
    [qc],
  );

  return { collapsed: data ?? false, setCollapsed };
}

This hook uses useQuery for the initial fetch, then applies the optimistic pattern. Elsewhere in the application, useDbSync (configured with ignoreSource: TAB_ID) receives change events and updates the query cache, ensuring cross-tab consistency without visual flicker.

Why This Architecture Works

Single Source of Truth: The server-side database remains the ultimate authority. useDbSync guarantees that every client receives every change event in version order, whether via SSE or poll.

No Stale Overwrites: By cancelling polls and tracking per-subscriber version cursors in packages/core/src/client/use-db-sync.ts, optimistic UI changes survive transient network latency without being overwritten by delayed poll responses.

Low Network Traffic: The transport only polls when SSE is unavailable, using the minimum interval among all subscribers to reduce unnecessary requests. The POLL_ABORT_MIN_MS timeout prevents resource exhaustion from hanging requests, as configured in packages/core/src/client/route-state.ts.

Cross-Tab Consistency: Because the transport is a singleton per browser tab and tags requests with TAB_ID from templates/slides/app/lib/tab-id.ts, multiple components share the same event stream while correctly filtering their own mutations. For advanced synchronization, packages/core/src/client/use-change-version.ts provides helpers that bump per-source change counters when queries embed useChangeVersion(source).

Summary

  • useDbSync creates a singleton SSE/polling transport per tab that fans out database events to all subscribers.
  • Optimistic updates require cancelling in-flight queries before mutating local state to prevent race conditions.
  • Version cursors (versionRef and subscriberVersion) ensure events are applied in order and stale responses are discarded.
  • Request tagging with X-Request-Source and the ignoreSource option prevent duplicate processing of the tab's own mutations.
  • Graceful degradation via keepalive requests and invalidation on error ensures the UI eventually converges to the server state.

Frequently Asked Questions

How does useDbSync prevent stale poll responses from overwriting optimistic updates?

Each subscriber maintains a subscriberVersion cursor that tracks the latest event it has processed. When a poll response arrives, useDbSync compares event versions against this cursor in packages/core/src/client/use-db-sync.ts (lines 107-127), discarding any events older than the subscriber's current state. This ensures that optimistic updates applied locally are never reverted by delayed network responses.

What is the purpose of the X-Request-Source header in optimistic mutations?

The X-Request-Source header contains the tab's unique TAB_ID (defined in templates/slides/app/lib/tab-id.ts), allowing the server to tag change events with their originating tab. When useDbSync is configured with the ignoreSource option, it filters out events matching the current tab's ID, preventing the hook from processing its own writes twice and creating visual flicker.

Why must I cancel queries before performing optimistic updates?

Cancelling in-flight queries with queryClient.cancelQueries stops active poll requests that could return stale data after you've applied the optimistic mutation. As shown in templates/slides/app/hooks/use-sidebar-collapsed.ts (lines 78-82), this prevents race conditions where a delayed poll response would otherwise overwrite your fresh optimistic state with old server data.

How does the transport handle multiple components using useDbSync simultaneously?

useDbSync uses a module-level transportRegistry to create only one SyncTransport per unique URL pair, shared across all hook instances in the tab (source). The transport computes an effectiveInterval based on the fastest subscriber's requirements, ensuring efficient polling while delivering events to all registered callbacks via the onEvents method.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →