DEV Community

Cover image for Why Your React App Lags After Integrating AI Chatbots (And How to Debug It)
Emma Schmidt
Emma Schmidt

Posted on

Why Your React App Lags After Integrating AI Chatbots (And How to Debug It)

Integrating an AI chatbot into a React application can feel like a major win until users start complaining about slow responses, frozen UIs, and janky scroll behavior. If you are building a product that relies on conversational AI, you need to Hire React.js Developers who understand not just React fundamentals but also the performance pitfalls that come with streaming APIs,large state trees, and real-time rendering. These issues are not always obvious at first glance,but with the right debugging strategies, you can track down the root cause and fix it before it hurts your user experience.

This guide breaks down the most common reasons your React app slows down after adding an AI chatbot, walks through real-world examples, and shows you exactly how to diagnose and resolve each problem.


Understanding the Performance Problem

When you add an AI chatbot to a React app, you introduce a specific combination of challenges that most React apps never face at the same time:

  • Streaming data: Tokens arrive one by one, triggering state updates dozens of times per second.
  • Long message history: Conversations grow over time, and rendering hundreds of messages becomes expensive.
  • Rich content: Markdown, syntax-highlighted code, and tables require heavy parsing and rendering.
  • Real-time UX expectations: Users expect immediate feedback, low latency, and smooth scrolling.

Each of these factors alone can slow an app down. Combined, they can make your app nearly unusable. The good news is that every one of these problems has a clear solution once you know what to look for.


Cause 1: Uncontrolled Re-renders from Streaming State Updates

The Problem

When you stream AI responses token by token using the OpenAI API or a similar provider, you typically update a state variable on every token. If your component tree is not optimized, this causes the entire chat UI to re-render 10 to 30 times per second.

Example of the Problem

// Bad approach: updating state on every token causes cascading re-renders
function ChatWindow() {
  const [messages, setMessages] = useState([]);
  const [streamingText, setStreamingText] = useState('');

  const streamResponse = async (prompt) => {
    const response = await fetch('/api/chat', { method: 'POST', body: JSON.stringify({ prompt }) });
    const reader = response.body.getReader();
    const decoder = new TextDecoder();

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      const chunk = decoder.decode(value);
      setStreamingText((prev) => prev + chunk); // fires re-render on every token
    }
  };

  return (

       {/* re-renders on every token */}


  );
}
Enter fullscreen mode Exit fullscreen mode

Every call to setStreamingText re-renders ChatWindow, which re-renders MessageList, even though the existing messages have not changed.

The Fix

Split the streaming message out of the main message list and memoize the message list component.

import { memo, useState, useRef } from 'react';

const MessageList = memo(({ messages }) => {
  return (

      {messages.map((msg) => (

      ))}

  );
});

function StreamingBubble({ textRef }) {
  const [text, setText] = useState('');

  useEffect(() => {
    textRef.current = setText;
  }, [textRef]);

  return {text};
}

function ChatWindow() {
  const [messages, setMessages] = useState([]);
  const streamSetterRef = useRef(null);

  const streamResponse = async (prompt) => {
    const response = await fetch('/api/chat', { method: 'POST', body: JSON.stringify({ prompt }) });
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let fullText = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      const chunk = decoder.decode(value);
      fullText += chunk;
      streamSetterRef.current?.(fullText); // only StreamingBubble re-renders
    }

    setMessages((prev) => [...prev, { id: Date.now(), role: 'assistant', content: fullText }]);
  };

  return (




  );
}
Enter fullscreen mode Exit fullscreen mode

Now only StreamingBubble re-renders during streaming. MessageList stays frozen until the stream completes.


Cause 2: Blocking the Main Thread with Large Payloads

The Problem

AI responses can be several thousand tokens long. When the response finishes, parsing the full JSON payload, processing markdown, and updating the DOM all happen synchronously on the main thread, causing a visible freeze.

Example of the Problem

// This can freeze the UI for 200-500ms on large responses
const handleResponse = async () => {
  const data = await fetchLargeAIResponse(); // returns 4000 tokens
  const parsed = parseMarkdown(data.content); // synchronous, heavy
  setMessages((prev) => [...prev, { ...data, parsed }]);
};
Enter fullscreen mode Exit fullscreen mode

The Fix

Use startTransition from React 18 to mark the heavy state update as non-urgent.

import { startTransition, useState } from 'react';

const handleResponse = async () => {
  const data = await fetchLargeAIResponse();

  startTransition(() => {
    setMessages((prev) => [...prev, data]);
  });
};
Enter fullscreen mode Exit fullscreen mode

For CPU-intensive parsing, offload to a Web Worker:

// worker.js
self.onmessage = (e) => {
  const { markdown } = e.data;
  const result = parseMarkdown(markdown); // runs off the main thread
  self.postMessage(result);
};

// ChatWindow.jsx
const worker = new Worker(new URL('./worker.js', import.meta.url));

worker.onmessage = (e) => {
  startTransition(() => {
    setMessages((prev) => [...prev, { role: 'assistant', content: e.data }]);
  });
};

const handleResponse = async (rawText) => {
  worker.postMessage({ markdown: rawText });
};
Enter fullscreen mode Exit fullscreen mode

Cause 3: Memory Leaks from Uncleaned Subscriptions and Streams

The Problem

If a user sends a message and then navigates away or closes the chat, the streaming fetch request continues running in the background. When it tries to update state on an unmounted component, you get memory leaks and potential errors.

Example of the Problem

// Memory leak: no cleanup when component unmounts
useEffect(() => {
  const streamResponse = async () => {
    const response = await fetch('/api/chat');
    const reader = response.body.getReader();

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      setStreamingText((prev) => prev + decode(value)); // crashes if unmounted
    }
  };

  streamResponse();
}, []);
Enter fullscreen mode Exit fullscreen mode

The Fix

Use an AbortController to cancel the fetch when the component unmounts.

useEffect(() => {
  const controller = new AbortController();

  const streamResponse = async () => {
    try {
      const response = await fetch('/api/chat', { signal: controller.signal });
      const reader = response.body.getReader();

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        setStreamingText((prev) => prev + decode(value));
      }
    } catch (err) {
      if (err.name !== 'AbortError') {
        console.error('Stream error:', err);
      }
    }
  };

  streamResponse();

  return () => {
    controller.abort(); // clean up on unmount
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

Cause 4: Improper Message List Virtualization

The Problem

A long conversation with 200 or more messages renders all message DOM nodes at once. Even if most messages are off-screen, React still mounts and maintains them, consuming memory and slowing scroll performance.

The Fix

Use react-window to only render messages that are currently visible.

npm install react-window
Enter fullscreen mode Exit fullscreen mode
import { VariableSizeList as List } from 'react-window';
import { useRef, useCallback } from 'react';

const ESTIMATED_ROW_HEIGHT = 80;

function VirtualMessageList({ messages }) {
  const listRef = useRef(null);
  const rowHeights = useRef({});

  const getItemSize = (index) => rowHeights.current[index] || ESTIMATED_ROW_HEIGHT;

  const setRowHeight = useCallback((index, height) => {
    if (rowHeights.current[index] !== height) {
      rowHeights.current[index] = height;
      listRef.current?.resetAfterIndex(index);
    }
  }, []);

  const Row = ({ index, style }) => {
    const rowRef = useRef(null);

    useEffect(() => {
      if (rowRef.current) {
        setRowHeight(index, rowRef.current.getBoundingClientRect().height);
      }
    }, [index, messages[index].content]);

    return (





    );
  };

  return (

      {Row}

  );
}
Enter fullscreen mode Exit fullscreen mode

Cause 5: Inefficient Context API Usage

The Problem

Many developers store the entire chat state in a single React Context object. When any part of that state changes (for example, a streaming token update), every component that consumes the context re-renders even if it only needs the user's name or the input field value.

Example of the Problem

// Bad: single monolithic context
const ChatContext = createContext();

function ChatProvider({ children }) {
  const [messages, setMessages] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [userProfile, setUserProfile] = useState({});
  const [inputValue, setInputValue] = useState('');

  return (

      {children}

  );
}

// Re-renders on every token because it consumes ChatContext
function UserAvatar() {
  const { userProfile } = useContext(ChatContext);
  return ;
}
Enter fullscreen mode Exit fullscreen mode

The Fix

Split context into separate, focused providers.

const MessagesContext = createContext();
const UIContext = createContext();
const UserContext = createContext();

function ChatProvider({ children }) {
  const [messages, setMessages] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [userProfile] = useState({ name: 'Alex', avatar: '/avatar.png' });
  const [inputValue, setInputValue] = useState('');

  return (



          {children}



  );
}

// Now only re-renders when userProfile changes
function UserAvatar() {
  const userProfile = useContext(UserContext);
  return ;
}
Enter fullscreen mode Exit fullscreen mode

Cause 6: Markdown and Code Block Rendering Overhead

The Problem

AI responses frequently contain markdown with code blocks, tables, and lists. Parsing and rendering this on every re-render during streaming is expensive. Libraries like react-markdownwith rehype-highlight can take 50 to 150ms per render on large responses.

The Fix

Defer markdown rendering until streaming is complete, and memoize the rendered output.

import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { memo, useMemo } from 'react';

const RenderedMessage = memo(({ content, isStreaming }) => {
  const renderedContent = useMemo(() => {
    if (isStreaming) {
      return {content};
    }

    return (

                {String(children).replace(/\n$/, '')}

            ) : (

                {children}

            );
          },
        }}
      >
        {content}

    );
  }, [content, isStreaming]);

  return {renderedContent};
});
Enter fullscreen mode Exit fullscreen mode

Cause 7: Network Latency Misattributed as UI Lag

The Problem

Sometimes what feels like UI lag is actually just slow API response time. Developers often spend hours optimizing React code when the real bottleneck is a cold API endpoint or a missing streaming setup on the server.

How to Distinguish Them

Open Chrome DevTools and check the Network tab:

  • If the request shows a long Waiting (TTFB) time → the server is slow.
  • If TTFB is fast but the page still feels sluggish → it is a rendering issue.

The Fix for Perceived Latency

Implement optimistic UI updates and loading skeletons to make the app feel immediate.

function ChatInput({ onSend }) {
  const [input, setInput] = useState('');

  const handleSubmit = () => {
    const userMessage = { id: Date.now(), role: 'user', content: input };

    setMessages((prev) => [
      ...prev,
      userMessage,
      { id: 'loading', role: 'assistant', content: null, isLoading: true },
    ]);

    setInput('');
    onSend(input);
  };

  return (

      <input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Ask anything..." />
      Send

  );
}

function MessageBubble({ message }) {
  if (message.isLoading) {
    return (

    );
  }

  return {message.content};
}
Enter fullscreen mode Exit fullscreen mode

How to Use React DevTools Profiler to Debug Lag

Step 1: Install React DevTools
Install the Chrome or Firefox extension from the browser's extension store.

Step 2: Open the Profiler Tab
Open DevTools, navigate to the "Profiler" tab, and click the record button.

Step 3: Trigger the Lag
Send a message to your chatbot and let it stream a response.

Step 4: Stop Recording and Inspect

Signal Meaning
A component rendering hundreds of times Uncontrolled re-renders from state updates
Long render durations (50ms+) on a single component Heavy computation inside render — move to useMemo
The same component in every frame Subscribed to a context or state that updates too often
Many components rendering when one thing changed Missing memo or incorrect dependency arrays

Step 5: Use the "Why did this render?" Feature
In Profiler settings, enable "Record why each component rendered." This shows exactly which
prop or state change triggered each render.


Performance Checklist

  • [ ] Streaming text updates only re-render the active streaming bubble, not the full message list
  • [ ] memo is applied to MessageList and MessageBubble
  • [ ] useCallback is used for event handlers passed as props
  • [ ] startTransition wraps state updates triggered after the stream completes
  • [ ] An AbortController cleans up open streams on component unmount
  • [ ] Message history longer than 50 items uses a virtualized list
  • [ ] Chat state is split into multiple contexts or managed with Zustand / Jotai
  • [ ] Markdown parsing is skipped or deferred during active streaming
  • [ ] A loading skeleton or typing indicator appears immediately after the user sends a message
  • [ ] React DevTools Profiler has been used to confirm no unexpected re-renders exist

Conclusion

AI chatbot integration is one of the most performance-intensive features you can add to a React application. The combination of streaming updates, long message lists, rich content rendering,
and real-time UX expectations creates challenges that standard React best practices do not fully address on their own.

The fixes are not complicated once you understand the root cause:

  • Isolate streaming updates to dedicated components
  • Memoize stable parts of your UI
  • Virtualize long lists
  • Split your context
  • Defer expensive rendering
  • Always use the Profiler to verify your changes actually help

By applying the patterns in this guide, you will turn a laggy chatbot integration into a smooth,production-grade experience that users actually enjoy.

Top comments (0)