skip to content

A card in a web page registers handlers for both touchstart and mousedown; on a phone the action runs twice. Why does that happen, and how do Pointer Events avoid the problem?

level: middleimportance: should knowfreq 48%

answer

  1. one tap, two event families
  2. legacy pages needed mouse events
  3. synthesised after the touch ends
  4. one stream tagged by device
  5. pointerType, pointerId, isPrimary

basics

~20 s

Touchscreen browsers fire the touch sequence and then replay it as compatibility mouse events so older mouse-only pages keep working, so both handlers run. Pointer Events replace both families with one stream — pointerdown, pointermove, pointerup — tagged by pointerType.

solid answer

~50 s

On a touchscreen, the browser dispatches `touchstart`/`touchend` for the gesture and then, for backwards compatibility with pages written before touch existed, synthesises a mouse sequence — `mousemove`, `mousedown`, `mouseup`, `click` — for the same tap. A page that listens to both families therefore handles one tap twice. The historical patches were ugly: call `preventDefault()` on `touchstart` to suppress the compatibility events, or set a "just handled a touch" flag and ignore mouse events for a few hundred milliseconds. Pointer Events fix it at the source. `pointerdown`, `pointermove`, `pointerup` and `pointercancel` are dispatched for mouse, touch and pen alike, so you register one set of handlers; `event.pointerType` tells you which device it was (`"mouse"`, `"touch"`, `"pen"`) when you genuinely need to branch, and `event.pointerId` plus `event.isPrimary` let you keep multiple simultaneous touches apart. For simple activation, plain `click` remains the best choice — it fires once for touch, mouse and keyboard.

go deeper

for a junior

Recall that a tap on a phone produces both touch events and synthesised mouse events, so listening to both runs your code twice. Knowing that pointer events give one unified stream is enough here.

for a middle

Explain the ordering — the compatibility mouse batch arrives after the touch sequence — and describe what preventDefault on touchstart suppresses and why pointerType, pointerId and isPrimary remove the need for that trick.

for a senior

Show the judgment of choosing per interaction: click for activation, pointer events for continuous gestures, and always handling pointercancel so a browser-owned scroll cannot leave the widget stuck mid-drag.

for a principal

Own the input strategy across a component library: one supported event family, an explicit multi-touch and stylus policy, and a plan for hybrid touch-plus-mouse devices where capability sniffing at load time is the wrong model.

## Why the browser sends a tap twice Touchscreen browsers arrived into a web made entirely of mouse handlers. If a phone had dispatched only touch events, every existing page would have been dead on arrival. So touch browsers do two things for one finger tap: they dispatch the real touch sequence, and then they *synthesise* a mouse sequence describing the same interaction. The compatibility batch — typically `mousemove`, `mousedown`, `mouseup`, then `click` — is dispatched after the touch sequence ends, not interleaved with it, which is why the second run of your handler feels like a delayed echo. A page that registers `touchstart` **and** `mousedown` for the same behaviour therefore runs it twice on a phone and once on a desktop. Symptoms in the wild: a counter incrementing by two, a modal that opens and immediately re-opens, an analytics event double-counted only on mobile. ## The old workarounds Two patterns dominated before Pointer Events shipped broadly. **Cancel the touch default.** Per the Touch Events specification, calling `preventDefault()` on `touchstart` instructs the browser not to dispatch the compatibility mouse events (and with them the synthesised click) for that gesture: ```js el.addEventListener('touchstart', (e) => { e.preventDefault(); // suppresses the compatibility mouse events activate(); }, { passive: false }); ``` It works, but it is a blunt instrument: it also cancels the browser's own default handling of the gesture, and it requires an explicitly non-passive listener because a cancelling handler forces the browser to wait for you before it can scroll. **Guard with a flag.** Set a boolean or timestamp in the touch handler and have the mouse handler bail out if a touch happened recently. This is the "ghost click" folklore, and it is fragile: the window is device-dependent and it breaks on hybrid devices where a user really does alternate finger and mouse. ## What Pointer Events actually change The Pointer Events model treats mouse, single-touch, multi-touch and stylus as instances of one abstraction, a *pointer*. One set of events covers them all: - `pointerdown`, `pointermove`, `pointerup` - `pointercancel` — the browser took the gesture over (it started scrolling or zooming), and **no `pointerup` will follow** - `pointerover` / `pointerout`, `pointerenter` / `pointerleave` - `gotpointercapture` / `lostpointercapture` `PointerEvent` extends `MouseEvent`, so everything you already read off a mouse event — `clientX`, `button`, `buttons`, `shiftKey` — is still there, plus the pointer-specific properties: - `pointerType` — `"mouse"`, `"touch"` or `"pen"`. Branch on this instead of registering two families. - `pointerId` — a stable id per active pointer; essential for multi-touch, where several pointers are live at once. - `isPrimary` — true for the first/primary pointer of each type, letting a single-pointer widget ignore extra fingers with one check. - `pressure`, `width`, `height`, `tiltX`, `tiltY` — richer stylus and contact information that mouse events never had. With one handler set, the double-fire cannot happen, because you never registered the second family: ```js el.addEventListener('pointerdown', (e) => { if (!e.isPrimary) return; // ignore extra fingers if (e.pointerType === 'pen') { /* … */ } activate(); }); ``` ## Compatibility mouse events still exist Adopting Pointer Events does not switch the compatibility mouse events off; the browser still emits them for touch so untouched legacy code keeps working. The discipline is simply: **pick one family per interaction**. Mixing `pointerdown` with `mousedown` for the same behaviour reintroduces exactly the bug you were fixing. ## When plain click is still the right answer For "the user activated this control", `click` beats all of the above. It fires once per activation on mouse, on touch, and on keyboard activation of a focusable control, which means you get keyboard support for free rather than reimplementing it. Reach for pointer events when you need the *stream* — drag, draw, resize, custom gestures — not when you need a tap. ## Two facts that catch people out - **`pointercancel` is not optional.** When the browser decides an in-progress gesture is a scroll, it cancels the pointer. A drag implementation that only resets state in `pointerup` leaves the widget stuck mid-drag. - **Hover is not universal.** `pointerenter` fires for a pen hovering above the digitiser and for a mouse, but touch has no hover state, so hover-only affordances remain unreachable by finger regardless of which event family you use.

  • If a page uses only pointer events, do the compatibility mouse events stop being dispatched?
    No. The browser still synthesises them for touch so that pages written before pointer events keep working; you simply have no listeners registered for them. That is why the rule is to pick one family per interaction — adding a mousedown handler alongside pointerdown for the same behaviour brings the double-fire straight back.
  • When would you still prefer click over pointerdown for a button?
    Almost always, for activation. `click` fires once for mouse, touch and keyboard activation of a focusable control, so keyboard support comes for free and you cannot double-handle. Pointer events earn their place when you need the continuous stream — drag, draw, resize, pinch — where a single activation event tells you nothing.
  • What does event.isPrimary let you do that pointerId alone does not?
    It identifies, without bookkeeping, the one pointer of each type that should drive single-pointer UI. With several fingers down you get several pointerIds and would otherwise have to remember which arrived first; `if (!event.isPrimary) return;` expresses "ignore the extra fingers" in one line while multi-touch code uses pointerId to track each contact separately.

saying these in an interview costs you the question

  • Thinks touch devices fire only touch events
  • Believes pointer events remove the compatibility mouse events
  • Debounces mouse handlers with a timer to suppress ghost clicks
  • Uses pointerdown for plain activation and loses keyboard support
  • Assumes one pointerdown always has a matching pointerup

context