Inside ROS 2: Anatomy of a Robot Communication System
An under-the-hood tour of ROS 2’s communication fabric—from DDS and core primitives to QoS, executors, and zero-copy paths for large model payloads.
Introduction
In this post, I'll gradually introduce all of the core system components and advanced features that make up a modern, high-performance robot communication system. In particular, I'll be doing an under-the-hood breakdown of ROS 2 (Robot Operating System 2).
This post is the first in a series. It starts broad and then layers in detail (following an inverse-pyramid approach) so you can form an accurate high-level mental model of the complete system without drowning in boilerplate code.
Structure
- The Architecture: The fundamental shift from a centralized master to DDS middleware.
- Core Primitives: Nodes,
Topics, Services, and Actions. - Quality of Service (QoS): Tuning for real-time, high-frequency control loops.
- Executors and Callback Groups: Parallelism, scheduling, and deterministic execution.
- Scaling Up & Zero-Copy: Hardware-aware optimization for deploying large foundation models on edge hardware.
Notes & Assumptions
- Target Audience: Anyone curious about how state-of-the-art robotic systems communicate, as well as engineers building robust, production-grade autonomy stacks.
- Language Targets: Code examples demonstrate high-level policies using Python (rclpy) and performance-critical, real-time paths using modern C++ (rclcpp) targeting the Humble, Iron, and Jazzy distributions.
- The first section on DDS covers essential networking fundamentals—the rest of the post dives straight into practical systems code, concurrency mechanics, and memory architecture.
1. The Architecture: Nodes and the DDS Middleware
Before diving into the mechanics, let's establish exactly what ROS 2 is. Despite the name, it is not an operating system like Linux or Windows. It is a middleware framework that sits directly between your host operating system kernel and your application process.
Its fundamental purpose is to solve the distributed systems problem in robotics: how do you get dozens of independent hardware drivers, state estimators, planners, and neural networks to talk to each other seamlessly, concurrently, and in real time?
Nodes: The Fundamental Unit
In ROS 2, a Node is a single, self-contained executable process (or shared library component) that performs a specific, isolated task. For example, one node might be a camera driver publishing RGB images, another might be a neural network inference script evaluating action chunks, and a third might be a high-frequency PID controller for an actuator. The ROS ecosystem is essentially a decentralized computational graph of these independent nodes exchanging data.

The Communication Fabric and the Bottleneck
The "communication fabric" is the underlying networking and serialization layer that physically moves data between these isolated nodes. The inputs are raw sensor streams (images, joint encoders) and the outputs are processed commands (target joint torques, navigation waypoints).
If you are developing complex foundation models for physical tasks—such as evaluating dense visual-motor policies for architectures like or VLA models—this fabric can easily become a major system bottleneck. When a camera node generates a 10 MB image tensor, standard communication protocols require the CPU to serialize that data, copy it through the network stack, and deserialize it on the receiving end. If this happens at 60 Hz, you consume massive memory bandwidth just moving bytes around, starving your GPU, spiking your inter-token latency (ITL), and breaking real-time guarantees.
ROS 1: The Centralized Switchboard
To understand why ROS 2 was built, we have to look at the architectural faults of ROS 1. ROS 1 relied on a custom TCP/UDP protocol governed by a centralized master process called roscore.
- Metaphor: Think of ROS 1 like an old-school telephone switchboard. If Node A wanted to talk to Node B, it first had to ask the central
roscoreoperator for Node B's IP address and port to establish a socket connection. - Strengths: It provided unprecedented standardization. Instead of every robotics lab writing custom TCP socket code, researchers could plug-and-play modules from anywhere in the world.
- Weaknesses: It was a single point of failure. If the roscore process crashed, discovery collapsed system-wide. It also lacked real-time Quality of Service (QoS) guarantees, making it brittle over lossy wireless networks and unsuitable for safety-critical hardware.

The Shift to DDS
ROS 2 solves this by replacing the custom protocol and centralized master with DDS (Data Distribution Service)—an OMG (Object Management Group) industry-standard, decentralized middleware protocol originally developed for military, aerospace, autonomous vehicles, and high-frequency trading.
In ROS 2, DDS creates a virtual "Global Data Space." When a ROS 2 node spins up, it doesn't phone home to a master process. Instead, it uses two decentralized discovery phases:
- SPDP: The node broadcasts participant presence messages over UDP multicast (using a deterministic port range calculated as ). This is how nodes on the same subnet announce their existence to one another without a central server.
- SEDP: Once participants discover each other, they exchange unicast SEDP packets containing detailed metadata regarding topic names, interface data types, and Quality of Service (QoS) compatibility.

2. Core Primitives: Topics, Services, and Actions
To enable inter-node communication, ROS 2 provides three distinct communication patterns built on strict, language-agnostic interface definitions (.msg, .srv, and .action files).
Topics (Publish / Subscribe)
Topics are unidirectional, continuous data streams built on an asynchronous publish-subscribe model. A Publisher broadcasts data onto a named /joint_states without knowledge of subscribers. Any number of Subscribers can consume that topic in real time.

import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
class TopicExampleNode(Node):
def __init__(self):
super().__init__('topic_example_node')
# Publisher: broadcasts to 'joint_commands' (Queue depth = 10)
self.publisher_ = self.create_publisher(JointState, 'joint_commands', 10)
# Subscriber: listens to 'joint_states'
self.subscription = self.create_subscription(
JointState, 'joint_states', self.listener_callback, 10
)
def listener_callback(self, msg: JointState):
self.get_logger().info(f"Received joint positions: {msg.position}")
def publish_command(self, positions: list[float]):
msg = JointState()
msg.position = positions
self.publisher_.publish(msg)When to use: Continuous data streams such as sensor feeds, camera frames, LIDAR point clouds, or high-frequency motor torque commands.
Services (Request / Response)
Services are synchronous or asynchronous bidirectional Remote Procedure Calls (RPCs). A Service Server advertises a service name, and a Service Client sends a Request payload, blocking or yielding until the server processes the data and returns a matching Response.

from example_interfaces.srv import AddTwoInts
import rclpy
from rclpy.node import Node
class ServiceExampleNode(Node):
def __init__(self):
super().__init__('service_example_node')
self.srv = self.create_service(AddTwoInts, 'add_two_ints', self.add_two_ints_callback)
def add_two_ints_callback(self, request, response):
response.sum = request.a + request.b
self.get_logger().info(f"Incoming request: a={request.a}, b={request.b}")
return responseWhen to use: Discrete, transactional operations where execution state must be verified before proceeding (e.g., "Reset localization filter," "Trigger gripper vacuum," or "Compute inverse kinematics").
Actions (Goal / Feedback / Result)
Actions are designed for long-running, non-blocking tasks. Since a standard Service call blocks until completion, using a Service for a multi-second motion trajectory would freeze execution. Actions resolve this by combining three messages under the hood:
- Goal: Client sends target state to server (e.g., "Navigate to Waypoint X").
- Feedback: Server streams periodic progress updates (e.g., "Distance remaining: 1.2m").
- Result: Final status response upon success, failure, or preemptive cancellation.

from rclpy.action import ActionServer
from rclpy.node import Node
from nav2_msgs.action import NavigateToPose
class ActionExampleNode(Node):
def __init__(self):
super().__init__('action_example_node')
self._action_server = ActionServer(
self, NavigateToPose, 'navigate_to_pose', self.execute_callback
)
async def execute_callback(self, goal_handle):
self.get_logger().info('Executing navigation trajectory...')
# distance_remaining is a real float32 field on nav2_msgs/action/NavigateToPose.Feedback
# (confirmed against Humble/Iron/Jazzy nav2_msgs) — the full message also carries
# current_pose, navigation_time, estimated_time_remaining, and number_of_recoveries,
# omitted here for brevity.
feedback_msg = NavigateToPose.Feedback()
for i in range(5):
feedback_msg.distance_remaining = float(5 - i)
goal_handle.publish_feedback(feedback_msg)
goal_handle.succeed()
result = NavigateToPose.Result()
return resultMismatched Frequencies: Stream Coalescing
In multi-modal stacks, sensor data arrives asynchronously across varying frequencies (e.g., 30 Hz RGB camera feeds vs. 500 Hz joint encoders vs. a 10 Hz VLA policy loop). Subscribing to these streams via naive, isolated callbacks causes temporal misalignment and race conditions.
To synchronize heterogeneous feeds without manual buffering, ROS 2 provides message_filters using an approximate-time sliding window:
import message_filters
from rclpy.node import Node
from sensor_msgs.msg import Image, JointState
class VLAInferenceNode(Node):
def __init__(self):
super().__init__('vla_inference_node')
image_sub = message_filters.Subscriber(self, Image, '/camera/rgb')
joint_sub = message_filters.Subscriber(self, JointState, '/joint_states')
# Coalesces streams running at 30Hz and 500Hz within a 10ms slop window
self.ts = message_filters.ApproximateTimeSynchronizer(
[image_sub, joint_sub], queue_size=10, slop=0.01
)
self.ts.registerCallback(self.synchronized_callback)
def synchronized_callback(self, image_msg: Image, joint_msg: JointState):
# Callback executes ONLY when a temporally aligned pair is matched
self.get_logger().info(
f"Processing aligned pair at timestamp: {image_msg.header.stamp.sec}.{image_msg.header.stamp.nanosec}"
)Data Logging & Replay: rosbag2 Mechanics
Offline dataset collection, trajectory replay, and regression testing rely on the high-throughput rosbag2 subsystem.
- Disk I/O Pipeline: rosbag2 attaches directly to DDS topics, buffering incoming serialized byte streams into memory queues before writing to disk using pluggable storage backends (SQLite3 or high-performance MCAP).
- Deterministic Simulation Time: To evaluate policy execution offline without wall-clock timing jitter, rosbag2 publishes stored timestamps to the
/clocktopic. Downstream nodes setuse_sim_time = True, forcing execution timers, TF tree calculations, and callback schedules to advance strictly driven by log ticks.
3. Quality of Service (QoS): Tuning for Real-Time Control
In distributed systems, networking requirements differ by payload type. High-frequency joint positions require minimum latency over guaranteed delivery, whereas static map files require complete data integrity regardless of transmission time.
ROS 2 exposes fine-grained DDS QoS profiles to configure delivery behavior per topic:
Reliability:
- Reliable: Guarantees packet delivery. Missing packets trigger TCP-like retransmission over UDP.
- Best Effort: Fire-and-forget delivery. Dropped packets are ignored.
History:
- Keep Last (Depth = N): Retains only the N most recent samples in memory buffers.
- Keep All: Retains samples up to hardware/DDS resource limits.
The QoS Compatibility Matrix
For a publisher and subscriber to establish a connection, their QoS policies must be compatible. If a publisher offers a lower level of service than a subscriber requests, the connection silently fails to establish traffic flow.

Advanced Fault-Tolerance Policies
- Durability: Controls historical message availability for late-joining subscribers (Volatile drops past messages; Transient Local stores the last N messages for new nodes, ideal for static TF transforms or URDF descriptions).
- Deadline: Specifies an expected maximum time interval between consecutive messages. If missed, ROS 2 fires an event callback, allowing system safety managers to safely trip hardware emergency brakes.
- Liveliness: Monitors node health across network boundaries. If a publishing node crashes or hangs, the DDS middleware detects liveliness loss and fires a callback—applications build on top of this signal to trigger failover logic, such as promoting a standby node.
4. Executors and Callback Groups: Scheduling Compute
Now let's examine process-level concurrency. Calling rclcpp::spin(node) or rclpy.spin(node) hands control to the Executor—the internal task scheduler that coordinates timers, subscription callbacks, service requests, and action triggers.
The Single-Threaded Executor Problem
By default, ROS 2 nodes utilize a single-threaded executor. The executor queries the DDS layer via kernel epoll or DDS wait-sets to build an execution set. When work arrives, callbacks are executed sequentially on a single thread.
If an inference callback performs an expensive tensor operation blocking the main thread for 100ms, all other timers and callbacks within that node stall. A 500 Hz actuator control loop running in the same process will miss its timing deadline.

Multi-Threaded Executors & Callback Groups
To distribute work across CPU cores, we deploy the MultiThreadedExecutor. However, multithreading shared node state introduces data race conditions. To control concurrent execution safely, callbacks are assigned to Callback Groups:
- Mutually Exclusive: Callbacks assigned to this group will never execute concurrently. Even if multiple executor threads are free, callbacks within the same group queue sequentially. This protects non-thread-safe internal state.
- Reentrant: Callbacks assigned to this group can be executed fully concurrently across available executor threads, including overlapping instances of the exact same callback.

from rclpy.callback_groups import MutuallyExclusiveCallbackGroup, ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
class ParallelComputeNode(Node):
def __init__(self):
super().__init__('parallel_compute_node')
# Group 1: High-priority isolated control timer
self.control_group = MutuallyExclusiveCallbackGroup()
# Group 2: Reentrant group for parallel heavy compute
self.compute_group = ReentrantCallbackGroup()
self.control_timer = self.create_timer(
0.002, self.control_loop_callback, callback_group=self.control_group
)
self.inference_sub = self.create_subscription(
Image, '/camera/rgb', self.inference_callback, 10, callback_group=self.compute_group
)
def control_loop_callback(self):
pass # 500 Hz execution loop
def inference_callback(self, msg):
pass # Heavy model inference callTwo Critical Concurrency Pitfalls
- The Default Group Trap: Unassigned timers or subscribers default to the node's single Default Mutually Exclusive Callback Group. Switching to a MultiThreadedExecutor without assigning custom groups provides zero speedup, as callbacks remain serialized.
- The Synchronous Service Deadlock: Calling a synchronous service client from inside a timer callback assigned to the same Mutually Exclusive group freezes the node. The timer thread blocks waiting for the response, while the executor cannot allocate a thread to process the response because the group lock is held by the timer. The fix is to place synchronous clients in a callback group separate from anything that might block waiting on them—Reentrant is the usual choice, but any distinct group resolves the deadlock.
5. Scaling Up: Zero-Copy Transport for Foundation Models
Composable Nodes: Eliminating IPC Overhead
When running nodes as distinct OS processes, intra-machine communication incurs OS kernel context switches, CPU page-table swaps, and Inter-Process Communication (IPC) serialization overhead.
ROS 2 resolves this via Node Composition. Nodes are compiled as shared libraries (.so or .dylib artifacts) and loaded dynamically at runtime into a shared OS container process (component_container).


When composed into a single container, publishing a topic bypasses serialization, sockets, and DDS entirely. The publisher passes a C++ std::shared_ptr directly to the subscriber's memory space, dropping IPC latency to near zero.
Production C++ Zero-Copy Implementation
#include <memory>
#include <utility>
#include "rclcpp/rclcpp.hpp"
#include "sensor_msgs/msg/image.hpp"
class ZeroCopyCameraNode : public rclcpp::Node {
public:
ZeroCopyCameraNode() : Node("zero_copy_camera") {
// Publisher negotiates loaned message capabilities with underlying DDS transport
pub_ = this->create_publisher<sensor_msgs::msg::Image>("camera/image_raw", 10);
}
void publish_frame() {
// 1. Verify DDS shared-memory transport capabilities
if (!pub_->can_loan_messages()) {
RCLCPP_WARN_ONCE(
this->get_logger(),
"Shared memory zero-copy not configured! Falling back to standard transport.");
return;
}
// 2. Loan a pre-allocated memory chunk directly from /dev/shm
auto loaned_msg = pub_->borrow_loaned_message();
// 3. Populate memory directly in place (zero stack-to-heap copy)
sensor_msgs::msg::Image & msg = loaned_msg.get();
msg.header.stamp = this->now();
msg.header.frame_id = "camera_optical_frame";
msg.width = 1920;
msg.height = 1080;
msg.encoding = "rgb8";
msg.step = 1920 * 3;
// NOTE: .resize() on a dynamic field like `data` works here because the loan is
// pre-sized to the shared-memory chunk's capacity, but a strict, fixed-size POD
// loan does not universally accept a runtime resize on every DDS vendor — behavior
// here can differ between FastDDS and Cyclone DDS implementations, and between
// rmw implementations more generally. Verify against your specific RMW vendor
// before relying on this in a hard-real-time path.
msg.data.resize(1920 * 1080 * 3); // Written directly in shared buffer
// 4. Transfer pointer ownership to middleware
pub_->publish(std::move(loaned_msg));
}
private:
rclcpp::Publisher<sensor_msgs::msg::Image>::SharedPtr pub_;
};Three Strict Systemic Constraints
- The Python (rclpy) Wall: True zero-copy loaned messages are supported only in C++ (rclcpp). The rclpy Python bindings have no implemented API for loaned messages—every message is constructed as a Python object through the generated message bindings, which requires a real allocation and copy rather than a raw pointer handoff. High-performance pipelines implement sensor ingest and preprocessing components in C++, bridging to Python solely at the GPU boundary or using TorchScript/ONNX C++ execution primitives.
- Fixed-Size Data (POD) Constraint: Loaned message allocators require pre-allocating deterministic memory chunks. Thus, ROS 2 .msg interfaces must consist of Plain Old Data (POD) or fixed-capacity arrays. Dynamic arrays or unbounded strings invalidate static chunk pre-allocation, causing borrow_loaned_message() to fail.
- The GPU VRAM Memory Gap: CPU shared memory keeps tensors in host RAM. However, neural network inference requires execution on the GPU. To eliminate host-to-device
cudaMemcpyoverhead, the ROS 2 Hardware Acceleration Working Group introduced the general Type Adaptation mechanism, which lets a node negotiate a custom, transport-specific representation for a message type instead of the default serialized form. NVIDIA's Isaac ROS builds on top of this mechanism with NITROS (NVIDIA Isaac Transport for ROS), which specifically enables nodes to pass nativecudaMallocVRAM pointers across graph pipelines rather than routing tensors through host RAM at all.
A Latency Model for Robotics Communication
To formalize transport optimization decisions, it helps to borrow the standard latency-bandwidth decomposition used in parallel and distributed computing—the same family of fixed-overhead-plus-transfer-time models that includes the classic Hockney network model—and apply it to inter-process communication:

In standard serialized ROS 2 transport, end-to-end delivery latency is governed by:
Where is payload size in bytes, and represents fixed kernel context switching and DDS scheduling latency. is the host CPU's memory bus bandwidth—the rate at which the CPU can copy bytes between user-space and kernel-space buffers during serialization and deserialization—and is the effective bandwidth of the transport actually carrying the packet, which on a single machine is the loopback/NIC bandwidth rather than a physical network link. The factor of 2 on the memcpy term accounts for publisher serialization and subscriber deserialization each paying that cost once.
Under Zero-Copy Transport, payload size drops to a fixed pointer address size (8 bytes on a 64-bit system):
Regime Analysis
- Latency-Bound Regime (): For joint states, IMU metrics, or TF frames, standard transport is optimal. Fixed scheduling overhead dominates. Enabling zero-copy shared memory can actually introduce net latency due to POSIX allocation management locks.
- Bandwidth-Bound Regime (): For RGB images, 3D point clouds, or large embeddings, payload transfer terms dominate. Latency scales linearly with payload size, saturating CPU memory bus bandwidth.
Production System Architectural Cheat Sheet
| Workload / Data Stream | Payload Size () | Frequency Target | Recommended Architecture | QoS & Concurrency Setup |
|---|---|---|---|---|
| Joint Encoders / IMU Feeds | Standard Pub/Sub (rclcpp / rclpy) | Best Effort, Keep Last (Depth=1), Mutually Exclusive Group | ||
| Raw Camera / Point Cloud | Composable Nodes or Zero-Copy C++ | Shared Memory (/dev/shm + Iceoryx), Reentrant Group | ||
| VLA Policy Tensors (GPU) | Type Adaptation (NITROS) | Direct VRAM Pointer Pass (cudaMalloc), Reentrant Group | ||
| IK Solver / Hardware State | Discrete / On-Demand | Asynchronous Service | Dedicated Reentrant Group (Prevents Deadlock) | |
| Long Navigation Paths | Long-Running | Action Server | Multi-Threaded Executor with Cancellation Handlers |
Epilogue
This post stayed inside the communication fabric: decentralized DDS discovery, Topics / Services / Actions, QoS for real-time control, executor scheduling and callback groups, and zero-copy paths when payloads get large. That is the substrate every ROS 2-based autonomy stack depends on—but it is not the whole of ROS 2.
Plenty of production-critical capabilities were intentionally left out here, including:
- Lifecycle Nodes: managed state machines (Unconfigured → Inactive → Active → Finalized) for deterministic bring-up, teardown, and fault recovery.
- SROS 2: TLS / x.509 authentication, access-control policies, and payload encryption over DDS domains.
- TF2: the distributed transform tree that keeps sensor, robot, and map frames consistent in time.
ros2_controland higher-level stacks (Nav2, MoveIt 2): hardware interfaces, controllers, navigation, and manipulation built on top of this fabric.- Parameters, launch, and composition tooling: runtime configuration, process graphs, and how teams actually ship multi-node systems.
Each of those deserves its own treatment. Communication is the first cut because if the fabric is wrong, nothing above it stays real-time.
Next in the series: Inside Robot Data Storage—an introduction to the production-grade methods for storing queryable, large-scale multimodal robot data (MCAP, Zarr, sharding, featurization, and the layout patterns that keep fleet corpora usable at training time).
Acknowledgements
A huge thank you to my former colleagues at the Stanford AI Lab and peers across the robotics community for reviewing early technical outlines, auditing discovery protocol details, and providing feedback on real-time execution bounds!
References
ROS 2 Documentation, docs.ros.org
Data Distribution Service (DDS) Specification, OMG Standard
Eclipse Iceoryx Shared Memory Middleware, iceoryx.io
Macenski et al., "Robot Operating System 2: Design, Architecture, and Uses In The Wild," Science Robotics, 2022. arXiv:2211.07752
