diff --git a/components/common/cpp/include/ftl/handle.hpp b/components/common/cpp/include/ftl/handle.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..0fe697e8435e5d33548cd6159025f6964c8b5fe4
--- /dev/null
+++ b/components/common/cpp/include/ftl/handle.hpp
@@ -0,0 +1,100 @@
+#ifndef _FTL_HANDLE_HPP_
+#define _FTL_HANDLE_HPP_
+
+#include <ftl/threads.hpp>
+#include <functional>
+#include <unordered_map>
+
+namespace ftl {
+
+struct Handle;
+struct BaseHandler {
+	virtual void remove(const Handle &)=0;
+
+	inline Handle make_handle(BaseHandler*, int);
+
+	protected:
+	MUTEX mutex_;
+	int id_=0;
+};
+
+/**
+ * A `Handle` is used to manage registered callbacks, allowing them to be
+ * removed safely whenever the `Handle` instance is destroyed.
+ */
+struct Handle {
+	friend struct BaseHandler;
+
+	/**
+	 * Cancel the timer job. If currently executing it will block and wait for
+	 * the job to complete.
+	 */
+	inline void cancel() { if (handler_) handler_->remove(*this); handler_ = nullptr; }
+
+	inline int id() const { return id_; }
+
+	Handle() : handler_(nullptr), id_(0) {}
+
+	Handle(const Handle &)=delete;
+	Handle &operator=(const Handle &)=delete;
+
+	inline Handle(Handle &&h) : handler_(nullptr) {
+		if (handler_) handler_->remove(*this);
+		handler_ = h.handler_;
+		h.handler_ = nullptr;
+		id_ = h.id_;
+	}
+
+	inline Handle &operator=(Handle &&h) {
+		if (handler_) handler_->remove(*this);
+		handler_ = h.handler_;
+		h.handler_ = nullptr;
+		id_ = h.id_;
+		return *this;
+	}
+
+	inline ~Handle() { if (handler_) handler_->remove(*this); }
+
+	private:
+	BaseHandler *handler_;
+	int id_;
+
+	Handle(BaseHandler *h, int id) : handler_(h), id_(id) {}
+};
+
+template <typename ...ARGS>
+struct Handler : BaseHandler {
+	Handle on(const std::function<bool(ARGS...)> &f) {
+		std::unique_lock<std::mutex> lk(mutex_);
+		int id = id_++;
+		callbacks_[id] = f;
+		return make_handle(this, id);
+	}
+
+	void trigger(ARGS ...args) {
+		std::unique_lock<std::mutex> lk(mutex_);
+		try {
+			for (auto &f : callbacks_) {
+				f.second(std::forward<ARGS...>(args...));
+			}
+		} catch (const std::exception &e) {
+			LOG(ERROR) << "Exception in callback: " << e.what();
+		}
+	}
+
+	void remove(const Handle &h) override {
+		std::unique_lock<std::mutex> lk(mutex_);
+		callbacks_.erase(h.id());
+	}
+
+	private:
+	std::unordered_map<int, std::function<bool(ARGS...)>> callbacks_;
+};
+
+}
+
+ftl::Handle ftl::BaseHandler::make_handle(BaseHandler *h, int id) {
+	return ftl::Handle(h, id);
+}
+
+#endif
\ No newline at end of file
diff --git a/components/common/cpp/test/CMakeLists.txt b/components/common/cpp/test/CMakeLists.txt
index 9112234bfadbd4de3be225dce71937a38ed1f62a..9e86ae5b1b6d66c4ed63e901ed480b6238fc7d0b 100644
--- a/components/common/cpp/test/CMakeLists.txt
+++ b/components/common/cpp/test/CMakeLists.txt
@@ -28,6 +28,15 @@ target_include_directories(timer_unit PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/../inc
 target_link_libraries(timer_unit ftlcommon
 	Threads::Threads ${OS_LIBS})
 
+### Handle Unit ################################################################
+add_executable(handle_unit
+	$<TARGET_OBJECTS:CatchTest>
+	./handle_unit.cpp
+)
+target_include_directories(handle_unit PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/../include")
+target_link_libraries(handle_unit ftlcommon
+	Threads::Threads ${OS_LIBS})
+
 ### URI ########################################################################
 add_executable(msgpack_unit
 	$<TARGET_OBJECTS:CatchTest>
diff --git a/components/common/cpp/test/handle_unit.cpp b/components/common/cpp/test/handle_unit.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..1c1c27d61b55af2b26bb9a37f0827e2e2fe98a7d
--- /dev/null
+++ b/components/common/cpp/test/handle_unit.cpp
@@ -0,0 +1,81 @@
+#include "catch.hpp"
+#define LOGURU_REPLACE_GLOG 1
+#include <loguru.hpp>
+#include <ftl/handle.hpp>
+
+using ftl::Handler;
+using ftl::Handle;
+
+TEST_CASE( "Handle release on cancel" ) {
+	Handler<int> handler;
+
+	int calls = 0;
+
+	auto h = handler.on([&calls](int i) {
+		calls += i;
+		return true;
+	});
+
+	handler.trigger(5);
+	REQUIRE(calls == 5);
+	h.cancel();
+	handler.trigger(5);
+	REQUIRE(calls == 5);
+}
+
+TEST_CASE( "Handle multiple triggers" ) {
+	Handler<int> handler;
+
+	int calls = 0;
+
+	auto h = handler.on([&calls](int i) {
+		calls += i;
+		return true;
+	});
+
+	handler.trigger(5);
+	REQUIRE(calls == 5);
+	handler.trigger(5);
+	REQUIRE(calls == 10);
+}
+
+TEST_CASE( "Handle release on destruct" ) {
+	Handler<int> handler;
+
+	int calls = 0;
+
+	{
+		auto h = handler.on([&calls](int i) {
+			calls += i;
+			return true;
+		});
+
+		handler.trigger(5);
+		REQUIRE(calls == 5);
+	}
+
+	handler.trigger(5);
+	REQUIRE(calls == 5);
+}
+
+TEST_CASE( "Handle moving" ) {
+	SECTION("old handle cannot cancel") {
+		Handler<int> handler;
+
+		int calls = 0;
+
+		auto h = handler.on([&calls](int i) {
+			calls += i;
+			return true;
+		});
+
+		handler.trigger(5);
+		REQUIRE(calls == 5);
+
+		auto h2 = std::move(h);
+		h.cancel();
+
+		handler.trigger(5);
+		REQUIRE(calls == 10);
+	}
+}
\ No newline at end of file