ros2 bag record from a ROS 2 launch file: missing first messages

한국어로 읽기

What happened when I ran it

The usual answer to “how do I start rosbag2 recording from a launch file?” is one line: run ros2 bag record with ExecuteProcess. That works. What the answer leaves out is that the bag doesn’t start when launch starts the recorder. It starts when the recorder has finished subscribing, and every message your nodes publish before that moment is gone.

I wanted to see how many messages that actually costs, so I put a small publisher node and ros2 bag record into one Python launch file and ran four setups in the official ros:jazzy-ros-base container, five times each. Every message carried a sequence number, so the bag shows exactly which ones are missing. Here’s what came out:

  • No delay, recorder in the same launch file: the bag still missed the first 1 camera message and the first 5 to 7 IMU messages in every run.
  • Recorder delayed with TimerAction(period=2.0): I predicted 30 Hz × 2.0 s = 60 lost camera messages. The real loss was 66 to 110. The timer value is only a lower bound, because the recorder still needs time to start and subscribe after the timer fires.
  • --use-sim-time, first /clock 3 s late: the recorder did not create its topic subscription until /clock arrived. None of the 90 camera messages published while it waited ended up in the bag, in all five runs.
  • /tf_static: it survived a recorder that started 2 s late, but a QoS override file that set it to volatile left it out of the bag every time, and no warning was printed.

In every run, the number of messages missing from the bag was exactly the number published before the recorder’s subscription appeared. There were no gaps after the first recorded message.

The short answer

ros2 bag record isn’t a node executable inside a package. It’s a verb of the ros2 command. The rosbag2 README, “Using in launch” says to start it as an executable, not with a node action (the same section is in the Jazzy README). Its two examples, XML and Python:

<executable cmd="ros2 bag record -a" output="screen" />
launch.actions.ExecuteProcess(cmd=['ros2', 'bag', 'record', '-a'], output='screen')

-a records every topic. To record selected topics, check your distro first:

  • Humble: positional topics, ros2 bag record /cam /imu (Humble README).
  • Jazzy and Kilted: --topics /cam /imu; positional topics are marked “(deprecated)” (Jazzy record.py).
  • Rolling: only --topics.

ros2 bag record --help on your own machine settles it.

Here is the launch file the test harness generated for the 2-second-delay case, trimmed for reading. In the real file the robot command also carries a script path and a JSON config argument. Topic syntax is Jazzy’s.

from launch import LaunchDescription
from launch.actions import ExecuteProcess, TimerAction


def generate_launch_description():
    robot = ExecuteProcess(cmd=['python3', 'robot.py'], output='screen')
    recorder = ExecuteProcess(
        cmd=['ros2', 'bag', 'record', '-o', 'bag', '--topics', '/cam', '/imu', '/tf_static'],
        output='screen')
    return LaunchDescription([robot, TimerAction(period=2.0, actions=[recorder])])

The TimerAction(period=2.0, ...) wrapper copies the official launch tutorial, which delays an ExecuteProcess in the same way (example_substitutions_launch.py). Here it’s the “what if” case, not a recommendation. If you need the start of your sensor streams, don’t delay the recorder.

Rule of thumb for what you lose:

messages lost ≈ topic rate (Hz) × time from the topic’s first publish until the recorder has subscribed (s)

Plug your timer value into that and you get a floor, not an estimate. Latched topics like /tf_static don’t follow this formula at all; for them, durability decides.

Measured results

Environment. Image ros:jazzy-ros-base (ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca), ROS 2 Jazzy, Python 3.12.3, rclpy 7.1.12-1noble.20260902.053513, RMW rmw_fastrtps_cpp (default), no network, limits 1 CPU and 1 GiB. Script experiment.py, sha256 e4138b37ec03ad7b1d9821384abf88397d3f2806b152366041c9095c5b09d708. 5 runs, each running all four cases; all 5 exited with code 0 and took 19.84 s to 21.4 s. Each case used its own ROS_DOMAIN_ID. The publisher sent std_msgs/UInt32 sequence numbers starting at 0 on /cam (30 Hz) and /imu (200 Hz, cases A and B), plus one /tf_static transform from tf2_ros.StaticTransformBroadcaster (cases A, B, D). It kept publishing for 1.5 s after every recorded topic had a subscriber; then the harness sent SIGINT to the launch process group. Bags were read back with rosbag2_py. Predictions are from plan.md, written before the runs.

Case Predicted (plan.md) Measured, 5 runs
A: recorder in the same launch, no timer, /cam lower bound 0 (30 × 0); startup and discovery time may add more missing 1 in all 5 runs
A: /imu lower bound 0 missing 6, 6, 5, 7, 7
A: when the recorder subscribed — 0.026–0.033 s after the publisher’s first message; the publisher’s first message came 1.702–1.735 s after ros2 launch started
B: TimerAction(period=2.0), /cam 30 Hz 60 or more (30 × 2.0) missing 67, 66, 66, 110, 72
B: /imu 200 Hz 400 or more (200 × 2.0) missing 441, 439, 439, 727, 475
B: when the recorder subscribed to /cam — 2.193–3.635 s after the publisher’s first message; 2.768–4.227 s after ros2 launch started (publisher’s first message at 0.575–0.604 s)
B: /tf_static (sent once, before the recorder started) missing 0, 1 in the bag (1 − 1) 1 in the bag in all 5 runs; recorder subscribed 2.197–3.64 s after it was sent
C: --use-sim-time, first /clock sent 3.0 s after the recorder node appeared 90 not written (30 × 3), 0 of them in the bag; no /cam subscription before the first /clock /cam published while waiting: 90 in all 5 runs; of those in the bag: 0 in all 5; subscription before first /clock: false in all 5
C: when the recorder subscribed to /cam — 0.006–0.011 s after the first /clock; first /clock at 3.093–3.141 s, recorder node visible at 0.093–0.14 s after the publisher started
C: total /cam missing — 94, 95, 94, 93, 95
C: first /cam receive stamp in the bag — 100.0–100.03 s (sim time of the first /clock: 100.0 s)
D: override YAML sets /tf_static to durability: volatile, recorder delayed 1.0 s missing 1, 0 in the bag (1 − 0) 0 in the bag in all 5 runs; “latched messages will not be retrieved” warning: not printed
D: /cam not in plan; rate × timer = 30 (30 × 1.0) missing 37, 36, 36, 36, 36
All cases missing ≈ published before the recorder subscribed equal in every case of every run; gaps after the first sequence number in the bag: 0 everywhere
Shutdown by SIGINT — launch exit code 0, recorder logged “process has died … exit code 2” in every case; every bag (mcap) opened

In case C the launch output contained both “waiting for /clock before starting recording” and “Sim time /clock found, starting recording” in all 5 runs. Neither string appeared in cases A, B or D.

Raw output, run 1 summary line:

{"summary": true, "total_seconds": 19.05, "cases": {"A_same_launch_no_timer": {"missing": {"/cam": 1, "/imu": 6}, "published_before_subscribed": {"/cam": 1, "/imu": 6}, "tf_static_in_bag": 1, "bag_ok": true}, "B_timer_2s": {"missing": {"/cam": 67, "/imu": 441}, "published_before_subscribed": {"/cam": 67, "/imu": 441}, "tf_static_in_bag": 1, "bag_ok": true}, "C_use_sim_time_first_clock_3s": {"missing": {"/cam": 94}, "published_before_subscribed": {"/cam": 94}, "tf_static_in_bag": null, "bag_ok": true}, "D_override_volatile_tf_static": {"missing": {"/cam": 37}, "published_before_subscribed": {"/cam": 37}, "tf_static_in_bag": 0, "bag_ok": true}}}

Run 4, case B, the slowest recorder start:

"B_timer_2s": {"missing": {"/cam": 110, "/imu": 727}, "published_before_subscribed": {"/cam": 110, "/imu": 727}, "tf_static_in_bag": 1, "bag_ok": true}

Run 1, case C, sim-time block:

{"clock_wait_after_recorder_node_s": 3.0, "recorder_node_alive_s_after_robot_start": 0.113, "first_clock_s_after_robot_start": 3.114, "cam_subscribed_s_after_first_clock": 0.006, "cam_subscription_existed_before_first_clock": false, "clock_msgs_published": 151, "cam_published_while_waiting_for_clock": 90, "of_those_in_bag": 0, "cam_published_before_first_clock": 94, "cam_before_first_clock_in_bag": 0, "rate_x_clock_wait": 90.0, "bag_first_cam_receive_stamp_s": 100.01, "sim_time_of_first_clock_s": 100.0}

Recorder exit line from the launch log, run 1, case B:

ros2-2: process has died [pid 80, exit code 2, cmd 'ros2 bag record -o /tmp/rosbag_launch_exp/B_timer_2s/bag --topics /cam /imu /tf_static'].

What the numbers say

The formula holds, but the time in it isn’t your timer value. Loss matched “rate × time until the recorder subscribed” in every run. The time that matters runs from the publisher’s first message to the recorder’s subscription, and in case B that was 2.193 to 3.635 s, not 2.0 s. Multiply it back out and you get the measured counts: 30 × 2.193 ≈ 66 and 30 × 3.635 ≈ 109, against 66 and 110 measured. So after the 2 s timer fired, starting the ros2 CLI, creating the recorder node and discovering topics added between about 0.2 and 1.6 s on this 1-CPU container. One run in five was much slower than the others. If you size a delay from the timer value alone, plan for that kind of spread.

“Same launch file, no delay” is a race, not a guarantee. In case A the recorder and the publisher started at the same time and competed for one CPU. The publisher sent its first message about 1.7 s after launch, about a second later than in cases B and D, and the recorder subscribed 26 to 33 ms after that. That was enough to lose the first camera frame and 5 to 7 IMU samples. On your machine the winner depends on how heavy your nodes are and how many topics the recorder has to find, so the loss could be zero or much larger.

With --use-sim-time, the bag starts at the first /clock. The recorder node was up about 0.1 s after the publisher started, but it created no /cam subscription for the next 3 s. It subscribed within 11 ms of the first /clock. The 93 to 95 missing messages are the 90 from the wait plus the few published before the recorder node existed. The first recorded message carried a sim-time stamp of 100.0 to 100.03 s, right at the first /clock value, so nothing was written with time 0.

For /tf_static, timing didn’t matter but QoS did. A recorder that subscribed 2 to 3.6 s late still got the transform. A recorder that started only 1 s late, with a volatile override, got nothing, and it didn’t warn.

Why messages from before the subscription are gone

Launching the recorder process is not the same as recording. The process has to start, create the recorder node, find the requested topics in the ROS graph and create subscriptions. Topic discovery re-checks the graph every --polling-interval, 100 ms by default on Jazzy (record.py).

Most sensor topics use the default volatile durability: the publisher keeps nothing for subscribers that show up later. In the QoS compatibility table, a volatile publisher and a volatile subscriber get “New messages only” (ROS 2 QoS docs). A message published before the recorder subscribes never reaches it, so it can’t be in the bag.

The official launch-testing example puts a talker and ros2 bag record in one launch description and only checks that the bag has more than zero messages (record_rosbag_launch_test.py). That’s consistent with what I measured: putting both in one launch file doesn’t pin down where the bag begins.

Also note that TimerAction waits on wall-clock time. It uses asyncio.wait(..., timeout=period) (timer_action.py). If you need a delay in ROS time, launch_ros has ROSTimer, and its docstring says sim time also needs the SetUseSimTime action (ros_timer.py). I didn’t test ROSTimer.

–use-sim-time: no subscriptions until the first /clock

The README says “Until the first /clock message is received, the recorder will not write any messages.” The reason it gives: before that the time is 0, and a bag with time-0 messages followed by time-N messages is “essentially unplayable” (README, Simulation time).

The source goes a step further than “won’t write”. When use_sim_time is set, discovery logs “use_sim_time set, waiting for /clock before starting recording…”, loops until the clock has started, logs “Sim time /clock found, starting recording.” and only then creates subscriptions. The comment reads “wait until /clock topic received before even creating subscriptions”. Humble, Jazzy and Rolling share this structure (Jazzy recorder.cpp, Humble, Rolling). Case C confirms it: no /cam subscription existed before the first /clock, and both log lines showed up in the launch output.

Whether this costs you anything depends on who publishes before /clock. In my test the camera publisher ignored /clock and ran from the start, like a real-time driver would. If your simulator publishes both /clock and the sensors, the sensors probably aren’t publishing before the first /clock either, and you lose close to nothing. I didn’t test a real simulator.

To check that your installed version behaves this way, look for “Until first /clock message is received, no messages will be written to bag.” under --use-sim-time in ros2 bag record --help (Jazzy record.py). Also, --use-sim-time can’t be combined with --no-discovery.

/tf_static: durability decides, not timing

/tf_static is usually published once, so you can’t count it with rate × time. It’s either in the bag or it isn’t. tf2’s static broadcaster publishes it as transient_local with depth 1 (tf2_ros qos.cpp). With transient local, the publisher keeps the message and hands it to subscribers that join later, the ROS 2 version of a latched topic. Both sides have to agree on transient local for that to work.

The recorder picks its subscription QoS from the publishers it sees. It requests transient_local only if every publisher on the topic offers it; otherwise it falls back to volatile and warns “Previously-published latched messages will not be retrieved.” (Jazzy qos.cpp). That’s why case B kept the transform even though the recorder was late.

A QoS override file bypasses that choice. The README’s sample override profile uses durability: volatile (README, Overriding QoS Profiles). Case D used this override:

/tf_static:
  history: keep_last
  depth: 1
  reliability: reliable
  durability: volatile

That silently cost the transform in every run. The warning above didn’t fire, because it covers mixed publishers, not overrides. If you need an override for /tf_static, set durability: transient_local. Rolling’s README says the same in its --repeat-transient-local section: “the QoS override takes precedence. Ensure the override includes transient_local durability to receive latched messages.” (README L157–L159). That sentence isn’t in the Jazzy or Humble README, and I didn’t run the transient_local override.

One more condition: the publishing node must still be alive when the recorder subscribes, because the publisher is what holds the stored message. In my test it was.

“process has died … exit code 2” on Ctrl+C

When you stop the launch with Ctrl+C, launch reports the recorder as process has died [..., exit code 2, cmd 'ros2 bag record ...'], even though launch itself exits with 0. In this test that happened every time, and every mcap bag still opened and read back completely with rosbag2_py. A 2021 user report described an SQLite bag that looked damaged after Ctrl+C. I only tested the Jazzy default mcap storage, so I can’t speak to those.

Where to put the recorder

Base it on what you can afford to lose:

  • You need the first messages of continuous sensor topics: don’t put the recorder behind a TimerAction. Even with no timer, a same-launch start can drop a few messages, so if the very first samples matter, check the bag rather than trusting the formula.
  • Getting the recorder ready first: start it first and delay the robot nodes instead, or run the recorder as a node or component. The README shows rosbag2_transport running as a Node, which skips the Python CLI layer (README L705–L721). I measured neither, so compare the first sequence number in the bag before and after you switch. Event handlers such as OnProcessStart fire when a process starts, not when the recorder has subscribed.
  • You record with sim time: the bag starts at the first /clock. Anything that runs on real time before then won’t be in it. Arrange the simulator’s startup order with that in mind.
  • Latched topics: don’t count them. Check the durability in your override file and the count in ros2 bag info.

Check it on your own setup

  1. Run ros2 bag record --help. It tells you whether to use --topics and shows the --use-sim-time wording.
  2. Compute rate × delay for each topic, for example 30 Hz × 0.5 s = 15 for a camera with a half-second timer. Treat it as a floor.
  3. Make a short test recording and compare ros2 bag info counts with what was published. To find the exact loss at the start, give a test publisher a sequence number, as the harness does: the first number in the bag is the number of messages you lost.
  4. With sim time, look at the time between “waiting for /clock before starting recording” and “Sim time /clock found, starting recording” in the launch output.
  5. If /tf_static shows 0 messages, check your override file before anything else.

Limits of this test

  • One container with 1 CPU, small UInt32 messages, all processes on one host. Camera images, multi-host networks, other RMW implementations and faster machines will shift the startup times, and with them the counts.
  • /clock came from the test script, not from Gazebo or another simulator. Real simulators decide when the first /clock and the first sensor messages go out.
  • The subscription time is when the publisher saw the match. The recorder’s side can be a few milliseconds off, which is worth about one message at these rates.
  • Case A is a race, and its result depends on node weight and topic count.
  • Only Jazzy was run. The Humble and Rolling behavior described here comes from reading their source, not from running them.
  • Not run: ROSTimer, the recorder as a Node or component, a transient_local override, the SQLite storage plugin, and real robots.

Sources and reproduction

All upstream links are pinned to commits checked on 2026-10-19. No affiliate links or sponsorship.