Back to Performance
Performance
medium
mid

How would you handle drag events and optimize their performance in the browser?

Drag events fire dozens of times per second per pointer. Three principles: (1) keep the work per event tiny — only mutate transform via requestAnimationFrame, never trigger layout in the handler; (2) use CSS transforms (translate3d) over top/left for compositor-only updates; (3) decouple visual updates from state updates — debounce or batch the React state set, keep the dragging visual purely in CSS. Use the modern Pointer Events + setPointerCapture for unified mouse/touch/pen.

9 min read·~15 min to think through

Drag-and-drop performance lives or dies on what you do inside the per-event handler. With pointermove firing 60–120 times per second per pointer, even small overhead becomes visible jank.

Use Pointer Events, not mouse + touch

Pointer Events unify mouse, touch, and pen with one API and one event lifecycle:

js
el.addEventListener('pointerdown', onDown);
el.addEventListener('pointermove', onMove);
el.addEventListener('pointerup', onUp);

setPointerCapture(e.pointerId) on pointerdown routes all subsequent events to the same element even if the cursor leaves it — no need for window-level listeners.

The cheap-handler principle

Inside pointermove:

  • ❌ Don't read layout (offsetWidth, getBoundingClientRect unless cached).
  • ❌ Don't setState per event (forces React render).
  • ❌ Don't trigger style changes that hit layout.
  • ✅ Compute next (x, y), write to a ref, request an animation frame.
js
const target = useRef(null);
const pos = useRef({ x: 0, y: 0, startX: 0, startY: 0 });
const raf = useRef(0);

function onMove(e) {
  pos.current.x = e.clientX - pos.current.startX;
  pos.current.y = e.clientY - pos.current.startY;
  if (!raf.current) {
    raf.current = requestAnimationFrame(apply);
  }
}

function apply() {
  raf.current = 0;
  if (target.current) {
    target.current.style.transform =
      `translate3d(${pos.current.x}px, ${pos.current.y}px, 0)`;
  }
}

This pattern — RAF-coalesce + direct DOM write — uncouples the event rate from the paint rate. The browser only repaints once per frame regardless of how many pointermoves fire.

Use transform, not top/left

transform: translate3d(x, y, 0) is compositor-only — no layout, no paint of the dragged element's pixels. top/left triggers full layout + paint, which is 10x+ more expensive on complex pages.

will-change: transform on the draggable element promotes it to its own compositor layer:

css
.draggable { will-change: transform; }

Add will-change only during drag and remove it after — keeping it on permanently uses GPU memory unnecessarily.

Visual state ≠ React state

If you also need React state (current position for other components, drop target highlight), update it on drop, not on every move. While dragging, the visual lives in raw DOM. On pointerup, commit final position to state:

js
function onUp() {
  setPosition(pos.current);  // single setState at end
  target.current.style.willChange = '';
}

If you need React state during drag (e.g., for hit testing against other components), throttle or use useTransition to mark it as non-urgent.

Hit testing

For drop zones, don't query elementFromPoint on every move — cache zone rects on drag start, intersect with cursor in the RAF callback.

js
const zonesRef = useRef([]);
function onDown() {
  zonesRef.current = Array.from(document.querySelectorAll('.dropzone'))
    .map(el => ({ el, rect: el.getBoundingClientRect() }));
}
function hitTest(x, y) {
  return zonesRef.current.find(({ rect }) =>
    x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
  );
}

touch-action and scroll prevention

For touch dragging, prevent the browser's default scroll/refresh:

css
.draggable { touch-action: none; }

This is much faster than preventDefault on every event.

Auto-scroll

When dragging near a container edge, scroll the container. Use the RAF loop you already have:

js
function apply() {
  // … position update
  const { y } = pos.current;
  if (y > viewport.bottom - 50) scrollContainer.scrollBy(0, 5);
  if (y < viewport.top + 50)    scrollContainer.scrollBy(0, -5);
  raf.current = requestAnimationFrame(apply);   // keep looping
}

Library reach

Hand-rolled drag is fine for simple cases. For sortable lists, multi-select, kanbans:

  • dnd-kit — modern, hooks-based, a11y-aware.
  • react-dnd — older, HOC-based.
  • SortableJS / react-sortablejs — non-React option, very fast.

Accessibility

Mouse drag is half the story. Keyboard users need a way to move items (Up/Down to select, Space to grab, arrows to move, Enter to drop). Screen readers need announcements (aria-live). dnd-kit and similar libraries bake this in; rolling your own means re-implementing it.

Pitfalls

  • React state per pointermove → 60+ re-renders per second → jank.
  • Animating top/left instead of transform → layout thrash.
  • Forgetting setPointerCapture → losing events when the cursor leaves the element.
  • Missing touch-action: none → browser fights you for the scroll gesture.
  • will-change left on permanently → GPU memory wasted.
  • Computing layout (rects) inside the per-move handler → forced reflow.

Follow-up questions

  • Why use Pointer Events over mouse + touch separately?
  • How does setPointerCapture work and why is it useful?
  • What's the GPU cost of will-change and when does it backfire?
  • How do you make drag-and-drop accessible to keyboard users?

Common mistakes

  • setState on every pointermove — re-renders per frame.
  • Animating top/left instead of transform — full layout per frame.
  • Forgetting touch-action: none — touch dragging fights the browser's scroll.
  • Querying getBoundingClientRect inside the move handler instead of caching.
  • No requestAnimationFrame coalescing — running expensive work per event instead of per frame.
  • Rolling your own drag for a sortable list when dnd-kit handles a11y + edge cases.

Performance considerations

  • Done right, a drag interaction runs at 60fps (16ms/frame) even on mid-range mobile. The dominant cost is layout: avoid it. Compositor-only transforms are GPU-cheap. RAF coalescing caps work at the display rate. With these three primitives, even hundreds of draggables in the same view stay smooth.

Edge cases

  • Multi-touch: track multiple pointerIds; setPointerCapture per pointer.
  • Pen pressure / tilt available on Pointer Events (e.pressure, e.tiltX).
  • iOS Safari has unique behaviors around touch-action and overscroll — test there.
  • Drag inside a virtualized list (react-window): the dragged item may unmount as you scroll. Either pin it or use a portal.
  • RTL languages flip the math — base position calc on container layout, not viewport.

Real-world examples

  • Trello, Notion, Asana — sortable boards with hand-tuned drag for snappy feel.
  • Figma — drag uses Canvas/WebGL, not DOM, for extreme performance.
  • dnd-kit powers many modern React apps' DnD.

Senior engineer discussion

Seniors think about the *frame budget*: 16ms per frame, and per-event handlers must not consume it. They decouple visual (CSS transform via RAF) from state (React setState on drop), reach for Pointer Events, and respect a11y (keyboard support is non-negotiable). For complex sorting/drop UIs, they default to dnd-kit instead of hand-rolling.

Related questions