ROS 2 — Distributed Real-Time Robotics, Taught to an Engineer

An engineer's path from "how do processes talk" — through the DDS middleware, QoS as the real-time knob, executors and callback groups, lifecycle and composable nodes — to the discipline of shipping a robot stack. Current on ROS 2 Kilted (2025) with Jazzy (LTS, 2024) as the workhorse.

Contents
  1. Why robotics needs a new middle layer
  2. The four primitives: topics, services, actions, parameters
  3. Under the hood: ROS 2 is a shim over DDS
  4. QoS — the real-time knob
  5. Nodes, executors, and callback groups
  6. Interfaces: how messages are defined and shipped
  7. Lifecycle nodes and composable nodes
  8. The tooling stack: colcon, launch, rviz2, rosbag2
  9. Security: the DDS-Security plugin
  10. Worked example — a laser-avoidance stack from scratch
  11. Common pitfalls, and the discipline behind them
  12. The one-paragraph summary
  13. Foxglove — live visualization and simulation playback

1. Why robotics needs a new middle layer

A modern robot is a distributed system. Its perception runs on a stereo camera and a LiDAR, often on a GPU-loaded box. Its localization runs on a separate CPU to keep the GPU free for vision. Its planner wants the highest-priority thread on yet another core. Its drivers talk to a CAN bus at 1 kHz. None of these processes are on the same machine, none share memory, and none trust each other. And yet the loop must close at 100 Hz.

This is exactly the graph problem: heterogeneous processes, heterogeneous machines, typed contracts, and a hard real-time budget on the critical edge. A chat app can tolerate 200 ms of jitter; a motor servo cannot. A web service can restart when it crashes; a flight controller cannot lose a sample. ROS 2 is the framework that turns this into a solvable problem.

Why is this not just Kafka, or gRPC, or ZeroMQ? Those give you pub/sub or request/reply, sure — but they hand you a single knob: "reliable or not". A robot needs more. It needs to say: "this topic is unreliable on purpose, this one is reliable on purpose; the sensor sample has to appear within 20 ms of publication, or the frame is dropped; the parameter store must outlive the process that publishes it; the whole graph must tolerate one node dying without tearing down the others." Those are the knobs the ROS 2 middleware exposes.

DDS / multicast bus — SPDP discovery, unicast data
LiDAR driver /scan
IMU driver /imu
Motor ctrl /cmd_vel
Perception /depth
Localizer /odom
Planner /action/navigate
Logger /rosout
Monitor /metrics
UI rviz2
Fig. 1 — A robot stack is a graph of nodes across machines. Every edge is a typed contract carried on a shared bus (DDS); every node can crash without tearing down the others.

ROS 2 is that middle layer. It is not the wire protocol, and it is not the runtime — it is a thin abstraction over DDS that makes the abstractions above (nodes, topics, QoS, services, actions) feel like a language rather than a configuration. Everything else in this document is what you build on top of that.

ROS 2 is the contract layer that lets ten machines run twenty heterogeneous processes and still close a 100 Hz control loop.

2. The four primitives: topics, services, actions, parameters

Every ROS 2 program is composed of four communication shapes. Knowing which one you are writing against is the first skill. They are not interchangeable — they encode different timing contracts.

Primitive 1

Topic (pub/sub)

PUB SUB /scan

Many publishers, many subscribers, decoupled in time. Fire-and-forget unless QoS says otherwise. Continuous stream.

Primitive 2

Service (req/rep)

CLIENT SERVER call reply

One request, one reply. Blocking from the caller's point of view. Short, transactional.

Primitive 3

Action (long task)

CLIENT SERVER goal feedback result

Goal + feedback stream + terminal result, plus a cancel path. Minutes-long, cancellable.

Primitive 4

Parameter (config)

param store node declare / get / set

Typed key/value store owned by the node; settable at runtime. State, not traffic.

Fig. 2 — The four communication shapes and the timing contract each implies. Use the cheapest one that fits the timing.

Why four and not one? Each one encodes a different answer to the question "when does the caller learn the outcome?". A topic publisher has no answer — the message is fire-and-forget. A service caller blocks until the reply. An action client gets an ongoing stream plus a terminal value. A parameter is not even a message — it is state that outlives the sender.

The mapping is mechanical:

The action primitive deserves a footnote: it is not a service on steroids. It is three topics and one service bundled into a typed contract — goal (submit a request), feedback (progress stream), result (terminal), and a cancel path. Navigation, whole-body control, and any task where the operator must be able to say "stop" are actions, not services.

Pick the primitive by its timing contract, not by its shape — the four cover the entire timing space ROS 2 sees.

3. Under the hood: ROS 2 is a shim over DDS

Here is the fact that most beginner tutorials hide: ROS 2 does not move a byte of data. It is a thin library — the ROS Middleware Interface (RMW) — that translates your create_publisher calls into calls on a Data Distribution Service implementation, e.g. CycloneDDS Fast DDS eProsima. DDS is an OMG standard (DDS-DCPS) that has been shipping in aerospace and defense for two decades. It gives you everything ROS 2 pretends to offer: discovery, reliability, durability, deadlines, security, multicast, cross-language, cross-vendor.

So the real architecture is three layers: your code → rcl/rclcpp/rclpy (a tiny adapter that hides DDS) → RMW (a stable C API) → DDS vendor (Cyclone, Fast DDS, or eProsima). This layering is why switching DDS vendors is a single environment variable (ROS_DISTRO aside, RMW_IMPLEMENTATION) — the ABI you depend on is the RMW, not the DDS implementation.

Machine A — ROS 2 process Machine B — ROS 2 process
Your code
rclcpp::Node
rcl
C core
RMW
stable C API
Cyclone DDS
UDP multicast
Your code
rcl
RMW
Fast DDS
UDP multicast
Fig. 3 — Three layers between your code and the wire. The RMW is the ABI; the DDS vendor is a plug-in.

The DDS discovery mechanism is worth understanding because it is where most "why can't I see the topic?" bugs live. Every DDS domain (a ROS_DOMAIN_ID integer, 0–232) is a logical partition of the network. When a participant starts, it sends an SPDP packet (Standard Participant Discovery Protocol) on UDP multicast to announce: "I am here, on domain 0, with these types, at this address." Peers reply; the two negotiate an RTPS endpoint pairing and begin sending serialized samples directly to each other.

Why this matters: multicast is fire-and-forget from the network's perspective. If your switch drops multicast, or your firewall blocks UDP 7400/7401 (the default SPDP ports), your two machines will not discover each other and you will get a silent failure: every ros2 topic list returns nothing. The fix is usually one of three things — set ROS_DOMAIN_ID to something non-zero, disable multicast and switch to PEERS-based discovery, or open UDP multicast on the switch.

The wire format is CDR (Common Data Representation) — the OMG standard binary serialization. Every message type you define is translated (at build time) into a CDR-serializable struct, and every RMW serializes it into that format before UDP. That is also why cross-language works: your C++ node and your Python node agree on the bytes, not on the language.

ROS 2 does not move data — DDS does. ROS 2 provides the shape of the graph; DDS provides the transport and the reliability policy.

4. QoS — the real-time knob

If you take one thing from ROS 2, make it this: QoS is the real-time contract. Without QoS you get a pub/sub system that is essentially best-effort UDP. With QoS you get a pub/sub system that can be deliberately made real-time, deliberately made lossy, deliberately made durable, or deliberately made reliable. The knob is per topic, per endpoint, per message.

The six policies that matter in practice are reliability, durability, history, deadline, lifespan, and liveliness. Let's build them up.

4.1 Reliability — reliable vs. best-effort

Reliability answers "what happens when a sample is dropped on the wire?" Two choices:

For a servo command, you want reliable — losing a command is catastrophic. For a LiDAR scan at 20 Hz, you want best_effort — a dropped frame is tolerable, and retrying would add jitter that would hurt the deadline of every other scan.

4.2 Durability — volatile vs. transient_local vs. transient

Durability answers "what if a subscriber joins late?" Three choices:

The transient_local policy is how a parameter server works: the first /tf frame is published before your listener subscribes, but you still receive it. And it is how a launch file gets its initial parameters into a just-started node.

4.3 History — keep_last(N) vs. keep_all

History answers "how many samples does the middleware remember?"

This is the buffer you are choosing when you set depth=10 on your subscriber. It is also the knob that determines your memory budget:

\[\text{memory}_{\text{buffer}} \;=\; N \times |\text{sample}|\]

For a 4 MB stereo depth image at keep_last(5), that is 20 MB per subscriber — and every slow subscriber on a topic multiplies that cost. This is why camera topics ship with small history depths.

4.4 Deadline — the hard real-time contract

Deadline is the only QoS policy with meaningful numbers. It is a bound on the maximum time between two consecutive samples. If the publisher does not publish within the deadline, DDS raises a deadline missed event. That is how you convert "the sensor is stuck" into a signal your supervisor node can act on.

The way to use it: if your perception loop runs at 100 Hz (period $T_p = 10$ ms) and you allow jitter of at most $J_{\max}$, set the deadline to

\[D_{\text{deadline}} \;=\; T_p + J_{\max}\]

Not $T_p$ itself — the deadline is a violation signal, not a pacing signal. If you set it equal to the period, you will trigger spurious deadline misses on any thread migration or GC pause, and the noise will drown out real faults.

4.5 Lifespan and liveliness

Lifespan caps how long a sample is valid after publication — a stale sensor sample should not reach the planner. Liveliness asks DDS to watch the publisher: if the process dies or its executor stalls, DDS raises a liveliness-lost event. Together they are how a node says "my samples are worth X seconds, and if I stop being a node, notice."

4.6 The compatibility matrix — the rule that bites people

QoS is checked pairwise between a publisher and a subscriber. Two endpoints match only if their policies are compatible. The compatibility rules are asymmetric:

PolicyCompatibility ruleWrong direction → symptom
ReliabilityPUB ≥ SUB (PUB reliable is compatible with SUB best_effort, not the reverse)PUB best_effort + SUB reliable → no data
DurabilityPUB ≥ SUB (transient > transient_local > volatile)Missed initial samples, "cold start" bugs
HistoryIndependent (each endpoint picks its own buffer)Subscriber sees a different history than publisher
The single most common ROS 2 "why is my topic empty" bug: the publisher declares best_effort and the subscriber (via the default rclcpp::QoS) declares reliable. The compatibility check fails, the subscription is not offered, and you get silence. Fix: either raise the publisher to reliable, or drop the subscriber to best_effort.
Reliability → Durability → volatile / best_effort sensor streams volatile / reliable commands, events transient_local / best_effort rare events, telemetry transient_local / reliable parameters, TF, config transient / best_effort transient / reliable — data outlives pub
Fig. 4 — The practical QoS matrix. Most real-time control topics live in the top-right cells.
QoS is per-topic contract between publisher and subscriber; when they mismatch, nothing flows and nothing is logged — the graph silently refuses to connect.

5. Nodes, executors, and callback groups

A node is a self-contained program that publishes, subscribes, calls, and serves. But a node is also a set of callbacks: one per subscription, one per service, one per parameter change, one per timer, one per action goal. Something has to decide which callback runs next, and on which thread. That something is the executor.

5.1 The executor is a scheduler

A single-threaded executor is the default. It spins: check every guard condition, run whichever callback is ready, repeat. Deterministic, easy to reason about, and it runs your 100 Hz timer on the same thread as your 1 kHz timer — so a slow 1 kHz callback will block the 100 Hz one. That is the trap.

A multi-threaded executor gives each callback its own thread (up to N). It fixes the blocking, but it introduces a second trap: callbacks in the same group are still serialized. Callbacks in different groups can run concurrently.

5.2 Callback groups — the concurrency primitive

Every callback you register is assigned to a callback group. Two groups exist by default:

The rule is: concurrent execution is granted between groups, not within groups. So to parallelize three independent subscriptions while keeping their internal work serialized, you give each subscription its own Reentrant group:

auto g_sensor  = node->create_callback_group(rclcpp::CallbackGroupType::Reentrant);
auto g_vision  = node->create_callback_group(rclcpp::CallbackGroupType::Reentrant);
auto g_control = node->create_callback_group(rclcpp::CallbackGroupType::Reentrant);

node->create_subscription<Scan>("/scan", QoS(1), cb_scan, g_sensor);
node->create_subscription<Image>("/camera/image", QoS(1), cb_vision, g_vision);
node->create_wall_timer(100ms, cb_control, g_control);

Now a stuck perception callback cannot starve the control callback. That is the whole trick.

Single-threaded executor All groups scan cb vision cb control cb timer cb slow scan cb blocks everything behind it Multi-threaded executor + groups Group A (reentrant) scan cb vision cb Group B (mutually exclusive) control cb Threads ↔ groups: the concurrency model t1 t2 t3 scan cb vision cb control cb (own group) Different groups run on different threads; the same group serializes within a thread.
Fig. 5 — The executor is a scheduler; callback groups are its concurrency primitive.

5.3 Latency budget of a callback

Every callback has three costs stacked on it:

\[\Delta t_{\text{total}} \;=\; \Delta t_{\text{network}} \;+\; \Delta t_{\text{deserialize}} \;+\; \Delta t_{\text{callback}}\]

And if it shares a group with a slow callback, add:

\[\Delta t_{\text{queue}} \;=\; \sum_{\text{prior cbs in group}} \Delta t_i\]

For a servo command at 1 kHz, you are budgeting microseconds. The rule of thumb: never put a control-critical callback in the same group as a callback that can block (vision, logging, file I/O). Give the control path its own group and its own thread — this is the single most valuable piece of advice in the whole document.

Executors decide when callbacks run; callback groups decide whether they can run in parallel. Isolate the control path.

6. Interfaces: how messages are defined and shipped

ROS 2 messages are declared in a small IDL — not JSON, not protobuf, not Thrift — with its own syntax. It looks like:

# msg/LaserScan.msg — ROS 2 message definition
std_msgs/Header header          # timestamp + frame_id
float32 range_min
float32 range_max
float32 angle_min
float32 angle_max
float32 angle_increment
float32[] ranges          # one range per beam
float32[] intensities     # optional per-beam intensity

Why its own IDL and not protobuf? Three reasons. First, it has built-in array types (float32[]) and constants — no repeated keyword, no enum ceremony. Second, it is versioned by content: the type description is a hash, so a message definition change is a wire-format change that DDS detects automatically. Third, it is cross-language by construction: rosidl generates C++, Python, and (via plugins) Rust and Go bindings from the same source.

6.1 The code generation pipeline

From .msg to wire bytes .msg / .srv / .action your IDL rosidl generators C++/py/rust bindings type description IDL hash + fields CDR serialization binary wire format Generated language bindings C++ (rclcpp) Python (rclpy) Rust / Go wire
Fig. 6 — The type system is a code-generation pipeline. Change the .msg, rebuild, and every node that uses it sees the new fields.

6.2 The three interface kinds

A .msg is a data structure — a topic type. A .srv is a request + response pair — a service type. A .action is three things bundled together:

# action/NavigateTo.action
# Goal — what the client asks for
geometry_msgs/PoseStamped pose
---
# Result — what the server returns
bool success
string failure_reason
---
# Feedback — what the server streams
geometry_msgs/Pose current_pose
float32 remaining_distance

The --- separator defines the boundary between the three halves. The generated code exposes one class per half, and the client/server APIs are built on top.

6.3 Versioning is automatic

Every interface has a type description — a canonical JSON document that includes the IDL hash and every field. When two nodes on different machines try to talk, they exchange type descriptions and compare hashes. If they differ, DDS refuses to connect. That is why editing LaserScan.msg and forgetting to rebuild a dependent node is a loud failure, not a silent one — DDS prints a type-mismatch error.

The interface is a code-generation pipeline, not a schema — change the .msg and the whole type system moves with it.

7. Lifecycle nodes and composable nodes

Two more node shapes exist for when the plain Node is not enough.

7.1 Lifecycle nodes — controlled startup, teardown, and error recovery

A plain node starts and immediately begins publishing. A lifecycle node has a state machine, and transitions are explicit:

Unconfigured Configuring Inactive Active Error config on_config ok activate fault deactivate → shutdown → reset
Fig. 7 — The lifecycle node state machine. Every transition is an explicit call; the node does not publish until it reaches Active.

Why this matters: on a real robot, you launch 30 nodes in a specific order — driver → filter → perceiver → planner. If the planner starts before the perceiver, it gets garbage. A lifecycle node solves that: the launch file can call transition_config, transition_activate, etc. for each node in order, and each node's on_configure can validate its parameters (or fail cleanly) before it starts publishing. Failure is a state, not a crash.

7.2 Composable nodes — share a process, share a scheduler

A plain node is a separate process. A composable node is a plain node compiled into a shared library and loaded by a component container — one process that hosts many nodes. The payoff: intra-process communication is zero-copy (a pointer, not a serialized UDP packet), and one executor schedules all nodes in the process. The cost: any node crash takes down the whole process, so composable nodes are only for nodes that you trust to be well-behaved.

Real-time guidance: put the hot path (control, planning) in a composable node with its own RT-scheduled thread; put the cold path (vision, logging, UI) in separate processes. The composable process owns the CPU core; the rest of the system is best-effort.

Lifecycle nodes give you startup/teardown/error as a state machine; composable nodes give you zero-copy intra-process communication and a single scheduler for the hot path.

8. The tooling stack: colcon, launch, rviz2, rosbag2

ROS 2 ships a small but complete build/run/visualize/replay stack.

8.1 colcon — the build system

Colcon is not CMake; it is an orchestrator around CMake (or setuptools for Python). It walks your src/ tree, finds each package's package.xml, resolves dependencies, and runs each package's build tool in the right order. Your build command is always:

colcon build --symlink-install --packages-select my_package

The --symlink-install flag is important for development: it makes msg, srv, and action files symlinks into build/, so you do not have to rebuild after editing a message definition — the C++/Python bindings are already there, and the new definition is picked up on the next source install/setup.bash.

8.2 launch — the deployment script

A launch file is a Python script that says "start these nodes with these parameters, in this order, and wait for them to reach this state before starting the next." It is not a Makefile and not a Docker Compose — it is the state machine of a deployment. A typical launch file:

from launch import LaunchDescription
from launch_ros.actions import Node

def generate_launch_description():
    return LaunchDescription([
        Node(package='lidar_driver', executable='scan_pub',
             parameters=[{'frame_id': 'laser', 'rate_hz': 20}]
        ),
        Node(package='nav2_bringup', executable='navigate_to_pose',
             parameters=['nav2_params.yaml']
        ),
    ])

8.3 rviz2 — the visualization client

rviz2 is not a debugging tool; it is a topic consumer that renders sensor_msgs/LaserScan, sensor_msgs/Image, geometry_msgs/Pose, and arbitrary marker arrays as a live 3D scene. It is the fastest way to see whether your data is real. Every ROS 2 developer lives in rviz2 for the first week.

8.4 rosbag2 — record and replay

rosbag2 records every topic into a SQLite database with a compressed message format (Cyclone's mcap is the modern default). Replay is just publishing from a bag at a controlled rate. That is how you debug a real-time bug: capture the failing run, replay at 0.5× speed, watch the callbacks, fix, replay again.

8.5 The CLI — your daily driver

The ros2 CLI is the swiss-army knife. Learn six commands and you can debug 90% of issues:

ros2 topic list                 # what exists?
ros2 topic echo /scan --once    # what does one sample look like?
ros2 topic hz /scan             # what is the actual rate?
ros2 node list                  # what nodes are running?
ros2 node info /localizer       # what topics does it use?
ros2 param list /localizer      # what parameters are set?
colcon builds, launch deploys, rviz2 visualizes, rosbag2 replays — and the ros2 CLI is your first-line debugger.

9. Security: the DDS-Security plugin

Out of the box, ROS 2 is wide open: any process on the network can subscribe to any topic. DDS ships an optional DDS-Security plugin (OMG standard) that adds three layers:

Turning it on is one environment variable:

export ROS_SECURITY_ENABLE=true
export ROS_SECURITY_KEYSTORE=/etc/ros/security
export ROS_AUTH_PUBLIC_KEY=/etc/ros/security/pki/ca/root.crt

Behind that is a directory of per-participant certificates and an ACL YAML file that says, e.g., "the controller participant may write to /cmd_vel but may not read /camera/image_raw." Every DDS packet carries a signature; the peer verifies it against the ACL before processing the sample. Failure is silent at the DDS layer — the topic simply never connects.

Why this matters: on a shared research robot, or a robot in a public building, an attacker with network access can inject commands without security. DDS-Security is the same model TLS uses, applied to the DDS wire. It is not optional for anything leaving the lab.

DDS-Security is the same TLS-plus-ACL pattern as web, but for pub/sub: auth + access control + encryption, enforced per topic.

10. Worked example — a laser-avoidance stack from scratch

Let's build the smallest useful ROS 2 program and see every concept come together.

10.1 The interface

We define one message (a simplified laser reading) and one service (a manual stop):

# msg/Proximity.msg
std_msgs/Header header
float32 nearest_distance      # meters
float32 bearing               # radians, -pi..pi

10.2 The publisher — a stub sensor at 20 Hz

# nodes/proximity_pub.py
import rclpy
from rclpy.node import Node
from my_pkg.msg import Proximity

class Pub(Node):
    def __init__(self):
        super().__init__('proximity_pub')
        self.pub = self.create_publisher(Proximity, '/proximity',
                                          rclpy.qos.qos_profile_sensor_data)
        # 20 Hz wall-clock timer; best_effort, keep_last(5)
        self.create_timer(1/20, self.tick)
    def tick(self):
        msg = Proximity()
        msg.header.stamp = self.get_clock().now().to_msg()
        msg.nearest_distance = 0.42
        msg.bearing = 0.0
        self.pub.publish(msg)

if __name__ == '__main__':
    rclpy.init(); rclpy.spin(Pub())

10.3 The subscriber — an avoidance controller

# nodes/avoidance_ctrl.py
import rclpy
from rclpy.node import Node
from my_pkg.msg import Proximity
from geometry_msgs.msg import Twist

class Ctrl(Node):
    def __init__(self):
        super().__init__('avoidance_ctrl')
        self.declare_parameter('brake_distance', 0.5)
        self.brake = self.get_parameter('brake_distance').value

        self.create_subscription(Proximity, '/proximity', self.on_prox,
                                  qos_profile='rclpy.qos.qos_profile_sensor_data')
        self.vel_pub = self.create_publisher(Twist, '/cmd_vel', 10)
        self.last_stamp = None

    def on_prox(self, msg):
        twist = Twist()
        twist.linear.x = 0.0
        if msg.nearest_distance < self.brake:
            twist.linear.x = -0.2   # reverse
            self.get_logger().warn(f'obstacle at {msg.nearest_distance:.2f} m')
        self.vel_pub.publish(twist)

if __name__ == '__main__':
    rclpy.init(); rclpy.spin(Ctrl())

10.4 The launch file

# launch/avoidance.launch.py
from launch_ros.actions import Node

def generate_launch_description():
    return LaunchDescription([
        Node(package='my_pkg', executable='proximity_pub'),
        Node(package='my_pkg', executable='avoidance_ctrl',
             parameters=[{'brake_distance': 0.5}]
        ),
    ])

10.5 What you can now do

With three files and two commands you have a real-time distributed system:

Two nodes, two topics, one service-ready publisher, one launch file — the same 40 lines of code will scale to a 30-node robot stack.

11. Common pitfalls, and the discipline behind them

Pitfall 1: QoS mismatch → silent silence.
The publisher says best_effort, the subscriber (via the default rclcpp::QoS) says reliable. DDS refuses the connection; nothing is logged at the ROS layer. Diagnosis: ros2 topic info /scan -v prints the publisher's and subscriber's QoS; they must satisfy the compatibility rules.
Pitfall 2: The slow callback starves the fast one.
Everything is in the default MutuallyExclusive group; a single 200 ms vision callback blocks the 100 Hz control callback for 200 ms. Fix: put the control callback in its own Reentrant group and run a MultiThreadedExecutor. Better: put the control path in a composable node with RT priority.
Pitfall 3: Discovery across machines "just doesn't work."
Multicast is blocked by the switch or firewall. Try: (a) export ROS_DOMAIN_ID=42 (moves you off the default domain 0), (b) export ROS_MULTICAST_ENABLED=false with ROS_DISCOVERY_SERVER=<ip>:1181 (switch to a discovery server), (c) open UDP 7400/7401. Diagnosis: run two ros2 topic list commands on different machines; if one sees nothing, it is discovery, not QoS.
Pitfall 4: The spin() loop blocks your main thread.
If you call spin() and then do more work in the same thread, your work never runs. Use spin_until_future_complete() for synchronous waits, or spin_once() inside a loop where you also do other work. The rule: spin is the scheduler; it owns the thread.
Pitfall 5: Forgetting source install/setup.bash.
Your custom package builds fine, but the shell cannot find the my_pkg.msg types. Every new shell needs the workspace sourced: source /path/to/ws/install/setup.bash. Add it to ~/.bashrc (with the correct path).
Pitfall 6: Treating best_effort as "unreliable" instead of "deliberately lossy."
A 200 Hz IMU over unreliable Wi-Fi with best_effort is correct — you want the latest sample, not the 30 old ones. Switching to reliable would add retransmission jitter to a stream that is already faster than the network. The rule: match the QoS to the data's tolerance for loss, not to its importance.
Pitfall 7: Using a topic where a parameter belongs.
Publishing a configuration value as a topic means every subscriber must parse it as a stream, and you need transient_local durability just to catch the late subscriber. A parameter is cheaper: it is a typed value owned by the node, settable at runtime via ros2 param set, and it lives on the node's lifecycle, not on a topic. Use a topic when the value is traffic; use a parameter when it is state.
Seven out of ten "why is it broken" questions have one of these seven answers — learn them, and ROS 2 stops being mysterious.

12. The one-paragraph summary

ROS 2 is a thin abstraction over DDS: you describe a graph of nodes and their communication contracts (topics, services, actions, parameters), and the RMW layer translates them into DDS calls on Cyclone, Fast DDS, or eProsima. QoS is the real-time knob — per topic, per endpoint, with reliability (retransmit or not), durability (replay to late joiners or not), history (buffer depth), and deadline (violation signal) as the four knobs that matter; when publisher and subscriber QoS disagree, DDS silently refuses the connection. Underneath each node, an executor schedules callbacks and callback groups decide which callbacks can run in parallel; isolate the control path in its own group and, for the hot path, in a composable node with a real-time-scheduled thread. Interfaces are declared in a small IDL (.msg, .srv, .action), code-generated into every language and hashed at runtime so a schema change is a loud failure. Lifecycle nodes give you a state machine for startup/teardown/error, and DDS-Security gives you TLS-plus-ACL for anything leaving the lab. With colcon, launch, rviz2, rosbag2, and the ros2 CLI, you have a complete build-deploy-visualize-replay-debug stack — and the discipline of the framework is to pick the cheapest primitive that fits the timing, give the control path its own scheduler, and remember that DDS is the real thing and ROS 2 is the shape you put on top of it.


Further reading

13. Foxglove — live visualization and simulation playback

rviz2 is the right tool for the developer on the robot. Foxglove Studio is the right tool for everyone else — the stakeholder, the remote engineer, the QA engineer on a different continent, and you three months later trying to remember what went wrong. It is a web-based visualization client that speaks a JSON-over-WebSocket protocol, so it does not need DDS, does not need to be on the same machine, and does not need to know anything about your message types beyond what the bridge tells it.

13.1 The bridge architecture

Foxglove does not talk DDS. Something has to translate your ROS 2 graph into its WebSocket protocol — and the recommended thing is the foxglove_bridge, a C++ node written against the Foxglove SDK. It auto-discovers every topic over DDS, reads each message's type description, and re-publishes them as WebSocket frames on port 8765.

Robot — ROS 2 stack Your nodes Drivers foxglove_bridge DDS → JSON/WS (port 8765) WebSocket Any browser / laptop Foxglove Studio studio.foxglove.dev or desktop app 3D panel Plot Image Log
Fig. 8 — The bridge pattern: ROS 2 stack → foxglove_bridge → WebSocket → Foxglove Studio. No DDS on the viewer side.

13.2 Two connection modes

ModeData pathUse case
LiveROS 2 → foxglove_bridge (8765) → WebSocket → StudioReal-time visualization, teleop, debugging a running robot
Recordedros2 bag record → .mcap → drag-drop into StudioReplay, analysis, sharing a captured run

13.3 Install and connect

# Install the bridge (binary packages exist for Humble, Jazzy, Kilted)
sudo apt install ros-$ROS_DISTRO-foxglove-bridge

# Launch — auto-subscribes to every topic
ros2 launch foxglove_bridge foxglove_bridge_launch.xml

Add it to your launch file so the bridge comes up with the stack:

Node(package='foxglove_bridge', executable='foxglove_bridge'),

Then in Foxglove Studio: Open connection → Foxglove WebSocket → ws://localhost:8765 (same machine) or ws://<robot-ip>:8765 (same LAN). For remote access without a VPN or port-forward, run the bridge behind Tailscale — the pattern recommended in the official docs.

13.4 The recommended panel layout

The single most common Foxglove mistake is adding ten panels and getting a wall of noise. A good layout has a clear visual hierarchy: one hero panel and three supporting panels.

Hero

3D panel

Pick visualization_msgs/Marker, PointCloud2, OccupancyGrid, PoseArray, Path. Add your TF frames and "follow" the robot's base_link. This is the picture the audience sees.

Support

Image panel

Pick sensor_msgs/Image or CompressedImage. Foxglove decodes compressed images on the client — real bandwidth savings over Wi-Fi.

Support

Plot panel

Pick numeric paths inside topics: /cmd_vel/linear/x, /battery/percentage. One clean line that says "the robot is doing the right thing."

Support

Log + Raw Messages

Log panel for rcl_interfaces/Log warnings; Raw Messages for one interesting topic (e.g. /diagnostics) in a JSON tree.

Optional but high-value: URDF Viewer (renders /robot_description in the 3D panel), Teleop (publish geometry_msgs/Twist on /cmd_vel right from the browser — the single most impressive feature in a demo), Diagnostics (stale/OK/error per diagnostic_msgs/DiagnosticArray), Charts (time-series with units and thresholds).

13.5 Record, replay, and share

# Record with the modern MCAP format (Jazzy/Kilted default)
ros2 bag record -a -o run1

# Older distros: install the MCAP storage plugin first
sudo apt install ros-$ROS_DISTRO-rosbag2-storage-mcap
ros2 bag record -s mcap --all -o run1

Drag run1.mcap into Foxglove and you get full playback controls (play/pause, seek, speed) — scrub at 0.25× to catch a missed obstacle or a planner stall.

Why MCAP and not the old .db3? MCAP captures message definitions inline, so custom types replay correctly on any machine. .db3 does not — you will get "unknown message type" errors unless the machine opening the file has your workspace built. The mcap CLI can convert old files: mcap convert input.db3 output.mcap.

13.6 What makes a simulation look good

Three things separate a "this is just rviz" view from a "this looks like a product demo":

  1. A real robot model. Publish your URDF/Xacro on /robot_description and let the URDF viewer render it. A mesh that moves is ten times more convincing than a frame axis.
  2. Ground-truth overlay. Put the simulation's "truth" in one color (green planned path, blue obstacles) and the robot's estimate in another (red executed trajectory, yellow detected obstacles). The gap between them is the story.
  3. One plot that tells the story. Pick the single signal that says "the robot is doing the right thing" — cmd_vel.linear.x going positive then zeroing at the obstacle, or odom.pose.position.x tracking a reference. One clean line, not eight squiggles.
Bridge + three panels + one plot + one saved layout = a shareable simulation. The bridge is the ABI; the panels are the story.

Foxglove references