Godot / Performance / 2026-05-25 / 9 min read
Why your Godot pathfinding causes frame spikes and how to fix it
Pathfinding rarely shows up in the average frame time. It shows up as a spike when every agent repaths in the same frame. Here is why Godot pathfinding stalls, and the patterns that keep it inside the budget.
The problem: spikes, not averages
Godot pathfinding is rarely slow on average. The profiler shows a calm line, the game feels fine in the editor, and then the build stutters at exactly the wrong moment: a wave spawns, a squad gets a new order, a door closes, and the frame time jumps from a comfortable 8 ms to 30-something. The average barely moves. The spike is what the player feels.
This is the trap with navigation performance. "Godot pathfinding slow" is almost never about one path being slow. It is about many paths happening in the same frame, synchronously, while everything else in the frame is still expected to finish inside 16.6 ms.
The good news is that the causes are predictable, and most of the fixes are scheduling decisions rather than clever algorithms. You usually do not need a faster A*. You need to stop asking for so much of it at once.
Why grid pathfinding is spiky
A* on a grid is a search. Its cost scales with how far apart start and goal are, how large and open the grid is, and how many cells it has to expand before it finds the goal. A short hop is cheap. A path across a large, open map with a partially blocked route is not, because the search expands a lot of cells before it commits.
One such search per frame is usually invisible. The spike appears when the count goes up together: every unit in a selection repaths on the same input, every enemy in a wave queries on spawn, or every agent reacts to the same world change in the same physics tick. Ten cheap searches and one expensive search in a single frame still add up to one expensive frame.
Two more multipliers make it worse. Synchronous queries mean the whole batch is paid in the calling frame, with no spreading. And per-query allocations — a fresh array per path, temporary structures, signal churn — hand the work to the garbage collector, which then schedules its own surprise later.
The naive loop most tutorials show
The first version everyone writes looks reasonable. Each agent asks for a path to the target, every physics frame, forever:
It works in a demo with three units. It falls over in a real game because it recomputes paths that did not change, for agents whose goal did not move, on every single tick. Multiply by a crowd and you have built a frame-spike generator with a friendly API.
# Naive: every agent asks for a full path every physics frame.
func _physics_process(_delta: float) -> void:
for agent in agents:
var path := astar_grid.get_id_path(agent.cell, target_cell)
agent.follow(path) Fix 1: stop repathing when nothing changed
The cheapest path query is the one you do not make. Most agents, most frames, do not need a new path: their goal has not moved and the world has not changed under them. Cache the path, recompute only on a real trigger, and cap how often any single agent is allowed to repath.
This one change removes the bulk of the problem in most projects, because it converts "every agent every frame" into "a few agents, occasionally."
# Repath only when the goal moved, and never more often than the cooldown.
const REPATH_COOLDOWN := 0.25 # seconds
func _physics_process(delta: float) -> void:
for agent in agents:
agent.repath_timer -= delta
var goal_moved := agent.goal_cell != agent.last_goal_cell
if goal_moved and agent.repath_timer <= 0.0:
agent.path = astar_grid.get_id_path(agent.cell, agent.goal_cell)
agent.last_goal_cell = agent.goal_cell
agent.repath_timer = REPATH_COOLDOWN
agent.advance_along_path(delta) Fix 2: spread the remaining queries across frames
Even with caching, some moments demand many fresh paths at once: a wave spawns, or a player orders a whole army to move. If you compute them all in the triggering frame, you get the spike back. The fix is to treat path requests as a queue with a per-frame budget instead of a synchronous loop.
Each frame, you pull a fixed number of requests off the queue and answer those. The rest wait one or two frames. A unit getting its path 30 ms later is invisible to a player. A 30 ms frame is not. You are trading a tiny, unnoticeable latency for a stable frame time, which is almost always the right trade in real-time games.
# Spread path requests across frames with a fixed per-frame budget.
const PATHS_PER_FRAME := 4
var request_queue: Array[Agent] = []
func request_path(agent: Agent) -> void:
if not request_queue.has(agent):
request_queue.push_back(agent)
func _physics_process(_delta: float) -> void:
var processed := 0
while processed < PATHS_PER_FRAME and not request_queue.is_empty():
var agent: Agent = request_queue.pop_front()
agent.path = astar_grid.get_id_path(agent.cell, agent.goal_cell)
processed += 1 Fix 3: share one map across many agents
When a lot of agents are heading to the same place — a crowd flowing to an exit, a wave marching toward a base, workers returning to a hub — running a separate A* per agent is wasted work. They are solving the same problem from different starting cells.
Compute the answer once as a field over the whole grid instead. A Dijkstra map (also called an integration field or, with directions baked in, a flow field) does a single pass from the goal outward and stores, for every cell, the way to go next. After that, each agent reads a cell instead of running a search. For many-to-one movement this turns N searches per change into one, and it scales to crowds that per-agent A* simply cannot afford.
Flow fields are not a replacement for A*. They are the right tool when many agents share a destination; A* stays the right tool for individual units with individual goals. A production navigation layer usually needs both, picked per situation rather than per dogma.
Fix 4: stop allocating on the hot path
Once the query count is under control, the remaining stutter is often the garbage collector reacting to per-frame allocations. A new array for every path, temporary dictionaries inside the movement loop, and string building for debug output all create pressure that gets collected at a time you did not choose.
Reuse buffers where you can, keep path results on the agent rather than reallocating them each frame, and move debug string work behind a flag so it is not running in shipped builds. The goal is a hot path that does steady, boring work with no allocation confetti.
Profile before you trust any of this
Do not optimize from a blog post, including this one. Open the Godot profiler, reproduce the stutter, and find which frames actually spike and what is inside them. Frame-time profiling tells you when the spike happens; the function breakdown tells you whether it is the search, the allocation, or something pretending to be pathfinding while actually being your own per-frame logic.
Optimization without measurement is just rearranging code until the anxiety stops. Measure first, change one thing, measure again. The first capture is usually the most honest collaborator you will have all day.
The tradeoffs
None of this is free. Caching paths means an agent can briefly act on a stale route after the world changes, so anything that must react instantly needs an explicit invalidation trigger. A per-frame budget adds a small, bounded latency between request and path. Flow fields cost memory and a build step, and they only pay off when enough agents share a destination.
These are good trades for real-time games because they convert an unpredictable cost into a predictable one. A consistent 9 ms frame beats an occasional 30 ms frame, even if the 30 ms version is technically doing the freshest possible work. Players feel variance, not averages.
The deeper point: production pathfinding is mostly a scheduling and data problem, not an algorithm contest. The algorithm is the easy part. Deciding who repaths, when, how often, and from which shared structure is the part that keeps the frame budget honest.