How do I display markets on a plane with onclick events without dropping fps after a few minutes?

I’m trying to display a large number of markers (0–1000, depending on the search query). When I add an onClick event to them, it works fine initially, but after a while, it starts slowing down and causes my FPS to drop for a second. I’ve wrapped the scene with <Bvh>. In another post with a similar issue, someone mentioned that the problem might be related to the raycast, but that solution isn’t working for me.

I make a request to the API and update the markers in my Zustand store, which are then used to render the markers. First, I normalize the (Lat, Lng) coordinates and loop through them to create the markers.

import { useCallback, useState } from "react";
import { normalizedLocations, FIXED_BOUNDS } from "../../../../utils/normalize";
import Marker from "../Markers/MarketMarker";
import { useAppStore } from "../../../../stores/appStore";

const Markers = () => {
	const markers = useAppStore((state) => state.data.markers);
	const [activeMarkerIndex, setActiveMarkerIndex] = useState(null);

	const normalizedSearch = normalizedLocations(markers, FIXED_BOUNDS);

	const handleMarkerClick = useCallback((id) => {
		setActiveMarkerIndex(id);
	}, []);

	const handleMarkerClose = useCallback(() => {
		setActiveMarkerIndex(null);
	}, []);

	return (
		<>
			{normalizedSearch.map((marker) => (
				<Marker
					key={marker.id}
					market={marker}
					isActive={activeMarkerIndex === marker.id}
					onClose={() => handleMarkerClose()}
					onClick={() => handleMarkerClick(marker.id)}
				/>
			))}
		</>
	);
};

export default Markers;

This is how the Marker component is set up:

import { Html } from "@react-three/drei";
import Location from "../../../../assets/Location";

const Marker = ({ market, isActive, onClose, onClick }) => {
	return (
		<group
			key={market.id}
			position={[market.normalizedX, 0.1, -market.normalizedY]}
		>
			<Html as="div" className="relative" distanceFactor={5} center>
				{isActive && (
					<div className="bg-white px-8 py-16 w-80 absolute bottom-full mb-4 left-1/2 transform -translate-x-1/2">
						{/* header popup */}
						<div className="absolute w-full h-16 bg-[#343534] top-0 left-0 z-10"></div>
						<p
							onClick={onClose}
							className="absolute top-2 right-2 text-white z-20 cursor-pointer"
						>
							X
						</p>

						{/* content popup */}
						<div className="relative z-20 flex flex-col items-center">
							<div className="w-12 h-12 bg-[#BEAF87] rounded-full mt-[-20px] flex items-center justify-center mb-2">
								TT
							</div>
							<p>{market?.tags?.name}</p>
							<p>{market?.tags?.cuisine}</p>
							{(market?.tags?.source || market?.tags?.website) && (
								<a
									href={market?.tags?.source || market?.tags?.website}
									target="_blank"
									rel="noopener noreferrer"
									className="underline text-lg mt-2 text-[#BEAF87]"
								>
									link
								</a>
							)}
						</div>
					</div>
				)}

				<Location className={"w-6 h-6"} color="#000" onClick={onClick} />
			</Html>
		</group>
	);
};

export default Marker;

Rendering hundreds of <Html> overlays with onClick handlers can be expensive due to many DOM elements and pointer events. Here are some tips to improve performance:

  1. Use Instanced Geometry: If you only need markers in 3D (not each with a custom <Html> UI), use <InstancedMesh> and a single onPointerDown. You’ll get one draw call and fewer raycasts.
  2. Fewer HTML Elements: Instead of an <Html> for every marker, only render <Html> for the currently selected/hovered marker. That way, you avoid creating lots of DOM elements.
  3. Memoization: Make sure each marker component is memoized to avoid extra re-renders when your Zustand store updates.
  4. Batch Raycasting: Consider a single custom raycast pass rather than letting each marker handle pointer events. For example, detect which marker is intersected, then show the popup.
  5. BVH: Using <Bvh> is good for 3D geometry, but if each marker is an <Html> element, it won’t help with DOM-based pointer events.

A common optimized approach is to do the picking in 3D with instancing, then display a single <Html> component for whichever marker is active.

An additional trick to HEOJUNFO’s great answer is to include lowpoly “colliders shape” with your models (they could be on another layer or use invisible mat ).

Maybe it’s overkill, maybe not. Depend how high your polycount is, how fast you want to cast.