Real-Time Streaming with Socket.IO: Building Responsive Web3 Chat
March 27, 2025
How Lunark implements real-time message streaming using Socket.IO, including the challenges of React state batching, room-based broadcasting, and coordinating blockchain transactions with chat responses.
ListenReady
0:00
0:00
3:48
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 Streaming Architecture
Lunark's streaming system has three layers:
OpenAI streaming - The LLM generates tokens one at a time
Backend relay - Express server receives tokens and broadcasts via Socket.IO
Frontend rendering - React updates the UI for each token
Real-time chat streaming in action
Backend Implementation
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.
Notice that the HTTP response returns immediately. The actual streaming happens asynchronously, with updates pushed via WebSocket.
Room-Based Broadcasting
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.
Chat history showing previous conversations
The React Batching Problem
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.
Auto-Scroll Behavior
Chat interfaces need smart scrolling. Auto-scroll when new messages arrive, but stop auto-scrolling if the user scrolls up to review history.
TypeScript
const containerRef =useRef<HTMLDivElement>(null);const[userScrolled, setUserScrolled]=useState(false);const scrollTimeoutRef =useRef<NodeJS.Timeout>();// Detect manual scrollingconsthandleScroll=()=>{const container = containerRef.current;if(!container)return;const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight <100;if(!isNearBottom){setUserScrolled(true);// Reset after 2 seconds of no scrollingclearTimeout(scrollTimeoutRef.current); scrollTimeoutRef.current =setTimeout(()=>{setUserScrolled(false);},2000);}else{setUserScrolled(false);}};// Auto-scroll on new messagesuseEffect(()=>{if(!userScrolled && containerRef.current){requestAnimationFrame(()=>{ containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight, behavior:'smooth',});});}},[messages, userScrolled]);
The requestAnimationFrame ensures smooth scrolling that doesn't fight with React's rendering cycle.
Transaction Events
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.
Network Switching
When the agent decides to switch networks (e.g., user asks to "check my balance on Polygon"), it emits a network switch event:
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`);}}});
Connection Management
Socket connections need careful lifecycle management. Connect when the user authenticates, disconnect on logout, handle reconnection gracefully: