Vav Labs
Back to blog

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.

Technical diagram comparing a same-tick NavigationAgent2D route burst and one tall workload spike with bounded requests spread across four smaller pulses.

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.

Route a crowd-navigation symptom to the correct first intervention
What you seeLikely causeFirst action
One hitch when a selected group receives a move orderSame-tick request burstUse update groups or a per-tick request budget.
Steady stutter while units chase a moving targetPer-frame target_position resetsUse distance and time repath thresholds.
Lag only where the crowd bunches uppath_max_distance recalculationsMeasure path deviation, tune tolerance, and freeze arrivals.
A unit dances between two positionsFrequent target updates or a path_max_distance value that is too shortApply thresholds first, then widen the tolerance carefully.
A hitch when the destination cannot be reachedDocumented long unreachable-target searchQuery once and inspect the returned final position.
Every query is slow, including a single unitSearch-side polygon or edge countOptimize the navigation mesh; scheduling is not the root fix.
A small squad is smooth but hundreds share one destination poorlyPer-agent paths have reached their architectural ceilingMove 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.
# Battle-scene integration; the receipt covers the governor, not this wiring.
func _ready() -> void:
    Performance.add_custom_monitor("repath/requests_per_tick",
        func(): return _requests_this_tick)
    Performance.add_custom_monitor("repath/queue_depth",
        func(): return _governor.queue_depth())
    Performance.add_custom_monitor("repath/oldest_request_ticks",
        func(): return _governor.oldest_request_age_ticks(
            int(Engine.get_physics_frames())))

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.

class_name RepathGovernor
extends RefCounted

const PRIORITY_NORMAL := 0
const PRIORITY_HIGH := 1

const REASON_NONE := &""
const REASON_GROUP_ORDER := &"GROUP_ORDER"
const REASON_TARGET_DRIFT := &"TARGET_DRIFT"
const REASON_PERIODIC_REFRESH := &"PERIODIC_REFRESH"

var queries_per_tick := 8
var max_entries_scanned_per_tick := 64
var high_priority_burst := 3
var compaction_min_head := 256

var _high_queue: Array[Dictionary] = []
var _normal_queue: Array[Dictionary] = []
var _high_head := 0
var _normal_head := 0
var _high_dispatch_streak := 0
var _next_token := 1
var _pending: Dictionary = {} # unit_id -> active entry Dictionary
var _last_scan_count := 0


func request(
    unit_id: int,
    target: Vector2,
    priority: int = PRIORITY_NORMAL,
    map_revision: int = 0,
    requested_tick: int = 0,
    reason: StringName = REASON_GROUP_ORDER
) -> void:
    var normalized_priority := _normalize_priority(priority)
    if _pending.has(unit_id):
        var current: Dictionary = _pending[unit_id]
        var effective_priority: int = maxi(int(current["priority"]), normalized_priority)
        if effective_priority == int(current["priority"]):
            current["target"] = target
            current["request_revision"] = map_revision
            current["latest_request_tick"] = requested_tick
            current["reason"] = str(reason)
            return

        var promoted := _make_entry(
            unit_id,
            target,
            effective_priority,
            map_revision,
            int(current["requested_tick"]),
            requested_tick,
            reason
        )
        _pending[unit_id] = promoted
        _append_entry(promoted)
        return

    var entry := _make_entry(
        unit_id,
        target,
        normalized_priority,
        map_revision,
        requested_tick,
        requested_tick,
        reason
    )
    _pending[unit_id] = entry
    _append_entry(entry)

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.

func drain(
    run_query: Callable,
    is_alive: Callable,
    current_map_revision: int,
    now_tick: int
) -> Array[Dictionary]:
    var dispatched: Array[Dictionary] = []
    var scanned := 0
    var query_limit: int = maxi(0, queries_per_tick)
    var scan_limit: int = maxi(0, max_entries_scanned_per_tick)

    while dispatched.size() < query_limit and scanned < scan_limit:
        var entry := _take_next_raw_entry()
        if entry.is_empty():
            break
        scanned += 1

        var unit_id := int(entry["unit_id"])
        if not _pending.has(unit_id):
            continue
        var active: Dictionary = _pending[unit_id]
        if int(active["token"]) != int(entry["token"]):
            continue
        if is_alive.is_valid() and not bool(is_alive.call(unit_id)):
            _pending.erase(unit_id)
            continue

        _pending.erase(unit_id)
        var priority := int(entry["priority"])
        if priority == PRIORITY_HIGH:
            _high_dispatch_streak += 1
        else:
            _high_dispatch_streak = 0

        var query_result: Variant = null
        if run_query.is_valid():
            query_result = run_query.call(unit_id, entry["target"], current_map_revision)

        dispatched.append({
            "unit_id": unit_id,
            "target": entry["target"],
            "priority": priority,
            "reason": entry["reason"],
            "requested_tick": entry["requested_tick"],
            "latest_request_tick": entry["latest_request_tick"],
            "request_revision": entry["request_revision"],
            "dispatch_revision": current_map_revision,
            "dispatched_tick": now_tick,
            "query_result": query_result,
        })

    _last_scan_count = scanned
    _compact_if_needed()
    return dispatched

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.

func _take_next_raw_entry() -> Dictionary:
    var has_high := _high_head < _high_queue.size()
    var has_normal := _normal_head < _normal_queue.size()
    if not has_high and not has_normal:
        return {}

    var choose_high := has_high and (
        not has_normal or _high_dispatch_streak < maxi(1, high_priority_burst)
    )
    if choose_high:
        var high_entry: Dictionary = _high_queue[_high_head]
        _high_head += 1
        return high_entry

    var normal_entry: Dictionary = _normal_queue[_normal_head]
    _normal_head += 1
    return normal_entry

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.

func _physics_process(_delta: float) -> void:
    var now_tick := int(Engine.get_physics_frames())
    _governor.drain(
        Callable(self, "_apply_agent_target"),
        Callable(self, "_unit_is_alive"),
        _map_revision,
        now_tick
    )

func _apply_agent_target(
    unit_id: int,
    target: Vector2,
    _current_map_revision: int
) -> Dictionary:
    var agent: NavigationAgent2D = _agents[unit_id]
    agent.target_position = target
    return {"accepted": true}

func _unit_is_alive(unit_id: int) -> bool:
    return _agents.has(unit_id) and is_instance_valid(_agents[unit_id])

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().

static func repath_reason(
    planned_target: Vector2,
    live_target: Vector2,
    repath_distance: float,
    now_tick: int,
    next_repath_tick: int,
    arrived: bool
) -> StringName:
    if arrived:
        return REASON_NONE
    var drift := planned_target.distance_to(live_target)
    var threshold := maxf(0.0, repath_distance)
    if drift > 0.0 and (threshold == 0.0 or drift >= threshold):
        return REASON_TARGET_DRIFT
    if now_tick >= next_repath_tick:
        return REASON_PERIODIC_REFRESH
    return REASON_NONE


static func distance_scaled_interval_ticks(
    distance_to_target: float,
    near_distance: float,
    far_distance: float,
    near_interval_ticks: int,
    far_interval_ticks: int
) -> int:
    var near_ticks := maxi(1, near_interval_ticks)
    var far_ticks := maxi(near_ticks, far_interval_ticks)
    if far_distance <= near_distance:
        return near_ticks
    var weight := clampf(
        (distance_to_target - near_distance) / (far_distance - near_distance),
        0.0,
        1.0
    )
    return roundi(lerpf(float(near_ticks), float(far_ticks), weight))

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.

  1. 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.
  2. Freeze arrivals. Units inside their destination tolerance stop consuming path updates and cannot be pushed into another recalculation cycle.
  3. 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.

Choose the smallest intervention that matches the measured failure
FixWhat it eliminatesBest for
Update groups or per-tick budgetThe same-tick burst from one group orderRTS selection orders
Distance and time thresholdsRedundant requests against slowly moving targetsChase and escort behavior
Off-path tolerance plus arrival freezeRepeated path_max_distance recalculations in crowdsCrowded destinations
One shared fieldPer-agent route queries for one shared destinationMassive 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.

  1. Profile first and confirm the hitch belongs to navigation rather than physics, rendering, or gameplay scripts.
  2. Record requests per tick, per-unit rate, application reasons, queue depth, oldest age, and TIME_NAVIGATION_PROCESS.
  3. Map GROUP_ORDER spikes to Cause A and sustained TARGET_DRIFT to Cause B.
  4. Correlate unexplained path_changed signals with stable target/map state and measured deviation before labeling PROBABLE_OFF_PATH.
  5. Check whether arrived units still consume path updates.
  6. Apply one budget, threshold, or tolerance change and repeat the same measurement.
  7. If requests are minimal but one query is slow, inspect polygon count and unreachable targets.
  8. 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.