ROS 2 transient_local: how many old messages does a late subscriber actually get? (measured)
You set a ROS 2 topic to transient_local so that a node started later still gets recent state. The question is how much it actually gets. The usual answer is arithmetic: depth limits the count, lifespan limits the age, the tighter one wins. I ran it to check, and the arithmetic was right about depth and wrong about lifespan.
The setup
A publisher sends a std_msgs/Float64 at 10 Hz. A subscriber joins 3 seconds later. Both sides use transient_local, reliable and keep_last. I counted only the samples that were published before the subscriber existed, and ran each configuration 5 times.
| QoS | Arithmetic says | Measured (5 runs) |
|---|---|---|
| depth 10, no lifespan | 10 samples, oldest 0.9 s old | 10 samples, oldest 0.9 s old, every run |
| depth 10, publisher lifespan 0.45 s | 5 samples (4 if the join lands late in a period) | 4 samples in 4 runs, 3 in 1 run |
| depth 100, publisher lifespan 0.45 s | 5 samples | 4 samples, every run |
Without lifespan, the numbers match exactly. With lifespan, 5 never showed up.
{"depth": 10, "pub_lifespan_s": 0.45, "rmw": "rmw_fastrtps_cpp(default)", "history_count": 4, "oldest_age_at_join_s": 0.3, "history_arrival_after_join_s": 0.075}
{"depth": 10, "pub_lifespan_s": 0.45, "rmw": "rmw_fastrtps_cpp(default)", "history_count": 3, "oldest_age_at_join_s": 0.3, "history_arrival_after_join_s": 0.132}
Environment and limits. Official ros:jazzy-ros-base container, default RMW (Fast DDS, rmw_fastrtps_cpp), Python 3.12.3, publisher and subscriber in one process on one machine, container network disabled. Across machines, or with Cyclone DDS, the discovery delay differs and so may the count.
Why lifespan delivered fewer
The history does not arrive the instant the subscription is created. In these runs it arrived 0.075 s after the join in most runs, and 0.132 s after in the run that got only 3 samples. A sample that was 0.4 s old at the join is 0.475 s old when it arrives, which is past the 0.45 s lifespan.
So my reading is that the effective window is the lifespan minus the discovery delay. The measurements fit that reading, but I did not inspect where inside the DDS implementation old samples are dropped.
What to do with this
If you size lifespan to keep a specific number of samples, add the discovery delay of your own setup to the arithmetic. Here it was 0.04 to 0.13 s; the only way to know yours is to measure it the same way. Depth alone behaved exactly as documented.
The measurement counts samples whose publish time is earlier than the moment the subscription was created:
history = [(rx, sent) for rx, sent in received if sent < joined]
oldest_age_at_join = joined - min(sent for _, sent in history)
arrival_after_join = min(rx for rx, _ in history) - joined
The full script and the raw output of every run are on GitHub. One command reproduces it with Docker.