Godot pathfinding / 2026-07-15 / 15 min read
Verified as of Godot 4.7 stable
Why NavigationAgent2D Lags With Hundreds of Units
Hundreds of NavigationAgent2D units lag Godot through same-tick request bursts, per-frame target resets, and path_max_distance repath storms. Fix each.

The direct answer
When a Godot 4.7 game hitches as hundreds of NavigationAgent2D units receive or refresh routes, count path requests per physics tick before changing algorithms. The common request-side causes are a group-order burst, per-frame target_position resets against a moving target, and recalculations triggered when avoidance pushes an agent past path_max_distance.
If a single query is already slow, the problem is search-side instead: inspect navigation mesh polygon count and unreachable targets. When one query is healthy, a hundred same-tick requests are a scheduling problem.
Godot's performance guide recommends dividing NavigationAgents into update groups or using random timers so they do not all request paths in the same frame. This article turns that guidance into an instrumented, downloadable governor.
Match the symptom to the cause
Start with the frame-spikes guide to confirm that navigation owns the hitch. This page starts after that attribution and stays on the request side.
For the search side, the documentation states that path-search cost correlates with navigation-mesh polygons and edges rather than the world's physical size. An unreachable target can require a much longer search through the available polygons. Use the broader empty-path diagnosis when the endpoint itself is uncertain instead of adding a second reachability query.
| What you see | Likely cause | First action |
|---|---|---|
| One hitch when a selected group receives a move order | Same-tick request burst | Use update groups or a per-tick request budget. |
| Steady stutter while units chase a moving target | Per-frame target_position resets | Use distance and time repath thresholds. |
| Lag only where the crowd bunches up | path_max_distance recalculations | Measure path deviation, tune tolerance, and freeze arrivals. |
| A unit dances between two positions | Frequent target updates or a path_max_distance value that is too short | Apply thresholds first, then widen the tolerance carefully. |
| A hitch when the destination cannot be reached | Documented long unreachable-target search | Query once and inspect the returned final position. |
| Every query is slow, including a single unit | Search-side polygon or edge count | Optimize the navigation mesh; scheduling is not the root fix. |
| A small squad is smooth but hundreds share one destination poorly | Per-agent paths have reached their architectural ceiling | Move to the shared-field design in the 10,000-agents guide. |
Count repaths before tuning them
Measure the request pattern before changing budgets or thresholds. Application-owned reasons such as GROUP_ORDER, TARGET_DRIFT, and PERIODIC_REFRESH should be counted separately from the engine's path_changed signal.
path_changed carries no reason payload. Record PROBABLE_OFF_PATH only as an application-side inference when the target and map revision stayed fixed while the agent exceeded its path tolerance.
Performance.TIME_NAVIGATION_PROCESS is aggregate context for a navigation step, including map updates and avoidance calculations; it is not a documented per-query timer. Custom monitors appear in the editor Debugger's Monitor tab.
Increment _requests_this_tick only where the application queues a new target, then snapshot and reset it once per physics tick. Remove custom monitors in _exit_tree() when the controller can be unloaded.
- Path requests per physics tick: exposes a one-frame group-order burst.
- Requests per unit per second: exposes sustained every-tick target resets.
- Application request reasons: separates group orders, target drift, and periodic refresh.
- Queue depth and oldest-request age: exposes a budget that is too tight for acceptable response latency.
Cause A: one click, one hundred path requests
Setting NavigationAgent2D.target_position requests a new path from the NavigationServer. Assign one destination to one hundred selected units and the application has created one hundred requests in the same physics tick.
A request governor is the explicit version of Godot's update-group advice. The count ceiling controls how many requests start in one drain call, but query costs still vary. A cross-map route through a dense mesh can cost more than a short hop, so let the profiler set the final count.
Queue latency is measurable before running the game: with N pending requests and budget B, draining requires ceil(N / B) ticks and the last batch is served on that tick. At 60 Hz, 500 requests with B = 8 place the final batch about one second after the order.
If that delay is unacceptable, raise the budget for small groups, prioritize visible or player-controlled units, or stop issuing one path per unit. The 10,000-agents guide owns the shared-field architecture.
Array.pop_front() shifts the remaining indexes back and can become noticeable on large arrays. The verified helper uses two arrays with head cursors plus bounded cleanup.
- Latest target wins: repeated requests for one pending unit update its target instead of appending unlimited work.
- Priority promotion replaces the old token: the stale entry remains cheap to skip and cannot dispatch later.
- Dead and cancelled units stop before the callback: their queued entries do not reach NavigationAgent2D.
- Request and scan budgets are separate: stale cleanup cannot consume an unbounded tick.
Build a bounded, coalescing request queue
Every governor snippet in this article is a fragment of the same receipt-bound repath_governor.gd. The complete assembled file is in the source download.
The pending dictionary points to the active entry object, not an array index. A same-priority retarget mutates that entry in place and keeps its request age. A promotion creates a new token in the high queue while the old token becomes stale.
Bound dispatch and stale-entry cleanup separately
Cancelled, dead, superseded, and promoted entries still have to be inspected before the live request callback can run. max_entries_scanned_per_tick bounds that maintenance independently from queries_per_tick.
Dispatch supplies the current map revision to the callback and retains the request revision for diagnostics. Delayed work may start from a different unit position or map state, so route equality is claimed only for an identical start, target, and map snapshot.
Let priority work preempt without starving the queue
Player-visible or near-camera units may need to move before background units, but permanent high-priority traffic cannot be allowed to starve the normal queue.
The selector serves a bounded high-priority burst and then gives a waiting normal request a turn. FIFO order remains defined within each priority.
Wire the governor beside the unit registry
The governor belongs to the battle, squad, or level controller that owns the unit registry and navigation-map revision. One queue inside every agent recreates the same burst with more objects. Games with multiple navigation maps should keep one governor and revision per map.
The callback below applies the dispatched target to the real NavigationAgent2D. It is battle-scene integration code, not part of the receipt-bound helper.
This budgets target assignments; it does not replace the agent's consumption contract. A moving agent still calls get_next_path_position() once per physics frame. Stop calling it after the target or path end has been reached, as the official tutorial warns.
Cause B: the target moved one pixel
A chasing unit often resets target_position whenever its target moves. Every reset requests a new path. The official tutorial attributes dancing between positions to very frequent path updates, while the performance guide says to avoid unnecessary path resets and queries every frame.
Use a distance threshold to ignore drift that cannot materially change the route and a time threshold to refresh a slowly moving target. Longer intervals are reasonable at long range; shorter intervals become useful near the destination.
Arrival is a hard boundary. An arrived unit returns REASON_NONE, consumes no new target request, and should stop calling get_next_path_position().
Cause C: avoidance pushes, path_max_distance pulls
path_max_distance is the allowed distance from the ideal path. Godot documents that exceeding it makes the agent recalculate the ideal path, including when collision avoidance pushes the agent away.
The crowd-scale chain is an inference from that documented behavior: avoidance nudges one agent beyond its tolerance, the new route changes local pressure, and a neighbor may cross its own tolerance. Correlate path_changed with an unchanged target and map revision plus measured deviation before recording PROBABLE_OFF_PATH.
Avoidance and pathfinding remain separate systems. Avoidance also consumes CPU; neighbor_distance and max_neighbors are documented cost knobs. The avoidance-vs-pathfinding guide owns that deeper boundary, while the stuck-agent guide covers the single-agent symptom.
The underlying properties and setup order are covered in the NavigationAgent setup guide.
- Tune from measured deviation. Increase path_max_distance only far enough that normal steering stays inside it, then recheck recovery from a genuinely lost corridor.
- Freeze arrivals. Units inside their destination tolerance stop consuming path updates and cannot be pushed into another recalculation cycle.
- Keep steering separate from planning. A transient avoidance offset does not automatically require a new target assignment.
Pick the fix from the failure
Apply the smallest intervention that removes the measured cause, then repeat the same counters. The shared-field row is a separate architecture rather than a larger value for the governor.
| Fix | What it eliminates | Best for |
|---|---|---|
| Update groups or per-tick budget | The same-tick burst from one group order | RTS selection orders |
| Distance and time thresholds | Redundant requests against slowly moving targets | Chase and escort behavior |
| Off-path tolerance plus arrival freeze | Repeated path_max_distance recalculations in crowds | Crowded destinations |
| One shared field | Per-agent route queries for one shared destination | Massive crowds with a common goal |
Run the repath-governor proof yourself
The standalone Godot 4.7 source project contains the assembled governor, runnable scene, verifier, README, and license. Its machine-readable receipt reports 20/20 named checks and zero failures.
The logic checks cover both ceilings, FIFO within a priority, high-priority preemption without normal starvation, coalescing and promotion, dead/cancelled entries, threshold policies, arrival freeze, current-revision dispatch, same-snapshot route equality, and compaction order.
The scene smoke creates a real NavigationServer2D region, obtains direct and NavigationAgent2D paths, and drains ten logical requests as 3, 3, 3, 1 under a three-query budget. The packaged ZIP was extracted, parsed, rerun, and matched against all 19 workspace check IDs.
The ZIP is 11,641 bytes with SHA-256 5c58b4a53df42cf9b8aa81a0a75758bc64656435344fc0d44db36e92e6f94bef. Measurement-gap: this is scheduling-correctness and scene-integration evidence, not a timing, FPS, throughput, memory, or supported-unit-count benchmark.
Debug hundreds-of-units lag in this order
The order separates request storms, arrived-unit mistakes, search-side cost, and an architectural ceiling instead of changing several systems at once.
- Profile first and confirm the hitch belongs to navigation rather than physics, rendering, or gameplay scripts.
- Record requests per tick, per-unit rate, application reasons, queue depth, oldest age, and TIME_NAVIGATION_PROCESS.
- Map GROUP_ORDER spikes to Cause A and sustained TARGET_DRIFT to Cause B.
- Correlate unexplained path_changed signals with stable target/map state and measured deviation before labeling PROBABLE_OFF_PATH.
- Check whether arrived units still consume path updates.
- Apply one budget, threshold, or tolerance change and repeat the same measurement.
- If requests are minimal but one query is slow, inspect polygon count and unreachable targets.
- If many units still need separate routes to one destination, move to a shared field.
Frequently asked questions
Why does my game freeze when I order many units to move?
Each selected NavigationAgent2D requests its own path in the same physics tick. Divide agents into update groups or route targets through a budgeted queue with explicit latency and priority rules.
How often should NavigationAgent2D recalculate its path?
Do not reset target_position every frame. Recalculate when target drift, a map change, persistent off-path deviation, or an explicit gameplay event makes the current route stale, and stop after arrival.
Does avoidance slow down pathfinding in Godot?
Avoidance and pathfinding are separate systems, but both consume CPU. Avoidance can indirectly cause extra path calculations when steering pushes an agent beyond path_max_distance.
What does path_max_distance actually do?
It is the allowed drift from the ideal path. Exceeding it makes the agent request a new path. Too short can turn ordinary crowd steering into repeated recalculation; too large delays recovery from a genuinely lost corridor.
Why does one unreachable click lag the whole game?
An unreachable target can require a much longer search through the available navigation polygons. Query once, inspect the returned final position, and cache known-unreachable results against the current map revision. Use the empty-path diagnosis for endpoint failures.
Should I compute paths on a background thread?
Direct NavigationServer queries can run on worker threads, but NavigationAgent2D and the active SceneTree cannot simply be moved there. Result ownership, cancellation, stale-query handling, and main-thread handoff need a separate architecture.