I’ve been working on a large-scale Roblox project involving a map with around 10,000 to 50,000 small anchored parts. I needed a way to handle high-throughput part removal, so I built a pipeline where the server handles the logic and the clients handle the physics
The core idea was to keep the server completely free of physics simulation. When an action occurs, the server does some validation and calls workspace:GetPartBoundsInRadius with MaxParts set to 250. It tags the caught parts with CollectionService to prevent double-processing, serializes their visual properties (CFrame, Size, Color, etc.), and immediately calls Destroy() on them.
That lightweight data payload is then broadcasted to all clients. The clients take over entirely from there, instantiating temporary clones, applying velocity, and managing their own debris with custom collision groups and a hard FIFO cap of 250 parts before fading them out.
Everything runs incredibly smooth at first. But then I started looking at the server microprofiler during sustained gameplay, and something clicked that I think points to a deeper engine behavior worth discussing.
After about 300 to 500 cumulative parts are removed from the workspace hierarchy, the server starts experiencing noticeable stutters. The frame drops aren’t coming from physics or replication — the lag is isolated specifically to subsequent GetPartBoundsInRadius calls.
This feels like a spatial hashing degradation issue. When you rapidly mutate the workspace topology by calling Destroy() hundreds of times in the same region, it seems like Roblox’s internal spatial partitioning structure (whether it’s an octree or a hashmap) becomes increasingly expensive to query. The API abstraction of GetPartBoundsInRadius is ergonomic, but it hides the cost of what actually happens to the spatial index when the tree gets punched full of holes over time.
There is also a secondary factor. To filter the query, I am building the OverlapParams.FilterDescendantsInstances list dynamically on every call using CollectionService:GetTagged("Destructible"). With thousands of tagged instances in the map, I’m starting to wonder if this array allocation is compounding the spatial query bottleneck.
I’ve already tried the standard isolation tactics: capping the overlap query at 250 parts, enforcing instant destruction, and ensuring zero server-side physics. The lag scales strictly with the cumulative number of removed parts.
Now I’m at an architectural crossroads.
I’m considering dropping the native spatial API entirely and building a custom Lua-side spatial grid/hashmap to handle manual distance checks, but I’m not sure at what scale that actually outperforms the native C++ implementation.
Alternatively, I’m wondering if the overhead is strictly tied to how Destroy() forces the engine to clean up internal references. Would reparenting to nil bypass some of this cleanup overhead? Would batching these removals into a single RunService.Heartbeat cycle instead of doing it inline flatten the curve? Or is the only true solution here to build a massive object pool and just toggle Transparency and CanCollide instead of ever destroying anything?
Curious if anyone else has run into this specific degradation pattern with spatial queries after mass removals, and what your approach was to bypass the internal engine overhead without killing the ergonomics of the native overlap APIs.