Select to view content in your preferred language

Hover interaction significantly slower on high-DPI / Retina displays — hitTest performance degrades with devicePixelRatio

932
4
Jump to solution
05-14-2026 03:33 AM
JonathanDawe_BAS
Frequent Contributor

Hi all,

I've been building a web app using the ArcGIS Maps SDK for JavaScript (v5.x) that displays imagery footprints as polygons on a FeatureLayer using a polar stereographic projection (EPSG:3031). The core interaction is simple: hover over a polygon and highlight it.

The problem

On a MacBook with a Retina display, the hover feels noticeably sluggish. There's a visible delay between moving the mouse and the highlight updating. When I switch to a standard (non-Retina) external monitor, the same app feels much more snappy and responsive.

I've measured this by timing mapView.hitTest() calls with performance.now() across 100 samples:

Display Device Pixel Ratio Avg hitTest p50 p95
Retina (built-in)258.96 ms69 ms108 ms
External (non-Retina)130.03 ms16.9 ms80 ms

 

The hitTest on Retina is consistently around 2× slower, and that difference is very noticeable in practice. I've put together a video showing the issue occurring in a standalone HTML example — no framework, just ArcGIS and vanilla JS — with Chrome's device emulation toggling between DPI=1 and DPI=2

(view in My Videos)

Profiling in Chrome DevTools

To dig deeper I recorded Chrome Performance traces during identical hover patterns at emulated DPI settings:

  • On DPI=1, the Maps SDK's animation loop runs at a smooth 120 fps with no gaps.
  • On DPI=3, it runs at roughly 20 fps on average, with the main thread sitting idle for about 94% of the recording. The renderer isn't busy — it's just waiting.

The pattern is that after each highlight update renders, the SDK stops requesting new frames from the browser entirely. It only starts again once the next hitTest promise resolves. On DPI=1, that happens quickly enough that rendering stays continuous. On DPI=3, with hitTest taking over 60 ms, there are visible pauses of 100–140 ms between each highlight update — and that's what the user feels as lag.

The bottom-up CPU profile shows no expensive JS during these gaps. The main thread is genuinely doing nothing. The work appears to happen inside the ArcGIS SDK's own rendering pipeline, not in application code.

I'm not sure exactly what's happening inside the SDK at this point, but I wonder if it's related to how the WebGL canvas scales with devicePixelRatio — at DPI=2 that's 4× the pixel area compared to DPI=1, which could potentially be a factor in why hitTest takes longer?

What I'd actually prefer

For this use case of imagery footprints, I'd happily trade Retina sharpness for responsive hover interaction. Is there a supported way to cap the dpi used in the hittest somehow to speed up the checks?

Has anyone else hit this? Any suggestions welcome.

0 Kudos
1 Solution

Accepted Solutions
JonathanDawe_BAS
Frequent Contributor

Following up on my own question here, in case it helps anyone who lands on this thread later.

I never found a truly effective way to cap the DPI used by mapView.hitTest(), short of overriding window.devicePixelRatio itself. But I did find a better approach for my use case: for features drawn on the client, running a geometry query against the layer view turned out to be more performant than hit-testing, and far more consistent across DPI.

The approach

Instead of mapView.hitTest(), I run a geometry query against the layer view. It's generally faster and, more importantly, stays consistent as DPI changes. The only residual GPU cost is generating the hover highlight graphic itself, which does still lag as DPI / canvas size increase, but that's a much smaller cost than the hit-test was.

The reason comes down to what each one actually does:

  • mapView.hitTest() forces a full WebGL render pass per pointer move and reads back from the GPU to work out what's under the cursor. That readback cost scales with the canvas pixel area, which is exactly why it doubled on a Retina display (DPI=2 is 4x the pixels of DPI=1). It's a rendering operation, so resolution is baked into the cost.
  • layerView.queryFeatures() with a point geometry runs against the features held client-side in the layer view, using the in-memory spatial index on the CPU. It's a pure geometry operation. It has no idea what resolution your screen is, so devicePixelRatio simply doesn't enter into it.

So instead of asking the GPU "what did you draw at this pixel", I convert the pointer location to a map point with view.toMap() and ask the layer view "which features intersect this point". Same answer, no render pass.

What it looks like

async function pickAtScreenPoint(view, layerView, screenPoint) {
  const mapPoint = view.toMap(screenPoint);
  if (!mapPoint) return [];

  // createQuery() folds in the layer view's effective display filter,
  // outFields and returnGeometry, so filtered-out features are excluded for free.
  const query = layerView.createQuery();
  query.geometry = mapPoint;
  query.spatialRelationship = 'intersects';

  const { features } = await layerView.queryFeatures(query);
  return features;
}

A few things worth noting:

  • Use the layer view's queryFeatures (view.whenLayerView(layer)), not the layer's. FeatureLayer.queryFeatures() goes to the feature service; FeatureLayerView.queryFeatures() runs against the features available for drawing on the client, so there's no server round trip and no waiting on a fetch.
  • createQuery() is doing useful work here. It applies the effective display filter for the current scale, so whatever the user has filtered out of view is automatically excluded from the pick, with no extra bookkeeping.
  • If you want a bit of click tolerance, buffer the map point slightly before the query rather than relying on pixel tolerance.

One limitation worth flagging

As I understand it mapView.hitTest() picks against the rendered symbology, so it accounts for things like line widths, marker sizes, and symbol offsets. A thin line drawn with a wide stroke is still pickable across the full width you see on screen, and an offset symbol is pickable where it's actually drawn, not where its geometry sits.

The geometry query has none of that context. It tests the true feature geometry only, so symbol width and offset are ignored. For my polygon footprints that makes no practical difference, but for thin lines or large/offset point symbols the query will feel less forgiving, since you have to be over the actual geometry. Buffering the query point (scaled to the current resolution) recovers most of the width tolerance, though it won't reproduce symbol offsets exactly. Worth weighing up if your layer leans heavily on symbology for its clickable area.

Hope this saves someone the rabbit hole I went down. Happy to share more detail on the setup if useful.

The video below shows a comparison: first the view.hitTest() method (map with blue footprints), then the new layer query approach (map with red footprints).

 

(view in My Videos)

View solution in original post

0 Kudos
4 Replies
JonathanDawe_BAS
Frequent Contributor

Here is a video of the running application. If I manually reduce the screen device pixel ratio (2 => 1.4), I drastically improve the interaction snappiness. 

Is this expected behaviour? 

(view in My Videos)

0 Kudos
JonathanDawe_BAS
Frequent Contributor

My current fix which feels like a massive hack is as follows: 

 

// Caps reads of `window.devicePixelRatio`. ArcGIS WebGL hit-testing seems to scale with
// DPR; on high-DPI screens, the function calls become expensive enough to block new animation frames
// during hover or pan (causing noticeable UI jank).

const MAX_DEVICE_PIXEL_RATIO = 1.4;

const descriptor =
  Object.getOwnPropertyDescriptor(Window.prototype, 'devicePixelRatio') ??
  Object.getOwnPropertyDescriptor(window, 'devicePixelRatio');

if (descriptor?.get) {
  const nativeGet = descriptor.get.bind(window) as () => number;

  Object.defineProperty(window, 'devicePixelRatio', {
    configurable: true,
    enumerable: descriptor.enumerable ?? true,
    get: (): number => Math.min(MAX_DEVICE_PIXEL_RATIO, nativeGet()),
  });
}
AndreV
by
Frequent Contributor

Amazing, Jonathan. I guess this also affects every popup opening in mapview. Hope this will be addressed in a future release. By the way great application UI.

gdi-hohenlohekreis.de
0 Kudos
JonathanDawe_BAS
Frequent Contributor

Following up on my own question here, in case it helps anyone who lands on this thread later.

I never found a truly effective way to cap the DPI used by mapView.hitTest(), short of overriding window.devicePixelRatio itself. But I did find a better approach for my use case: for features drawn on the client, running a geometry query against the layer view turned out to be more performant than hit-testing, and far more consistent across DPI.

The approach

Instead of mapView.hitTest(), I run a geometry query against the layer view. It's generally faster and, more importantly, stays consistent as DPI changes. The only residual GPU cost is generating the hover highlight graphic itself, which does still lag as DPI / canvas size increase, but that's a much smaller cost than the hit-test was.

The reason comes down to what each one actually does:

  • mapView.hitTest() forces a full WebGL render pass per pointer move and reads back from the GPU to work out what's under the cursor. That readback cost scales with the canvas pixel area, which is exactly why it doubled on a Retina display (DPI=2 is 4x the pixels of DPI=1). It's a rendering operation, so resolution is baked into the cost.
  • layerView.queryFeatures() with a point geometry runs against the features held client-side in the layer view, using the in-memory spatial index on the CPU. It's a pure geometry operation. It has no idea what resolution your screen is, so devicePixelRatio simply doesn't enter into it.

So instead of asking the GPU "what did you draw at this pixel", I convert the pointer location to a map point with view.toMap() and ask the layer view "which features intersect this point". Same answer, no render pass.

What it looks like

async function pickAtScreenPoint(view, layerView, screenPoint) {
  const mapPoint = view.toMap(screenPoint);
  if (!mapPoint) return [];

  // createQuery() folds in the layer view's effective display filter,
  // outFields and returnGeometry, so filtered-out features are excluded for free.
  const query = layerView.createQuery();
  query.geometry = mapPoint;
  query.spatialRelationship = 'intersects';

  const { features } = await layerView.queryFeatures(query);
  return features;
}

A few things worth noting:

  • Use the layer view's queryFeatures (view.whenLayerView(layer)), not the layer's. FeatureLayer.queryFeatures() goes to the feature service; FeatureLayerView.queryFeatures() runs against the features available for drawing on the client, so there's no server round trip and no waiting on a fetch.
  • createQuery() is doing useful work here. It applies the effective display filter for the current scale, so whatever the user has filtered out of view is automatically excluded from the pick, with no extra bookkeeping.
  • If you want a bit of click tolerance, buffer the map point slightly before the query rather than relying on pixel tolerance.

One limitation worth flagging

As I understand it mapView.hitTest() picks against the rendered symbology, so it accounts for things like line widths, marker sizes, and symbol offsets. A thin line drawn with a wide stroke is still pickable across the full width you see on screen, and an offset symbol is pickable where it's actually drawn, not where its geometry sits.

The geometry query has none of that context. It tests the true feature geometry only, so symbol width and offset are ignored. For my polygon footprints that makes no practical difference, but for thin lines or large/offset point symbols the query will feel less forgiving, since you have to be over the actual geometry. Buffering the query point (scaled to the current resolution) recovers most of the width tolerance, though it won't reproduce symbol offsets exactly. Worth weighing up if your layer leans heavily on symbology for its clickable area.

Hope this saves someone the rabbit hole I went down. Happy to share more detail on the setup if useful.

The video below shows a comparison: first the view.hitTest() method (map with blue footprints), then the new layer query approach (map with red footprints).

 

(view in My Videos)

0 Kudos