ROS 2 Lifecycle Nodes: Controlled Startup and Shutdown for Sensors and Drivers
Tom Cosgrove,

TL;DR: A ROS 2 lifecycle node is a managed node with a defined state machine – unconfigured, inactive, active, finalized – plus transition states in between. Instead of doing everything in a constructor, you split your driver into on_configure(), on_activate(), on_deactivate(), and on_cleanup() callbacks. An external supervisor (your own script, a launch action, or nav2_lifecycle_manager) drives the transitions. The payoff: deterministic bring-up order, sensors that can be opened without publishing, drivers that release hardware cleanly, and failures you can detect at a known point instead of in the middle of a mission. Use lifecycle nodes for anything that touches hardware or that other subsystems depend on. Skip them for small, stateless helper nodes.
Why does a ROS 2 lifecycle node matter for sensors and drivers?
Consider a typical mobile robot bring-up. A depth camera driver, an IMU, a wheel controller, a localization node, and a navigation stack all start at once because ros2 launch spawned them in the same second. The localization node subscribes to /imu/data before the IMU driver has finished opening the serial port. The navigation stack starts planning before the costmap has a single valid depth frame. Nothing crashes, but the first thirty seconds of operation are noise.
That is the gap a ROS 2 lifecycle node fills. A plain rclcpp::Node or rclpy.node.Node has exactly two meaningful states from the outside: the process exists, or it does not. Everything that happens inside the constructor is opaque. If the constructor opens a USB device and that device is not enumerated yet, you get an exception, the process dies, and your launch file either restarts it blindly or gives up.
A managed node makes those internal stages explicit and externally observable. ROS 2 defines the contract in the Node Lifecycle design document, and it is implemented in rclcpp_lifecycle for C++ and rclpy.lifecycle for Python. The key practical consequence is that another process can ask “is this driver configured?” and “is it publishing?” as separate questions, and can act on the answers.
On field robotics platforms like the Leo Rover, where payloads get swapped between deployments and a sensor may or may not be plugged in on a given day, that distinction stops being academic. You want the rover’s core drivers to come up in a known order, and you want a missing LiDAR to produce a clear failed transition rather than a stack trace buried in a log file.
What are the lifecycle states and transitions?
The ROS 2 lifecycle node state machine has four primary states and six transition states. The primary states are the ones your node rests in; the transition states are the ones it passes through while a callback is running.
These are the four primary states and what they mean in practice:
- Unconfigured – the node object exists and the process is running, but its operational resources have not been configured. This is where a lifecycle node lands immediately after construction. The lifecycle state machine does not itself prevent a constructor from declaring parameters or creating ROS entities, but hardware setup normally belongs in
on_configure(). - Inactive – resources are allocated. Parameters are loaded, the serial port or USB handle is open, publishers and timers may exist, but lifecycle publishers are deactivated. The node holds the hardware but produces no output on its lifecycle publishers.
- Active – the node is doing its job. Timers fire, callbacks run, lifecycle publishers actually emit messages. A correctly written lifecycle driver should publish sensor data only in this state.
- Finalized – the node has shut down and cannot be revived. The only thing left is destroying the object.
The standard transitions and their callbacks are:
configuremoves unconfigured to inactive and runson_configure(). Read parameters, open devices, create publishers here.activatemoves inactive to active and runson_activate(). Start timers, enable publishers, begin streaming.deactivatemoves active to inactive and runson_deactivate(). Stop timers, disable publishers, keep the hardware handle.cleanupmoves inactive to unconfigured and runson_cleanup(). Close the device, destroy publishers, free buffers.shutdownmoves unconfigured, inactive, or active to finalized and runson_shutdown().- An error return from a transition callback routes the node into the
errorprocessingtransition state, whereon_error()runs. Ifon_error()returns success, the node lands in unconfigured; otherwise it goes to finalized.
The useful property here is that inactive exists at all. A camera driver in the inactive state has the device open, the exposure configured, and the memory-mapped buffers allocated. Transitioning to active is cheap. On a USB3 camera that takes several seconds to initialize, this is the difference between a one-second mode switch and a ten-second one. The exact numbers depend entirely on your hardware, so measure yours rather than trusting a generic figure.
When should you use a lifecycle node instead of a regular node?
Lifecycle nodes add boilerplate and an external supervisor. That cost is worth paying in specific situations and not in others.
Use a ROS 2 lifecycle node when any of the following apply to your component:
- It owns an exclusive hardware resource – a serial port, a CAN socket, a V4L2 device, a GPIO line. Exclusive ownership means you need a defined point at which the resource is released.
- Another subsystem has a hard dependency on it. If navigation cannot start before odometry is valid, odometry should be a managed node so the supervisor can gate on it.
- You need to stop output without killing the process. Thermal throttling a camera, pausing a LiDAR to save power during a long transit, or silencing a driver during a calibration routine are all deactivate operations, not kill operations.
- Startup can fail for recoverable reasons. A failed
configurethat you can retry after ten seconds is far more useful than a crashed process. - You are integrating with Nav2, which already manages its own servers through
nav2_lifecycle_managerand expects lifecycle semantics.
Stick with a regular node when the component is a stateless transform, a diagnostic aggregator, a teleoperation bridge, or a short-lived utility. Wrapping a 40-line republisher in a state machine adds maintenance burden without buying anything. The same applies to most one-off scripts in a research setup – unless that script is the thing holding the only handle to a sensor.
One honest limitation worth stating: lifecycle nodes do not change how DDS discovery works. Subscribers still discover a deactivated lifecycle publisher as an existing publisher. The messages simply do not arrive. If you are debugging with ros2 topic hz and see zero, check the node state before you check the wiring.
How do you write a lifecycle sensor driver in Python?
Below is a minimal but complete lifecycle driver skeleton for a serial sensor. It is written against rclpy.lifecycle as available in ROS 2 Iron and later. Replace the mock read with your actual protocol.
Create lifecycle_sensor.py:
import rclpy
from rclpy.lifecycle import Node, State, TransitionCallbackReturn
from sensor_msgs.msg import Range
class LifecycleRangeSensor(Node):
def __init__(self):
super().__init__('range_sensor')
self._pub = None
self._timer = None
self._port = None
self.declare_parameter('device', '/dev/ttyUSB0')
self.declare_parameter('rate_hz', 20.0)
def on_configure(self, state: State) -> TransitionCallbackReturn:
device = self.get_parameter('device').value
try:
self._port = open_serial(device) # your driver call
except OSError as exc:
self.get_logger().error(f'cannot open {device}: {exc}')
return TransitionCallbackReturn.FAILURE
self._pub = self.create_lifecycle_publisher(Range, '~/range', 10)
self.get_logger().info('configured')
return TransitionCallbackReturn.SUCCESS
def on_activate(self, state: State) -> TransitionCallbackReturn:
period = 1.0 / self.get_parameter('rate_hz').value
self._timer = self.create_timer(period, self._on_tick)
return super().on_activate(state)
def on_deactivate(self, state: State) -> TransitionCallbackReturn:
if self._timer is not None:
self.destroy_timer(self._timer)
self._timer = None
return super().on_deactivate(state)
def on_cleanup(self, state: State) -> TransitionCallbackReturn:
if self._timer is not None:
self.destroy_timer(self._timer)
self._timer = None
if self._port is not None:
self._port.close()
self._port = None
if self._pub is not None:
self.destroy_lifecycle_publisher(self._pub)
self._pub = None
return TransitionCallbackReturn.SUCCESS
def on_shutdown(self, state: State) -> TransitionCallbackReturn:
return self.on_cleanup(state)
def _on_tick(self):
msg = Range()
msg.header.stamp = self.get_clock().now().to_msg()
msg.range = self._port.read_range()
self._pub.publish(msg)
def main():
rclpy.init()
node = LifecycleRangeSensor()
rclpy.spin(node)
rclpy.shutdown()
Three details carry most of the value here. First, create_lifecycle_publisher returns a publisher that does not publish while the node is inactive, so you cannot accidentally leak data through that publisher. Second, returning TransitionCallbackReturn.FAILURE from on_configure() leaves the node in unconfigured, which means a supervisor can retry the same transition later without restarting the process. Third, on_shutdown() delegating to on_cleanup() guarantees the serial port is released no matter which state you shut down from.
How do you drive the transitions from the command line?
Every lifecycle node automatically exposes services and a topic. You interact with them through ros2 lifecycle. Start the node in one terminal, then run these commands in another:
- List managed nodes:
ros2 lifecycle nodes - Read the current state:
ros2 lifecycle get /range_sensor - List which transitions are legal right now:
ros2 lifecycle list /range_sensor - Configure it:
ros2 lifecycle set /range_sensor configure - Activate it:
ros2 lifecycle set /range_sensor activate - Watch state changes as they happen:
ros2 topic echo /range_sensor/transition_event
The transition_event topic is the one most people overlook. It publishes a lifecycle_msgs/msg/TransitionEvent with the start state, goal state, and transition label every time the node moves. Logging that topic during a field run gives you a precise timeline of when each driver came up, which is far easier to read than correlating timestamps across a dozen log streams.
What does the C++ side look like?
The C++ API mirrors the Python one. You inherit from rclcpp_lifecycle::LifecycleNode and return rclcpp_lifecycle::node_interfaces::LifecycleNodeInterface::CallbackReturn values. The abbreviated form:
#include <chrono>
#include "rclcpp_lifecycle/lifecycle_node.hpp"
#include "sensor_msgs/msg/range.hpp"
using namespace std::chrono_literals;
using CallbackReturn =
rclcpp_lifecycle::node_interfaces::LifecycleNodeInterface::CallbackReturn;
class RangeDriver : public rclcpp_lifecycle::LifecycleNode
{
public:
RangeDriver() : rclcpp_lifecycle::LifecycleNode("range_driver") {}
CallbackReturn on_configure(const rclcpp_lifecycle::State &) override
{
if (!open_device()) { return CallbackReturn::FAILURE; }
pub_ = this->create_publisher<sensor_msgs::msg::Range>("~/range", 10);
return CallbackReturn::SUCCESS;
}
CallbackReturn on_activate(const rclcpp_lifecycle::State & s) override
{
LifecycleNode::on_activate(s); // activates managed publishers
timer_ = this->create_wall_timer(50ms, [this]() { tick(); });
return CallbackReturn::SUCCESS;
}
CallbackReturn on_deactivate(const rclcpp_lifecycle::State & s) override
{
LifecycleNode::on_deactivate(s);
timer_.reset();
return CallbackReturn::SUCCESS;
}
private:
rclcpp_lifecycle::LifecyclePublisher<sensor_msgs::msg::Range>::SharedPtr pub_;
rclcpp::TimerBase::SharedPtr timer_;
};
In C++, create_publisher on a LifecycleNode already returns a LifecyclePublisher, so you do not need a separate factory method. Calling the base-class on_activate and on_deactivate is what flips the publisher’s internal enable flag – forget that call and your node will sit in the active state publishing nothing.
How do you orchestrate multiple lifecycle nodes?
A single managed node is not much use on its own. The value appears when you sequence several of them. There are three practical approaches.
The first is nav2_lifecycle_manager, which ships with the Navigation2 stack. You give it an ordered list of node names and it configures and activates them in that order, then deactivates and cleans up in reverse order on shutdown. It also runs a bond-based heartbeat, so if a managed node dies, the manager notices and can tear down the rest of the stack rather than leaving it in an inconsistent state. Even outside navigation, this package is a reasonable off-the-shelf supervisor.
The second is launch-based sequencing with launch_ros.actions.LifecycleNode plus RegisterEventHandler and OnStateTransition. You declare that when node A reaches inactive, emit a configure request for node B. This keeps the orchestration in the launch file where the rest of your bring-up already lives, at the cost of launch files that get long quickly.
The third is a custom supervisor node that calls the ChangeState services directly. This is the right choice when your startup logic is genuinely conditional – for example, when the rover should only activate the LiDAR driver if the configuration file says the mast payload is installed, and should fall back to a stereo camera otherwise. Hardware that changes between missions tends to push you toward this option.
For teams building custom robotics platforms with swappable payloads, the third approach usually scales best, because the supervisor becomes the single place encoding which sensors are expected on which configuration. In academic and research deployments, where the sensor set changes between experiments, a small supervisor reading a YAML manifest saves a lot of launch-file duplication.
What are the common mistakes to avoid?
Several failure patterns show up repeatedly when teams first adopt the ROS 2 lifecycle node pattern. Watching for them saves debugging time:
- Blocking in a transition callback. Transition callbacks run on the executor. A ten-second blocking device probe inside
on_configure()blocks everything else in that executor, including the service response. Spawn a worker thread or poll with a deadline. - Allocating in
on_activate(). Activation should be fast. Memory allocation, device opening, and parameter parsing belong inon_configure(). If activation takes a long time, you have put work in the wrong callback. - Forgetting that subscribers are not managed. ROS 2 provides lifecycle publishers, but subscriptions keep delivering callbacks regardless of node state. If your subscription callback touches hardware, guard it with an explicit state check.
- Using
FAILUREandERRORinterchangeably.FAILUREmeans the requested transition did not complete successfully and normally leaves the node in a recoverable primary state.ERRORmeans something unexpected broke and routes throughon_error(). ReturningERRORfor a missing USB device makes retries harder than they need to be. - Not handling the deactivate-to-activate round trip. Test it. A driver that works once but produces garbage after a deactivate and reactivate cycle usually has stale state in a buffer or a timestamp offset that never got reset.
Frequently asked questions
Does a ROS 2 lifecycle node add measurable runtime overhead?
Once the node is in the active state, the publishing path is essentially the same as a regular node plus a boolean check on the enable flag. The overhead is in the transition machinery and the extra services, which are only exercised during state changes. For steady-state high-rate publishing, treat the cost as negligible and verify on your own hardware if you are near a latency budget.
Can a lifecycle node and a regular node coexist in the same system?
Yes, and mixed systems are normal. A lifecycle publisher looks like any other publisher to a regular subscriber. The only thing a non-managed node cannot do is participate in ordered bring-up, so keep the components with hard ordering requirements managed and leave the rest alone.
Which ROS 2 distributions support lifecycle nodes in Python?
The C++ implementation in rclcpp_lifecycle has been available since the early ROS 2 releases. The Python rclpy.lifecycle API arrived later and is available in Iron and subsequent distributions. Check the API reference for the exact distribution you are targeting, since method names have shifted slightly between releases.
How do you check a lifecycle node’s state from inside your own code?
In C++, call get_current_state() on the node and compare id() against the constants in lifecycle_msgs::msg::State. In Python, call get_current_state() on the lifecycle node. From a separate process, call the <node_name>/get_state service, which returns a lifecycle_msgs/msg/State.
What happens if a transition callback throws an exception?
The framework catches it and treats it as an ERROR return, routing the node into errorprocessing and calling on_error(). Relying on this is poor practice. Catch exceptions explicitly in your callback so you control whether the result is a recoverable FAILURE or a genuine ERROR.
Can you convert an existing driver to a lifecycle node incrementally?
Yes. The usual path is to change the base class, move constructor body into on_configure(), move timer creation into on_activate(), and add a launch action that immediately configures and activates the node. At that point behavior matches the original. You then refine the split between configure and activate as you find things that belong in the other callback.
Do lifecycle nodes help with clean shutdown on battery-powered robots?
They help, with a caveat. A lifecycle manager can issue deactivate and cleanup in reverse dependency order, giving each driver a chance to park actuators and close files. That only works if the system has enough warning before power loss. Combine lifecycle shutdown with a supervised power-down sequence or a supercapacitor hold-up if clean shutdown is a hard requirement.
Where to go from here
The fastest way to internalize the ROS 2 lifecycle node pattern is to convert one real driver in your stack, wire it to ros2 lifecycle from the command line, and watch the transition_event topic while you move it through configure, activate, deactivate, and cleanup. Once that loop feels natural, adding a supervisor for the remaining nodes is mostly mechanical work.
If you are designing a robot where payloads change between deployments and bring-up order genuinely matters, Fictionlab’s team works on exactly these integration problems. Talk to Fictionlab about a custom robotics build and bring your sensor list and your state machine requirements to the conversation.