In a React Native stopwatch app, why does the running timer freeze while a large JSON response is parsed, though ScrollView still scrolls?
answer
- one task at a time
- the tick is JavaScript too
- JSON.parse cannot be interrupted
- scrolling lives on the UI thread
- derive time from a start timestamp
basics
~20 sJSON.parse runs synchronously on the single JavaScript thread, so no setInterval callback, state update or re-render can run until it returns. Scrolling is performed natively on the UI thread, so it keeps working while JS-driven updates stall.
solid answer
~40 sThe stopwatch's tick is a `setInterval` callback that calls `setState`, and both the callback and the re-render run on the **JS thread**. `response.json()` ends in a `JSON.parse` of the whole body, one synchronous task that holds that thread until it finishes, so the tick cannot run and the `<Text>` keeps its last mounted value, then jumps. Scrolling a `ScrollView`, native stack transitions and natively driven animations belong to the **UI thread**, so they carry on. I would fix two things: derive elapsed time from a start timestamp (`performance.now()`) so the display is right whenever it renders, and stop blocking the thread by paginating the payload or moving the heavy work to native code or another runtime. `requestIdleCallback` only delays the parse; it cannot split it.
code
tsx · 17 linesimport { useEffect, useRef, useState } from 'react';
import { Text } from 'react-native';
export function Stopwatch() {
const startedAt = useRef(performance.now());
const [elapsedMs, setElapsedMs] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// Derived from the start time, so it is right even after a stall.
setElapsedMs(performance.now() - startedAt.current);
}, 100);
return () => clearInterval(id);
}, []);
return <Text>{(elapsedMs / 1000).toFixed(1)} s</Text>;
}go deeper
Recall that the timer callback, setState and the render are all JavaScript, and that a long synchronous call blocks the one thread they share.
Explain why scrolling survives while the Text freezes, and why await and requestIdleCallback do not make a single JSON.parse interruptible.
Separate the two fixes: timestamp-derived display for correctness, and moving or shrinking the work for responsiveness, then confirm it with Perf Monitor or a trace.
Treat large client-side parsing as a contract problem: push pagination or field selection into the API so no screen ever pays a multi-hundred-millisecond parse.
## What the stopwatch is actually doing A stopwatch screen in React Native usually has three moving parts, and every one of them is JavaScript: - a `setInterval` callback that fires every 100 ms or so; - a `setState` call inside it that stores the new elapsed time; - a re-render that puts the new number into a `<Text>`. The timer is *scheduled* by native code, but its callback, the state update and the render all run on the **JS thread**, the single thread where the Hermes runtime executes your bundle. Then the screen loads a large catalog: ```typescript const res = await fetch(CATALOG_URL); const catalog = await res.json(); // ends in JSON.parse on the JS thread ``` The download happens in React Native's native networking code, off the JS thread. But `response.json()` finishes with `JSON.parse` over the whole body, and `JSON.parse` is **synchronous**: once it starts, it runs to the end as one task. ## Why the display freezes 1. The parse starts on the JS thread and holds it for, say, 800 ms. 2. The interval's next due time passes, but its callback cannot run: the JS thread is still inside `JSON.parse`. 3. No callback means no `setState`, no render, no commit and nothing new to mount, so the UI thread keeps showing the last mounted `<Text>`. 4. When the parse returns, the pending callback finally runs and the number jumps. The same happens with any long synchronous JavaScript: sorting a very large array, building a big derived structure, or rendering a huge component tree in one pass. `await` does not help. It only splits `loadCatalog` into steps that each still run on the JS thread; the `JSON.parse` step itself cannot be interrupted. ## What keeps moving and what does not | Keeps working during the parse | Stalls until the parse ends | |---|---| | Scrolling a `ScrollView` over rendered content | The stopwatch `<Text>` | | Native stack screen transitions | `onPress` handlers and JS-computed press feedback | | Animations whose frames are produced natively | Animations whose frames are computed in JavaScript | | The last mounted UI, redrawn by the OS | `onScroll` handlers, which receive their events late | The left column is owned by the **UI (main) thread**, which never needed JavaScript to do it. That asymmetry is the signature of a blocked JS thread: the app still scrolls, but nothing it says changes. ## Fixing the symptom and the cause Two separate problems need two separate fixes. **The displayed time must stay correct.** A stopwatch that counts ticks depends on every callback running on time. Store the start time once and derive elapsed time from a clock on each render: - keep the start in a ref: `startedAt.current = performance.now()`; - in each tick, set `performance.now() - startedAt.current`; - after any stall, the first render that does run shows the right value. **The JS thread must stop being blocked.** Options: - shrink the work at the source: paginate the endpoint or request only the fields the screen needs; - parse or process the data in native code and hand JavaScript a smaller result; - run the heavy step on a separate JavaScript runtime rather than the main one; - defer non-urgent work with `requestIdleCallback`, which waits for an idle moment but does **not** split a single synchronous call; once the parse starts, it blocks for its full duration. `InteractionManager.runAfterInteractions`, the older answer for deferring work, was deprecated in React Native 0.82 and removed in 0.87; `requestIdleCallback` is its replacement, with the same limit. ## Diagnosing it in a real app - Toggle **Perf Monitor** from the Dev Menu: a JS frame rate that collapses while the UI rate holds points at the JS thread. - In a trace, the JS thread (`mqt_v_js` on Android, `com.facebook.react.runtime.JavaScript` on iOS) shows one long slice where the parse runs. - Wrap the suspect step in `performance.mark` and `performance.measure` (stable since React Native 0.83) to time it with a realistic payload. - Always measure in a release build; development mode adds a lot of JS-thread work of its own. The interview point is the split itself: a frozen number next to a list that still scrolls tells you the JS thread is blocked, not that the device is slow.
- In React Native, why does a ScrollView keep scrolling during the parse while its onScroll handler reports late?The scroll itself is done by the native scroll view on the UI thread; it needs nothing from JavaScript to move content that is already mounted. The scroll events are then dispatched to the JS thread, and the `onScroll` handler can only run once the parse returns, so it sees the positions late.
- How would you stop a large JSON payload from blocking a React Native app's JS thread at all?Cut the work before it reaches JavaScript: paginate the endpoint or request only the fields the screen needs. If the payload must stay large, parse or reduce it in native code and pass a small result to JavaScript, or run the step on a separate JavaScript runtime. `requestIdleCallback` only chooses when the parse starts; it still blocks for its full duration.
saying these in an interview costs you the question
- The UI thread is busy parsing the JSON, so it cannot draw anything.
- Timers are native, so the tick callback runs even while JavaScript is busy.
- Awaiting response.json() moves the parse onto a background thread.
- Wrapping JSON.parse in requestIdleCallback splits it into small chunks.
- Counting interval ticks is an accurate way to measure elapsed time.