# Build a Real-Time Base Flashblocks Visualizer with Next.js

Base blocks finalize every 2 seconds. Since July 2025, the Base sequencer emits **flashblocks** - pre-confirmed state updates every 200 milliseconds. That is 10 updates per block, each revealing new transactions before the full block seals.

In this guide, we will build a side-by-side visualizer that polls the same RPC endpoint with two different block tags - `"pending"` for flashblocks and `"latest"` for standard finality - and watch the speed difference in real time. We will also build a transaction race feature that tracks a single transaction through both detection methods, showing exactly how many seconds of advance visibility flashblocks provide.

The entire app uses raw `fetch` calls to JSON-RPC. No ethers.js, no viem, no SDK abstractions. Every RPC interaction is visible and portable to any language or framework. The complete source code is available at [github.com/dwellir-public/example-base-flashblocks](https://github.com/dwellir-public/example-base-flashblocks).

## What you will learn

- Poll Base flashblocks using `eth_getBlockByNumber("pending")` with raw JSON-RPC
- Detect state changes by comparing successive flashblock responses
- Display side-by-side update feeds showing the \~10x speed advantage of flashblocks over standard blocks
- Track individual transactions through flashblock pre-confirmation to standard block finality
- Parse hex-encoded block data returned by Ethereum JSON-RPC methods

## Prerequisites

- **Node.js 18.18+** - run `node --version` to confirm (required by Next.js 15)
- **A Dwellir API key** - sign up free at [dashboard.dwellir.com](https://dashboard.dwellir.com/register) and copy your key
- **Familiarity with React and TypeScript** - we use hooks like `useState`, `useCallback`, and `useRef`
- **A code editor** - VS Code, Cursor, or any editor with TypeScript support

## How flashblocks work

Before we write code, a quick mental model. Base uses a single sequencer that builds blocks over 2-second windows. With flashblocks enabled, the sequencer publishes in-progress block state roughly every 200 milliseconds. Each flashblock is a snapshot of the transactions executed so far within the current block.

From an RPC perspective, the interface is straightforward:

| Block Tag   | What It Returns                                             | Update Frequency           |
| ----------- | ----------------------------------------------------------- | -------------------------- |
| `"pending"` | The in-progress block with all transactions executed so far | \~200ms (every flashblock) |
| `"latest"`  | The most recently sealed, finalized block                   | \~2s (every full block)    |

That is the entire API surface. Same endpoint, same method, different tag. Our visualizer makes this difference visible.

## Project setup

We are building a Next.js 15 application with React 19 and TypeScript. No additional UI libraries or state management, just framework defaults.

Create the project:

```bash
npx create-next-app@latest base-flashblocks-demo --typescript --app --src-dir --no-tailwind --no-eslint
cd base-flashblocks-demo
```

Select the default options when prompted. We skip Tailwind and ESLint to keep dependencies minimal.

Create a `.env` file in the project root:

```bash
# Get your free API key at https://dashboard.dwellir.com
NEXT_PUBLIC_DWELLIR_API_KEY=your-api-key-here

# Network: mainnet or sepolia (default: mainnet)
NEXT_PUBLIC_NETWORK=mainnet
```

Replace `your-api-key-here` with the API key from your Dwellir dashboard.

**Important:** We use `mainnet` throughout this guide so we see real transaction volume. Sepolia works too, but the flashblock speed difference is less dramatic with lower traffic.

### Styling

The visualizer uses a custom CSS file with Dwellir brand tokens for dark theme, card layouts, gas bars, animated feed items, and the transaction race timeline. Copy `globals.css` from the [source repository](https://github.com/dwellir-public/example-base-flashblocks/blob/main/src/app/globals.css) into `src/app/globals.css`, replacing the default Next.js styles. The file defines all the class names we reference throughout this guide (`.app`, `.panel`, `.stats-bar`, `.gas-bar`, `.feed-item`, `.tx-race`, and more).

We will not walk through the CSS line by line since it is purely visual styling. The key design decision is using CSS custom properties (`--primary`, `--cardBackground`, `--borderColor`) so the entire color scheme can be swapped by changing the theme class on the `<body>` element.

## Types and constants

Create `src/components/FlashblocksDemo.tsx`. We will build this component incrementally across the next several sections. Start with the type definitions and constants:

```tsx
'use client';

import { useState, useEffect, useRef, useCallback } from 'react';

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

interface BlockData {
  number: number;
  hash: string;
  timestamp: number;
  gasUsed: number;
  gasLimit: number;
  txCount: number;
  baseFeePerGas: number;
  txHashes: string[];
}

interface FeedEvent {
  id: number;
  time: number;
  blockNumber: number;
  txCount: number;
  txDelta: number;
  latency: number;
  isNewBlock: boolean;
}

interface PanelState {
  current: BlockData | null;
  previous: BlockData | null;
  updateCount: number;
  totalLatency: number;
  events: FeedEvent[];
  lastUpdateTime: number;
}

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

const FLASH_INTERVAL = 200; // Poll pending every 200ms
const STANDARD_INTERVAL = 2000; // Poll latest every 2s
const MAX_EVENTS = 30; // Keep last 30 events in feed

const ENDPOINTS: Record<string, string> = {
  mainnet: 'https://api-base-mainnet-archive.n.dwellir.com',
  sepolia: 'https://api-base-sepolia-archive.n.dwellir.com',
};
```

Three types drive the application:

- **`BlockData`** - parsed block data from a single RPC response. We store transaction hashes for the race feature later.
- **`FeedEvent`** - a single row in the update feed. `txDelta` tracks how many new transactions arrived since the last flashblock.
- **`PanelState`** - accumulated state for each panel (flash and standard), including running averages and event history.

The polling intervals match flashblock cadence: 200ms for pending, 2 seconds for latest. `ENDPOINTS` maps network names to Dwellir's Base archive endpoints.

Save the file. Your editor's TypeScript server should show no errors at this point. If you see red squiggles, check that the `"use client"` directive is on the first line and the React imports match the code above.

## Hex parsing and block helpers

Ethereum JSON-RPC returns all numeric values as hex strings. We need helpers to convert them and parse block responses. Add these below the constants:

```typescript
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function hex(val: string | null | undefined): number {
  if (!val) return 0;
  return parseInt(val, 16);
}

function fmtNum(n: number): string {
  return n.toLocaleString('en-US');
}

function fmtGas(gas: number): string {
  if (gas >= 1_000_000) return (gas / 1_000_000).toFixed(1) + 'M';
  if (gas >= 1_000) return (gas / 1_000).toFixed(0) + 'K';
  return gas.toString();
}

function parseBlock(raw: Record<string, unknown>): BlockData {
  const txs = Array.isArray(raw.transactions) ? raw.transactions : [];
  return {
    number: hex(raw.number as string),
    hash: (raw.hash as string) ?? 'pending',
    timestamp: hex(raw.timestamp as string),
    gasUsed: hex(raw.gasUsed as string),
    gasLimit: hex(raw.gasLimit as string),
    txCount: txs.length,
    baseFeePerGas: hex(raw.baseFeePerGas as string),
    txHashes: txs.map((t: unknown) => (typeof t === 'string' ? t : (t as Record<string, string>)?.hash ?? '')),
  };
}

function emptyPanel(): PanelState {
  return {
    current: null,
    previous: null,
    updateCount: 0,
    totalLatency: 0,
    events: [],
    lastUpdateTime: 0,
  };
}

let eventIdCounter = Math.floor(Math.random() * 1e9);
```

`parseBlock` handles a subtlety: when we call `eth_getBlockByNumber` with `false` as the second parameter, transactions come back as hash strings. With `true`, they come back as full objects. We handle both cases for resilience, but use `false` in this guide to minimize response size.

**Tip:** The `hash` field on a pending block response is `null` because the block is not yet sealed. Our parser falls back to `"pending"` in that case.

## The RPC layer

With our types and helpers in place, we can build the core component. The RPC call function is the single point of contact with the blockchain. Add the component skeleton with the RPC logic:

```tsx
export default function FlashblocksDemo() {
  const network = (process.env.NEXT_PUBLIC_NETWORK as string) || 'mainnet';
  const envKey = process.env.NEXT_PUBLIC_DWELLIR_API_KEY ?? '';

  const [apiKey, setApiKey] = useState(envKey);
  const [isRunning, setIsRunning] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const [flash, setFlash] = useState<PanelState>(emptyPanel);
  const [standard, setStandard] = useState<PanelState>(emptyPanel);

  const flashRef = useRef(flash);
  const standardRef = useRef(standard);
  const flashTimer = useRef<ReturnType<typeof setInterval>>(undefined);
  const standardTimer = useRef<ReturnType<typeof setInterval>>(undefined);

  flashRef.current = flash;
  standardRef.current = standard;

  // Auto-start if env key is present
  useEffect(() => {
    if (envKey) {
      setApiKey(envKey);
      setIsRunning(true);
    }
  }, [envKey]);

  // Build RPC URL from endpoint + API key
  const rpcUrl = `${ENDPOINTS[network] ?? ENDPOINTS.mainnet}/${apiKey}`;

  const callRpc = useCallback(
    async (method: string, params: unknown[]) => {
      const start = performance.now();
      const res = await fetch(rpcUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method, params }),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      if (data.error) throw new Error(data.error.message);
      return { result: data.result, latency: Math.round(performance.now() - start) };
    },
    [rpcUrl],
  );

  // Polling functions added in the next section
}
```

`callRpc` sends a raw JSON-RPC request using `fetch`, measures round-trip latency with `performance.now()`, and returns both the result and the timing. Both panels use this same function - the only difference is the parameters we pass.

The URL structure `https://api-base-mainnet-archive.n.dwellir.com/{apiKey}` authenticates via the path, so we append the API key directly.

## Polling flashblocks and standard blocks

Now we create two polling functions that call the same RPC method with different block tags. Add these inside the component, below `callRpc`:

```typescript
const pollFlash = useCallback(async () => {
  try {
    const { result, latency } = await callRpc('eth_getBlockByNumber', ['pending', false]);
    if (!result) return;

    const block = parseBlock(result);
    const prev = flashRef.current.current;

    // Only record updates when data actually changes
    if (prev && prev.txCount === block.txCount && prev.number === block.number) return;

    const isNewBlock = prev !== null && block.number !== prev.number;
    const txDelta = prev && !isNewBlock ? block.txCount - prev.txCount : block.txCount;

    const evt: FeedEvent = {
      id: ++eventIdCounter,
      time: Date.now(),
      blockNumber: block.number,
      txCount: block.txCount,
      txDelta,
      latency,
      isNewBlock,
    };

    setFlash((s) => ({
      current: block,
      previous: prev,
      updateCount: s.updateCount + 1,
      totalLatency: s.totalLatency + latency,
      events: [evt, ...s.events].slice(0, MAX_EVENTS),
      lastUpdateTime: Date.now(),
    }));

    setError(null);
  } catch (err) {
    setError((err as Error).message);
  }
}, [callRpc]);

const pollStandard = useCallback(async () => {
  try {
    const { result, latency } = await callRpc('eth_getBlockByNumber', ['latest', false]);
    if (!result) return;

    const block = parseBlock(result);
    const prev = standardRef.current.current;
    const isNewBlock = prev !== null && block.number !== prev.number;

    const evt: FeedEvent = {
      id: ++eventIdCounter,
      time: Date.now(),
      blockNumber: block.number,
      txCount: block.txCount,
      txDelta: 0,
      latency,
      isNewBlock,
    };

    setStandard((s) => ({
      current: block,
      previous: prev,
      updateCount: s.updateCount + 1,
      totalLatency: s.totalLatency + latency,
      events: [evt, ...s.events].slice(0, MAX_EVENTS),
      lastUpdateTime: Date.now(),
    }));

    setError(null);
  } catch (err) {
    setError((err as Error).message);
  }
}, [callRpc]);
```

The critical detail is the **change detection** in `pollFlash`:

```typescript
if (prev && prev.txCount === block.txCount && prev.number === block.number) return;
```

We poll every 200ms, but the sequencer only publishes a new flashblock when there are new transactions. If nothing changed, we skip the update. This keeps our event feed clean and our update counter accurate.

`txDelta` works differently per panel. For flashblocks, it shows how many new transactions arrived since the last update. For the standard panel, each event represents a complete sealed block, so there is no incremental delta.

## Starting and stopping the polls

With both polling functions defined, we wire up the lifecycle with `useEffect` and add a render. Still inside the component:

```tsx
// Start/stop polling based on isRunning state
useEffect(() => {
  if (!isRunning || !apiKey) return;
  pollFlash();
  pollStandard();
  flashTimer.current = setInterval(pollFlash, FLASH_INTERVAL);
  standardTimer.current = setInterval(pollStandard, STANDARD_INTERVAL);
  return () => {
    clearInterval(flashTimer.current);
    clearInterval(standardTimer.current);
  };
}, [isRunning, apiKey, pollFlash, pollStandard]);

// Derived stats
const flashAvgLatency = flash.updateCount > 0 ? Math.round(flash.totalLatency / flash.updateCount) : 0;
const standardAvgLatency = standard.updateCount > 0 ? Math.round(standard.totalLatency / standard.updateCount) : 0;
const speedMultiple = standard.updateCount > 0 ? Math.round(flash.updateCount / standard.updateCount) : 0;

return (
  <div className="app">
    <header className="header">
      <div className="header-left">
        <span className="logo">dwellir</span>
        <span className="header-title">Base Flashblocks</span>
        <span className="network-badge">{network}</span>
      </div>
    </header>

    {error && <div className="error-msg">{error}</div>}

    <div className="stats-bar">
      <div className="stat-card">
        <div className="stat-label">Flash Updates</div>
        <div className="stat-value">{fmtNum(flash.updateCount)}</div>
        <div className="stat-sub">{flashAvgLatency}ms avg</div>
      </div>
      <div className="stat-card">
        <div className="stat-label">Standard Updates</div>
        <div className="stat-value">{fmtNum(standard.updateCount)}</div>
        <div className="stat-sub">{standardAvgLatency}ms avg</div>
      </div>
      <div className="stat-card">
        <div className="stat-label">Speed Multiple</div>
        <div className="stat-value">{speedMultiple > 0 ? `${speedMultiple}x` : '--'}</div>
        <div className="stat-sub">faster updates</div>
      </div>
    </div>

    <div className="panel-grid">
      {/* We will build the Panel component next */}
      <div>Flashblocks panel</div>
      <div>Standard panel</div>
    </div>
  </div>
);
```

`speedMultiple` divides flash update count by standard update count. After a few seconds of running, this consistently shows **8-12x**, meaning the flashblocks panel sees state changes roughly 10 times more frequently than the standard panel.

Now wire up the page. Replace the contents of `src/app/page.tsx`:

```tsx
import FlashblocksDemo from '@/components/FlashblocksDemo';

export default function Home() {
  return <FlashblocksDemo />;
}
```

Start the dev server:

```bash
npm run dev
```

Open `http://localhost:3000`. You should see the stats bar counting up, with flash updates accumulating much faster than standard updates. The panel grid shows placeholder text for now - we build the real panels next.

## Building the panel component

Each panel displays the current block data and a scrolling feed of recent events. Add this as a separate function below the main component, in the same file:

```tsx
function Panel({ title, interval, type, state }: { title: string; interval: string; type: 'flash' | 'standard'; state: PanelState }) {
  const { current, events, updateCount } = state;

  const gasPercent = current && current.gasLimit > 0 ? Math.round((current.gasUsed / current.gasLimit) * 100) : 0;

  return (
    <div className={`panel ${type}`}>
      <div className="panel-header">
        <div className="panel-header-left">
          <div className={`panel-dot ${updateCount > 0 ? 'active' : ''} ${type === 'flash' ? 'fast' : ''}`} />
          <div className="panel-title">{title}</div>
        </div>
        <div className="panel-interval">{interval}</div>
      </div>

      <div className="panel-body">
        {!current ? (
          <div className="waiting">Waiting for data...</div>
        ) : (
          <>
            <div className="block-info">
              <div className="block-field">
                <span className="block-field-label">Block</span>
                <span className="block-field-value">{current.number > 0 ? `#${fmtNum(current.number)}` : 'Pending'}</span>
              </div>
              <div className="block-field">
                <span className="block-field-label">Transactions</span>
                <span className="block-field-value">
                  {fmtNum(current.txCount)}
                  {type === 'flash' && events.length > 0 && events[0].txDelta > 0 && <span className="delta">+{events[0].txDelta}</span>}
                </span>
              </div>
              <div className="block-field">
                <span className="block-field-label">Gas Used</span>
                <span className="block-field-value">{fmtGas(current.gasUsed)}</span>
              </div>
              <div className="block-field">
                <span className="block-field-label">Base Fee</span>
                <span className="block-field-value">
                  {current.baseFeePerGas > 0 ? (current.baseFeePerGas / 1e9).toFixed(2) + ' gwei' : '--'}
                </span>
              </div>
            </div>

            <div className="gas-bar-container">
              <div className="gas-bar-label">
                <span>Gas</span>
                <span>
                  {fmtGas(current.gasUsed)} / {fmtGas(current.gasLimit)} ({gasPercent}%)
                </span>
              </div>
              <div className="gas-bar">
                <div className={`gas-bar-fill ${type}`} style={{ width: `${gasPercent}%` }} />
              </div>
            </div>

            <div className="feed-label">{type === 'flash' ? 'Flashblock Updates' : 'Block Confirmations'}</div>
            <div className="event-feed">
              {events.map((evt) => (
              ))}
            </div>
          </>
        )}
      </div>
    </div>
  );
}
```

And the individual feed item component:

```tsx
function FeedItem({ event, type }: { event: FeedEvent; type: 'flash' | 'standard' }) {
  const timeStr = new Date(event.time).toLocaleTimeString('en-US', {
    hour12: false,
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit',
    fractionalSecondDigits: 1,
  } as Intl.DateTimeFormatOptions);

  const isFlash = type === 'flash';

  let text: string;
  if (event.isNewBlock) {
    text = `New block #${fmtNum(event.blockNumber)}`;
  } else if (isFlash) {
    text =
      event.txDelta > 0
        ? `+${event.txDelta} txns (#${fmtNum(event.blockNumber)})`
        : `${event.txCount} txns (#${fmtNum(event.blockNumber)})`;
  } else {
    text = `#${fmtNum(event.blockNumber)} - ${event.txCount} txns`;
  }

  return (
    <div className={`feed-item ${event.isNewBlock ? 'new-block' : type}`}>
      <div className="feed-item-text">
        <span>{timeStr}</span>
        <span>{text}</span>
      </div>
      <span className="feed-item-latency">{event.latency}ms</span>
    </div>
  );
}
```

Now replace the placeholder `<div>` elements in the main component's return with the real `Panel` components:

```tsx
<div className="panel-grid">
</div>
```

Reload the page. You should see two panels side by side. The left panel scrolls with flashblock updates every 200ms, showing `+3 txns`, `+7 txns` as new transactions arrive. The right panel updates only when a full 2-second block seals. The gas bar on the flashblocks panel fills incrementally as transactions are included.

## Adding the transaction race

With both panels live, we can add the most compelling feature. The transaction race picks a live transaction from the flashblock feed and measures how long before the same transaction appears in a standard confirmed block.

Add the `TxTrace` interface near the other type definitions at the top of the file:

```typescript
interface TxTrace {
  hash: string;
  mode: 'wallet' | 'auto';
  sentAt: number | null;
  flashSeenAt: number | null;
  confirmedAt: number | null;
  blockNumber: number | null;
}
```

Add the transaction tracking state inside the main component, alongside the existing state declarations:

```typescript
const [txTrace, setTxTrace] = useState<TxTrace | null>(null);
const [txStatus, setTxStatus] = useState('idle');
const [autoTrackArmed, setAutoTrackArmed] = useState(false);

const txTraceRef = useRef<TxTrace | null>(null);
const autoTrackRef = useRef(false);

txTraceRef.current = txTrace;
autoTrackRef.current = autoTrackArmed;
```

We need to modify `pollFlash` and `pollStandard` to check for the tracked transaction. Add this block inside `pollFlash`, right after the change detection check (the `if (prev && prev.txCount === ...)` guard):

```typescript
// Check if tracked tx appeared in this flashblock
const trace = txTraceRef.current;
if (trace && !trace.flashSeenAt && trace.hash) {
  const lower = trace.hash.toLowerCase();
  if (block.txHashes.some((h) => h.toLowerCase() === lower)) {
    setTxTrace((p) => (p ? { ...p, flashSeenAt: Date.now(), blockNumber: block.number } : p));
  }
}

// Auto-track: pick a tx from new arrivals
if (autoTrackRef.current && !txTraceRef.current && txDelta > 0 && block.txHashes.length > 0) {
  const hash = block.txHashes[block.txHashes.length - 1];
  if (hash) {
    setTxTrace({
      hash,
      mode: 'auto',
      sentAt: null,
      flashSeenAt: Date.now(),
      confirmedAt: null,
      blockNumber: block.number,
    });
    setTxStatus('tracking');
    setAutoTrackArmed(false);
  }
}
```

And inside `pollStandard`, after parsing the block and computing `isNewBlock`:

```typescript
// Check if tracked tx's block is now confirmed
const trace = txTraceRef.current;
if (trace && trace.blockNumber === block.number && !trace.confirmedAt) {
  setTxTrace((p) => (p ? { ...p, confirmedAt: Date.now() } : p));
  setTxStatus('done');
}
```

The auto-track handler picks the most recent transaction hash from the current flashblock. Add these functions inside the component:

```typescript
function handleAutoTrack() {
  const current = flashRef.current.current;
  if (current && current.txHashes.length > 0) {
    const hash = current.txHashes[current.txHashes.length - 1];
    setTxTrace({
      hash,
      mode: 'auto',
      sentAt: null,
      flashSeenAt: Date.now(),
      confirmedAt: null,
      blockNumber: current.number,
    });
    setTxStatus('tracking');
  } else {
    setAutoTrackArmed(true);
    setTxStatus('armed');
  }
}

function handleTxReset() {
  setTxTrace(null);
  setTxStatus('idle');
  setAutoTrackArmed(false);
}
```

The flow works like this:

1. The user clicks "Track Live Tx"
2. We grab the latest transaction hash from the flashblock feed and record the timestamp
3. Each `pollStandard` cycle, we check if the tracked transaction's block number matches the latest confirmed block
4. When it matches, we record the confirmation timestamp and display the difference

On mainnet, expect **1-2 seconds of advance visibility** - the gap between when flashblocks revealed the transaction and when it appeared in a standard confirmed block.

**Note:** The full application also includes a "Send Test Tx" mode that uses MetaMask to broadcast a zero-value self-transfer and race it. That adds wallet connection logic via `window.ethereum`. The auto-track mode shown here demonstrates the same concept without requiring a wallet.

To render the transaction race, add this JSX to the main component's return, between the stats bar and the panel grid:

```tsx
{
  /* Transaction Race */
}
<div className="tx-race">
  <div className="tx-race-header">
    <h3>Transaction Race</h3>
    <div className="tx-race-actions">
      {!txTrace && txStatus !== 'armed' && (
        <button className="btn-primary btn-sm" onClick={handleAutoTrack}>
          Track Live Tx
        </button>
      )}
      {txTrace?.confirmedAt && (
        <button className="btn-primary btn-sm" onClick={handleTxReset}>
          Try Again
        </button>
      )}
    </div>
  </div>

  {txTrace && (
    <div className="tx-timeline-wrap">
      <div className="tx-steps">
        <div className={`tx-step ${txTrace ? 'done' : ''}`}>
          <span className="tx-step-num">1</span>
          <div className="tx-step-body">
            <div className="tx-step-title">Transaction spotted in flashblock</div>
          </div>
        </div>
        <div className={`tx-step ${txTrace.confirmedAt ? 'done' : txTrace.flashSeenAt ? 'active' : ''}`}>
          <span className="tx-step-num">2</span>
          <div className="tx-step-body">
            <div className="tx-step-title">
              {txTrace.confirmedAt
                ? `Block confirmed (${((txTrace.confirmedAt - (txTrace.flashSeenAt ?? 0)) / 1000).toFixed(2)}s later)`
                : 'Waiting for block confirmation...'}
            </div>
          </div>
        </div>
      </div>

      {txTrace.confirmedAt && txTrace.flashSeenAt && (
        <div className="tx-verdict">
          Flashblocks gave <strong>{((txTrace.confirmedAt - txTrace.flashSeenAt) / 1000).toFixed(2)}s</strong> of advance visibility
        </div>
      )}
    </div>
  )}
</div>;
```

This simplified version shows the core race mechanic. The [full source](https://github.com/dwellir-public/example-base-flashblocks/blob/main/src/components/FlashblocksDemo.tsx) includes an animated timeline, wallet integration, and Basescan links.

## Wiring up the layout

With the component complete, update `src/app/layout.tsx` to set metadata and fonts:

```tsx
import type { Metadata } from 'next';
import './globals.css';

export const metadata: Metadata = {
  title: 'Base Flashblocks Demo | Dwellir',
  description: 'Visualize Base flashblocks pre-confirmations vs standard block finality, powered by Dwellir RPC infrastructure.',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <link rel="preconnect" href="https://fonts.googleapis.com" />
        <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
        <link
          href="https://fonts.googleapis.com/css2?family=Ubuntu:wght@400;600;700&family=Roboto:wght@400;600;700&family=Roboto+Mono:wght@400;600;700&display=swap"
          rel="stylesheet"
        />
      </head>
      <body className="next-dark-theme">{children}</body>
    </html>
  );
}
```

`next-dark-theme` activates the dark color scheme defined in the CSS. Ubuntu is used for headings, Roboto for body text, and Roboto Mono for data displays.

## Putting it all together

The component is complete. Start the dev server:

```bash
npm run dev
```

Open `http://localhost:3000`. You should see:

1. **Stats bar** at the top showing flash updates, standard updates, and the speed multiple (expect \~8-12x after 30 seconds)
2. **Two side-by-side panels** - the left (Flashblocks) scrolling rapidly with `+N txns` events every 200ms, the right (Standard) updating every \~2 seconds with full block summaries
3. **Gas bar** on the flashblocks panel filling incrementally as the block builds
4. **Transaction race** section where clicking "Track Live Tx" picks a transaction from the feed and times it through both detection methods

After about a minute of running, the stats bar will show something like:

```text
Flash Updates: 247        Standard Updates: 28        Speed Multiple: 8x
```

When you run a transaction race, expect to see output like:

```text
Flashblocks gave 1.82s of advance visibility
```

The flashblocks panel detected the transaction 1.82 seconds before the standard panel saw it in a confirmed block.

## Going to production

This demo polls at high frequency for visualization purposes. For a production application using flashblocks, consider the following.

### Rate limiting and error handling

The demo fires a request every 200ms without retry logic. In production, add exponential backoff and respect rate limits:

```typescript
const callRpc = async (method: string, params: unknown[], retries = 3) => {
  for (let attempt = 0; attempt < retries; attempt++) {
    try {
      const res = await fetch(rpcUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), method, params }),
      });
      if (res.status === 429) {
        await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
        continue;
      }
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      if (data.error) throw new Error(data.error.message);
      return data.result;
    } catch (err) {
      if (attempt === retries - 1) throw err;
    }
  }
};
```

### WebSocket instead of polling

Polling works for visualization, but production apps should use WebSocket subscriptions for lower overhead. Subscribe to `newHeads` to receive push-based updates:

```typescript
const ws = new WebSocket(`wss://api-base-mainnet-archive.n.dwellir.com/${apiKey}`);
ws.onopen = () => {
  ws.send(
    JSON.stringify({
      jsonrpc: '2.0',
      id: 1,
      method: 'eth_subscribe',
      params: ['newHeads'],
    }),
  );
};
```

This eliminates the polling interval and delivers updates as soon as the node receives them.

### API key management

The demo uses `NEXT_PUBLIC_` environment variables, which expose the API key in the browser bundle. For production:

- Route RPC requests through your own backend API that injects the key server-side
- Use [IP whitelisting](https://www.dwellir.com/docs/getting-started/ip-whitelisting) to restrict key usage
- Rotate keys periodically via the [Dwellir dashboard](https://dashboard.dwellir.com)

### Reorg awareness

Flashblocks are pre-confirmations, not final state. While the Base sequencer reorg rate is extremely low, production applications should verify state after the full block confirms. Do not treat `"pending"` responses as authoritative for irreversible actions like withdrawals.

## Next steps

With a working flashblocks visualizer, here are a few directions to explore:

- **Flashblock-aware DEX interface** - show users pending price updates 200ms after each trade instead of waiting for block confirmation
- **Mempool analytics dashboard** - track transaction inclusion times across blocks to measure sequencer behavior
- **Latency benchmarking tool** - compare flashblock response times across RPC providers to evaluate infrastructure quality

The same `"pending"` tag works on any OP Stack chain with flashblocks enabled, including Unichain (200ms flashblocks) and OP Mainnet (250ms flashblocks, rolling out). Dwellir supports all three.

This guide used Dwellir's Base archive endpoint for all RPC calls. To run the visualizer yourself, [create a free account](https://dashboard.dwellir.com/register) and grab an API key.
