Introduction to ROS 2 Actions: How to Build Long-Running Tasks on a Mobile Robot
Adrian Krzemiński,

TL;DR: This ROS 2 actions tutorial shows you how to implement long-running tasks (navigation, docking, inspection sweeps) on a mobile robot using the rclpy action API. Actions extend the topic/service model with three channels: a goal, periodic feedback, and a final result, plus the ability to cancel mid-execution. You will see when to choose actions over services or topics, how the protocol works under the hood, and a working server + client example you can run on a Leo Rover or any ROS 2 Humble/Jazzy system.
Why ROS 2 Actions Matter for Mobile Robots
On a mobile robot, most interesting behaviors take seconds to minutes: drive to a waypoint, rotate to scan an area, dock with a charger, follow a person. You need three things at once – command the task, monitor its progress, and cancel it if conditions change. Topics give you streaming data but no acknowledgment. Services give you a single request/response but no progress feedback. ROS 2 actions are the middleware primitive built exactly for this gap, and this ROS 2 actions tutorial walks through the mechanics end to end.
Actions are used throughout the ROS 2 ecosystem. Nav2 exposes NavigateToPose and FollowWaypoints as actions. MoveIt 2 plans and executes arm trajectories through actions. If you build field robotics applications – inspection, surveying, agricultural scouting – actions will be the backbone of your task layer.
What ROS 2 Actions Are and How They Differ From Services and Topics
An action in ROS 2 is a client-server interaction built on top of three services and two topics under the hood. You do not interact with those directly. The rclcpp_action and rclpy.action libraries wrap them into a clean API with goal handles, feedback callbacks, and cancellation.
The action interface file (.action) has three sections separated by ---: goal, result, and feedback. Here is the comparison you need before choosing the right primitive:
Topics vs Services vs Actions
The table below summarizes the practical differences when building robot behaviors:
- Topic – one-way, asynchronous, many-to-many. No acknowledgment, no result. Best for sensor streams,
/cmd_vel,/tf. Latency: lowest. Cancellable: not applicable. - Service – request/response, one-to-one per call. Best for short, atomic queries: “get current map”, “set parameter”. Should return quickly. Cancellable: no.
- Action – asynchronous goal with feedback and result, cancellable. Best for tasks taking more than approximately one second. Examples: navigation, docking, scripted maneuvers. Cancellable: yes.
A useful rule of thumb: if the call can fail partway and you would want to know how far it got, use an action.
How the Action Protocol Works Step by Step
When a client sends a goal, the server may accept or reject it. If accepted, the client gets a goal handle associated with a UUID, and the server begins executing. While running, the server publishes feedback messages at a frequency you control. The client can request cancellation at any time; the server decides whether to honor it and reports back. When the task ends, the server returns a result with one of three terminal states: SUCCEEDED, ABORTED, or CANCELED.
This state machine is what makes actions safe for long-running tasks on mobile robots. If your Leo Rover loses a wheel encoder mid-traverse, the navigation action server aborts cleanly and your behavior tree can react, rather than the client hanging forever.
A Working Example: Drive Forward Action on a Mobile Robot
Below is a minimal but realistic action: drive the robot forward a given distance, publishing the traveled distance as feedback, ending with success or cancellation. It is written for ROS 2 Humble / Jazzy with Python, and works on a Leo Rover or any differential-drive platform that accepts geometry_msgs/Twist on /cmd_vel and publishes nav_msgs/Odometry on /odom.
Step 1: Define the Action Interface
Create a package drive_actions with an action/DriveDistance.action file:
float32 distance
float32 speed
---
bool success
float32 distance_traveled
---
float32 distance_traveled
float32 percent_complete
Add this to CMakeLists.txt and package.xml using rosidl_default_generators. After colcon build, the type drive_actions/action/DriveDistance is available.
Step 2: Implement the Action Server
The server subscribes to odometry, accepts a goal, drives forward at the requested speed, and publishes feedback every 100 ms:
import math
import time
import rclpy
from rclpy.node import Node
from rclpy.action import ActionServer, CancelResponse, GoalResponse
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from geometry_msgs.msg import Twist
from nav_msgs.msg import Odometry
from drive_actions.action import DriveDistance
class DriveServer(Node):
def __init__(self):
super().__init__('drive_server')
self.cb_group = ReentrantCallbackGroup()
self.cmd_pub = self.create_publisher(Twist, '/cmd_vel', 10)
self.odom_sub = self.create_subscription(Odometry, '/odom', self.odom_cb, 10, callback_group=self.cb_group)
self.x = None; self.y = None
self._srv = ActionServer(self, DriveDistance, 'drive_distance',
execute_callback=self.execute_cb,
goal_callback=self.goal_cb,
cancel_callback=lambda c: CancelResponse.ACCEPT,
callback_group=self.cb_group)
def odom_cb(self, msg):
self.x = msg.pose.pose.position.x
self.y = msg.pose.pose.position.y
def goal_cb(self, goal):
if goal.distance <= 0.0 or goal.speed <= 0.0:
self.get_logger().warn('Rejecting goal: distance and speed must be positive')
return GoalResponse.REJECT
return GoalResponse.ACCEPT
def execute_cb(self, goal_handle):
goal = goal_handle.request
while self.x is None and rclpy.ok():
time.sleep(0.1)
x0, y0 = self.x, self.y
fb = DriveDistance.Feedback()
twist = Twist(); twist.linear.x = float(goal.speed)
d = 0.0
while rclpy.ok():
d = math.hypot(self.x - x0, self.y - y0)
if goal_handle.is_cancel_requested:
self.cmd_pub.publish(Twist())
goal_handle.canceled()
return DriveDistance.Result(success=False, distance_traveled=d)
if d >= goal.distance:
break
self.cmd_pub.publish(twist)
fb.distance_traveled = d
fb.percent_complete = min(100.0, 100.0 * d / goal.distance)
goal_handle.publish_feedback(fb)
time.sleep(0.1)
self.cmd_pub.publish(Twist())
goal_handle.succeed()
return DriveDistance.Result(success=True, distance_traveled=d)
def main():
rclpy.init()
node = DriveServer()
executor = MultiThreadedExecutor()
executor.add_node(node)
executor.spin()
Step 3: Implement the Action Client
The client sends a goal of 2.0 meters at 0.2 m/s and prints feedback:
import rclpy
from rclpy.node import Node
from rclpy.action import ActionClient
from drive_actions.action import DriveDistance
class DriveClient(Node):
def __init__(self):
super().__init__('drive_client')
self.cli = ActionClient(self, DriveDistance, 'drive_distance')
def send(self):
self.cli.wait_for_server()
goal = DriveDistance.Goal(distance=2.0, speed=0.2)
fut = self.cli.send_goal_async(goal, feedback_callback=self.fb_cb)
fut.add_done_callback(self.goal_resp_cb)
def fb_cb(self, msg):
self.get_logger().info(f'progress: {msg.feedback.percent_complete:.1f}%')
def goal_resp_cb(self, fut):
handle = fut.result()
if not handle.accepted:
self.get_logger().warn('goal rejected')
return
handle.get_result_async().add_done_callback(
lambda f: self.get_logger().info(f'done: {f.result().result.success}'))
def main():
rclpy.init(); n = DriveClient(); n.send(); rclpy.spin(n)
Step 4: Run and Inspect
After building with colcon build and sourcing the workspace, run the server and client in separate terminals. You can also drive the action from the command line, which is useful for debugging:
ros2 action list
ros2 action info /drive_distance
ros2 action send_goal /drive_distance drive_actions/action/DriveDistance "{distance: 1.5, speed: 0.2}" --feedback
To cancel mid-execution, call cancel_goal_async() from a client or send a request to the action cancel service. The server stops the robot and returns a CANCELED terminal state.
Case Study: Navigation as an Action on Leo Rover
When you run Nav2 on a Leo Rover for outdoor research missions, every waypoint command becomes an action goal. The NavigateToPose action takes a target pose, streams feedback containing the current pose, distance remaining, and estimated time of arrival, and finishes with an action status indicating success or failure. In newer Nav2 releases, the result can also include error codes or messages describing the failure reason.
This matters for field robotics. On a research deployment – vegetation monitoring, soil sampling, perimeter inspection – your mission supervisor needs to know not just whether a waypoint was reached but why it failed. The action feedback channel is what makes that visibility cheap to implement. You can see practical research deployments on the Fictionlab research applications page.
A typical pattern: a behavior tree node sends a NavigateToPose goal, monitors feedback to detect stalls (no pose change for N seconds), and cancels the goal to trigger a recovery behavior. Without actions, you would need to reinvent this protocol on top of topics, including UUID tracking and cancellation semantics.
Common Pitfalls When Writing Action Servers
A few issues tend to bite first-time action authors:
- Blocking the executor. If your
execute_callbackblocks without yielding or without a multithreaded executor, feedback and cancel requests may not be processed. UseMultiThreadedExecutoror write async/await code that yields regularly. - Forgetting to handle cancellation. Always check
goal_handle.is_cancel_requestedin your loop, stop the robot, and callgoal_handle.canceled(). Otherwise the client may wait forever for a terminal result. - Not setting a terminal state. Every accepted goal must end in
succeed(),abort(), orcanceled(). Missing this leaks goal handles. - Sending feedback too fast. 10 Hz is usually plenty. Flooding the feedback topic wastes bandwidth, especially on Wi-Fi-connected outdoor robots.
FAQ
When should you use an action instead of a service in ROS 2?
Use an action when the task takes more than approximately one second, when you want progress feedback, or when you need to cancel mid-execution. Use a service for short atomic calls that always complete quickly.
Can multiple clients send goals to the same action server?
Yes. The default server can accept multiple goals and can execute them concurrently or sequentially depending on how you configure the handle_accepted_callback and executor. Most navigation servers serialize goals or implement their own preemption behavior.
How is action cancellation different from preemption in ROS 1?
In ROS 2, cancellation is explicit and the server can reject it via CancelResponse.REJECT. Preemption is not an automatic protocol feature; servers that support replacing an active goal implement that behavior themselves, often by accepting a new goal and canceling or superseding the previous one.
Do actions work over Wi-Fi or only on localhost?
Actions work over any DDS-supported transport, including Wi-Fi. Feedback frequency and message size affect bandwidth, so keep feedback compact when running a robot like Leo Rover over a wireless link.
Can you write action servers in C++ and clients in Python in the same system?
Yes. Actions are language-agnostic at the wire level. rclcpp_action and rclpy.action interoperate freely. Many production stacks have C++ servers (for performance) and Python clients (for scripting).
What happens if the action server crashes mid-goal?
The client’s goal handle will not receive a result and may wait indefinitely depending on how you wait on it. Robust clients should set timeouts around get_result_async() and treat lost servers as an abort condition.
Are ROS 2 actions suitable for safety-critical commands?
Actions are useful for reliable task orchestration, but they are not real-time deterministic. For safety functions like emergency stop, use a dedicated topic with a watchdog or a hardware safety circuit. Actions are the right tool for task orchestration, not for safety interlocks.
To go further, consult the official ROS 2 actions documentation and adapt the example above to your platform. If you want a field-ready base to run these examples on real hardware, explore the Leo Rover platform.