skip to content

In React Native, why does a PanResponder drawing stroke trail behind the finger when the JavaScript thread is busy, and how do you fix it?

level: seniorimportance: must knowfreq 55%

answer

  1. touch arrives natively, decided in JS
  2. callbacks queue behind other JS work
  3. setState per move re-renders the canvas
  4. native driver cannot follow a JS callback
  5. UI-thread recognizers for hard cases

basics

~20 s

Every responder decision and PanResponder callback runs on the JavaScript thread, so moves wait behind other JS work and a re-render before the stroke appears. Keep move handlers cheap, or move the gesture to the UI thread.

solid answer

~50 s

The OS delivers the touch to the native UI thread, but React Native's responder system, including the should-set negotiation, `onResponderMove` and every `onPanResponder*` callback, runs in JavaScript. Each move is queued for the JS thread. If that thread is busy, for example re-rendering the whole canvas because the handler calls `setState` on every move, the events wait and the stroke catches up in jumps. Fix it in layers: keep `onPanResponderMove` tiny by buffering points in a ref and flushing state once per animation frame; render finished strokes in a memoized component so only the live stroke re-renders; drive a dragged sticker with `Animated.Value.setValue` so a move updates only that animated view instead of re-rendering the canvas; and defer non-urgent work with `requestIdleCallback`, since `InteractionManager` was removed in 0.87. `useNativeDriver: true` cannot help, because PanResponder hands values over in a JS callback. When a gesture must stay smooth under load, move recognition to the UI thread.

code

tsx · 76 lines
tsx
import {memo, useRef, useState} from 'react';
import {PanResponder, StyleSheet, View} from 'react-native';

type Point = {x: number; y: number};

const Dots = memo(function Dots({points}: {points: Point[]}) {
  return (
    <>
      {points.map((p, i) => (
        <View key={i} style={[styles.dot, {left: p.x - 3, top: p.y - 3}]} />
      ))}
    </>
  );
});

export function FastCanvas() {
  const [done, setDone] = useState<Point[]>([]);
  const [live, setLive] = useState<Point[]>([]);
  const pending = useRef<Point[]>([]);
  const frame = useRef<number | null>(null);

  const stop = () => {
    if (frame.current != null) cancelAnimationFrame(frame.current);
    frame.current = null;
  };

  const pan = useRef(
    PanResponder.create({
      onStartShouldSetPanResponder: () => true,
      onPanResponderGrant: () => {
        pending.current = [];
      },
      onPanResponderMove: evt => {
        const {locationX, locationY} = evt.nativeEvent;
        pending.current.push({x: locationX, y: locationY});
        if (frame.current == null) {
          frame.current = requestAnimationFrame(() => {
            frame.current = null;
            setLive([...pending.current]);
          });
        }
      },
      onPanResponderRelease: () => {
        stop();
        const stroke = pending.current;
        pending.current = [];
        setLive([]);
        setDone(prev => [...prev, ...stroke]);
      },
      onPanResponderTerminate: () => {
        stop();
        pending.current = [];
        setLive([]);
      },
    }),
  ).current;

  return (
    <View style={styles.canvas} {...pan.panHandlers}>
      <Dots points={done} />
      <Dots points={live} />
    </View>
  );
}

const styles = StyleSheet.create({
  canvas: {flex: 1, backgroundColor: 'white'},
  dot: {
    position: 'absolute',
    width: 6,
    height: 6,
    borderRadius: 3,
    backgroundColor: 'orange',
    pointerEvents: 'none',
  },
});

go deeper

for a junior

Recall that PanResponder callbacks are JavaScript, so anything that blocks the JS thread delays the drag or the stroke.

for a middle

Explain the path from the UI thread to the JS thread and back, and why a setState on every move multiplies the cost.

for a senior

Diagnose and fix in layers: cheap handlers, per-frame flushing, memoized layers, deferred work, and when to move recognition to the UI thread.

for a principal

Set the policy: which interactions may stay on JS-driven gestures, which must run on the UI thread, and how that choice affects the app's dependencies.

## Where a touch travels To see why a stroke lags, follow one move event from the glass to the pixels: 1. The OS reports the touch to the app's **UI thread** (the platform main thread). 2. React Native forwards the event to the **JavaScript thread**. 3. The **responder system**, running in JavaScript, decides which view owns the touch and calls its handlers, for example `onPanResponderMove`. 4. Your handler updates state, React re-renders, and the renderer commits the new view tree. 5. The UI thread mounts the changes, and the new segment of the line appears. Steps 3 and 4 are JavaScript. The finger is sampled on time; what is late is everything that happens in JS. ## Why a busy JS thread shows up as lag - The JavaScript thread runs **one task at a time**. A move event that arrives while it is rendering a long list, decoding a big JSON response or running a heavy effect simply waits. - The handler itself can be the problem. Calling `setState` with a growing points array on **every** move schedules a render of the whole canvas dozens of times a second, and its cost grows with the stroke's length. - Even the claim is delayed: the should-set handlers are JavaScript too, so on a stalled thread the grant itself arrives late. - When the thread frees up, the queued moves are processed in a burst, which is why the line appears to jump rather than trail smoothly. ## Fixes that stay within PanResponder | Fix | What it saves | Limit | |---|---|---| | Buffer points in a ref, flush state once per `requestAnimationFrame` | many renders per frame become one | still one render per frame on the JS thread | | Render finished strokes in a `memo` component | only the live stroke re-renders | the live stroke still grows while drawing | | Move a dragged sticker with `Animated.Value.setValue` | updates only the animated view, not the whole canvas | the values are still set from JS, so a stalled thread still stalls the sticker | | Defer non-urgent work with `requestIdleCallback` | fewer long tasks during the gesture | cannot help if the work is urgent | | Avoid expensive logic in should-set handlers | a faster claim | none | ## When to leave PanResponder Some screens cannot guarantee a quiet JS thread, for example a canvas that also streams a collaborator's strokes over the network. There, the answer is to take recognition off the JS thread entirely, with **react-native-gesture-handler** recognizers and **Reanimated** worklets that run on the UI thread. That moves the problem out of this system; the responder system itself has no UI-thread mode. ## Wrong fixes interviewers listen for - **`useNativeDriver: true` on an `Animated.event` passed as `onPanResponderMove`.** With the native driver, `Animated.event` must be attached to a native event on an animated component. PanResponder calls `onPanResponderMove` from JavaScript, so there is no native event to bind; the drag has to use `useNativeDriver: false`. - **`useCallback` around the handler.** The handler's identity is not the cost; its work and the render it triggers are. - **Assuming Hermes runs handlers on the UI thread.** Hermes executes JavaScript faster, but on the same JS thread. - **Relying on `InteractionManager`.** It was removed in React Native 0.87; `requestIdleCallback` is the replacement for deferring work. ## A useful mental check Ask two questions of any gesture: which thread decides, and which thread draws. With PanResponder, JavaScript both decides and, through React, draws. Every fix above either makes that JavaScript cheaper or removes it from the path. ## How the example applies this The canvas in the code example below follows the table: - `onPanResponderMove` only copies two numbers and pushes them into a ref, so each event costs almost nothing. - A single `requestAnimationFrame` per frame copies the buffer into state, so the canvas renders at most once per frame however many moves arrive. - Finished points live in a memoized layer that does not re-render while a new stroke is drawn. - Release and terminate both cancel a pending frame and reset the buffer, so an interrupted stroke never leaves a stray frame behind. The JavaScript thread still does the work, so a long task elsewhere still delays the stroke; the example shrinks the work, it does not move it.

  • Why does calling setState on every onPanResponderMove hurt more than the move events themselves?
    The events are cheap; the renders are not. Each `setState` schedules a render of the canvas component and its children, a reconciliation pass and a commit through the renderer to the UI thread. With dozens of moves per second and a points array that grows with the stroke, the cost per move keeps rising. Buffering in a ref and flushing once per frame caps the number of renders.
  • When is PanResponder still a reasonable choice in a current React Native app?
    For simple, short gestures on screens whose JavaScript thread is mostly idle, such as dragging a sticker to a bin or a swipe-to-dismiss card, and where adding a native gesture dependency is not justified. Once the gesture is continuous and must stay smooth while JS is busy, such as freehand drawing next to network sync, the JavaScript round trip becomes the limit.

A stenographer who also answers the office phone: the speaker's words arrive on time, but the transcript is only written when the stenographer is free, and then several sentences appear at once.

saying these in an interview costs you the question

  • The touch is detected late because JavaScript polls the screen for touches
  • useNativeDriver: true on the Animated.event in onPanResponderMove removes the lag
  • Hermes runs PanResponder callbacks on the UI thread
  • Wrapping onPanResponderMove in useCallback fixes a busy JS thread
  • Calling setState on every move is free because React batches updates