Guides

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

Build a Base flashblocks visualizer with Next.js and raw JSON-RPC. Compare 200ms pre-confirmations to 2s block finality side-by-side.

FrameworkNext.js 15
LanguageTypeScript
NetworkBase

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.

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 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 TagWhat It ReturnsUpdate 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 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) => (
                <FeedItem key={evt.id} event={evt} type={type} />
              ))}
            </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">
        <Panel
          title="Flashblocks"
          interval="~200ms"
          type="flash"
          state={flash}
        />
        <Panel
          title="Standard"
          interval="~2s"
          type="standard"
          state={standard}
        />
      </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 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 Dwellir's IP allowlisting to restrict key usage
  • Rotate keys periodically via the Dwellir dashboard

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 and grab an API key.