Real-Time Streaming with Socket.IO: Building Responsive Web3 Chat
Berke (pzzaworks)
March 27th, 2025
When building Lunark's chat interface, I quickly realized that HTTP request-response wouldn't cut it. Users expect to see AI responses appear word by word, not wait for a complete response. And blockchain operations add another layer: transactions need to be displayed inline with chat messages, confirmations need to update in real-time, and network switches need to propagate instantly.
Socket.IO solved all of these problems, but implementing it well required understanding both the backend architecture and React's rendering behavior.
The backend uses async generators to handle the streaming. When a message comes in, the agent's ask method returns an async generator that yields chunks as they arrive.
Note: The example below is simplified for clarity. The actual Lunark implementation uses a graph execution pattern with graph.addTaskNode() and graph.run() rather than direct agent.ask() calls.
Socket.IO's room system is perfect for chat applications. Each chat session gets its own room, and each user gets a personal room for account-specific events:
TypeScript
io.on('connection',(socket)=>{ socket.on('authenticate',({ address })=>{// Join user's personal room socket.join(`user:${address.toLowerCase()}`); socket.emit('authenticated',{ address });}); socket.on('joinChat',({ chatId })=>{// Join chat-specific room socket.join(`chat:${chatId}`); socket.emit('joinedChat',{ chatId });});});// Emit to everyone in a chatfunctionemitToChat(chatId:string, event:string, data:any){ io.to(`chat:${chatId}`).emit(event, data);}// Emit to a specific user (for transactions, network switches)functionemitToUser(address:string, event:string, data:any){ io.to(`user:${address.toLowerCase()}`).emit(event, data);}
This separation is important. Chat messages go to the chat room (in case we ever support collaborative sessions), while transaction notifications go directly to the user's room.
Here's where things got interesting. React 18 batches state updates for performance. When Socket.IO fires rapidly (multiple tokens per second), React batches those updates together, causing the UI to jump forward in chunks instead of streaming smoothly.
The solution is flushSync, which forces React to flush updates immediately:
TypeScript
import{ flushSync }from'react-dom';socket.on('streamResponse',(message: Message)=>{flushSync(()=>{setMessages((prev)=>{const index = prev.findIndex((m)=> m.id === message.id);if(index ===-1){// New message, add to arrayreturn[...prev, message];}// Existing message, update in placeconst updated =[...prev]; updated[index]= message;return updated;});});});
Without flushSync, you'd see updates every 50-100ms as React batches them. With it, each token appears immediately.
When the AI agent prepares a blockchain transaction, it needs to display in the chat and wait for user confirmation. This uses a different socket event:
TypeScript
// Backend emits when transaction is readyemitToUser(userAddress,'pendingTransaction',{ id: transactionId, chatId, type:'transfer', transaction:{ to, value, data }, details:{ recipient, amount, token }, buttonText:'Send 0.5 ETH',});
The frontend listens and attaches the transaction to the appropriate message:
TypeScript
socket.on('pendingTransaction',(tx: PendingTransaction)=>{setPendingTransaction(tx);// Retry logic for race conditionsconstattachToMessage=()=>{setMessages((prev)=>{const lastLunarkMessage =[...prev].reverse().find((m)=> m.role ==='lunark');if(lastLunarkMessage){ lastLunarkMessage.pendingTransaction = tx;return[...prev];}return prev;});};// The message might not exist yet, retry with increasing intervalsconst retryIntervals =[100,300,500,1000,2000];attachToMessage(); retryIntervals.forEach((ms)=>setTimeout(attachToMessage, ms));});
The retry logic handles a race condition: the transaction event might arrive before the streaming message that references it.
The frontend uses the wallet's API to request the switch:
TypeScript
socket.on('networkSwitch',async({ chainId, name })=>{try{await window.ethereum.request({ method:'wallet_switchEthereumChain', params:[{ chainId:`0x${chainId.toString(16)}`}],}); toast.success(`Switched to ${name}`);}catch(error){if(error.code ===4902){// Chain not added to wallet toast.error(`Please add ${name} to your wallet`);}}});
A deep dive into how Lunark uses the Astreus framework to create an AI agent that can execute blockchain operations through natural language. Covers the tool system, agent caching, context injection, and the challenges of building autonomous blockchain agents.
A technical walkthrough of Lunark's DEX aggregation system, covering quote fetching across Uniswap, SushiSwap, Curve, PancakeSwap, and TraderJoe, fee tier optimization, and the challenges of building a multi-protocol swap system.
A technical deep dive into Titan - a complete DeFi protocol featuring token swaps, concentrated liquidity, liquid staking with sTITAN, overcollateralized lending with tUSD, and snapshot-based on-chain governance.