ROS 2 Composition Guide 2026
Run multiple nodes in a single process with rclcpp_components: create composable nodes, launch ComposableNodeContainer, load/unload components at runtime with ros2 component, and achieve zero-copy messaging with unique_ptr intra-process communication.
When to Use Composition
Use composition when nodes exchange high-bandwidth data (camera images, LiDAR point clouds) and latency matters. A camera driver + preprocessor + detector running in one container eliminates serialization overhead entirely — the raw Image buffer pointer is handed directly from publisher to subscriber without copying. For typical control nodes with string/float messages, the overhead savings are small and composition adds complexity without benefit.
What Is ROS 2 Composition?
Composition allows multiple rclcpp nodes to run in a single process, sharing the same executor and DDS participant. This reduces overhead and enables zero-copy intra-process communication.
ROS 2 Node Composition
═══════════════════════════════════════════════════════
Traditional (without composition):
Process A ────── DDS ────── Process B
[Node 1] (network/IPC) [Node 2]
• Each process = separate executor + separate DDS participant
• All messages serialized/deserialized through DDS transport
• Higher latency and CPU overhead
With Composition:
Single Process
┌──────────────────────────────────────┐
│ Component Container │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ Node 1 │ │ Node 2 │ │ Node 3 │ │
│ └────────┘ └────────┘ └────────┘ │
│ ↕ intra-process comms │
└──────────────────────────────────────┘
• One executor, one DDS participant
• Intra-process: zero-copy via shared_ptr / unique_ptr
• External: still serialized through DDS (same as normal)
Key benefits:
✅ Zero-copy message passing between co-located nodes
✅ Lower latency (no DDS serialization overhead)
✅ Fewer OS processes to manage
✅ Runtime load/unload of nodes without restarting the container
Key classes:
rclcpp_components — registration macro + Node factory
rclcpp::executors::SingleThreadedExecutor / MultiThreadedExecutor
ComponentManager — manages loaded components in a container process⚡ Composition only works with rclcpp (C++) natively. Python composable nodes exist via rclpy_components but are less common. For production performance-sensitive pipelines, prefer C++.
Creating a Composable Node (C++)
A composable node is a standard rclcpp::Node that registers itself with the component system via a macro. The key difference: use Node(options) not Node(name) to allow container-injected options.
// include/my_pkg/talker.hpp
#pragma once
#include <rclcpp/rclcpp.hpp>
#include <std_msgs/msg/string.hpp>
namespace my_pkg {
class Talker : public rclcpp::Node {
public:
// MUST take NodeOptions — not (const std::string & name)
explicit Talker(const rclcpp::NodeOptions & options);
private:
void timer_callback();
rclcpp::Publisher<std_msgs::msg::String>::SharedPtr pub_;
rclcpp::TimerBase::SharedPtr timer_;
size_t count_ = 0;
};
} // namespace my_pkg
// ──────────────────────────────────────────────────────────
// src/talker.cpp
#include "my_pkg/talker.hpp"
#include <rclcpp_components/register_node_macro.hpp>
namespace my_pkg {
Talker::Talker(const rclcpp::NodeOptions & options)
: Node("talker", options) // pass options to base class
{
pub_ = create_publisher<std_msgs::msg::String>("chatter", 10);
timer_ = create_wall_timer(
std::chrono::milliseconds(500),
std::bind(&Talker::timer_callback, this)
);
}
void Talker::timer_callback() {
auto msg = std_msgs::msg::String{};
msg.data = "Hello " + std::to_string(count_++);
pub_->publish(std::move(msg));
}
} // namespace my_pkg
// Register as a component — this generates the factory function
// that ComponentManager uses to instantiate the class
RCLCPP_COMPONENTS_REGISTER_NODE(my_pkg::Talker)⚡ The NodeOptions argument is mandatory for composable nodes. The container injects remappings, parameters, and intra-process settings through NodeOptions at load time.
CMakeLists.txt for a Component Library
Composable nodes are built as shared libraries, not executables. The ament_target_dependencies and rclcpp_components_register_nodes calls wire the factory into the plugin system.
cmake_minimum_required(VERSION 3.8)
project(my_pkg)
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(rclcpp_components REQUIRED)
find_package(std_msgs REQUIRED)
# ── Build as SHARED library (not executable) ────────────────
add_library(talker_component SHARED src/talker.cpp)
ament_target_dependencies(talker_component
rclcpp
rclcpp_components
std_msgs
)
# Register component(s) in this library
rclcpp_components_register_nodes(talker_component
"my_pkg::Talker"
)
# ── Optional standalone executable (for development) ───────
add_executable(talker_node src/talker_main.cpp)
target_link_libraries(talker_node talker_component)
# ── Install ─────────────────────────────────────────────────
install(TARGETS talker_component talker_node
ARCHIVE DESTINATION lib
LIBRARY DESTINATION lib
RUNTIME DESTINATION lib/${PROJECT_NAME}
)
ament_package()
# ──────────────────────────────────────────────────────────
# src/talker_main.cpp (standalone wrapper)
// int main(int argc, char * argv[]) {
// rclcpp::init(argc, argv);
// rclcpp::NodeOptions options;
// auto node = std::make_shared<my_pkg::Talker>(options);
// rclcpp::spin(node);
// rclcpp::shutdown();
// }Launching a Component Container
The component_container is a pre-built ROS 2 executable that hosts any number of composable nodes. Launch files use ComposableNodeContainer and ComposableNode to declare which components load at startup.
# launch/composition_launch.py
from launch import LaunchDescription
from launch_ros.actions import ComposableNodeContainer, Node
from launch_ros.descriptions import ComposableNode
def generate_launch_description():
# ── ComposableNodeContainer: hosts multiple components ──
container = ComposableNodeContainer(
name="my_container",
namespace="",
package="rclcpp_components",
# Use component_container_mt for MultiThreadedExecutor
executable="component_container",
composable_node_descriptions=[
ComposableNode(
package="my_pkg",
plugin="my_pkg::Talker",
name="talker",
parameters=[{"publish_rate": 10.0}],
remappings=[("chatter", "/robot/chatter")],
# Enable intra-process comms for this component
extra_arguments=[{"use_intra_process_comms": True}],
),
ComposableNode(
package="my_pkg",
plugin="my_pkg::Listener",
name="listener",
extra_arguments=[{"use_intra_process_comms": True}],
),
],
output="screen",
)
# Normal nodes can coexist in the same launch file
tf_broadcaster = Node(
package="tf2_ros",
executable="static_transform_publisher",
arguments=["0", "0", "0.5", "0", "0", "0", "base_link", "camera_link"],
)
return LaunchDescription([container, tf_broadcaster])⚡ component_container uses SingleThreadedExecutor. Use component_container_mt for MultiThreadedExecutor (required with ReentrantCallbackGroups). Use component_container_isolated for a dedicated thread per component.
Loading and Unloading Components at Runtime
The ros2 component CLI lets you load, unload, and list components in a running container without restarting it — powerful for dynamic reconfiguration.
# ── Start an empty container ───────────────────────────────
ros2 run rclcpp_components component_container
# In another terminal:
# Load a component into the running container
ros2 component load /ComponentManager my_pkg my_pkg::Talker
# Load with parameters
ros2 component load /ComponentManager my_pkg my_pkg::Talker --param "publish_rate:=5.0"
# Load with name remapping
ros2 component load /ComponentManager my_pkg my_pkg::Talker --node-name my_talker --remap chatter:=/robot/chat
# List all loaded components
ros2 component list
# Output:
# /ComponentManager
# 1 /talker
# 2 /listener
# Unload component by ID (from ros2 component list)
ros2 component unload /ComponentManager 1
# ── Programmatic loading (C++ client) ────────────────────
# In a launch file or node:
# rclcpp::Client<composition_interfaces::srv::LoadNode>::SharedPtr load_client;
# auto request = std::make_shared<composition_interfaces::srv::LoadNode::Request>();
# request->package_name = "my_pkg";
# request->plugin_name = "my_pkg::Talker";Zero-Copy Intra-Process Communication
When both publisher and subscriber are in the same process, use unique_ptr messages to pass ownership without copying. This is the main performance advantage of composition.
#include <rclcpp/rclcpp.hpp>
#include <sensor_msgs/msg/image.hpp>
#include <rclcpp_components/register_node_macro.hpp>
namespace my_pkg {
class ImageProcessor : public rclcpp::Node {
public:
explicit ImageProcessor(const rclcpp::NodeOptions & options)
: Node("image_processor", options)
{
// ── Intra-process publisher: publish unique_ptr ───────
// Zero-copy: ownership transfers to subscriber directly
pub_ = create_publisher<sensor_msgs::msg::Image>(
"/processed/image", 10
);
// ── Subscriber receives unique_ptr (not shared_ptr) ───
// unique_ptr callback = take ownership (zero-copy guaranteed)
sub_ = create_subscription<sensor_msgs::msg::Image>(
"/raw/image", 10,
[this](sensor_msgs::msg::Image::UniquePtr msg) {
// msg moved in — modify in-place, no copy
msg->header.stamp = now();
pub_->publish(std::move(msg)); // zero-copy publish
}
);
}
private:
rclcpp::Publisher<sensor_msgs::msg::Image>::SharedPtr pub_;
rclcpp::Subscription<sensor_msgs::msg::Image>::SharedPtr sub_;
};
} // namespace my_pkg
RCLCPP_COMPONENTS_REGISTER_NODE(my_pkg::ImageProcessor)
// In launch file or code:
// extra_arguments=[{"use_intra_process_comms": True}]
// ↑ MUST set this for both publisher and subscriber nodes⚡ For zero-copy to work: (1) Both nodes in the same container, (2) use_intra_process_comms=True on BOTH nodes, (3) subscriber callback takes unique_ptr<Msg> not const Msg&. If any condition fails, messages are copied as normal.
Python Composable Nodes
Python composable nodes exist via rclpy_components but are not zero-copy. They are useful for dynamic loading in heterogeneous systems.
# Python composable node — uses rclpy_components (not common)
# Most production use cases: use C++ for composable nodes
# setup.py entry_points:
# 'rclpy_components': ['my_listener = my_pkg.listener:main']
# my_pkg/listener.py
import rclpy
from rclpy.node import Node
from std_msgs.msg import String
class Listener(Node):
def __init__(self):
super().__init__("listener")
self.sub = self.create_subscription(
String, "chatter", self._cb, 10
)
def _cb(self, msg):
self.get_logger().info(f"Heard: {msg.data}")
def main(args=None):
rclpy.init(args=args)
node = Listener()
rclpy.spin(node)
rclpy.shutdown()
# In launch file:
# from launch_ros.descriptions import ComposableNode
# ComposableNode(
# package="my_pkg",
# plugin="my_pkg.listener.Listener", # Python dotted path
# name="listener",
# )Debugging Composition
Use ros2 component and ros2 node commands to inspect running containers and loaded components.
# List all component managers in the system
ros2 component list
# Show container and all loaded components with types
ros2 component list -v
# Introspect nodes loaded in a specific container
ros2 node list
# → /my_container (the container itself)
# → /talker (loaded component)
# → /listener (loaded component)
# Show node parameters (parameters are scoped per component)
ros2 param list /talker
ros2 param get /talker publish_rate
# Check intra-process communication is active
# Look for 'intra_process_comms' in node info
ros2 node info /talker
# Topics showing "intra-process" = zero-copy path active
# Verify shared library is registered correctly
ros2 pkg executables rclcpp_components
# → component_container
# → component_container_isolated
# → component_container_mt
# Check component plugin exports
ros2 pkg executables my_pkg
colcon info my_pkg | grep -i componentQuick Reference
| Concept | Details |
|---|---|
| Composable node base | extends rclcpp::Node, takes NodeOptions, RCLCPP_COMPONENTS_REGISTER_NODE |
| Build target | add_library(X SHARED) not add_executable — must be a shared library |
| component_container | SingleThreadedExecutor container — use _mt for multi-threaded |
| ComposableNodeContainer | Launch file action that starts container + loads components |
| Runtime load | ros2 component load /ComponentManager pkg plugin_name |
| Runtime unload | ros2 component unload /ComponentManager <id> |
| Zero-copy requirement | use_intra_process_comms=True on ALL nodes + unique_ptr callbacks |
| List components | ros2 component list (shows container → component IDs) |
| Python composable | rclpy_components — supported but NOT zero-copy |
| component_container_isolated | Each component in its own thread — isolation without separate processes |