Vaibhav Shende Vaibhav Shende

ROS 2 Actions vs Services: When To Use Which (And Why You'll Choose Wrong)

Master the difference between ROS 2 services and actions: synchronous vs asynchronous, blocking vs preemptible, and the gotchas that make roboticists cry.

ROS 2

ROS 2 Actions vs Services: When To Use Which (And Why You'll Choose Wrong)

The Problem: Two Ways to Request Something

Your robot needs to:

  • Fetch the camera calibration (takes 1 millisecond)
  • Plan a path to the goal (takes 5 seconds)
  • Move the arm to a position (takes 10 seconds and can be interrupted)

Do you use a Service? An Action? How are they different?

This question will haunt you. Here’s the answer you’ve been looking for.


The Quick Answer

FeatureServiceAction
Call typeSynchronous (request-response)Asynchronous (goal-feedback-result)
BlockingCaller waitsCaller continues
FeedbackNoYes
PreemptionNoYes (can cancel)
Typical latencyMillisecondsSeconds+
ExampleGet camera calibrationMove arm to position

Rule of thumb:

  • Service = “Quick question, wait for answer”
  • Action = “Do this long task and let me know when done”

Part 1: Services (The Simple Case)

1.1 When to Use Services

Use services when:

  • Operation is fast (< 1 second typically)
  • You need synchronous behavior (caller waits)
  • No feedback during execution needed
  • Can’t be interrupted mid-operation

Examples:

  • Get sensor calibration
  • Compute IK solution
  • Set a parameter
  • Save/load data

1.2 Creating a Service Server (Python)

import rclpy
from rclpy.node import Node
from example_interfaces.srv import AddTwoInts  # Pre-defined service
 
class AdditionServer(Node):
    def __init__(self):
        super().__init__('add_server')
        
        # Create service
        self.service = self.create_service(
            AddTwoInts,
            'add_two_ints',
            self.add_callback
        )
        
        self.get_logger().info('Addition service ready')
    
    def add_callback(self, request, response):
        """Handle service request"""
        response.sum = request.a + request.b
        
        self.get_logger().info(f'Adding {request.a} + {request.b} = {response.sum}')
        
        return response  # Send back response
 
def main():
    rclpy.init()
    node = AdditionServer()
    rclpy.spin(node)
 
if __name__ == '__main__':
    main()

1.3 Calling a Service

import rclpy
from rclpy.node import Node
from example_interfaces.srv import AddTwoInts
 
class AdditionClient(Node):
    def __init__(self):
        super().__init__('add_client')
        
        # Create client
        self.client = self.create_client(AddTwoInts, 'add_two_ints')
        
        # Wait for service to be available
        while not self.client.wait_for_service(timeout_sec=1.0):
            self.get_logger().warn('Service not available, waiting...')
        
        # Call the service (BLOCKING)
        self.call_service()
    
    def call_service(self):
        """Call the service and wait for response"""
        request = AddTwoInts.Request()
        request.a = 5
        request.b = 3
        
        # This blocks until response arrives
        future = self.client.call_async(request)
        
        # Wait for response
        rclpy.spin_until_future_complete(self, future)
        
        if future.result() is not None:
            response = future.result()
            self.get_logger().info(f'Result: {response.sum}')
        else:
            self.get_logger().error('Service call failed')
 
def main():
    rclpy.init()
    node = AdditionClient()
    rclpy.spin(node)
 
if __name__ == '__main__':
    main()

1.4 The Gotcha: Services Block the Whole Node

# Service callback takes 5 seconds
def slow_callback(self, request, response):
    time.sleep(5)  # Blocking!
    response.result = "done"
    return response
 
# Meanwhile, if another request comes in while processing the first...
# ❌ It gets queued and waits
# ❌ Your node is unresponsive

Solution: Use Actions for long-running operations.


Part 2: Actions (The Complex Case)

2.1 When to Use Actions

Use actions when:

  • Operation is long-running (seconds or more)
  • You need asynchronous behavior (caller continues)
  • You need feedback during execution
  • Can be preempted (interrupted/canceled)

Examples:

  • Move robot to goal
  • Pick and place operation
  • Image capture and process
  • Any multi-step operation

2.2 Action Structure

An action has three parts:

Goal:     What you want done
Feedback: Updates while working
Result:   Final outcome

Example: Move arm to position

Goal:     target_x=0.5, target_y=0.5, target_z=0.3
Feedback: current_x=0.1, current_y=0.1, percent_complete=30%
Result:   success=true, final_x=0.5, final_y=0.5

2.3 Defining an Action Interface

# action/MoveArm.action
float64 target_x
float64 target_y
float64 target_z
---
bool success
float64 final_x
float64 final_y
float64 final_z
---
float64 current_x
float64 current_y
int32 percent_complete

The three parts separated by ---:

  • First block: Goal
  • Second block: Result
  • Third block: Feedback

2.4 Creating an Action Server

import rclpy
from rclpy.action import ActionServer
from rclpy.node import Node
from example_interfaces.action import Fibonacci
 
class FibonacciServer(Node):
    def __init__(self):
        super().__init__('fibonacci_server')
        
        # Create action server
        self.action_server = ActionServer(
            self,
            Fibonacci,
            'fibonacci',
            self.execute_callback
        )
        
        self.get_logger().info('Fibonacci action server started')
    
    def execute_callback(self, goal_handle):
        """Execute the action"""
        self.get_logger().info(f'Executing goal: {goal_handle.request.order}')
        
        sequence = []
        for i in range(goal_handle.request.order):
            # Check if canceled
            if goal_handle.is_cancel_requested:
                goal_handle.canceled()
                self.get_logger().info('Goal canceled')
                return Fibonacci.Result()
            
            # Compute fibonacci
            if i == 0:
                sequence.append(0)
            elif i == 1:
                sequence.append(1)
            else:
                sequence.append(sequence[i-1] + sequence[i-2])
            
            # Send feedback
            feedback = Fibonacci.Feedback()
            feedback.sequence = sequence
            goal_handle.publish_feedback(feedback)
            
            self.get_logger().info(f'Feedback: {feedback.sequence}')
            
            # Simulate work
            time.sleep(0.1)
        
        # Set result
        goal_handle.succeed()
        result = Fibonacci.Result()
        result.sequence = sequence
        
        return result
 
def main():
    rclpy.init()
    node = FibonacciServer()
    rclpy.spin(node)
 
if __name__ == '__main__':
    main()

2.5 Creating an Action Client

import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node
from example_interfaces.action import Fibonacci
 
class FibonacciClient(Node):
    def __init__(self):
        super().__init__('fibonacci_client')
        
        # Create action client
        self.action_client = ActionClient(self, Fibonacci, 'fibonacci')
        
        # Wait for server
        if not self.action_client.wait_for_server(timeout_sec=5.0):
            self.get_logger().error('Action server not available')
            return
        
        # Send goal
        self.send_goal()
    
    def send_goal(self):
        """Send a goal to the action server"""
        goal = Fibonacci.Goal()
        goal.order = 10  # Compute first 10 fibonacci numbers
        
        # Callbacks for feedback and result
        self.action_client.send_goal_async(
            goal,
            feedback_callback=self.feedback_callback,
            done_callback=self.done_callback
        )
    
    def feedback_callback(self, feedback_msg):
        """Called when server sends feedback"""
        feedback = feedback_msg.feedback
        self.get_logger().info(f'Feedback: {feedback.sequence}')
    
    def done_callback(self, future):
        """Called when server finishes"""
        goal_handle = future.result()
        
        if goal_handle.cancelled():
            self.get_logger().info('Goal was canceled')
        elif goal_handle.succeeded():
            result = goal_handle.result()
            self.get_logger().info(f'Final result: {result.sequence}')
        else:
            self.get_logger().error('Goal failed')
 
def main():
    rclpy.init()
    node = FibonacciClient()
    rclpy.spin(node)
 
if __name__ == '__main__':
    main()

2.6 Canceling an Action

def cancel_action(self):
    """Cancel the running action"""
    goal_handle = self.goal_handle  # Saved from send_goal_async
    
    future = goal_handle.cancel_goal_async()
    rclpy.spin_until_future_complete(self, future)
    
    if future.result().cancel_accepted:
        self.get_logger().info('Goal canceled')
    else:
        self.get_logger().warn('Cancel request was refused')

Part 3: Comparison in Real Scenarios

Scenario 1: Get Robot Configuration

# ✅ Use Service
srv = self.create_service(
    GetConfig,
    'get_config',
    self.get_config_callback
)
 
# Why: Fast operation, needs immediate response

Scenario 2: Move Arm to Position

# ✗ Don't use Service (blocks caller too long)
# ✅ Use Action (async, with feedback)
 
action = ActionServer(self, MoveArm, 'move_arm', self.move_arm_callback)
 
# Why: Takes time, caller wants to continue, needs feedback

Scenario 3: Capture Image

# ✅ Use Service (if synchronous capture OK)
# ✅ Use Action (if you want feedback on processing steps)
 
# Choose based on whether you need:
# - Feedback during capture? → Action
# - Just need result? → Service

Part 4: The Gotchas

Gotcha 1: Calling Service from Callback

# ❌ WRONG: Blocks the node while waiting
def callback1(self, request, response):
    result = self.client.call(other_service)  # Blocks!
    response.result = result
    return response
 
# ✅ Better: Use async or move logic elsewhere
def callback1(self, request, response):
    future = self.client.call_async(other_service)
    # Handle future asynchronously

Gotcha 2: Not Checking Service Availability

# ❌ WRONG: Assumes service exists
self.client.call_async(request)
 
# ✅ CORRECT: Wait for service
while not self.client.wait_for_service(timeout_sec=1.0):
    self.get_logger().warn('Waiting for service...')

Gotcha 3: Action Callbacks Are Complex

# The difference between:
# - goal_handle.is_cancel_requested (check if cancel was requested)
# - goal_handle.canceled() (mark as canceled)
# - goal_handle.succeed() (mark as succeeded)
# - goal_handle.abort() (mark as aborted)
 
# If you forget these, the client hangs waiting for result

Gotcha 4: Mixing Services and Actions

# ❌ Don't create 100 services when you should use 1 action
self.create_service(Grasp, 'grasp_object', ...)
self.create_service(Open, 'open_gripper', ...)
self.create_service(Close, 'close_gripper', ...)
 
# ✅ Better: One action for the whole sequence
ActionServer(self, PickPlace, 'pick_place', ...)

Part 5: Real-World Pattern - Pick and Place with Action

import rclpy
from rclpy.action import ActionServer
from rclpy.node import Node
import time
 
class PickPlaceServer(Node):
    def __init__(self):
        super().__init__('pick_place_server')
        
        self.action_server = ActionServer(
            self,
            PickPlace,
            'pick_place',
            self.execute_callback
        )
    
    def execute_callback(self, goal_handle):
        """Execute pick and place"""
        goal = goal_handle.request
        
        try:
            # Step 1: Move to approach position
            self.get_logger().info('Moving to approach position...')
            self.move_arm(goal.target_x, goal.target_y, goal.target_z + 0.1)
            self._publish_feedback(goal_handle, 20)
            
            # Step 2: Move to grasp position
            self.get_logger().info('Moving to grasp position...')
            self.move_arm(goal.target_x, goal.target_y, goal.target_z)
            self._publish_feedback(goal_handle, 40)
            
            # Step 3: Close gripper
            self.get_logger().info('Closing gripper...')
            self.close_gripper()
            self._publish_feedback(goal_handle, 60)
            
            # Step 4: Lift object
            self.get_logger().info('Lifting object...')
            self.move_arm(goal.target_x, goal.target_y, goal.target_z + 0.2)
            self._publish_feedback(goal_handle, 80)
            
            # Step 5: Move to place position
            self.get_logger().info('Moving to place position...')
            self.move_arm(goal.place_x, goal.place_y, goal.place_z)
            self._publish_feedback(goal_handle, 90)
            
            # Step 6: Open gripper
            self.get_logger().info('Opening gripper...')
            self.open_gripper()
            self._publish_feedback(goal_handle, 100)
            
            # Success!
            goal_handle.succeed()
            result = PickPlace.Result()
            result.success = True
            return result
        
        except Exception as e:
            self.get_logger().error(f'Pick and place failed: {e}')
            goal_handle.abort()
            return PickPlace.Result(success=False)
    
    def _publish_feedback(self, goal_handle, percent):
        """Send progress feedback"""
        feedback = PickPlace.Feedback()
        feedback.percent_complete = percent
        goal_handle.publish_feedback(feedback)
    
    def move_arm(self, x, y, z):
        """Simulated arm movement"""
        time.sleep(0.5)
    
    def close_gripper(self):
        """Simulated gripper close"""
        time.sleep(0.2)
    
    def open_gripper(self):
        """Simulated gripper open"""
        time.sleep(0.2)
 
def main():
    rclpy.init()
    node = PickPlaceServer()
    rclpy.spin(node)
 
if __name__ == '__main__':
    main()

Decision Tree

Does the operation take < 1 second?
├─ YES → Service
└─ NO ─┐
       ├─ Need feedback during execution?
       │  ├─ YES → Action
       │  └─ NO ─→ Service (if OK to block)
       │
       └─ Need to cancel mid-operation?
          ├─ YES → Action
          └─ NO ─→ Service (if OK to block)

Quick Reference

Service:

# Server
self.create_service(MyService, 'service_name', callback)
 
# Client
self.client.call_async(request)

Action:

# Server
ActionServer(self, MyAction, 'action_name', execute_callback)
 
# Client
self.action_client.send_goal_async(goal, feedback_callback, done_callback)

Remember: Services are for quick queries. Actions are for long tasks with updates.