---
name: arkiv-realtime
description: Build real-time UX on Arkiv, the queryable database on Ethereum — live feeds, chat, dashboards, presence/online status, notifications, dynamic NFT watchers — with @arkiv-network/sdk. Activate when a user mentions Arkiv real-time, live updates, subscriptions, subscribeEntityEvents, watchEntities, WebSocket push, polling, SSE, EventSource, presence, heartbeat, live channel, or event streams. Core truth this skill encodes - Arkiv eventing is POLLING (there is no push) and this skill makes polling feel live; the subscribeEntityEvents recipe with mandatory unsubscribe cleanup, cursor polls on $createdAtBlock for resumable feeds, optimistic UI, a one-poller SSE relay for audiences, presence modeled as short-expiration entities (expiry IS the offline signal), and polling-cadence guidance vs the ~2 s block time. Verified against SDK 0.6.8 on the Braga testnet; patterns evidenced from 124 real hackathon submissions including multiple Best Real-Time winners.
license: MIT
metadata:
  arkiv.skill-version: "1.1.0"
  arkiv.sdk-package: "@arkiv-network/sdk"
  arkiv.sdk-range: ">=0.6.5 <0.7.0"
  arkiv.sdk-verified: "0.6.8"
  arkiv.network-fingerprint: "braga:60138453102"
  arkiv.surface: "createPublicClient, createWalletClient, http, privateKeyToAccount, braga, subscribeEntityEvents, getEntity, query, buildQuery, eq, gt, withAttributes, withPayload, limit, fetch, createEntity, extendEntity, mutateEntities, ExpirationTime, jsonToPayload, getBlockTiming"
  arkiv.last-verified-at: "2026-06-11"
  arkiv.evidence: "6 patterns from 124 hackathon submissions across 5 events"
---

# arkiv-realtime — real-time UX on Arkiv is polling done well

Use this skill when an app on Arkiv (the queryable database on Ethereum) needs to FEEL live: chat, activity feeds, live dashboards, online/offline presence, notifications, market tickers, dynamic-NFT watchers. The truth up front: **Arkiv has no push channel — all eventing is polling** under the hood. That is not a limitation to apologize for; teams have won "Best Real-Time" prizes on polling (dMegle streamed live VIDEO through it). This skill decides the polling architecture for you, designs entities so each poll is cheap, and layers optimistic UI on top so a ~2 s block time feels instant.

## Ground rules (read before any code)

- **Network facts are MUTABLE. Networks rotate. Verify at https://docs.arkiv.network before relying on these. Testnet entities do NOT migrate between networks.** Current as of 2026-06-11: testnet **Braga**, chainId `60138453102`, gas token GLM, RPC `https://braga.hoodi.arkiv.network/rpc`, WS `wss://braga.hoodi.arkiv.network/rpc/ws`, faucet `https://braga.hoodi.arkiv.network/faucet/`, explorer `https://explorer.braga.hoodi.arkiv.network`.
- SDK: `@arkiv-network/sdk` pinned `>=0.6.5 <0.7.0` (this skill verified against **0.6.8** source). The SDK re-exports all of viem — do NOT install viem separately.
- **Pin Node 22 LTS.** Node 24 hangs entity-update promises forever (the tx lands on-chain, the promise never resolves — sdk issue #14, open).
- **Two clients, always.** Subscriptions and queries exist ONLY on `createPublicClient`'s client; the wallet client has no Arkiv read actions. Reads and subscriptions need no key — safe in the browser.

```ts
import { createPublicClient, http } from "@arkiv-network/sdk"
import { braga } from "@arkiv-network/sdk/chains"

const publicClient = createPublicClient({ chain: braga, transport: http() })
```

## The decision (pick one, don't deliberate)

1. **Default — one screen, a handful of viewers:** `subscribeEntityEvents` on a browser `PublicClient` at **2000 ms** (pattern 1).
2. **Need "everything since X" / resume after disconnect or tab sleep:** cursor poll on `$createdAtBlock` (pattern 2). Subscriptions restart from "now" and silently drop the gap.
3. **An audience (tens+ of viewers) or a serverless deploy:** SSE relay — ONE backend subscription fans out to N browsers (pattern 3). Never point 100 browser subscriptions at the public RPC.
4. **Online/offline status:** do not subscribe at all. Model presence as short-expiration entities; the expiration date (a.k.a. TTL) IS the offline signal (pattern 4).

---

### poll-backed-realtime-ui (subscribeEntityEvents, done right)

When to use: the default for any live view. `subscribeEntityEvents(handlers, pollingInterval = 1000, fromBlock?)` returns a `Promise<() => void>` — an **unsubscribe function you MUST call on unmount**. Three facts the API hides: events carry **keys, not content** (hydrate with `getEntity`); there is **no server-side filter** (filter client-side); the handler set is `{ onError, onEntityCreated, onEntityUpdated, onEntityDeleted, onEntityExpired, onEntityExpiresInExtended }`.

```tsx
import { useEffect } from "react"
import type { Entity } from "@arkiv-network/sdk"

const attr = (e: Entity, k: string) => e.attributes.find((a) => a.key === k)?.value

function useRoomMessages(roomId: string, onMessage: (e: Entity) => void) {
  useEffect(() => {
    let unsub: (() => void) | undefined
    let cancelled = false

    publicClient.subscribeEntityEvents(
      {
        onError: (err) => console.error("arkiv subscription error", err),
        onEntityCreated: async ({ entityKey }) => {
          try {
            const e = await publicClient.getEntity(entityKey) // throws NoEntityFoundError
            // No server-side filter exists — this hydrate-then-filter step is MANDATORY:
            if (attr(e, "type") !== "chat_message" || attr(e, "room") !== roomId) return
            onMessage(e)
          } catch {
            /* entity expired/deleted between event and fetch — skip */
          }
        },
      },
      2000, // ms — Braga blocks land every ~2 s; polling faster than that buys nothing
    ).then((u) => (cancelled ? u() : (unsub = u)))

    return () => {
      cancelled = true
      unsub?.() // ALWAYS. A 1st-place winner shipped `return () => {}` here and leaked the poller.
    }
  }, [roomId])
}
```

The `cancelled` flag matters: the subscription resolves async, so an unmount during the await must still tear it down. Seen in 7 of 124 submissions — NEXUS, Workiv, Arklib, GhostMesh, SoulScape, dMegle (winner, Best Real-Time — live video chunks + signaling over exactly this loop).

### live-channel-cursor-poll (resumable feeds + optimistic UI)

When to use: chat/feeds that must survive reconnects, tab sleeps, and SSE gaps. Write a `room` (string) and `sentAt` (NUMBER, epoch ms) attribute on every message at create time, then poll with a block cursor — `$createdAtBlock` is a queryable synthetic attribute, and the raw `query()` response hands you the served `blockNumber` to use as the next cursor.

```ts
let lastSeenBlock = 0n // 0n on first poll = full backfill; persist it to resume later

async function pollRoom(roomId: string) {
  const safe = roomId.replace(/["\\]/g, "") // user strings in query strings: strip quotes (injection)
  const res = await publicClient.query(
    `type = "chat_message" && room = "${safe}" && $createdAtBlock > ${lastSeenBlock}`,
    { includeData: { attributes: true, payload: true } },
  )
  lastSeenBlock = res.blockNumber // bigint — never JSON.stringify it raw
  // NEVER .orderBy() — the server silently ignores it (removed in 0.7.0). Sort in JS:
  return res.entities.sort((a, b) => Number(attr(a, "sentAt")) - Number(attr(b, "sentAt")))
}
setInterval(() => pollRoom(roomId).then(appendNew), 2000)
```

Make it feel instant with the **deterministic-id optimistic append**: stamp `sentAt = Date.now()` client-side, render the bubble immediately under id `` `${roomId}-${sentAt}` ``, send that SAME `sentAt` to the writer so the on-chain entity reproduces the identical id — the next poll dedupes against the optimistic bubble instead of doubling it. Roll the bubble back if the write fails. Seen in 2 of 124 submissions — on-message (winner, 1st place ETH Argentina 2025 — this exact trick), dMegle (winner, Best Real-Time — `lastSeenBlock` loops). If you prefer subscriptions, `subscribeEntityEvents(handlers, 2000, savedBlock)` accepts a `fromBlock` to backfill on resubscribe.

### sse-relay (one poller, many viewers)

When to use: audiences. Each browser subscription is its own RPC polling loop; an SSE relay runs ONE subscription server-side and re-broadcasts to native `EventSource` clients — no WS client lib needed.

```ts
// app/api/feed/route.ts (Next.js) — runtime: nodejs, dynamic: force-dynamic
export async function GET(req: Request) {
  const enc = new TextEncoder()
  const stream = new ReadableStream({
    async start(controller) {
      const send = (event: string, data: unknown) =>
        controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(
          data, (_, v) => (typeof v === "bigint" ? v.toString() : v), // bigints (cost, blocks) crash JSON.stringify
        )}\n\n`))
      const unsub = await publicClient.subscribeEntityEvents(
        {
          onEntityCreated: async ({ entityKey }) => {
            try {
              const e = await publicClient.getEntity(entityKey)
              send("created", { key: e.key, attributes: e.attributes, text: e.toText() })
            } catch { /* expired before hydrate — skip */ }
          },
        },
        2000,
      )
      const ping = setInterval(() => controller.enqueue(enc.encode(": ping\n\n")), 15_000)
      req.signal.addEventListener("abort", () => { unsub(); clearInterval(ping); controller.close() })
    },
  })
  return new Response(stream, {
    headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
  })
}
```

Two production traps, both shipped by a winner: (1) **serverless kills long streams** — one team's `vercel.json` capped the route at 30 s while the route declared 60 (the lower bound wins); `EventSource` auto-reconnects, but (2) **events during the reconnect gap are LOST** (no Last-Event-ID replay) — have the client run pattern 2's cursor poll once on every `open` event to backfill. Seen in 2 of 124 submissions — Workiv, tango.fun (winner, Best real-time usage of Arkiv stack, ETH Argentina 2025 — Arkiv subscription → named SSE events → `EventSource` hook with upsert-by-entityKey).

### ttl-presence-heartbeat (expiry IS the offline signal)

When to use: online/offline lists, lobbies, "active sessions". Do not write `status: offline` — write a presence entity with a 60 s expiration date and heartbeat it. Going offline is doing NOTHING: the entity expires and the user drops out of the query. No cleanup job, no stale rows. `extendEntity` **ADDS** seconds, so extending by the heartbeat interval keeps remaining lifetime oscillating between 30 and 60 s.

```ts
import { createWalletClient, http } from "@arkiv-network/sdk"
import { privateKeyToAccount } from "@arkiv-network/sdk/accounts"
import { braga } from "@arkiv-network/sdk/chains"
import { eq } from "@arkiv-network/sdk/query"
import { jsonToPayload, ExpirationTime } from "@arkiv-network/sdk/utils"

const walletClient = createWalletClient({
  chain: braga,
  transport: http(),
  account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`), // throwaway testnet key
})

const PROJECT = "myapp-v1" // unique versioned slug — the testnet is one SHARED store
const sanitize = (s: string) => s.replace(/["\\\n\r]/g, "") // quote-injection defense for query values
const WINDOW_SECS = 60
const BEAT_MS = 30_000

async function joinPresence(roomId: string, user: string) {
  const { entityKey } = await walletClient.createEntity({
    payload: jsonToPayload({ user, joinedAt: Date.now() }),
    contentType: "application/json",
    attributes: [
      { key: "project", value: PROJECT }, // namespace on EVERY write
      { key: "type", value: "presence" },
      { key: "room", value: roomId },
      { key: "user", value: user },
    ],
    expiresIn: ExpirationTime.fromSeconds(WINDOW_SECS), // expiresIn is SECONDS
  })
  const timer = setInterval(async () => {
    try {
      await walletClient.extendEntity({ entityKey, expiresIn: ExpirationTime.fromSeconds(BEAT_MS / 1000) })
    } catch {
      clearInterval(timer) // expired while the tab slept — call joinPresence again to come back online
    }
  }, BEAT_MS)
  return () => clearInterval(timer) // leaving = just stop heartbeating; expiration does the rest
}

// Who's online = whoever's presence entity still exists. Expired entities never match.
const online = await publicClient
  .buildQuery()
  .where([eq("project", PROJECT), eq("type", "presence"), eq("room", sanitize(roomId))])
  .withAttributes(true)
  .limit(100)
  .fetch()
```

Variant of the 7-of-124 realtime cluster — Arklib (`kb.presence` heartbeat entity; "expiry IS the offline signal"). If many users heartbeat through ONE shared server wallet, batch the per-tick extensions into ONE tx — `walletClient.mutateEntities({ extensions: keys.map((entityKey) => ({ entityKey, expiresIn: ExpirationTime.fromSeconds(WINDOW_SECS) })) })` — capped at 1000 ops/tx (chunk if more than 1000 are online); fall back to a write queue only for heartbeats that don't line up on a tick. See `arkiv-write-safety` (nonce collisions).

### dynamic-nft-live-feed (debounce before the expensive chain)

When to use: noisy data (sensors, market mood, wallet activity) should update NFT traits on another chain. Arkiv is the cheap, mutable, queryable buffer; the NFT contract is the expensive sink. Watch Arkiv, gate contract calls behind a debounce + change threshold.

```ts
let lastPushed = 0
let lastOnChain = 0
const unsub = await publicClient.subscribeEntityEvents(
  {
    onEntityUpdated: async ({ entityKey }) => {
      try {
        const e = await publicClient.getEntity(entityKey)
        const mood = Number(attr(e, "mood")) // numeric attribute, written by the sensor loop
        if (Date.now() - lastPushed < 60_000 || Math.abs(mood - lastOnChain) < 10) return // debounce + threshold
        lastPushed = Date.now()
        lastOnChain = mood
        await pushTraitUpdate(mood) // your NFT contract call on its own chain
      } catch { /* skip */ }
    },
  },
  5000, // a watcher doesn't need UI cadence
)
```

Seen in 3 of 124 submissions — Chiri, EchoMint, SoulScape.

### high-frequency-feeds (time-bucketed numeric attributes)

When to use: tickers, prediction markets, anything sub-second-feeling. Polling cannot beat the ~2 s block time, but you can make every poll fetch exactly one delta: stamp each tick with a NUMBER bucket attribute (`Math.floor(Date.now() / 1000)`), range-query past your cursor, sort in JS, and render with optimistic interpolation between polls.

```ts
import { eq, gt } from "@arkiv-network/sdk/query"

let lastBucket = 0
const page = await publicClient
  .buildQuery()
  .where([eq("market", marketId), gt("bucket", lastBucket)])
  .withAttributes(true)
  .withPayload(true)
  .limit(200) // pagination REQUIRES .limit(); max page size is 200
  .fetch()
const ticks = page.entities.slice().sort((a, b) => Number(attr(a, "bucket")) - Number(attr(b, "bucket")))
if (ticks.length) lastBucket = Number(attr(ticks[ticks.length - 1], "bucket"))
```

Build a fresh `buildQuery()` per poll — `fetch()` mutates the builder (a reused builder returns page 2). Short expiration dates on ticks keep the dataset self-pruning so the range scan stays cheap. Seen in 2 of 124 submissions — Arc - Range Markets (winner, Best DeFi — "sub-second updates and intervals, all powered by Arkiv"), Arkgram (winner, honorary).

---

## Polling cadence & RPC cost (the numbers)

- **Block time on Braga ≈ 2 s** (the SDK itself hardcodes 2 s/block for `expiresIn` conversion). Data cannot appear faster — **2000 ms is the right UI interval**; the SDK default is 1000 ms, which only doubles RPC load for zero freshness.
- Watchers/dashboards: 3000–5000 ms. Demos where the wow matters: keep 1000–2000 ms.
- **Every subscription is a per-tab RPC polling loop** (a `getLogs` round-trip per tick). 100 viewers at 1 s = ~100 req/s against the public RPC — that is the SSE-relay threshold. (Hub API keys for elevated RPC rate limits are a new, changing surface — re-check docs.)
- **Back off hidden tabs:** on `document.visibilityState === "hidden"`, unsubscribe (resume with `fromBlock`/cursor on return) or raise the interval to 10 s+.
- Show freshness honestly: `const { currentBlock, blockDuration } = await publicClient.getBlockTiming()` → "live as of block N". `currentBlock` is a **bigint** — `String()` it before rendering/serializing. For converting `expiresAtBlock` → wall-clock countdowns, see `arkiv-expiration-design`.

## Anti-patterns / gotchas (hit these BEFORE your agent does)

1. **"Where's the WebSocket push?"** There is none. Eventing is viem `watchEvent` polling over `http()`. Braga does declare a WS endpoint (`wss://braga.hoodi.arkiv.network/rpc/ws`) for viem's subscription transport, but the supported, evidenced recipe is polling over `http()`. Teams keep describing "real-time subscriptions" and discovering polling mid-build (4 of 124 hit this mismatch) — dMegle WON Best Real-Time on polling. Treat real-time as a default, not a stretch goal.
2. **`watchEntities` does not exist.** It is a legacy `golem-base-sdk` name (along with `createClient`, `GolemBaseClient`, annotations, BTL). The method is `subscribeEntityEvents`, on the PUBLIC client only. Never install or mix the legacy package.
3. **`onEntityOwnerChanged` is unreachable** — it exists in the implementation but the client decorator neither types nor forwards it (sdk issue #67 #4). If you need ownership changes live, cursor-poll with an `$owner` filter instead.
4. **Leaked subscriptions.** The returned unsubscribe fn must run on unmount/abort. Real shipped bug: on-message (1st place winner) captured the handle but its React cleanup returned `() => {}` — the poller kept running after unmount. Use the `cancelled`-flag shape from pattern 1.
5. **Events carry keys, not content — and the entity may already be gone.** Hydrate with `getEntity`, which **throws `NoEntityFoundError`** (try/catch, never null-check): an entity can expire or be deleted between the event and your fetch.
6. **No server-side event filter.** Every subscriber sees ALL entity events on the chain. The hydrate-then-filter step (type + room/project attributes) is mandatory, which is also why poll queries (which DO filter server-side) win for high-traffic chains.
7. **NEVER `.orderBy()`.** The server has silently ignored it since the query layer moved to roaring bitmaps, and it is removed in 0.7.0 (PR #70). Sort fetched results in JS — every live feed sorts by a numeric `sentAt`/`bucket`/`seq` attribute.
8. **Builder data defaults are EMPTY.** Without `.withPayload(true).withAttributes(true)` you get bare keys and `toText()`/`toJson()` throw. (Asymmetry: raw `client.query()` defaults payload **on**.) And pagination needs `.limit(n)` or `next()` throws `NoCursorOrLimitError`.
9. **bigints crash `JSON.stringify`** — event `cost`, query `blockNumber`, `getBlockTiming().currentBlock` are all bigint. Use a replacer (pattern 3) before SSE/JSON-serializing anything from the SDK.
10. **Sanitize user strings interpolated into query strings.** Quoted values are NOT escaped — a `"` in a room name breaks or alters the query (sdk issue #67 #5). Strip `"` and `\` (pattern 2) or whitelist slugs.

## Compose with

- **arkiv-query-patterns** — cheap poll-query design: numeric range cursors, pagination, sanitization, query recipes.
- **arkiv-expiration-design** — presence windows, countdown UIs, `expiresAtBlock` → wall-clock conversion.
- **arkiv-write-safety** — heartbeats/relays writing through one wallet collide on nonces; batch with `mutateEntities` (≤1000 ops/tx), or a write queue when writes can't be batched.
- **arkiv-app-architecture** — where the SSE relay lives, serverless duration limits, signer placement, deployment checklist.
