StondelBook a call
← All notes
Frontend & indexing

Designing for invalidation: optimistic UI and on-chain reality

A database write either succeeds or fails in 50ms. A chain write is probabilistic, reorderable and occasionally reversed. Here is the three-layer state engine we build to keep interfaces instant without lying to the user.

Probabilistic finality · reorg mitigation · custom indexers · state reconciliation

Stondel10 min read437 views

In Web2 engineering, state mutations are atomic and fast. A database transaction executes in under 50 milliseconds and either succeeds or fails immediately. Web3 frontends operate under a different paradigm entirely: probabilistic state synchronisation.

Chains have variable block intervals, mempool congestion, gas price volatility and reorganisations. If your interface waits for a twelve-second block inclusion before updating anything, it feels broken — not slow, broken.

The three-layer state engine

  • Layer 1, optimistic — on submit, the UI updates local state immediately, assuming inclusion. Buttons reflect the completed state at once.
  • Layer 2, mempool tracking — the frontend listens to RPC WebSocket streams and follows the hash from submitted to mined.
  • Layer 3, canonical indexer — off-chain indexers process finalised blocks, emit GraphQL or REST updates, and reconcile optimistic state against what actually happened.

The third layer is the one teams skip, and it is the one that makes the first layer honest. Optimism without reconciliation is just a UI that lies convincingly.

A hook for optimistic execution with rollback

The pattern is small. Apply the update, submit, await the receipt, and roll back on anything other than success — including a receipt that comes back reverted, which is the case most implementations forget.

typescript
import { useState, useCallback } from 'react';
import { usePublicClient } from 'wagmi';

interface UseOptimisticTxProps<T> {
  onOptimisticUpdate: (newData: T) => void;
  onRollback: () => void;
  onSuccess: (receipt: unknown) => void;
}

export function useOptimisticAction<T>({
  onOptimisticUpdate,
  onRollback,
  onSuccess,
}: UseOptimisticTxProps<T>) {
  const [isPending, setIsPending] = useState(false);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
  const publicClient = usePublicClient();

  const executeTx = useCallback(
    async (optimisticData: T, txFn: () => Promise<`0x${string}`>) => {
      setIsPending(true);
      setErrorMessage(null);

      // 1. Apply the local optimistic update immediately.
      onOptimisticUpdate(optimisticData);

      try {
        // 2. Submit to the chain or bundler.
        const hash = await txFn();

        // 3. Wait for the receipt.
        const receipt = await publicClient.waitForTransactionReceipt({ hash });

        if (receipt.status === 'success') {
          onSuccess(receipt);
        } else {
          throw new Error('Transaction execution reverted on-chain.');
        }
      } catch (err) {
        // 4. Revert optimistic state on any failure.
        onRollback();
        setErrorMessage(err instanceof Error ? err.message : 'Execution failed');
      } finally {
        setIsPending(false);
      }
    },
    [onOptimisticUpdate, onRollback, onSuccess, publicClient],
  );

  return { executeTx, isPending, errorMessage };
}

Decoding EVM errors into something actionable

RPC errors surface as raw hex selectors — 0x4e487b71 for a panic code, or an unparsed custom revert struct. Showing that to a user produces abandonment, and reasonably so.

A production frontend runs an off-chain ABI error decoder that maps error hashes to resolution steps: not "execution reverted", but "insufficient slippage tolerance — increase slippage by 0.5%". The mapping is boring to write and it measurably changes completion rates.