Crypto OS
Technical Crypto OSStage 3 · Full-stack Onchain Application

T18 · Realtime System

How do you push onchain changes to users in real time?

Capabilities practiced
Builder
Hands on
Implement a subscription API, reconnect after a 30-second outage, and verify that every missed event is recovered.
AI Lab
Have AI design a reconnect protocol. Test its packet-loss behavior on a weak network yourself.

A Real-World Problem

Your indexer is now reliable (the result of T17). Next you need to push changes to users. You add a persistent connection and send events whenever the indexer finishes a block. Locally, delivery takes seconds. It feels great.

Two weeks after launch, support tickets start describing the same hard-to-reproduce problem:

  • “Several entries are missing from my activity list, but they reappear when I refresh.”
  • “One transaction still says pending even though it succeeded long ago.”
  • “The balance in the interface does not match what the block explorer shows.”

You check the server logs. They are clean: every message was sent. The send function succeeded, with no exceptions or queue alerts.

Then you notice what these users have in common: they were on mobile networks and briefly disconnected.

The problem becomes clear. Your push system is “send and forget”: whoever is connected at that moment receives the message; whoever is not connected loses it forever. The message was lost in your design, not in the network, because you never planned for a user being absent.

The real question is: when a user returns after thirty seconds offline, where do you get the missed events to fill the gap?

Thought Experiment

Consider three ways to send messages.

First: a radio broadcast. You speak in the studio; everyone whose radio is on hears you. If you are away, that segment is gone, and nobody knows you missed it.

Here, the server remembers nothing about its listeners, so the cost is very low. The price is that delivery depends entirely on luck.

Second: a numbered notice board. Each message goes on a wall with a rising number. When you return, you tell the caretaker, “I saw number 812,” and they give you everything after 812.

A disconnection changes from an irrecoverable accident into a gap you can catch up on. Someone must maintain the wall, however, and its capacity is finite: old messages will eventually be erased.

Third: announce only that something changed and let people check for themselves. You send a signal, not the content. Anyone who hears it fetches the latest state.

This has an underappreciated advantage: losing a signal barely matters, because the next one refreshes the entire state. It is naturally idempotent and resilient to loss. It cannot answer “what happened in between”; it can only show “what is true now.”

All three point to the same criterion:

A real-time push system must either support replay or converge on the current state. Without either, it is gambling on the network.

The first method can neither replay nor converge, so it will inevitably lose data. The three support tickets are three symptoms of that one flaw.

Your Decision

Choose an approach for “update the interface whenever something changes onchain.”

What Happens

Rank the four approaches on different criteria and a distinction many people miss becomes visible:

LatencyLoss during disconnectionServer state costCan it show what happened in between?
Periodic pollingHighNo state loss, but intermediate steps are lostNoneNo
Send and forgetLowestPermanent lossNoneYes, but unreliably
Numbered events with catch-upLowestNoHigh (retained window)Yes
Change signal onlyLowNo state loss, but intermediate steps are lostVery lowNo

The first and second columns are independent. This is the main point to take away:

“Real time” measures latency. “No loss” measures correctness. Entirely different mechanisms provide those guarantees.

Most “real-time push” systems deliver only the first. They look good at launch because developers and testers use stable networks. Users encounter the failures as occasional missing items, the hardest sort of complaint to investigate.

There is another conclusion that connects this chapter to the previous one:

Events near the chain head can be revoked. Events already pushed to a user do not retract themselves.

A push stream therefore needs a “revoke” message, or it should send only events deep enough in the chain. That is the delivery condition for the outbox in T17. “Do not lose it” and “do not report a false event” are separate requirements. You need both.

Build a Model

Three ingredients

A push system that does not lose events needs just three things:

  1. Monotonic cursor
  2. Replayable window
  3. Catch-up protocol on reconnection
Miss any one and disconnections lose events. With all three, event loss is ruled out by design.

Where should the cursor come from? Three candidates, two wrong

Timestamp: wrong. It is not strictly monotonic (several events can share a millisecond), clocks can be adjusted, and different instances drift. A timestamp cursor causes duplicates or omissions during catch-up.

Database auto-increment sequence: usually wrong too. The reason is subtle: numbers are allocated at insertion, but transactions do not commit in that order. Transaction A receives 102 and transaction B receives 103; B commits first. A reader sees 103 and moves its cursor to 103. A then commits, and 102 is never read by anyone.

The gap causes no error; some events simply vanish. To use such a sequence, you need either to read only sufficiently old records or to allocate serially through a single writer.

Onchain coordinates: right. The tuple (chain_id, block_number, log_index) is a strictly increasing, globally unique cursor consistent across instances and still verifiable after a restart. Clients can also compare it directly against the chain.

Encode the three parts as a comparable string or a large integer:

cursor = chainId : blockNumber : logIndex
    e.g.  1 : 00000000020134 : 0007

Message protocol: five messages, all necessary

Define the protocol before choosing a library:

client → server    subscribe  { topics, cursor }   Subscribe from the position I have seen
server → client    snapshot   { cursor, state }    Optional current-state snapshot
server → client    event      { cursor, type, payload }
server → client    revoke     { fromCursor }       Events after this position are invalid
server → client    reset      { reason }           Your cursor is too old; subscribe again
both directions   ping / pong                      Heartbeat

The last three are the ones beginners most often omit:

reset is essential. The replay window is finite. A client returning after an hour may have a cursor that has already been purged. Without reset, the server silently replays nothing and the client thinks it is up to date. That is worse than an obvious disconnection because everything appears normal.

revoke is the exit for reorgs. If you push unconfirmed events, you need a way to tell clients that earlier events were invalidated. If you do not want to implement it, push only deep enough events and let confirmation depth handle the risk.

Heartbeats must go both ways. A heartbeat only from the client lets the server detect a dead peer; one only from the server lets the client do so. Both sides need timeouts, or a half-open connection can leave one side believing the other is still present.

The seam between snapshots and deltas: the easiest place to make a mistake

“Fetch the current state, then subscribe to later changes” sounds obvious, but it is wrong:

Wrong order:
  t0  Query snapshot (state through cursor=100)
  t1  ← Event cursor=101 happens here, while nobody is listening
  t2  Establish subscription; receive 102 and later

  Result: 101 is lost forever, with no visible warning

The right order is subscribe, buffer, fetch the snapshot, then release the buffer:

1. Connect and subscribe; the server starts enqueuing events for this connection
2. Do not deliver them yet; buffer them on the client or server
3. Fetch a snapshot and its cursor
4. Release buffered events whose cursor is greater than the snapshot cursor, in order; discard older ones
5. Switch to normal delivery

Reversing this order is the most common cause of “a few items disappeared after reconnecting.” It is almost impossible to reproduce locally: an event must arrive at exactly that instant.

Backpressure: degrade, never wait

A client reads more slowly than you send (weak network, background tab, or poor client code). There are three responses:

ApproachConsequence
Block until it readsOne slow client can stall the entire service
Buffer without a boundMemory grows until the process is killed, often at peak traffic
Bound the queue and degrade when it fillsSend that client reset so it fetches a snapshot; others are unaffected

The third is the only sound answer. Remember it this way:

The way out of backpressure is degradation, not waiting.

Give each connection a bounded queue, perhaps a thousand events. When full, discard all queued events for that connection and send a reset, sending it through the subscribe, snapshot, catch-up sequence again.

Do not merely discard a few oldest events. That creates a gap the client cannot detect. Discard the whole queue and explicitly say, “Start over.”

Reconnecting: jitter the backoff

A client that reconnects immediately after each failure can overwhelm a recovering server. Use exponential backoff:

Wait for retry n = min(base interval × 2^n, cap) × random factor
Random factor is between 0.5 and 1.5

The random factor is not optional. Without it, a server restart brings every client back in the same second. Another restart does the same, creating a periodic attack on your own service.

Delivery semantics: do not chase exactly once on the server

Exactly-once delivery is not achievable at the network layer. A workable combination is:

Server: at-least-once delivery (catch-up may resend events)
Client: deduplicate by cursor (discard anything at or below the last seen cursor)
Together: effectively exactly once

This is the idempotency of T15, the primary-key idempotency of T16, and the checkpoint idempotency of T17, appearing for the fourth time as the same pattern: do not require an operation to happen only once; make repeated execution have the same result.

Fanout across instances

Give every connection a bounded outgoing queue and maintain an index from topic to connections. Look up subscribers when an event arrives. That is straightforward on one instance.

With multiple instances, it is tempting to put events in a message broker and have each instance subscribe. That can work, but preserve the T16 rule: one source of truth. The broker transports events; it is not the source of facts. Catch-up must query the database by cursor, not ask the broker for history.

The reason is simple: a broker's retention policy, partition order, and consumer positions form another set of state that can fail. Let the broker provide speed and the database provide correctness.

What It Is Called

WebSocketFull-duplex persistent connection

A bidirectional persistent connection between browser and server; either side can initiate messages.

It solves only the channel problem. It does nothing to prevent lost events during disconnection; your protocol must solve that. Equating a persistent connection with reliable push is the first misunderstanding this chapter corrects.

SubscriptionSubscription

A client declares which topics it cares about, and the server filters events accordingly.

The subscription request must be able to carry a cursor. A request containing only topics starts every reconnection from “now,” silently discarding everything during the outage.

CursorCursor

A monotonically increasing position marking how far the client has received.

Timestamps and database auto-increment sequences are unreliable. The former are not strictly monotonic; the latter leave gaps when transactions commit out of order. Onchain coordinates are the natural choice here: strictly increasing, globally unique, valid after restart, and independently verifiable by the client.

ReplayReplay

The client returns with its cursor, and the server sends all later events in order.

This requires the server to retain a replayable window. Because the window is finite, the protocol must say when a cursor is too old. Otherwise the client remains in a state that looks healthy but is permanently missing data.

BackpressureBackpressure

When a consumer is slower than a producer, pressure travels back along the pipeline.

There is one sound response: degrade instead of waiting. Give each connection a bounded queue; when it fills, clear it and require a fresh snapshot. Blocking lets one slow client stall everyone, while unbounded buffering gets the process killed during a traffic peak.

HeartbeatHeartbeat

Periodic liveness messages exchanged by both peers to discover when the other has gone away.

They must work both ways. If only one side sends heartbeats, the other may keep a half-open connection indefinitely: one side keeps writing, believing it is connected, although the peer vanished. These connections quietly accumulate until resources run out.

Hands-on

Hands onBuild a subscription endpoint; reconnect after 30 seconds and verify every event is recoveredAny language's WebSocket server + PostgreSQL + the T17 indexerFree. Only local services and read-only testnet endpoints; no transactions or private keys

Everything stays local and read-only on testnet. This chapter does not sign, broadcast, or spend money.

The acceptance criterion is an assertion you can automate:

After a 30-second disconnection and reconnection, the event sequence received
must exactly match the sequence seen by a continuously connected control client
(count, order, and content).

Connect to the T17 outbox and use onchain coordinates as the cursor.

Do not create another event source. Push the rows already in the outbox and keep the previous chapter's condition that the block be deep enough before delivery.

Encode (chain_id, block_number, log_index) as a comparable string. Choose this correctly and catch-up becomes an ordinary range query.

Implement the five message types; do not omit reset.

Implement subscribe, snapshot, event, reset, and bidirectional heartbeats from the protocol above. You may omit revoke for now because you deliver only sufficiently deep events.

Catch-up is one SQL query:

select * from outbox
 where chain_id = $1
   and (block_number, log_index) > ($2, $3)   -- Cursor supplied by the client
   and block_number <= $safeHeight
 order by block_number, log_index
 limit 1000;

If there are more than the limit, do not send them all at once. Send batches and check between batches that the connection is still alive.

Get the order at the seam right.

Implement “subscribe, buffer, fetch snapshot, then release events by cursor.”

Run a targeted experiment afterward: insert a one-second delay just before and after the line that fetches the snapshot. Trigger an event during that second. The client must receive it. If it does not, your order is reversed. That is the source of “occasionally missing a few” in production.

Acceptance test: disconnect for thirty seconds.

Open two clients subscribed to the same topics. Keep one connected as the control and disconnect the other for thirty seconds, then reconnect it with its cursor.

Compare the event sequences afterward:

- Same count?
- Same order?
- Same content, event by event?
- Did the reconnecting client receive duplicates? If so, did deduplication work?

Turn those four checks into a script. It is your regression test for the system.

Force backpressure with a slow client.

Write a client that sleeps for one second after reading each event, then feed ten thousand events into the system.

First run with unbounded buffering and watch the server process's memory. Watching it climb is the most instructive three minutes in this Lab.

Switch to a bounded queue, perhaps a thousand events; clear the queue and send reset when it fills. Run again and verify both that memory remains steady and that other, normal clients are unaffected.

Test under a weak network.

Use the network simulation facilities in your operating system. Each platform has corresponding command-line tools; check their help first. Add latency, packet loss, and bandwidth limits to the local connection.

Run the acceptance script under each condition:

- 500 ms latency
- 10% packet loss
- Very low bandwidth, forcing backpressure

Packet loss is most likely to reveal heartbeats whose timeout is too short: a live connection is repeatedly declared dead and reconnected.

Trigger a reorg and verify no false event escapes.

Use the synthetic reorg from the T17 Lab to make the indexer roll back once.

Inspect the client: it should not have received even one event from the rolled-back heights. They had not reached a safe height in the outbox, and rollback deleted them there.

To see the opposite, set safe height to zero and repeat. You will watch a “transfer that never existed” reach the client. That is the problem posed in the first chapter, T1, in action.

Open 200 connections and restart the server.

Watch the distribution of reconnection times. If they all cluster in one second, your backoff lacks jitter.

Add jitter and repeat. Reconnections should spread over an interval. The difference is visible with 200 connections; production may have tens of thousands.

AI Lab

AI LabAsk AI to design a reconnection protocol; test its behavior under a weak network yourselfLevel 2 · AI Copilot

Work in three steps. The second and third are the real tests.

Step one:
Design a subscription protocol for pushing onchain events without loss after a disconnect.
List every message type and field, plus the client and server state machines.

Step two:
For each case, identify which part of your protocol handles it:
1. A client reconnects after 30 seconds; 12 events occurred meanwhile.
2. A client reconnects after 3 hours; the server's replay window is only 1 hour.
3. One client reads at one-tenth the push rate for 10 minutes.
4. The server restarts, disconnecting 50,000 clients at once.
5. An event happens exactly between "fetch snapshot" and "establish subscription."
6. Three events already pushed to a client are invalidated by a chain reorg.

Step three:
Which of those six cases can your protocol not yet handle?
For each, describe the smallest fix and its cost.

Models often give a fairly complete answer to step one, because cursor-based subscriptions have been described many times. Examine the cursor choice: the model may offer a timestamp, which fails with multiple instances, clock adjustments, and multiple events in one millisecond.

Step two separates a plausible answer from a robust one. Cases 2, 3, 5, and 6 are common failures: no reset after window expiry, unbounded buffering for backpressure, reversed seam order, and no mention of reorgs. Case 4 reveals whether it remembered jitter.

Step three is the most valuable part of this Lab. It tests honesty, not memorized knowledge. A good answer admits, “My protocol cannot handle cases 3 and 6,” then proposes fixes; a poor one insists it covers them all. Finding holes in the model's own design is more useful than getting a design from it, a lesson also raised in T1's AI Lab.

The final test is yours alone: try it on a weak network. Every failure mode in this chapter appears under real network conditions. Asked how its protocol behaves with 10% packet loss, a model can only offer a plausible guess.

Verify these points yourself after using AI

  • What cursor did it choose? Timestamps and database sequences both fail; ask why it did not use onchain coordinates
  • Does the protocol explicitly say the cursor is too old and the replay window has expired? Without it, the client silently misses data forever
  • Are heartbeats bidirectional, with timeout checks on both sides?
  • Is there random jitter in reconnection backoff? Without it, every client returns in the same second after a restart
  • What is the snapshot-delta seam order: subscribe before snapshot or snapshot before subscribe? Construct an event exactly at the seam to test it
  • Does backpressure block, buffer without limit, or discard a bounded queue and send reset? Reject the first two
  • Does it handle revocation after a chain reorg or explicitly deliver only sufficiently deep events?
  • Check documentation for every library API, frame type, and close code it proposes
  • Finally test under latency, packet loss, and bandwidth limits; compare event sequences with a continuously connected control

Real-World Case

A few missing events after reconnection that nobody can reproduce

This is the opening scenario. On reconnect, the client fetches a snapshot before subscribing. Nobody receives an event that occurs in the instant between those actions.

Its signature is rare but persistent: an active user sees it once or twice a day, support receives scattered reports, and developers cannot reproduce it locally because an event must land in a particular few milliseconds.

The fix is simply to reverse the order: subscribe and buffer first, fetch the snapshot, then release buffered events by cursor.

One slow client stalls the entire service

Push uses blocking writes: when a write cannot complete, it waits. A client on a very weak network remains connected; its send buffer fills and the write never returns.

If all connections share one delivery loop, pushing stops across the entire service, freezing every user.

Unlimited buffering changes only the failure mode: memory climbs until the system kills the process during a traffic peak, precisely when failure is most costly.

Use bounded queues and degradation instead.

Tens of thousands of half-open connections accumulate

Only the server sends heartbeats, and it never checks whether the client responds.

The user's network is interrupted midway through the path, perhaps by a mobile handoff or NAT timeout. The client no longer has a connection, but the server believes it is alive and keeps writing to its queue. Such connections accumulate in memory and topic indexes until resources run out.

Lesson: heartbeats must work in both directions, with timeouts on both sides. It takes minutes to implement and eventually causes an incident if omitted.

Unconfirmed events were pushed and never revoked after a reorg

The push system listens directly to the chain head and sends events as soon as a block appears. After a reorg, those transactions no longer exist, but clients have already displayed them.

This is the same problem as the opening complaint in T17, now surfaced in the interface rather than a notification.

Either solution works, but choose one: deliver only sufficiently deep events, or implement a revoke message. Doing neither shows users a history that never happened.

Change One Variable

What ifThe client is a mobile app that the operating system suspends in the background

Suspension silently breaks the connection. On resume, the client thinks it remains connected and writes to a dead socket for some time before noticing.

Treat “app enters the foreground” as an explicit reconnection trigger: close and reconnect proactively, bringing the cursor to catch up, rather than waiting for TCP to time out.

Also accept that suspension can last hours, so the cursor will often fall outside the replay window. On mobile, reset and refetching the snapshot are a normal path, not an exception.

What ifOne user opens five tabs at the same time

That creates five connections, five subscriptions, and five times the fanout. Their cursors may differ, so each catch-up query runs separately.

There are two options: share one background connection among the tabs on the client, or accept the server cost but limit connections per user as part of rate limiting. Otherwise a user with dozens of tabs can consume disproportionate resources.

This is a concrete push-system form of the “two kinds of rate limits” discussed in T15.

What ifYou switch from push to polling but require one-second latency

Every online user now queries once per second. Ten thousand users mean ten thousand requests per second, most returning “nothing changed.”

You will naturally add a cache for duplicate queries and hold requests open until something changes. After both changes, you have effectively rebuilt a persistent connection, in another form.

The conclusion is not that polling is bad. Polling cost rises sharply as the latency requirement tightens; persistent-connection cost grows linearly with connection count. Choose based on your latency target and online scale, not which sounds newer.

What ifSubscriptions grow from one thousand to one million

Three costs arrive together: memory for connections, topic-to-connection index lookups, and fanout writes for each event.

A common sequence of changes is to slim down the payload first (push only a change signal and let clients fetch, converting fanout into cacheable reads), then shard by topic (each instance handles a subset), and only afterward consider a more complex distribution layer.

The invariant remains: the distribution layer provides speed; the database provides correctness. Catch-up always queries the database by cursor. That is the reliable path even at a million connections.

Takeaway Questions

1
What problem does it solve?

What problem does it solve? This chapter asks how to keep the user's view consistent with the chain even when the network is unreliable. There are two independent goals: speed (latency) and no loss (correctness). A system that achieves only the first looks good at launch and leaves users to discover the bugs.

5
Who is paying?

Who pays? The cost of real-time push scales with online connections and fanout volume. Connection memory, fanout writes, and storage for the replay window can become a product's first runaway cost. Pushing only change signals or sufficiently deep events helps correctness and keeps the bill manageable.

9
Who bears the risk?

Who bears the risk? When a push is wrong or missing, users see an interface that disagrees with the chain, with no way to know what is incomplete. The cost is not just those missing events; it is lost trust in your interface. If users check a block explorer every time, your product becomes an unnecessary intermediary.

Chapter Check

Key idea

Real-time systems must support replay: users reconnecting after an outage must not miss intervening events.

Finished this chapter’s lab? Mark it completeView all progress

On this page