Good to knowReact Performance · 05

Expensive Calculations

Measure, cache with useMemo, or move the work to a Web Worker and read the result with use.

On this page · 7 sections
  1. In one minute
  2. The example: searching 150,000 products
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

In one minute

Everything in a component’s body runs on every render, so a slow calculation there runs again even when its inputs didn’t change. The options, roughly in order of effort:

  1. Don’t repeat it. Cache the result with useMemo(() => calc(a, b), [a, b]).
  2. Make it faster. A better algorithm, less data, a cheaper library.
  3. Move it off the main thread with a Web Worker, so it can be slow without freezing the page.
  4. Deprioritize the render around it (Concurrent Rendering).

useMemo only helps when the inputs didn’t change. When they did (each keystroke of a search), the work still blocks. A worker fixes that: the main thread just sends a message, and React reads the result as a promise with use.

You’ll be able to: decide whether a calculation is worth memoizing, cache it with useMemo, and move it to a worker with a promise React can suspend on.

Main thread
Search.tsx
1function Search() {2  const [query, setQuery] = useState('')3  const results = useMemo(() => rankProducts(query), [query])4  return (<>5    <input value={query} onChange={e => setQuery(e.target.value)} />6    <Results names={results} />7  </>)8}
What you see
Next frame after the key: –
Results: (all products)
  1. event<input value={query} onChange={e => setQuery(e.target.value)} />Type “l”.
  2. render phaseconst results = useMemo(() => rankProducts(query), [query])The query changed, so useMemo runs the search, on the main thread. Recorded: about 55 ms for “l”. (useMemo only helped for the unrelated “Refresh prices” click.)
  3. browserThe input can’t show the letter until the search is done.

0 / 0
A search across 150,000 products: every product is scored and sorted. Typing “l” (both versions recorded).

The example: searching 150,000 products

The store’s search scores every product against the query and sorts them. The page also has a “Refresh prices” button that re-renders it for an unrelated reason.

export function rankProducts(query: string) {
	const q = query.toLowerCase()
	return catalog
		.map((p) => ({ name: p.name, score: q ? score(p.name.toLowerCase(), q) : 1 }))
		.filter((r) => r.score > 0)
		.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
		.slice(0, 20)
		.map((r) => r.name)
}

The recordings: mount, click “Refresh prices”, then type “lamp” (200 ms between keys). rankProducts logs how long it took on the main thread.

1. In the render body

function SearchPlain() {
	const [query, setQuery] = useState('')
	const [, setRefresh] = useState(0)
	const results = timed(query) // runs on every render
	return (
		<>
			<input id="search" value={query} onChange={(e) => setQuery(e.target.value)} />
			<button id="refresh" onClick={() => setRefresh((n) => n + 1)}>Refresh prices</button>
			<Results names={results} />
		</>
	)
}

“Refresh prices”:

  rankProducts("") ran on the main thread: 75 ms

Typing “lamp”:

  rankProducts("l") ran on the main thread: 66 ms
keystroke "l": next frame after 70 ms
  rankProducts("la") ran on the main thread: 46 ms
keystroke "a": next frame after 49 ms
  rankProducts("lam") ran on the main thread: 20 ms
keystroke "m": next frame after 22 ms
  rankProducts("lamp") ran on the main thread: 16 ms
keystroke "p": next frame after 17 ms

The search ran again for a click that had nothing to do with it, and each keystroke waited for it.

2. useMemo

function SearchMemo() {
	const [query, setQuery] = useState('')
	const [, setRefresh] = useState(0)
	const results = useMemo(() => timed(query), [query]) // runs when query changes
	return (
		<>
			<input id="search" value={query} onChange={(e) => setQuery(e.target.value)} />
			<button id="refresh" onClick={() => setRefresh((n) => n + 1)}>Refresh prices</button>
			<Results names={results} />
		</>
	)
}

“Refresh prices”:

[]

Typing “lamp”:

  rankProducts("l") ran on the main thread: 65 ms
keystroke "l": next frame after 68 ms
  rankProducts("la") ran on the main thread: 44 ms
keystroke "a": next frame after 46 ms
  rankProducts("lam") ran on the main thread: 20 ms
keystroke "m": next frame after 23 ms
  rankProducts("lamp") ran on the main thread: 21 ms
keystroke "p": next frame after 24 ms

The refresh no longer runs the search. Typing still does, because the query really changed, and each keystroke still waits.

3. A Web Worker

The search moves into a worker file. The same rankProducts function runs there, on another thread:

import { rankProducts } from './search'

self.onmessage = (e: MessageEvent<{ id: number; query: string }>) => {
	self.postMessage({ id: e.data.id, results: rankProducts(e.data.query) })
}

On the main thread, a small client turns each request into a promise:

const worker = new Worker(new URL('./search.worker.ts', import.meta.url), { type: 'module' })
const waiting = new Map<number, (names: string[]) => void>()
let nextId = 0
worker.onmessage = (e: MessageEvent<{ id: number; results: string[] }>) => {
	waiting.get(e.data.id)?.(e.data.results)
	waiting.delete(e.data.id)
}

function searchInWorker(query: string) {
	return new Promise<string[]>((resolve) => {
		const id = nextId++
		waiting.set(id, resolve)
		worker.postMessage({ id, query })
	})
}

The component keeps the promise in state, reads it with use, and replaces it inside a transition:

function SearchWorker() {
	const [query, setQuery] = useState('')
	const [resultsPromise, setResultsPromise] = useState(() => searchInWorker(''))
	const [isPending, startTransition] = useTransition()

	function handleChange(q: string) {
		setQuery(q) // urgent: the input updates right away
		startTransition(() => setResultsPromise(searchInWorker(q))) // results follow when ready
	}

	return (
		<>
			<input id="search" value={query} onChange={(e) => handleChange(e.target.value)} />
			<div style={{ opacity: isPending ? 0.6 : 1 }}>
				<Suspense fallback={<p>Searching…</p>}>
					<WorkerResults promise={resultsPromise} />
				</Suspense>
			</div>
		</>
	)
}

function WorkerResults({ promise }: { promise: Promise<string[]> }) {
	return <Results names={use(promise)} />
}

Typing “lamp”:

keystroke "l": next frame after 17 ms
keystroke "a": next frame after 2 ms
keystroke "m": next frame after 2 ms
keystroke "p": next frame after 4 ms

Every keystroke painted within about one frame (~16 ms), usually much less. The results (the same first five as before) arrived when the worker replied:

Desk Lamp 1
Desk Lamp 10
Desk Lamp 100
Desk Lamp 1000
Desk Lamp 1001

How it works

Common mistakes

Interview Q&A

Why is a slow calculation in a component a problem?

The component body runs on every render, so the calculation repeats even when its inputs haven’t changed. Recorded: “Refresh prices” re-ran the 150,000-product search.

When is useMemo worth it?

When the calculation is measurably slow (about 1 ms or more) and the component re-renders with the same inputs. Recorded: with useMemo, the refresh click ran nothing.

What doesn't useMemo fix?

The case where the inputs change. Each keystroke changed the query, so the search still ran and the input still waited for it (recorded: tens of milliseconds per letter).

How does a Web Worker help, if the work is the same?

The work runs on another thread, so the main thread stays free to handle input and paint. Recorded: the next frame came within about one frame of every keystroke, instead of waiting for the search.

How do you show the worker's async result in React 19?

Keep the promise in state, read it with use(promise) inside Suspense, and replace the promise inside startTransition so the old results stay visible while the new ones compute.

Sources

Primary source: react.dev/reference/react/useMemo