Performance degradation after mass BasePart:Destroy() calls with GetPartBoundsInRadius — best practices for high-throughput part removal?

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.

I’m not sure the spatial querying is the root of the problem unless you have hard profiling to tell you otherwise. I’m not able to replicate what you’re seeing. I whipped together a model containing 50,000 parts, performed batches of queries to remove parts in chunks no greater than 250 per spatial query, and saw what one would expect: per-batch time is reduced as number of batches increases, since each batch performs spatial queries on fewer and fewer parts.

Here is the simple testing place used. You can find the script performing the tests in ServerScriptService. I will admit these tests are not particularly rigorous, so let me know where these tests differ from your implementation and I can try to more closely mirror your circumstances in an effort to gleam more insight.

We have had to implement part pools before due to performance issues, but this was because destroying MeshParts with MaterialVariants becomes more and more costly as the total number of parts increases (or, at least, it did at the time, haven’t checked back on this specific problem in a couple years). When there were only 5,000 parts, you could destroy them all in a fraction of a second, but once there were tens of thousands of parts, destroying one would take as long as destroying the entire 5,000 from before. To combat this, we would hold parts in the pool to be recycled while we reserved a certain amount of time each frame to clean them up.

Even with this system running in the background, we’d get what you expect out of spatial queries: querying dense space is more expensive than querying sparse space, and the duration of all queries trends proportionally with overall part count. For what it is worth, though, all of the parts in this pool were client-side only, so we didn’t have to worry about any replication.

1 Like

Thx so much for the response! It confirmed my suspicions and pointed me directly to the correct solution.

The bottleneck wasn’t GetPartBoundsInRadius itself, but the accumulated overhead of calling :Destroy() on hundreds of parts in a single frame, which caused massive garbage collection stutters on the server.

Following your advice on deferred cleanup, I implemented a time-sliced destruction queue:

  1. When debris parts expire, the server instantly sets part.Parent = nil. This is extremely cheap and immediately takes them out of the workspace (stopping physics, rendering, and queries).
  2. The parts are then pushed into a server-side queue.
  3. A handler connected to RunService.Heartbeat processes the queue by calling :Destroy() on a maximum of 15 parts per frame.

This successfully spread the cleanup cost across subsequent frames and completely eliminated the lag spikes/stutters!

'll mark ur post as the solution. Thx again for the help!

1 Like

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.