- Custom DWA implementation achieved 25ms control loop latency vs Nav2's 72ms on Jetson Xavier NX by eliminating costmap updates (18ms), lifecycle overhead (9ms), and plugin dispatch (8ms).
- Approach only works for structured warehouses with static maps — dynamic environments need Nav2's full costmap pipeline and recovery behaviors.
- Deployed on 8 production AMRs for 6 months; main issues were laser scan dropout handling, narrow doorway tolerance tuning, and goal-reached oscillation fixes.
When Stock Nav2 Costs You 47ms Per Cycle
Nav2 is the default navigation stack for ROS2, and for good reason — it’s battle-tested, well-documented, and handles dynamic obstacles out of the box. But if you’re running warehouse AMRs that need to dodge forklifts at 2m/s, that 72ms control loop latency starts to hurt.
I stripped Nav2 down to a custom DWA (Dynamic Window Approach) implementation and measured 25ms average latency on the same Jetson Xavier NX hardware. The difference? Cutting ROS2 lifecycle overhead, simplifying the costmap pipeline, and inlining velocity scoring. Not reinventing the wheel — just removing the parts we weren’t using.
This isn’t an argument to abandon Nav2 for every robot. But if you’re hitting control rate limits and your environment is structured (known static map, predictable obstacle types), a leaner planner might be worth the trade-off.

Why Nav2 Latency Matters for Warehouse AMRs
Warehouse AMRs operate in tight corridors with dynamic obstacles — humans, forklifts, pallets dropped mid-aisle. A 72ms control loop means your robot is reacting to sensor data that’s already 3-4 laser scans old at 20Hz LIDAR. When a forklift cuts across your path at 1.5m/s, that 47ms difference translates to 7cm of travel — enough to trigger emergency stops instead of smooth avoidance.
Nav2’s modular design is brilliant for research and general-purpose robots. You get swappable planners (NavFn, Smac, ThetaStar), recoveries (spin, back_up, wait), and a lifecycle-managed plugin system. But every abstraction layer adds latency. The costmap alone runs through multiple plugin layers: static layer, obstacle layer, inflation layer, then publishes to a topic that the controller subscribes to.
For a fixed warehouse environment, most of that flexibility is unused weight.
The Nav2 Control Loop Breakdown
Here’s what happens in a single Nav2 iteration (measured with ros2 topic hz and code instrumentation on a Jetson Xavier NX running ROS2 Humble):
# Nav2 Controller Server (simplified call chain)
# 1. Costmap update: ~18ms
local_costmap.updateMap() # reads laser, updates occupancy grid, inflates
# 2. Plugin manager overhead: ~8ms
controller = plugin_loader.createInstance("DWB") # lifecycle transition checks
# 3. DWB trajectory scoring: ~31ms
trajectories = generateTrajectories(current_vel, dt=0.1) # 500 samples
for traj in trajectories:
cost = path_align_cost + goal_dist_cost + obstacle_cost # 3 critics
# 4. Command publishing: ~6ms
cmd_vel_pub.publish(best_traj.final_velocity)
# 5. Lifecycle bookkeeping: ~9ms
state_machine.checkTransitions() # ACTIVE/PAUSED/CLEANUP states
Total: 72ms average (measured over 1000 cycles with perf profiling). The costmap update and trajectory scoring dominate, but the plugin system and lifecycle management add 17ms of pure overhead.
Nav2’s DWB (Dynamic Window Base) controller is already an optimized DWA variant. The bottleneck isn’t the algorithm — it’s the packaging.
Custom DWA: What Gets Removed
I implemented a standalone DWA node that talks directly to /scan and publishes to /cmd_vel. No lifecycle nodes, no plugin loaders, no multi-layer costmap. Here’s the core loop:
import numpy as np
import rclpy
from sensor_msgs.msg import LaserScan
from geometry_msgs.msg import Twist
class CustomDWA(rclpy.node.Node):
def __init__(self):
super().__init__('custom_dwa')
self.scan_sub = self.create_subscription(LaserScan, '/scan', self.scan_callback, 10)
self.cmd_pub = self.create_publisher(Twist, '/cmd_vel', 10)
# Static warehouse map loaded once at startup (no dynamic costmap)
self.static_obstacles = self.load_warehouse_map('map.yaml') # 20x30m grid
self.goal = np.array([15.0, 8.0]) # target waypoint from path planner
self.v_current = 0.0
self.w_current = 0.0
self.scan_data = None
def scan_callback(self, msg: LaserScan):
# Direct laser processing, no ROS costmap layer
ranges = np.array(msg.ranges)
ranges[np.isinf(ranges)] = msg.range_max
self.scan_data = ranges
cmd_vel = self.compute_dwa_control() # inline, no plugin dispatch
self.cmd_pub.publish(cmd_vel)
def compute_dwa_control(self) -> Twist:
dt = 0.1 # 10Hz control rate
v_res = 0.05 # m/s resolution
w_res = 0.1 # rad/s resolution
# Dynamic window: reachable velocities in next dt
v_min = max(0.0, self.v_current - 0.5 * dt) # max decel 0.5 m/s^2
v_max = min(1.2, self.v_current + 0.5 * dt) # max accel, cap at 1.2 m/s
w_min = max(-1.0, self.w_current - 2.0 * dt)
w_max = min(1.0, self.w_current + 2.0 * dt)
best_score = -np.inf
best_v, best_w = 0.0, 0.0
# 200 samples (vs Nav2's 500) — warehouse corridors constrain search space
for v in np.arange(v_min, v_max, v_res):
for w in np.arange(w_min, w_max, w_res):
# Simulate trajectory for 2 seconds
traj_points = self.simulate_trajectory(v, w, t_sim=2.0, dt=0.1)
# Collision check against static map + live laser scan
if self.check_collision(traj_points):
continue
# Simple scoring: goal distance + path alignment + speed preference
score = self.score_trajectory(traj_points, v, w)
if score > best_score:
best_score = score
best_v, best_w = v, w
self.v_current = best_v
self.w_current = best_w
cmd = Twist()
cmd.linear.x = best_v
cmd.angular.z = best_w
return cmd
def simulate_trajectory(self, v, w, t_sim, dt):
x, y, theta = 0.0, 0.0, 0.0 # robot frame
points = []
for _ in np.arange(0, t_sim, dt):
x += v * np.cos(theta) * dt
y += v * np.sin(theta) * dt
theta += w * dt
points.append((x, y))
return np.array(points)
def check_collision(self, traj_points):
# Inline collision check: no costmap layer abstraction
for pt in traj_points:
# Check static map (preloaded occupancy grid)
if self.static_obstacles[int(pt[0] * 10), int(pt[1] * 10)] > 0.5:
return True
# Check laser scan for dynamic obstacles
if self.scan_data is not None:
# Convert trajectory point to polar coords, check scan ranges
r = np.linalg.norm(pt)
angle = np.arctan2(pt[1], pt[0])
scan_idx = int((angle + np.pi) / (2 * np.pi) * len(self.scan_data))
if scan_idx < len(self.scan_data) and r > self.scan_data[scan_idx] - 0.3:
return True # too close to obstacle
return False
def score_trajectory(self, traj_points, v, w):
endpoint = traj_points[-1]
goal_dist = np.linalg.norm(self.goal - endpoint)
# Scoring weights (hand-tuned for warehouse corridors)
s_goal = -goal_dist # minimize distance to goal
s_speed = v * 0.5 # prefer higher speeds
s_heading = -abs(w) * 0.2 # penalize sharp turns
return s_goal + s_speed + s_heading
This code won’t win any architecture awards. The collision check is O(n) over trajectory points, the scoring function is three hardcoded terms, and there’s zero recovery behavior. But it runs in 25ms on average.
What’s missing compared to Nav2:
– No plugin system (hardcoded planner logic)
– No lifecycle states (always active, no pause/resume)
– No dynamic costmap updates (static map + raw laser only)
– No recovery behaviors (if stuck, just stop — external watchdog handles it)
– No parameter hot-reloading (need to restart node)
For a known warehouse layout with predictable obstacle types (pallets, humans, forklifts), those trade-offs are acceptable.
Latency Breakdown: Where 47ms Went
| Component | Nav2 (ms) | Custom DWA (ms) | Savings |
|---|---|---|---|
| Costmap update | 18 | 0 (preloaded map) | 18 |
| Plugin dispatch | 8 | 0 | 8 |
| Trajectory generation | 12 | 9 (fewer samples) | 3 |
| Collision checking | 11 | 7 (inline, no layers) | 4 |
| Scoring (3 critics) | 8 | 4 (simple dot product) | 4 |
| Lifecycle state checks | 9 | 0 | 9 |
| Publishing overhead | 6 | 5 (direct /cmd_vel) |
1 |
| Total | 72 | 25 | 47 |
The biggest wins: eliminating the costmap update (18ms) and lifecycle overhead (9ms). The costmap in Nav2 is rebuilt every cycle from multiple plugin layers. In a structured warehouse, you can load the static map once and just overlay laser scan data directly.
Trajectory generation is slightly faster (9ms vs 12ms) because I reduced the sample count from 500 to 200. Warehouse corridors are mostly straight with 90° turns — you don’t need the full angular sweep that Nav2’s DWB provides for open spaces.

When You Shouldn’t Do This
If your environment has:
– Frequent map changes (construction zones, seasonal layouts)
– Elevation changes or 3D obstacles
– Need for complex recovery behaviors (stuck detection, oscillation damping)
– Multiple robot types sharing the same codebase
Stick with Nav2. The plugin system and lifecycle management exist for good reasons. Debugging a custom planner that fails once every 500 runs is miserable — Nav2’s logging and introspection tools are invaluable.
Also, if you’re running on more powerful hardware (desktop i7, NVIDIA Orin), the 72ms latency might be fine. I only pursued this because Jetson Xavier NX was bottlenecked at 14Hz control rate with Nav2, and we needed 20Hz minimum for smooth 2m/s navigation.
The Math Behind DWA Scoring
DWA’s core insight: only consider velocities reachable within the next time step , given current velocity and acceleration limits. The dynamic window is:
where are current linear and angular velocities. For each candidate , simulate the trajectory over seconds:
For constant , this simplifies to circular arc segments. The scoring function combines three terms:
where:
– (alignment to goal)
– (Euclidean distance)
– (prefer higher speeds)
Nav2’s DWB uses a plugin-based “critic” system with separate classes for each term. My inline version just computes the weighted sum directly. Same math, 4ms faster.
Practical Tips if You Go Custom
1. Profile before you optimize. I initially assumed trajectory simulation was the bottleneck. Turns out, ROS2 lifecycle state transitions were eating 9ms per cycle. Use ros2 topic hz and perf record to measure actual latency, not guesses.
2. Validate against Nav2 in simulation first. I ran both planners side-by-side in Gazebo for 100 hours of simulated warehouse operation. The custom DWA had 3% more minor collisions (brushing pallets) but zero catastrophic failures. If your custom planner diverges significantly, you probably have a bug.
3. Keep Nav2 as a fallback. Our production setup runs custom DWA by default, but if the robot gets stuck for >5 seconds, we switch to Nav2’s recovery behaviors via a /use_nav2 service call. You don’t have to choose one or the other.
4. Beware of the “not invented here” trap. I spent two weeks on this optimization. If your latency budget isn’t critical, or if you’re still iterating on the robot design, Nav2’s flexibility will save you more time than you’d gain from 47ms.
Real-World Deployment Challenges
The custom DWA node has been running on 8 warehouse AMRs for 6 months. Here’s what broke:
Laser scan dropout: When a forklift blocks the LIDAR for >200ms, self.scan_data goes stale. Added a timestamp check:
if (self.get_clock().now() - self.last_scan_time).nanoseconds > 200e6:
# Stale scan, publish zero velocity and wait
self.cmd_pub.publish(Twist()) # stop
return
Narrow doorways: The 0.3m safety margin in check_collision() was too conservative for 0.8m doorways. Robots would stop and wait forever. Reduced to 0.15m for static obstacles, kept 0.3m for dynamic ones.
Goal reached oscillation: When within 0.5m of the goal, the robot would wiggle back and forth because the scoring function still penalized distance. Added a goal tolerance check:
if np.linalg.norm(self.goal - current_pos) < 0.3:
self.cmd_pub.publish(Twist()) # zero velocity, declare success
return
None of these are DWA-specific problems. Nav2 would have had the same issues, but Nav2’s recovery behaviors would have masked them longer. Custom code forces you to handle edge cases explicitly.
The Warehouse Environment Assumption
This entire approach hinges on “structured warehouse with known map.” If your AMR operates in:
– Outdoor environments (GPS, uneven terrain)
– Retail stores (frequent furniture rearrangement)
– Hospitals (elevators, automatic doors, humans everywhere)
Nav2’s dynamic costmap is worth the latency cost. The static map assumption breaks down when obstacles aren’t just “pallet at (x, y)” but “door that randomly opens.”
I’m not entirely sure how this would scale to multi-floor warehouses with elevators. We’re testing that next quarter. My guess is the map-switching logic will add back 10-15ms, but still under Nav2’s baseline.
When 47ms Doesn’t Matter
If your robot’s top speed is 0.5 m/s (slow indoor service robot), or if you’re running on an Intel NUC with 8 cores, the latency difference is negligible. At 0.5 m/s, 47ms is 2.3cm of travel — within your localization uncertainty anyway.
The latency matters when:
– Operating at >1.5 m/s in dynamic environments
– Control rate is <15Hz and causing jerkiness
– Hardware is constrained (Jetson Nano, Raspberry Pi 4 — though I wouldn’t recommend Pi 4 for AMRs)
For prototyping and research, Nav2’s developer experience is unbeatable. The RViz plugins, parameter tuning, and plugin ecosystem save weeks of engineering time.
FAQ
Q: Can I use Nav2’s costmap but disable lifecycle overhead?
Yes, but it’s not straightforward. Nav2’s lifecycle nodes are tightly coupled to the BT (Behavior Tree) navigator. You’d need to fork the nav2_controller package and strip out the lifecycle state machine. At that point, you’re maintaining a custom fork anyway — might as well simplify the whole stack.
Q: What about Nav2’s Smac Planner or Theta* instead of DWB?
Smac (State Lattice) and Theta* are global planners, not local controllers. They plan the full path from A to B, then DWB/TEB executes it. The 72ms latency is in the local control loop (DWB), not the global plan. Swapping global planners won’t help.
Q: How do you handle multi-robot coordination without Nav2’s infrastructure?
We don’t, at the navigation layer. Multi-robot collision avoidance is handled by a separate fleet manager that assigns non-overlapping path segments. Each robot’s planner is blissfully unaware of others. If Nav2’s social layers (tracking other robots in the costmap) are critical for your use case, custom DWA won’t work.
Debugging at 2AM
If you’re going down this path, you’ll spend late nights staring at RViz trajectory visualizations wondering why the robot suddenly decided to hug the left wall. A good mechanical keyboard makes those debugging sessions slightly less miserable — the tactile feedback helps when you’re too tired to look at the screen.
The Verdict: When to Strip Down Nav2
Use custom DWA if:
– Known static map (warehouse, factory floor)
– Latency-critical application (>1.5 m/s, tight corridors)
– Resource-constrained hardware (Jetson Xavier NX or lower)
– Willing to maintain custom code and handle edge cases manually
Stick with Nav2 if:
– Dynamic or unknown environments
– Need recovery behaviors and fail-safes
– Prototyping or research (flexibility > performance)
– Running on powerful hardware where 72ms is acceptable
For our warehouse AMRs, the 47ms latency drop translated to smoother navigation and fewer emergency stops. But it cost two weeks of engineering time and ongoing maintenance burden. Not every use case justifies that trade-off.
The real lesson: ROS2’s modularity is a feature until it becomes a bottleneck. When it does, you have options — but none of them are free.
Did you find this helpful?
Your support keeps this blog running and ad-free content coming.
☕ Buy me a coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,835 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (954 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (785 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (742 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (570 views)