ROS1 move_base vs ROS2 Nav2: 8 Breaking Changes

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
  • ROS2 Nav2 splits move_base config into bt_navigator, controller_server, and planner_server with nested namespaces — flat ROS1 params will silently fail.
  • Behavior trees replace linear recovery chains — custom ROS1 recovery behaviors must be rewritten as BT action nodes with new lifecycle management.
  • DWA local planner becomes DWB with pluggable critics — tuning parameters don't transfer directly and require ground-up re-optimization.
  • Lifecycle nodes require explicit configure/activate transitions via lifecycle_manager — production monitoring must check node state instead of process existence.
  • Costmap update frequency removed in favor of sensor-driven updates — high-rate lidars (40Hz) will max out CPU on embedded systems without throttling to 10-15Hz.

The Navigation Stack Rewrite Nobody Warned You About

ROS2 Nav2 is not a drop-in replacement for ROS1 move_base. It’s a complete architectural redesign that will break your production robot if you treat it like a version bump.

I learned this the hard way migrating a warehouse AMR fleet from ROS1 Noetic to ROS2 Humble. The navigation stack worked fine in simulation, passed all integration tests, then immediately crashed on the factory floor when the first robot tried to recover from a blocked path. The recovery behavior API had been completely rewritten, and our custom recovery plugins were silently ignored.

This post walks through the 8 breaking changes that actually matter in production — not the ones mentioned in migration guides, but the ones that only surface when your robot is live.

A miniature tank robot navigating through rocky terrain near railway tracks in Đà Nẵng, Vietnam.
Photo by David Thái on Pexels

Parameter Namespaces: bt_navigator vs move_base

ROS1 move_base used flat parameter namespaces. You configured everything under /move_base/:

# ROS1 move_base
move_base:
  controller_frequency: 10.0
  planner_frequency: 1.0
  recovery_behavior_enabled: true
  base_local_planner: "dwa_local_planner/DWAPlannerROS"

ROS2 Nav2 splits configuration across multiple nodes with nested namespaces:

# ROS2 Nav2
bt_navigator:
  ros__parameters:
    global_frame: map
    robot_base_frame: base_link

controller_server:
  ros__parameters:
    controller_frequency: 20.0
    FollowPath:
      plugin: "dwb_core::DWBLocalPlanner"

planner_server:
  ros__parameters:
    planner_plugins: ["GridBased"]
    GridBased:
      plugin: "nav2_navfn_planner/NavfnPlanner"

The frequency parameters moved. In ROS1, controller_frequency controlled how often the local planner ran. In ROS2, it’s under controller_server and defaults to 20Hz instead of 10Hz. If you’re running on a resource-constrained embedded system (Jetson Nano, Raspberry Pi 4), this doubling of CPU usage will cause dropped frames.

But the real problem is planner_frequency disappeared entirely. In ROS1, you could throttle the global planner to run at 1Hz while the local planner ran at 10Hz. In ROS2, the global planner only runs on-demand when you send a new goal or explicitly trigger replanning. If your robot needs periodic replanning (dynamic obstacles, moving targets), you now have to implement that logic yourself.

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

Behavior Trees Replace Recovery Behaviors

ROS1 move_base used a linear recovery behavior chain. If the robot got stuck, it executed clear_costmap_recovery, then rotate_recovery, then aggressive_reset_recovery in sequence:

// ROS1 recovery sequence
recovery_behaviors:
  - name: 'conservative_reset'
    type: 'clear_costmap_recovery/ClearCostmapRecovery'
  - name: 'rotate_recovery'
    type: 'rotate_recovery/RotateRecovery'
  - name: 'aggressive_reset'
    type: 'clear_costmap_recovery/ClearCostmapRecovery'

ROS2 Nav2 uses behavior trees defined in XML. The default tree is navigate_w_replanning_and_recovery.xml:

<BehaviorTree ID="MainTree">
  <RecoveryNode number_of_retries="6" name="NavigateRecovery">
    <PipelineSequence name="NavigateWithReplanning">
      <RateController hz="1.0">
        <RecoveryNode number_of_retries="1" name="ComputePathToPose">
          <ComputePathToPose goal="{goal}" path="{path}" planner_id="GridBased"/>
          <ClearEntireCostmap service_name="global_costmap/clear_entirely_global_costmap"/>
        </RecoveryNode>
      </RateController>
      <RecoveryNode number_of_retries="1" name="FollowPath">
        <FollowPath path="{path}" controller_id="FollowPath"/>
        <ClearEntireCostmap service_name="local_costmap/clear_entirely_local_costmap"/>
      </RecoveryNode>
    </PipelineSequence>
    <RetriesExceeded>
      <SequenceStar name="RecoveryActions">
        <Spin spin_dist="1.57"/>
        <Wait wait_duration="5"/>
        <BackUp backup_dist="0.30" backup_speed="0.05"/>
      </SequenceStar>
    </RetriesExceeded>
  </RecoveryNode>
</BehaviorTree>

This is more flexible but requires understanding behavior tree semantics. RecoveryNode retries its child, then executes the recovery action if it fails. PipelineSequence allows the controller to preempt planning. SequenceStar runs children until one succeeds.

The migration trap: if you wrote custom recovery behaviors in ROS1, you can’t just port them. You need to rewrite them as BT action nodes implementing nav2_behavior_tree::BtActionNode<ActionT>. Our custom “call_human_operator” recovery behavior took three days to port because the action server lifecycle management changed.

Costmap Layers: Namespace vs Plugin Architecture

ROS1 costmap plugins were loaded from the plugins parameter:

# ROS1 costmap
global_costmap:
  plugins:
    - {name: static_layer, type: "costmap_2d::StaticLayer"}
    - {name: obstacle_layer, type: "costmap_2d::ObstacleLayer"}
    - {name: inflation_layer, type: "costmap_2d::InflationLayer"}

ROS2 Nav2 uses the same structure but with a critical difference — layer parameters are now nested:

# ROS2 Nav2 costmap
global_costmap:
  global_costmap:
    ros__parameters:
      plugins: ["static_layer", "obstacle_layer", "inflation_layer"]
      static_layer:
        plugin: "nav2_costmap_2d::StaticLayer"
        map_subscribe_transient_local: True
      obstacle_layer:
        plugin: "nav2_costmap_2d::ObstacleLayer"
        observation_sources: scan
        scan:
          topic: /scan
          data_type: "LaserScan"
      inflation_layer:
        plugin: "nav2_costmap_2d::InflationLayer"
        inflation_radius: 0.55

The map_subscribe_transient_local parameter didn’t exist in ROS1. In ROS2, the static map is published with transient local durability, meaning late-joining subscribers get the last published message. If you don’t set this to True, your robot will navigate without a map until the map server republishes.

And the data_type field is now required in observation sources. In ROS1, it was inferred from the topic message type. In ROS2, if you forget it, the obstacle layer silently fails to subscribe and you get a robot that drives through walls.

Action API: actionlib vs rclcpp_action

ROS1 move_base used actionlib with move_base_msgs/MoveBaseAction:

# ROS1 actionlib client
import actionlib
from move_base_msgs.msg import MoveBaseAction, MoveBaseGoal

client = actionlib.SimpleActionClient('move_base', MoveBaseAction)
client.wait_for_server()

goal = MoveBaseGoal()
goal.target_pose.header.frame_id = "map"
goal.target_pose.pose.position.x = 5.0
client.send_goal(goal)
client.wait_for_result()

ROS2 Nav2 uses rclcpp_action with nav2_msgs/NavigateToPose:

# ROS2 rclcpp_action client
import rclpy
from rclpy.action import ActionClient
from nav2_msgs.action import NavigateToPose

rclpy.init()
node = rclpy.create_node('nav_client')
client = ActionClient(node, NavigateToPose, '/navigate_to_pose')

goal_msg = NavigateToPose.Goal()
goal_msg.pose.header.frame_id = 'map'
goal_msg.pose.pose.position.x = 5.0

client.wait_for_server()
future = client.send_goal_async(goal_msg)
rclpy.spin_until_future_complete(node, future)
goal_handle = future.result()

if not goal_handle.accepted:
    node.get_logger().error('Goal rejected')
    return

result_future = goal_handle.get_result_async()
rclpy.spin_until_future_complete(node, result_future)

The async-first API means you can no longer block on wait_for_result(). If your ROS1 code had a simple loop like this:

while True:
    goal = get_next_waypoint()
    client.send_goal(goal)
    client.wait_for_result()
    if client.get_state() != GoalStatus.SUCCEEDED:
        handle_failure()

You need to rewrite it using futures or callbacks. The blocking spin functions exist but they don’t integrate well with multi-threaded executors, which Nav2 requires (it uses a multi-threaded executor internally for the behavior tree).

And the action namespace changed. ROS1 used /move_base, ROS2 uses /navigate_to_pose. If you have ROS1 client code calling move_base, it will silently fail — no error, just a timeout waiting for a server that doesn’t exist.

A sleek autonomous food delivery robot navigates a sunny urban landscape, showcasing modern innovation.
Photo by Kindel Media on Pexels

Planner Plugins: DWA vs DWB

ROS1 used dwa_local_planner. ROS2 Nav2 uses dwb_local_planner — same acronym, completely different API.

The scoring function changed. In DWA, the cost was:

cost=wg⋅g+wo⋅o+wv⋅v\text{cost} = w_g \cdot g + w_o \cdot o + w_v \cdot v

where gg is goal distance, oo is obstacle distance, vv is velocity deviation. In DWB, it’s:

cost=∑iwi⋅ci(traj)\text{cost} = \sum_{i} w_i \cdot c_i(\text{traj})

where cic_i are pluggable “critics” (goal distance, obstacle proximity, path following, velocity smoothing, etc.). Each critic returns a normalized score [0,1][0, 1], weighted by wiw_i.

This matters because DWA had fixed scoring logic. If you wanted to penalize jerky motion, you had to fork the planner. In DWB, you can write a custom critic plugin.

But migrating existing DWA tuning is painful. Our ROS1 config had path_distance_bias: 32.0 and goal_distance_bias: 20.0. In DWB, those map to PathDist.scale: 32.0 and GoalDist.scale: 20.0, but the critics normalize differently, so you can’t just copy the numbers. We had to re-tune from scratch.

One surprise: DWB is faster. On an AMR with an Intel i5-8265U, DWA took 12ms per control cycle. DWB takes 8ms for the same velocity space (441 samples). My best guess is the critic architecture allows better vectorization, but I haven’t profiled it deeply.

Lifecycle Nodes: Everything Is Stateful Now

ROS1 nodes were stateless. You launched them, they ran, you killed them.

ROS2 Nav2 nodes are lifecycle nodes with states: Unconfigured → Inactive → Active → Finalized. Each transition triggers callbacks:

// Lifecycle node transitions
Unconfigured --configure()--> Inactive
Inactive --activate()--> Active
Active --deactivate()--> Inactive
Inactive --cleanup()--> Unconfigured
Any --shutdown()--> Finalized

The behavior tree navigator, controller server, planner server, and recovery server are all lifecycle nodes. If you launch Nav2 and immediately send a goal, it fails with “service not available” because the nodes are in Inactive state.

You must call the lifecycle transition services:

ros2 lifecycle set /controller_server configure
ros2 lifecycle set /controller_server activate
ros2 lifecycle set /planner_server configure
ros2 lifecycle set /planner_server activate
ros2 lifecycle set /bt_navigator configure
ros2 lifecycle set /bt_navigator activate

Or use the nav2_bringup launch file, which includes a lifecycle_manager node that does this automatically. If you wrote custom launch files, you need to add:

lifecycle_manager = Node(
    package='nav2_lifecycle_manager',
    executable='lifecycle_manager',
    name='lifecycle_manager_navigation',
    output='screen',
    parameters=[{'autostart': True},
                {'node_names': ['controller_server',
                                'planner_server',
                                'bt_navigator']}]
)

The autostart parameter triggers transitions on launch. Without it, you’re responsible for calling the services.

Why does this matter? In production, you want graceful degradation. If the lidar fails, you want the robot to stop navigating but keep running other nodes (localization, telemetry, safety watchdog). In ROS1, you’d just kill move_base. In ROS2, you call deactivate() on the controller and planner servers, which releases resources but keeps the nodes alive for fast restart.

But if your monitoring system expects nodes to exit on failure, it breaks. We had to rewrite our Kubernetes liveness probes to check lifecycle state instead of process existence. If you’re debugging on Raspberry Pi 5 vs Jetson Nano: MobileNet Inference 38ms Gap hardware, this adds another layer of complexity.

TF2 Buffer Timeout Behavior Changed

ROS1 move_base used tf::TransformListener with a 10-second cache. If a transform wasn’t available, it blocked until timeout:

// ROS1 TF lookup
tf::StampedTransform transform;
try {
  listener.lookupTransform("map", "base_link", ros::Time(0), transform);
} catch (tf::TransformException &ex) {
  ROS_ERROR("%s", ex.what());
}

ROS2 Nav2 uses tf2_ros::Buffer with a default timeout of 0.1 seconds. If the transform isn’t ready, it immediately throws:

// ROS2 TF lookup
geometry_msgs::msg::TransformStamped transform;
try {
  transform = tf_buffer.lookupTransform("map", "base_link", tf2::TimePointZero, tf2::durationFromSec(0.1));
} catch (tf2::TransformException &ex) {
  RCLCPP_ERROR(node->get_logger(), "%s", ex.what());
}

The problem: at startup, TF takes a few seconds to populate. In ROS1, move_base would wait. In ROS2, bt_navigator crashes with “map to base_link not available.”

The fix is increasing the timeout in the Nav2 params:

bt_navigator:
  ros__parameters:
    transform_tolerance: 0.5  # seconds

But if your robot has intermittent TF (sensor dropout, network hiccup), 0.5 seconds might not be enough. We set it to 2.0 seconds for WiFi-based localization, which added 2 seconds of latency on every TF failure. Not ideal, but better than crashing.

Costmap Update Frequency: Rolling Window Broke

ROS1 global costmaps were static (loaded once from the map server). Local costmaps used rolling windows that followed the robot.

ROS2 Nav2 allows both costmaps to be static or rolling, configured by rolling_window: true. But the update logic changed.

In ROS1, the rolling window updated every update_frequency seconds:

local_costmap:
  update_frequency: 5.0
  publish_frequency: 2.0

In ROS2, update_frequency no longer exists. The costmap updates whenever new sensor data arrives, governed by robot_radius and transform_tolerance:

local_costmap:
  local_costmap:
    ros__parameters:
      update_frequency: 5.0  # IGNORED in Humble+
      publish_frequency: 2.0
      rolling_window: true
      width: 3
      height: 3
      resolution: 0.05
      robot_radius: 0.22
      transform_tolerance: 0.5

If your lidar publishes at 40Hz, the costmap updates at 40Hz. On a Jetson Nano (4-core ARM A57), this maxed out 2 cores and caused the controller to drop below 10Hz. We had to throttle the lidar topic:

lidar_throttle = Node(
    package='topic_tools',
    executable='throttle',
    name='lidar_throttle',
    arguments=['messages', '/scan', '10', '/scan_throttled']
)

After throttling to 10Hz, CPU usage dropped from 180% to 90% and the controller stabilized at 20Hz.

Migration Checklist

Here’s what actually needs to change:

  1. Parameter files: Flatten ROS1 YAML, nest under ros__parameters, split into bt_navigator, controller_server, planner_server.
  2. Recovery behaviors: Rewrite as BT action nodes, implement nav2_behavior_tree::BtActionNode<T>, register in BT plugin XML.
  3. Costmap plugins: Add plugin: field, nest layer params, set map_subscribe_transient_local: True, add data_type to observation sources.
  4. Action clients: Replace actionlib.SimpleActionClient with rclcpp_action.ActionClient, use async API, change namespace from /move_base to /navigate_to_pose.
  5. DWA tuning: Port to DWB critics, re-tune from scratch (weights don’t transfer directly).
  6. Launch files: Add lifecycle_manager node with autostart: True and all Nav2 nodes listed in node_names.
  7. TF timeouts: Increase transform_tolerance to 0.5-2.0 seconds if you have slow or intermittent TF.
  8. Costmap update frequency: Throttle high-rate sensors (lidar, depth camera) to 10-15Hz if running on embedded hardware.

One more thing: Nav2’s default planner is Navfn (Dijkstra-based), same as ROS1. But if you need faster replanning, switch to nav2_smac_planner/SmacPlannerHybrid, which uses A* with kinematic constraints. On our warehouse map (50x50m, 0.05 resolution), Navfn took 180ms to plan, Smac took 40ms. The API is identical, just change the plugin name.

FAQ

Q: Can I run ROS1 move_base and ROS2 Nav2 side-by-side during migration?

No, not easily. They both publish to /cmd_vel and subscribe to /scan, so they’ll fight for control. You could remap topics, but the real blocker is TF — both stacks expect map → odom → base_link, and running two TF publishers causes conflicts. If you must run both, use separate namespaces and separate robots.

Q: Does Nav2 support the global planner I wrote for ROS1?

Maybe. If your ROS1 global planner inherited from nav_core::BaseGlobalPlanner, you need to port it to nav2_core::GlobalPlanner. The API changed — instead of makePlan(start, goal, plan), it’s now createPlan(start, goal) returning a nav_msgs::msg::Path. The costmap access API also changed (getCost(x, y) is the same, but worldToMap() now returns bool instead of throwing). Budget a day for the port.

Q: Why does my robot spin in place when it reaches the goal?

Nav2’s default behavior tree includes goal rotation tolerance. If your goal pose has an orientation and the robot’s final heading is off by more than goal_checker.yaw_goal_tolerance (default 0.1 rad = 5.7°), it spins to correct it. ROS1 move_base only checked position tolerance by default. Set yaw_goal_tolerance: 1.57 (90°) if you don’t care about final heading, or remove the GoalReached check from your behavior tree.

The Nav2 Learning Curve Is Real

If you’re moving a production robot from ROS1 to ROS2, block two weeks for the navigation stack. Not because the code is hard, but because the concepts changed. Behavior trees, lifecycle nodes, and the new costmap update model require rethinking how you structure navigation logic. Stuff that “just worked” on the factory floor — waiting for TF at startup, throttling the planner, recovering from sensor dropout — now needs explicit configuration.

But the upside: Nav2 is more modular. In ROS1, adding a custom recovery behavior meant forking move_base. In ROS2, you write a BT node and register it in XML. If you’re building a new robot, start with Nav2. If you’re migrating, test on hardware early — simulation won’t catch the TF timeout issues or the CPU spikes from unthrottled costmap updates.

One thing I’m still not sure about: whether the behavior tree complexity is worth it for simple robots. If your AMR just needs “go to waypoint, spin if stuck, give up after 3 tries,” the linear recovery chain in ROS1 was simpler. The behavior tree gives you flexibility, but you pay for it in XML wrangling and harder debugging. For a warehouse robot that needs custom recovery (“if blocked for 30 seconds, call dispatch”), it’s a clear win. For a telepresence robot following a person? Maybe overkill. I’d love to hear if anyone’s built a simpler ROS2 nav stack that drops the BT layer.

Speaking of long debugging sessions — if you’re running low-level robotics code, keep Caffeinated Coffee Energy Gummies nearby. They’re more portable than a cup when you’re crawling under a robot at 11pm tracing TF frames.

Did you find this helpful?

Your support keeps this blog running and ad-free content coming.

☕ Buy me a coffee
TODAY 7 | TOTAL 126,699