From 3 FPS to Smooth: How a Misplaced async Froze Our Chat UI


TL;DR A walkthrough of how to identify and solve web performance issues using Chrome DevTools and react-scan.

While working on a React-based streaming chat UI, I ran into a serious performance issue, as shown in the video below:

As you can see from the FPS number at the top-right of the screen, it dropped to 3 FPS when performing heavy rendering tasks like syntax highlighting or custom markdown, and the browser would freeze in the worst cases—clearly not something you’d want to ship.

Chrome DevTools

So I opened Chrome DevTools, switched to the Performance tab, and recorded a flame graph to pinpoint where the issue was coming from.

Flame graph

From the flame graph, we can see there were a lot of long tasks causing the browser to freeze. Clicking into one of the long tasks:

Long task

It looked like the browser was spending most of its time inside state updates and event handlers. At least now we knew where to start digging.

react-scan

react-scan automatically highlights re-rendering issues in a React app. After dropping it in and reproducing the lag, the syntax-highlighted code blocks lit up as an obvious offender:

Components

The app was using react-syntax-highlighter—easy to set up, but with known re-rendering overhead (there are a number of open GitHub issues on this). Swapping it for rehype-highlight turned out to be a better fit: it plugs straight into react-markdown and is much cheaper to re-render on streaming updates.

Root cause

The actual culprit turned out to be surprisingly simple: the main thread was drowning in microtasks. Here’s a minimal reproduction of the pattern:

// Called on every stream chunk
async function streamEventHandler(event) {
	if (event.type === 'text') return asyncJob('text', event.data);
	if (event.type === 'image') return asyncJob('image', event.data);
	if (event.type === 'end-event') return asyncJob('end-event', event.data);
	// ...
}

async function asyncJob(type, data) {
	handlerSyncJob(data);
	if (type === 'end-event') {
		await endStreamEvent();
	}
	//...
}

streamEventHandler is called on every stream chunk, which could be hundreds or thousands of times. Even though asyncJob() only actually awaits something on the end-event, the fact that it’s an async function means it always returns a Promise. This creates a cascade of microtasks:

  1. Create a Promise for every single stream chunk
  2. Queue all these Promises as microtasks in the event loop
  3. The browser processes all these microtasks before moving to the next macrotask (like rendering)
  4. With hundreds of microtasks to process, the main thread is blocked long enough that rendering can’t happen—creating a long task

The fix is to only use async when you actually need to await:

// Fixed version
function streamEventHandler(event) {
	if (event.type === 'text') return handlerSyncJob(event.data);
	if (event.type === 'image') return handlerSyncJob(event.data);
	if (event.type === 'end-event') return asyncJob(event.data);
}

async function asyncJob(data) {
	handlerSyncJob(data);
	await endStreamEvent(); // Only this path is async
}

function handlerSyncJob(data) {
	// Pure synchronous logic, no Promise
}

Now only the end-event path creates a Promise, which avoids the microtask overflow. After the fix, the long tasks in the flame graph drop significantly:

Flame graph after fix showing reduced long tasks

Combined with the move to rehype-highlight and a useMemo around the react-markdown props, re-rendering cost—measured via react-scan—went down noticeably too:

Components

Takeaway

A problem that felt complex—3 FPS, frozen tabs, a flame graph full of long tasks—came down to an async keyword that didn’t need to be there.

A few things I’ll keep in mind next time:

If you’ve hit something similar, I’d be curious how you diagnosed it. Here’s the final result: