ROS 2 QoS Profiles and DDS Tuning: Reliability, Durability, Deadline and Lifespan over Lossy Wi-Fi (2026)
A robot that works flawlessly on a wired bench and then stutters the moment it joins the warehouse Wi-Fi is almost always a ROS 2 QoS problem in disguise. The camera topic that showed 30 Hz in the lab arrives at 2 Hz, the map never reaches the late-joining planner, and cmd_vel messages arrive in bursts a second late, which is worse than not arriving at all. Nothing is “broken”: the middleware is doing exactly what the quality-of-service profiles told it to do, over a link that behaves nothing like Ethernet.
This matters more every year because fleets are growing, payloads are fatter (3D lidar, multi-camera rigs), and the default behaviour of discovery and fragmentation was designed for wired LANs. This post goes from the policy definitions to the packet arithmetic, so you can predict what a profile will do on a lossy link instead of guessing.
You will leave with a precise mental model of each DDS QoS policy as ROS 2 exposes it, a compatibility checklist, worked fragmentation numbers, a per-topic tuning recipe for camera, lidar and cmd_vel topics, and runnable rclpy and YAML snippets for overrides and kernel buffers.
What this covers: the QoS stack and where each policy lives, reliability and durability semantics, deadline, lifespan and liveliness, compatibility rules and events, why Wi-Fi breaks fragmented UDP, sysctl and RMW-specific tuning, discovery at scale, and a recipe you can apply today.
A note on sourcing: the official ROS documentation host was blocking automated fetches during research, so behaviours below that come from the ROS QoS documentation are stated from the documented policy semantics and flagged where I could not re-verify a detail this run. Sysctl values, Fast DDS discovery server behaviour, rmw_zenoh behaviour and the Kilted release facts were checked against the sources linked inline. All packet-loss figures are illustrative arithmetic, not measurements.
Context and Background
ROS 2 delegates all transport to a middleware layer reached through the ROS Middleware Interface (RMW). Historically that middleware was a Data Distribution Service (DDS) implementation, and DDS is where the QoS vocabulary comes from: the Object Management Group’s DDS specification defines the policies, and the RTPS wire protocol (Real-Time Publish-Subscribe) defines how they are enforced on the network. When you set reliability=RELIABLE in a ROS 2 publisher, you are selecting DDS behaviour, and the RMW layer maps it onto whichever vendor stack is loaded.
Three RMW implementations matter in 2026. rmw_fastrtps_cpp wraps eProsima Fast DDS and has long been the default in ROS 2 distributions (I am confident this holds for Humble and Jazzy; I did not re-verify the default for the newest release). rmw_cyclonedds_cpp wraps Eclipse Cyclone DDS, which many navigation stacks recommend for reliable large-message delivery. rmw_zenoh_cpp wraps Eclipse Zenoh, which is not DDS at all, and which the Open Robotics announcement of ROS 2 Kilted Kaiju (released 23 May 2025) described as the first ROS release to support Zenoh as a Tier 1 middleware. Kilted is a standard release supported until November 2026, per the same announcement, so fleets planning beyond this month are looking at Jazzy (the long-term-support release) or the newer Lyrical Luth release, whose documentation lists a May 2026 date.
The reason the middleware choice matters for QoS is that policies are a contract, but enforcement is vendor-specific. Two stacks can both accept RELIABLE and still behave differently in retransmission timing, fragment size, history cache sizing and discovery chatter. That is why a QoS article cannot stop at the policy table: the interesting failures happen where the policy meets the packet.
Finally, a framing the rest of the post leans on. QoS settings are not performance knobs; they are semantic promises about what the application can tolerate: losing data, seeing stale data, or not seeing old data at all. Tuning is choosing the weakest promise your application can live with, because weaker promises are cheaper on a lossy link. If you build robot control on top of this, our walk-through of the ros2_control hardware interface shows where those promises end and hard real-time loops begin, and securing the same topics is covered in SROS2 enclaves, certificates and access control.
The ROS 2 QoS Stack: Where Each Policy Lives
Direct answer: ROS 2 QoS is a per-endpoint profile (history, depth, reliability, durability, deadline, lifespan, liveliness and lease duration) attached to each publisher and subscriber. The RMW layer translates it into DDS or Zenoh behaviour. Publisher and subscriber profiles must be compatible or no connection is made, so QoS is both a tuning surface and a matching rule.

Figure 1: The ROS 2 QoS stack. Policies are declared at the top, translated by the RMW, and finally enforced as fragmentation, retransmission and caching on a lossy link at the bottom.
Figure 1 shows why a QoS profile cannot be understood in isolation. The profile object you build in rclpy or rclcpp is a plain struct that gets handed down through rcl to the RMW. From there, a DDS-based RMW creates a DataWriter and DataReader whose history caches, heartbeat timers and fragment sizes implement the policies. Only then do bytes hit a UDP socket, get cut into IP fragments, and cross a radio link whose loss is bursty and contention-dependent.
The policies and what they actually do
ROS 2 exposes the following policies on every endpoint, as documented in the ROS 2 QoS concepts page:
- History (
KEEP_LASTwith a depth, orKEEP_ALL): how many samples the local cache retains. On a publisher, history is also the retransmission window for reliable delivery. On a subscriber, it is the queue between the middleware and your callback. - Reliability (
RELIABLEorBEST_EFFORT): whether the middleware retransmits lost samples. - Durability (
VOLATILEorTRANSIENT_LOCAL): whether a publisher keeps samples for subscribers that join later. - Deadline: the maximum expected interval between consecutive samples.
- Lifespan: the maximum age at which a sample is still valid.
- Liveliness (
AUTOMATICorMANUAL_BY_TOPIC) and lease duration: how a publisher proves it is alive and how quickly its absence is declared.
Defaults, as I recall them from the documented built-in profiles (verify against your distribution): the default profile is KEEP_LAST depth 10, RELIABLE, VOLATILE. The sensor-data profile is BEST_EFFORT, VOLATILE, KEEP_LAST with a small depth (five in the profile as I remember it). The services profile is RELIABLE, VOLATILE. Parameters use a reliable profile with a much deeper history so that bursts of parameter events are not dropped. The exact depths have changed between releases, so treat the numbers as indicative and read them from rmw/qos_profiles.h or rclpy.qos in your installed distribution.
Why history depth is a reliability setting in disguise
History depth is routinely mistaken for a latency knob. It is really a memory-versus-staleness trade. A KEEP_LAST depth of 1 on a reliable publisher means the retransmission window is exactly one sample: if the subscriber NACKs sample N after sample N+1 has already replaced it, the repair cannot be served, and the subscriber moves on. That is often precisely what you want for a velocity command and precisely what you do not want for a mission waypoint list.
On the subscriber side, depth sizes the queue ahead of your executor. A depth-10 queue on a 30 Hz camera topic means that if your callback stalls for 300 ms, you will process frames that are a third of a second old when it recovers, and then be late for the next ten as well. Smaller depth plus best-effort delivers the freshest sample; larger depth preserves data at the cost of age. Pick per topic according to whether the stream is a state (only the latest matters) or an event log (every item matters).
Where QoS can be overridden without recompiling
Hard-coding profiles in source makes fleet tuning painful. rclcpp and rclpy support QoS overrides via parameters: a node that opts in exposes parameters of the form qos_overrides./topic_name.publisher.reliability, .depth, .history, .durability and so on, which you can set from a launch file or a YAML parameters file. I am confident of the naming pattern, but the nodes must opt in when the publisher or subscription is created, so check that your driver packages do. Section “Runnable snippets” below shows both the opt-in and the YAML.
Command-line inspection is the fastest feedback loop. ros2 topic info /scan --verbose prints each endpoint’s QoS profile, and ros2 topic echo accepts --qos-reliability, --qos-durability, --qos-depth and related flags to reproduce a subscriber’s profile. When a topic shows publishers but your subscriber receives nothing, run the verbose info first: nine times in ten the answer is a compatibility mismatch, which the next sections explain.
Reliability, Durability, Deadline, Lifespan and Liveliness in Depth
Each policy answers a different question about time. Reliability asks “must every sample arrive?”, durability asks “must samples that predate the subscriber arrive?”, deadline asks “is the stream arriving on schedule?”, lifespan asks “is this sample still worth delivering?”, and liveliness asks “is the publisher still there at all?”. Mixing them up is the root of most field misconfigurations.
Reliability: RELIABLE versus BEST_EFFORT
With BEST_EFFORT, the writer sends each sample once and forgets it. If any fragment of a sample is lost, the whole sample is discarded at the reader, because RTPS reassembles fragments into a sample and cannot deliver half of one. There is no acknowledgement traffic, no retransmission, and no back-pressure on the publisher.
With RELIABLE, the writer keeps samples in its history cache and periodically announces what it has with a HEARTBEAT submessage. The reader answers with an ACKNACK listing which sequence numbers (and, for fragmented samples, which fragments) are missing, and the writer resends them. The cost is extra traffic, latency on loss, and a coupling between the slowest reader and the writer’s cache. When the history cache fills with unacknowledged samples, the writer can block or drop depending on resource limits and history kind.
The behavioural difference on a clean link is invisible. The difference on a lossy link is categorical, and it is the single most important number in this post: how many IP fragments make up one sample. We quantify that in the Wi-Fi section; for now, remember that best-effort loss compounds across fragments, while reliable loss is repaired fragment by fragment.
A subtle consequence for ROS 2 reliability best-effort deployments: a best-effort subscriber can connect to a reliable publisher (the publisher offers more than requested), but a reliable subscriber cannot connect to a best-effort publisher. That asymmetry is the first rule in the compatibility matrix below, and it explains why a driver that publishes sensor data as best-effort silently refuses to feed a planner that asked for the default reliable profile.
Durability: VOLATILE versus TRANSIENT_LOCAL
VOLATILE means a late-joining subscriber sees only samples published after it matched. TRANSIENT_LOCAL means the publisher retains up to depth samples and replays them to each newly matched subscriber. This is the mechanism behind “latched” topics from ROS 1: maps, robot descriptions, static transforms, and configuration that is published once.
Three constraints are easy to miss. First, replay is bounded by the publisher’s history depth, so a TRANSIENT_LOCAL publisher with depth 1 replays exactly one sample. Second, the data lives in the publisher’s process: if the publisher dies, late joiners get nothing, because ROS 2 does not offer persistent durability across processes (DDS has TRANSIENT and PERSISTENT kinds, but ROS 2 does not expose them). Third, TRANSIENT_LOCAL requires reliability: in practice you pair it with RELIABLE, since replaying a map over a best-effort channel would defeat the purpose.
For a map server that publishes a 2 MB occupancy grid, durable-reliable delivery to a robot that joins over Wi-Fi is exactly the right design: the cost is paid once per join, not per message. The anti-pattern is applying TRANSIENT_LOCAL to a high-rate stream with a large depth; every new subscriber then triggers a replay storm of depth large samples, which on a congested link can disrupt every other robot at once. The rmw_zenoh README even lists a known issue where, under high CPU load such as launching many nodes at once, some TRANSIENT_LOCAL historical publications may be dropped, which is a reminder that late-join replay is the part of QoS most sensitive to startup bursts.
Deadline: detecting that a stream has stalled
Deadline is the expected maximum period between samples. A publisher offers a deadline (“I will publish at least every 100 ms”) and a subscriber requests one (“I expect a sample at least every 200 ms”). If the interval is exceeded, the middleware raises a deadline missed event on the endpoint that noticed. Deadline does not make data arrive faster; it makes silence observable.
This is how you turn “the lidar feels laggy” into a metric. On the subscriber, a deadline-missed callback fires with a count, which you can export to your monitoring stack. Setting the subscriber’s deadline looser than the publisher’s offered deadline is the compatible direction; the reverse is incompatible. The practical rule: set the publisher’s deadline to roughly 1.5 to 2 times the nominal period and the subscriber’s to 2 to 3 times, so single dropped samples do not page anyone, but a half-second stall does.
Lifespan: dropping samples that are too old to act on
Lifespan sets a maximum age. A sample older than its lifespan is not delivered to readers, and in DDS it is also removed from the writer’s cache. The age is computed from the sample’s source timestamp, which means clocks must be synchronised between robot and base station for lifespan to behave. A robot whose clock is 400 ms ahead will see every cmd_vel with a 100 ms lifespan as already expired. This is a derived consequence of how source timestamps work, and the standard remedy is to run chrony or PTP on every host and monitor offset.
Lifespan is the correct tool for control commands over Wi-Fi. A reliable cmd_vel that retransmits a lost velocity command 300 ms later is dangerous: the robot executes a stale command after newer ones may already have been applied. A short lifespan (on the order of two or three control periods) combined with a shallow history makes stale commands vanish instead of being replayed. The compatibility rule for lifespan is permissive: it is not part of the request-versus-offer matching, so a mismatch does not block the connection.
Liveliness and lease duration
Liveliness answers whether a publisher is still alive. With AUTOMATIC, the middleware itself asserts liveliness whenever the participant is alive, even if your application code is hung. With MANUAL_BY_TOPIC, the application must publish (or call an assert function) within the lease duration, so the signal reflects application health, not just process health. When a lease expires, subscribers receive a liveliness changed event and the publisher a liveliness lost event.
The distinction is operationally important. An AUTOMATIC heartbeat from a robot whose navigation node deadlocked still says “alive”. A MANUAL_BY_TOPIC safety-state publisher with a 500 ms lease turns the same deadlock into an event within half a second. Do not set a lease shorter than a few multiples of your worst expected Wi-Fi stall, or roaming handoffs will generate false alarms; a lease of 1 s on a link that occasionally freezes for 1.5 s while roaming is a self-inflicted outage.
QoS Compatibility: The Matching Rules That Silently Block Topics
Direct answer: A publisher and subscriber connect only if the publisher’s offered policy is at least as strong as the subscriber’s requested one for reliability, durability, deadline and liveliness. Reliable can serve best-effort, transient-local can serve volatile, and a short deadline can serve a longer one. The reverse combinations are incompatible and produce no data, only an incompatible-QoS event.

Figure 2: The compatibility check, applied policy by policy. Any “no” branch produces zero data flow and raises an incompatible-QoS event on both endpoints.
Figure 2 mirrors the request-versus-offered model from the DDS specification. A useful memory aid is that the subscriber asks and the publisher offers, and the offer must meet or beat the ask. Table 1 lists the combinations that matter in practice.
| Publisher offers | Subscriber requests | Result |
|---|---|---|
| RELIABLE | RELIABLE | Connects, reliable delivery |
| RELIABLE | BEST_EFFORT | Connects, delivery is best-effort |
| BEST_EFFORT | RELIABLE | Incompatible, no connection |
| BEST_EFFORT | BEST_EFFORT | Connects |
| TRANSIENT_LOCAL | TRANSIENT_LOCAL | Connects, late joiners get replay |
| TRANSIENT_LOCAL | VOLATILE | Connects, no replay |
| VOLATILE | TRANSIENT_LOCAL | Incompatible, no connection |
| Deadline offered shorter or equal | Deadline requested longer | Connects |
| Deadline offered longer | Deadline requested shorter | Incompatible |
| MANUAL_BY_TOPIC liveliness | AUTOMATIC | Connects |
| AUTOMATIC liveliness | MANUAL_BY_TOPIC | Incompatible |
Table 1: Compatibility summary, consistent with the documented ROS 2 QoS compatibility tables; re-check the table for your distribution before relying on edge cases such as lease-duration comparisons.
One more compatibility trap: history depth and lifespan are not matched. A depth mismatch never prevents a connection, so it can never be diagnosed by an incompatible-QoS event, only by dropped data observed downstream.
Reading the events instead of guessing
When the matching rules fail, ROS 2 delivers events rather than errors. In rclcpp and rclpy, publishers can register callbacks for offered deadline missed, liveliness lost and offered incompatible QoS; subscribers for requested deadline missed, liveliness changed, requested incompatible QoS and message lost. The incompatible-QoS event includes the identity of the last policy that failed, which is exactly the clue you need. The message lost event, supported by some RMW implementations, counts samples the reader knows it missed and is the closest thing to a drop counter for best-effort streams.
In a fleet, wire these events into your telemetry from day one. A counter of incompatible-QoS events per topic per robot catches the common deployment bug where a driver update flips a publisher from reliable to best-effort and silently disconnects half of the downstream consumers. A rate of message-lost events per link is also the earliest warning that a Wi-Fi access point is saturating, well before operators complain.
The “system default” and sensor-data pitfalls
Two profile conventions cause recurring confusion. The default subscriber profile is reliable, yet most camera and lidar drivers publish with the sensor-data profile, which is best-effort. A subscriber that copied the default profile therefore will not connect to those drivers. The fix is to subscribe with the sensor-data profile (best-effort, small depth) in perception nodes. Conversely, RViz and some tools allow you to choose a reliability setting in their UI because they have to adapt to both worlds. Remember that when RViz shows a topic and your node does not, the first suspect is Table 1.
The second pitfall is “system default” semantics: an endpoint created with the system-default value inherits the RMW vendor’s default for that policy, which differs by vendor. Explicit beats implicit for anything you tune; use system default only where you genuinely do not care.
Why Wi-Fi Breaks DDS: Fragmentation, Loss and Packet Arithmetic
Direct answer: DDS over UDP splits large samples into datagrams, and IP then splits those datagrams into roughly 1,480-byte fragments for a standard 1,500-byte MTU. Losing any single IP fragment destroys the whole datagram. On a lossy Wi-Fi link, large best-effort samples therefore almost never arrive intact, while reliable delivery recovers only if kernel buffers survive long enough to reassemble.

Figure 3: Reliable delivery repairs a lost fragment through HEARTBEAT and ACKNACK exchange. Best effort has no repair path, so one lost fragment discards the whole sample.
Figure 3 shows the two layers of fragmentation that people conflate. RTPS can fragment a large sample into DATA_FRAG submessages sized to fit the configured maximum message size. The operating system’s IP layer then fragments any UDP datagram that exceeds the interface MTU. Reliable QoS repairs losses at the RTPS level, but IP-level fragments are invisible to RTPS: if one is lost, the kernel simply never completes the datagram, and RTPS sees a missing DATA_FRAG submessage.
Worked numbers (illustrative arithmetic)
Assume a 1,500-byte MTU, which leaves about 1,480 bytes of payload per IP fragment after the 20-byte IPv4 header. Assume independent per-fragment loss, which is optimistic because Wi-Fi loss is bursty, so real behaviour is usually worse for correlated bursts but sometimes better for isolated hits. These figures are arithmetic, not measurements.
- A Cyclone DDS configuration with
MaxMessageSizeof 65,500 bytes (the value used in the Autoware and Stereolabs examples) produces datagrams of about 65.5 KB, i.e. roughly 45 IP fragments each. At 1% per-fragment loss, a datagram survives with probability 0.99^45, about 64%. At 2%, 0.98^45, about 40%. - A 1280 by 720
rgb8image is 2,764,800 bytes. That is about 1,870 IP fragments. At 1% loss, probability that every fragment arrives is 0.99^1870, which is of the order of 10^-8. Raw uncompressed images over best-effort Wi-Fi effectively never arrive at 1% fragment loss. - A 100 KB JPEG-compressed frame is about 68 fragments, surviving at 1% loss with probability 0.99^68, about 50%. Compression does not just save bandwidth; it changes the exponent.
Reducing the datagram size changes these numbers too. If each datagram is cut to roughly 1,400 bytes so that no IP fragmentation occurs, a lost fragment kills only 1.4 KB, and RTPS-level reliability can repair it with a single resend. This is why small RTPS message sizes plus reliable QoS often behave better on Wi-Fi than large message sizes, even though large sizes look more efficient on a wired LAN. The trade-off is CPU overhead per packet and more heartbeat chatter.
The reassembly buffer problem
There is a second, quieter failure. When a fragmented datagram loses a fragment, the kernel holds the surviving fragments in its reassembly queue until net.ipv4.ipfrag_time expires. Stereolabs’ DDS and network tuning guide cites the default as 30 seconds, lowered to 3 seconds in their recommendation, and explains that incomplete fragments occupy kernel buffer space that can fill up on unreliable links such as Wi-Fi.
Estimate the pressure: a best-effort 2.76 MB image at 30 Hz where every image loses at least one fragment leaves up to about 83 MB of orphaned fragments per 30-second window (2.76 MB times 30 Hz times 30 s), before kernel per-buffer overhead is counted. With a 3 s timeout the steady-state figure drops tenfold, to about 8.3 MB. The ipfrag_high_thresh limit is the cap on that memory. When it is hit, the kernel starts discarding fragments, including those of otherwise healthy datagrams, so one broken stream degrades unrelated topics. (The two guides I checked quote different defaults for ipfrag_high_thresh, 4 MB in one and 256 KiB in the other, so read your own kernel’s value with sysctl.)
This interaction explains a classic field symptom: a robot’s camera feed breaks and then, minutes later, even small topics like odometry become lossy. Nothing is wrong with odometry; the fragment reassembly pool is exhausted by the broken camera stream.
DDS and RMW Tuning for Lossy Links
Tuning has three layers: the kernel, the DDS or RMW configuration, and the QoS profile itself. Work from the bottom up, because kernel limits cap what the upper layers can do.
Kernel: sysctl for fragments and socket buffers
Both Stereolabs and the Autoware DDS settings page recommend the same trio. The values below come directly from those pages; they are aggressive, tuned for large messages, and you should size them to your hardware.
| Parameter | Recommended in those guides | Why |
|---|---|---|
net.core.rmem_max |
2147483647 | Allow DDS to request large socket receive buffers; default is about 208 KiB |
net.ipv4.ipfrag_time |
3 | Expire orphaned fragments in 3 s, not 30 s |
net.ipv4.ipfrag_high_thresh |
134217728 | Allow 128 MiB of fragment reassembly memory |
Table 2: Kernel settings quoted from the Stereolabs and Autoware guides. Neither page covers write-buffer (wmem) tuning.
Persist them in a file under /etc/sysctl.d/ and apply them. Stereolabs warns that comments must be on their own line, because inline text after a value causes the setting to be rejected. A 2 GiB rmem_max is a ceiling, not an allocation: DDS asks for what it needs up to that ceiling, so memory is only consumed when the application requests it. On memory-constrained embedded boards, choose a smaller ceiling such as 64 to 128 MiB, which is my engineering judgement rather than a figure from those guides.
Cyclone DDS: the settings that matter
For rmw_cyclonedds_cpp, configuration is an XML file referenced by CYCLONEDDS_URI. The Autoware and Stereolabs examples both set MaxMessageSize to 65500B, SocketReceiveBufferSize with a 10 MB minimum, and Watermarks/WhcHigh of 500 kB, which bounds the writer history cache before the writer applies back-pressure. They also pin the network interface, which is vital on robots with Wi-Fi plus Ethernet plus a virtual docker bridge: unpinned, DDS may discover peers on the wrong interface and route data over the slowest one.
For lossy Wi-Fi specifically, I would deviate from the large-message example in one respect: reduce MaxMessageSize toward the MTU if you rely on reliable delivery of big samples, so that repair happens at the RTPS level rather than being defeated by IP-fragment loss. That recommendation follows from the arithmetic above; treat it as a hypothesis to test on your own network, because the best value depends on your access point’s loss profile and your CPU budget.
Fast DDS: profiles, discovery and publish mode
Fast DDS reads an XML profile from FASTRTPS_DEFAULT_PROFILES_FILE. Its tunables for reliable links include the writer’s heartbeat period, the delay before responding to NACKs, flow controllers that limit send rate, and the maximum datagram size for the UDP transport. The semantics are documented by eProsima; I did not re-verify the exact element names this run, so check the Fast DDS XML reference for your installed version. The environment variable RMW_FASTRTPS_PUBLICATION_MODE selects synchronous or asynchronous publishing in rmw_fastrtps; I recall asynchronous as the default, which lets large writes be fragmented and paced by a flow controller rather than blocking the publisher thread, but verify that against your distribution.
The practical advice for both stacks: change one parameter at a time, and measure with a repeatable test, such as publishing a fixed-size payload at a fixed rate between two machines over your real Wi-Fi while logging the message lost and received-rate figures. Without a baseline, DDS tuning is folklore.
rmw_zenoh: a different set of failure modes
rmw_zenoh_cpp replaces DDS discovery and transport with Zenoh. According to the rmw_zenoh README, multicast discovery is disabled by default, and nodes find each other through the rmw_zenohd router, started with ros2 run rmw_zenoh_cpp rmw_zenohd. Without a router, nodes cannot discover one another. The router does not forward messages between peers by default; remote nodes that need routed traffic run in client mode against a router endpoint. Bridging hosts means connecting one host’s router to another’s.
This directly addresses a Wi-Fi weakness: no multicast storms, and a TCP-capable transport that does its own retransmission instead of relying on IP fragmentation. Operational caveats from the same README: a high-CPU launch can drop some service calls or TRANSIENT_LOCAL replays (issue 978), the default router config listens on IPv6 only and crashes on IPv4-only systems until you replace the [::] endpoint with 0.0.0.0, and rmw_zenoh is not interoperable with zenoh-plugin-ros2dds. The README does not document how every DDS QoS policy maps onto Zenoh, so verify behaviours such as deadline and lifespan on your target distribution before relying on them.
Discovery Traffic at Fleet Scale
QoS governs data; discovery governs who can talk. With default DDS “simple discovery”, every participant announces itself via multicast and unicast and exchanges endpoint information with every other participant. The eProsima discovery server documentation states plainly that simple discovery requires multicasting capabilities that may not work reliably in some scenarios, and that traffic grows quickly as nodes are added. Wi-Fi access points frequently convert multicast to low-rate broadcast frames or drop it, which hurts every client in the cell.
Fast DDS Discovery Server
The Fast DDS Discovery Server replaces peer-to-peer discovery with a hub. You start a server with fastdds discovery -i 0, and set ROS_DISCOVERY_SERVER="<ip>:11811" in each client process so it becomes a discovery client. Multiple servers are separated by semicolons, ordered by server ID, and a leading semicolon skips an ID, which the docs use for discovery partitions. In Discovery Server v2, nodes that share no topics do not learn about each other, which cuts traffic further.
Two operational gotchas from the documentation. First, ROS 2 CLI tools need to see the whole graph, so the daemon and introspection tools must run as a super client, configured through an XML profile named in FASTRTPS_DEFAULT_PROFILES_FILE, followed by ros2 daemon stop and start. Second, running the daemon itself as the server is discouraged because it stops after two hours of inactivity and takes the server with it. For fleets, run at least two servers on different hosts so that discovery survives one failure.
Domains, ranges and static peers
Even with a perfect QoS profile, robots sharing a domain ID share a discovery universe. Use distinct ROS_DOMAIN_ID values per fleet segment, or per robot with a bridge, to bound discovery. Newer ROS 2 releases replaced the older ROS_LOCALHOST_ONLY switch with ROS_AUTOMATIC_DISCOVERY_RANGE and ROS_STATIC_PEERS (introduced from Iron onward, to the best of my knowledge). The Autoware page notes that ROS_LOCALHOST_ONLY is deprecated and does not work with Cyclone DDS because of an open issue, and uses an XML interface restriction to loopback instead. Also from that page: on Jazzy, Cyclone’s default participant-index limit of around 32 can trigger “Failed to find a free participant index for domain 0” when many nodes start, avoided by setting ParticipantIndex to none.
A Per-Topic Tuning Recipe for Camera, Lidar and cmd_vel
Direct answer: Classify every topic as state, stream, latched configuration or safety signal, then choose the weakest QoS that satisfies it. Streams get best-effort, depth 1 to 5 and compressed payloads. Commands get shallow history and a short lifespan. Latched data gets reliable plus transient-local. Safety signals get reliable plus deadline and manual liveliness.

Figure 4: Decision recipe. The topic class, not the message type, determines the profile; kernel and discovery tuning come last and apply to all classes.
Camera and lidar streams
These are high-rate states where only the latest sample matters. Use best-effort, KEEP_LAST with depth 1 to 5, and the sensor-data convention for subscribers. The decisive lever is payload size, not QoS: publish compressed images (a compressed_image_transport-style JPEG or a hardware H.264 stream) over Wi-Fi and keep raw images on the robot’s internal bus. By the arithmetic above, shrinking a frame from about 1,870 fragments to about 68 turns an essentially impossible delivery into a coin flip at 1% fragment loss, and a smaller reliable stream improves further.
For lidar, a 3D point cloud of several hundred kilobytes has the same problem. Options in descending order of preference: downsample or crop on the robot with a voxel filter before publishing to the network, publish a compressed point-cloud encoding, or keep processing on-robot and send only results. If you need reliability for a stream, use it deliberately with a small MaxMessageSize and expect added latency under loss; a reliable 10 Hz stream that stalls for 800 ms to repair one sample is worse than best-effort that skips it.
cmd_vel and control commands
Velocity commands are small (a Twist is a few dozen bytes), so fragmentation is not the issue; staleness is. Two defensible profiles exist. Best-effort, depth 1, short lifespan is the choice when a controller republishes at 10 to 50 Hz, since a lost command is replaced by the next. Reliable, depth 1, short lifespan is the choice for low-rate teleoperation where each command matters. In both, a lifespan of a few control periods ensures that a command delayed by retransmission or Wi-Fi roaming is discarded rather than executed late. Remember the clock-sync requirement from the lifespan section.
Pair this with a robot-side watchdog that stops the base if no fresh command arrives within a timeout. QoS can tell you that a deadline was missed; it cannot stop a motor. The watchdog belongs in the controller layer, as described in the ros2_control hardware interface tutorial.
Maps, transforms and static configuration
For /map, /tf_static, robot descriptions and similar once-published data, use reliable plus TRANSIENT_LOCAL, depth 1. Subscribers must also request TRANSIENT_LOCAL to receive replay (a volatile subscriber connects but gets no history, per Table 1). Note that /tf_static follows exactly this convention, and a node that subscribes with a volatile profile will intermittently miss static transforms depending on start order, a classic heisenbug.
Safety state and heartbeats
Publish a small state message at a fixed rate with reliable delivery, a deadline at about twice the period, and MANUAL_BY_TOPIC liveliness with a lease comfortably above your worst observed Wi-Fi stall. Consume the deadline-missed and liveliness-changed events on the base station, not just on the robot, because the link itself is the thing most likely to fail. Cross-link this with authentication: if you are enabling security, remember that encryption and access control add handshake and per-message overhead, and our SROS2 guide covers how enclaves interact with these topics.
Runnable snippets (untested sketches)
The following snippets are illustrative and have not been executed in this environment; check parameter names against your distribution.
# rclpy: explicit profiles for three topic classes (untested sketch)
from rclpy.qos import (QoSProfile, ReliabilityPolicy, DurabilityPolicy,
HistoryPolicy, LivelinessPolicy)
from rclpy.duration import Duration
sensor_qos = QoSProfile(
history=HistoryPolicy.KEEP_LAST, depth=2,
reliability=ReliabilityPolicy.BEST_EFFORT,
durability=DurabilityPolicy.VOLATILE,
deadline=Duration(seconds=0, nanoseconds=200_000_000)) # 5 Hz floor
cmd_qos = QoSProfile(
history=HistoryPolicy.KEEP_LAST, depth=1,
reliability=ReliabilityPolicy.RELIABLE,
durability=DurabilityPolicy.VOLATILE,
lifespan=Duration(seconds=0, nanoseconds=150_000_000))
latched_qos = QoSProfile(
history=HistoryPolicy.KEEP_LAST, depth=1,
reliability=ReliabilityPolicy.RELIABLE,
durability=DurabilityPolicy.TRANSIENT_LOCAL)
safety_qos = QoSProfile(
history=HistoryPolicy.KEEP_LAST, depth=5,
reliability=ReliabilityPolicy.RELIABLE,
deadline=Duration(seconds=0, nanoseconds=200_000_000),
liveliness=LivelinessPolicy.MANUAL_BY_TOPIC,
liveliness_lease_duration=Duration(seconds=2))
# rclpy: observe QoS events (untested sketch)
from rclpy.qos_event import SubscriptionEventCallbacks # name may differ by release
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import LaserScan
class Watch(Node):
def __init__(self):
super().__init__('scan_watch')
cbs = SubscriptionEventCallbacks(
deadline=self.on_deadline,
incompatible_qos=self.on_incompat)
self.create_subscription(LaserScan, '/scan', self.on_scan,
qos_profile_sensor_data, event_callbacks=cbs)
def on_scan(self, msg): pass
def on_deadline(self, ev): self.get_logger().warn(f'deadline missed {ev.total_count}')
def on_incompat(self, ev): self.get_logger().error(f'incompatible QoS policy {ev.last_policy_kind}')
In the second sketch, qos_profile_sensor_data stands for the sensor_qos object above, and the event-callback module name has changed between ROS 2 releases (it is rclpy.event_handler in newer ones, to my recollection), so verify the import path. The YAML below overrides QoS from a parameters file for a node that opted in with qos_overrides support.
# params.yaml: QoS overrides for a node that opted in (untested sketch)
/camera_driver:
ros__parameters:
qos_overrides:
/image_raw/compressed:
publisher:
reliability: best_effort
history: keep_last
depth: 1
# /etc/sysctl.d/60-ros-buffers.conf (values from the Stereolabs and Autoware guides)
# comments on their own line, never inline
net.core.rmem_max=2147483647
net.ipv4.ipfrag_time=3
net.ipv4.ipfrag_high_thresh=134217728
<!-- cyclonedds.xml: pin interface and size buffers (adapted from the Autoware example) -->
<CycloneDDS>
<Domain Id="any">
<General>
<Interfaces><NetworkInterface name="wlan0" autodetermine="false"/></Interfaces>
<AllowMulticast>default</AllowMulticast>
<MaxMessageSize>65500B</MaxMessageSize>
</General>
<Internal>
<SocketReceiveBufferSize min="10MB"/>
<Watermarks><WhcHigh>500kB</WhcHigh></Watermarks>
</Internal>
</Domain>
</CycloneDDS>
A measurement loop that keeps you honest
Before and after each change, record four numbers per topic and per robot: delivered rate versus published rate, end-to-end age (receive time minus header stamp, which again needs clock sync), deadline-missed count, and message-lost count. Run the test at the worst RF position you care about, not next to the access point. Changes that look good at one meter often vanish at the far end of the warehouse.
Trade-offs, Gotchas, and What Goes Wrong
Reliable is not safe. Reliable delivery over a stalled link blocks history caches, increases latency, and can deliver stale data in order. For anything control-related, reliable without a lifespan is an anti-pattern. For monitoring data, it is fine.
Large TRANSIENT_LOCAL depth multiplies join cost. Each new subscriber triggers a replay of up to depth samples. A fleet of 40 robots rebooting after a power event, each requesting a replay of large samples, creates a synchronised burst that can saturate a shared access point. Keep depth 1 for big payloads.
Mixed RMW fleets are fragile. Different RMW implementations use different wire protocols; Fast DDS and Cyclone DDS interoperate over RTPS in principle, but vendor-specific extensions, defaults and discovery behaviour differ, and rmw_zenoh does not speak RTPS at all (and, per its README, must be paired with matching ROS distributions because of type-hash differences since Iron). Standardise one RMW per fleet and pin it with RMW_IMPLEMENTATION in your service definitions.
Multicast on Wi-Fi. Many enterprise access points rate-limit or convert multicast. Symptoms include discovery that works for minutes and then fails, or nodes that appear only after reboots. Use a discovery server, unicast peer lists, or Zenoh’s router instead of fighting the AP.
Clock skew breaks lifespan and age metrics. Without synchronised clocks, lifespan silently drops valid data or keeps stale data. Measure offset continuously.
Tuning one robot, deploying forty. Per-robot sysctl and XML drift is a fleet-management problem. Bake kernel settings and DDS config into your image or configuration management, and verify with an automated check at boot (a simple script comparing sysctl output with expected values).
Confusing QoS with bandwidth. QoS cannot create airtime. If total offered load exceeds what the cell can carry, no profile fixes it. Compression, rate limits, and moving heavy processing on-board address the cause.
Over-tightening deadlines and leases. A deadline barely above the nominal period raises events on every jitter spike. Wi-Fi roaming can pause traffic for hundreds of milliseconds; set thresholds from measured worst cases, not from nominal rates.
Practical Recommendations
Start by inventorying topics and classifying each into one of the four classes in Figure 4. Write the profiles down in one place (a shared Python or C++ constants module plus a YAML override file) so they are reviewed like code. Then fix payloads before profiles: compress images, crop clouds, and decide what stays on the robot.
Next, apply the kernel settings from Table 2 (or smaller values sized to your hardware), pin interfaces in the DDS configuration, and move discovery off multicast with a discovery server, static peers or the Zenoh router. Only then iterate on QoS, using the measurement loop above, one change at a time. Expect to revisit MaxMessageSize and heartbeat timing for reliable streams, since they are the least portable parameters.
Checklist:
- Every topic classified and its profile documented, including deadline and lifespan where relevant.
- Subscribers for sensor streams use best-effort; subscribers for latched topics request transient-local.
- Clock synchronisation (chrony or PTP) running and monitored on every host.
rmem_max,ipfrag_timeandipfrag_high_threshset and verified at boot.- DDS interface pinned; discovery server, static peers or Zenoh router in place of multicast.
- Incompatible-QoS, deadline-missed, liveliness and message-lost events exported to monitoring.
- A robot-side watchdog stops motion when commands age out.
- One RMW implementation per fleet, pinned by environment variable; plan the move off Kilted before its November 2026 end of support.
Fleet engineering note: this is a systems-design discussion of robot communications; nothing here replaces a safety assessment of your specific machinery.
Frequently Asked Questions
What is the difference between BEST_EFFORT and RELIABLE in ROS 2?
BEST_EFFORT sends each sample once and never retransmits, so any lost fragment drops the whole sample. RELIABLE keeps samples in a history cache, announces them with heartbeats, and resends what readers report missing. Reliable costs extra traffic and latency under loss, but guarantees in-order delivery within the history window. Use best-effort for high-rate sensor states and reliable for commands, configuration and events where every item matters.
Why does my subscriber receive nothing even though the publisher exists?
The most common cause is incompatible QoS. A reliable subscriber cannot connect to a best-effort publisher, and a volatile publisher cannot satisfy a transient-local subscriber. Run ros2 topic info <topic> --verbose to compare profiles, and register incompatible-QoS event callbacks to see which policy failed. Sensor drivers usually publish best-effort, so perception subscribers should use the sensor-data profile rather than the default.
When should I use TRANSIENT_LOCAL durability?
Use it for data published once or rarely that late joiners must still receive: maps, /tf_static, robot descriptions and configuration. Pair it with reliable delivery and a depth equal to the number of samples a joiner needs, usually 1. Subscribers must also request transient-local. Avoid it for high-rate or large streams, because every new subscriber triggers a replay of up to depth samples.
How do I make ROS 2 work better over lossy Wi-Fi?
Shrink payloads first, since loss probability compounds across IP fragments. Then raise rmem_max, shorten ipfrag_time and enlarge ipfrag_high_thresh, pin the DDS network interface, and replace multicast discovery with a discovery server, static peers or the Zenoh router. Finally choose per-topic QoS: best-effort shallow streams, short lifespans on commands, and reliable plus transient-local for latched data.
What is the difference between deadline and lifespan?
Deadline monitors the arrival rate: if the expected interval between samples is exceeded, the endpoint raises a deadline-missed event, but nothing is discarded. Lifespan monitors sample age: samples older than the lifespan are dropped and never delivered. Deadline tells you a stream stalled; lifespan prevents acting on stale data. Lifespan relies on synchronised clocks because age derives from the source timestamp.
Should I switch to rmw_zenoh for a Wi-Fi fleet?
It is attractive because it avoids multicast discovery and fragmented UDP, and it reached Tier 1 status in ROS 2 Kilted Kaiju according to Open Robotics. Verify the QoS behaviours you depend on (deadline, lifespan, transient-local under load), run a router, and note its documented issues, such as IPv6-only router defaults. Test in your own cell before migrating, and standardise on one RMW per fleet.
Further Reading
- ROS 2 control hardware interface tutorial: where QoS ends and deterministic control loops begin.
- SROS2 security, DDS security enclaves, certificates and access control: securing the same topics.
- About Quality of Service Settings (ROS 2 documentation): the canonical policy and compatibility reference.
- Fast DDS Discovery Server for ROS 2 and the rmw_zenoh repository: discovery and Zenoh operational detail.
- Stereolabs DDS and network tuning and the Autoware DDS settings: production sysctl and Cyclone DDS values.
By Riju — about
