ROS2 Nav2 vs Custom DWA: 47ms Latency Drop on AMRs

Disclosure: As an Amazon Associate, I earn from qualifying purchases. Some links in this post are affiliate links — they cost you nothing extra.
⚡ Key Takeaways
  • 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.

Top view of a book, coffee, and cookies on a bed with white roses.
Photo by Şehâdet Yoldaç on Pexels

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.

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

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.

Flat lay of books with white hydrangea blooms, creating a serene and artistic composition.
Photo by Alina Matveycheva on Pexels

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 (v,ω)(v, \omega) reachable within the next time step Δt\Delta t, given current velocity and acceleration limits. The dynamic window is:

Vd={(v,ω)∣v∈[vc−v˙maxΔt,vc+v˙maxΔt], ω∈[ωc−ω˙maxΔt,ωc+ω˙maxΔt]}V_d = \{(v, \omega) \mid v \in [v_c – \dot{v}_{max} \Delta t, v_c + \dot{v}_{max} \Delta t], \ \omega \in [\omega_c – \dot{\omega}_{max} \Delta t, \omega_c + \dot{\omega}_{max} \Delta t]\}

where vc,ωcv_c, \omega_c are current linear and angular velocities. For each candidate (v,ω)(v, \omega), simulate the trajectory over TsimT_{sim} seconds:

x(t)=x0+∫0tvcos⁡(θ(τ))dτ,y(t)=y0+∫0tvsin⁡(θ(τ))dτ,θ(t)=θ0+ωtx(t) = x_0 + \int_0^t v \cos(\theta(\tau)) d\tau, \quad y(t) = y_0 + \int_0^t v \sin(\theta(\tau)) d\tau, \quad \theta(t) = \theta_0 + \omega t

For constant v,ωv, \omega, this simplifies to circular arc segments. The scoring function combines three terms:

S(v,ω)=α⋅heading(v,ω)+β⋅dist(v,ω)+γ⋅velocity(v,ω)S(v, \omega) = \alpha \cdot \text{heading}(v, \omega) + \beta \cdot \text{dist}(v, \omega) + \gamma \cdot \text{velocity}(v, \omega)

where:
– heading(v,ω)=π−∣angle(goal⃗−endpoint⃗)∣\text{heading}(v, \omega) = \pi – |\text{angle}(\vec{goal} – \vec{endpoint})| (alignment to goal)
– dist(v,ω)=∥goal⃗−endpoint⃗∥\text{dist}(v, \omega) = \|\vec{goal} – \vec{endpoint}\| (Euclidean distance)
– velocity(v,ω)=v\text{velocity}(v, \omega) = v (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 coffee
TODAY 15 | TOTAL 120,408