diff --git a/include/libcamera/internal/request.h b/include/libcamera/internal/request.h
index 1340ffa2a683..740ab21ac7e0 100644
--- a/include/libcamera/internal/request.h
+++ b/include/libcamera/internal/request.h
@@ -7,10 +7,17 @@
 #ifndef __LIBCAMERA_INTERNAL_REQUEST_H__
 #define __LIBCAMERA_INTERNAL_REQUEST_H__
 
+#include <chrono>
+#include <map>
 #include <memory>
 
+#include <libcamera/base/event_notifier.h>
+#include <libcamera/base/timer.h>
+
 #include <libcamera/request.h>
 
+using namespace std::chrono_literals;
+
 namespace libcamera {
 
 class Camera;
@@ -32,16 +39,24 @@ public:
 	void cancel();
 	void reuse();
 
+	void prepare(std::chrono::milliseconds timeout = 0ms);
+	Signal<> prepared;
+
 private:
 	friend class PipelineHandler;
 
 	void doCancelRequest();
+	void notifierActivated(FrameBuffer *buffer);
+	void timeout();
 
 	Camera *camera_;
 	bool cancelled_;
 	uint32_t sequence_ = 0;
+	bool prepared_ = false;
 
 	std::unordered_set<FrameBuffer *> pending_;
+	std::map<FrameBuffer *, std::unique_ptr<EventNotifier>> notifiers_;
+	std::unique_ptr<Timer> timer_;
 };
 
 } /* namespace libcamera */
diff --git a/src/libcamera/request.cpp b/src/libcamera/request.cpp
index 699ea1db2e16..3d66ac6d2b03 100644
--- a/src/libcamera/request.cpp
+++ b/src/libcamera/request.cpp
@@ -135,6 +135,8 @@ void Request::Private::doCancelRequest()
 
 	cancelled_ = true;
 	pending_.clear();
+	notifiers_.clear();
+	timer_.reset();
 }
 
 /**
@@ -163,6 +165,135 @@ void Request::Private::reuse()
 	sequence_ = 0;
 	cancelled_ = false;
 	pending_.clear();
+	notifiers_.clear();
+	timer_.reset();
+}
+
+/**
+ * \brief Prepare the Request to be queued to the device
+ * \param[in] timeout Optional expiration timeout
+ *
+ * Prepare a Request to be queued to the hardware device by ensuring it is
+ * ready for the incoming memory transfers.
+ *
+ * This currently means waiting on each frame buffer acquire fence to be
+ * signalled. An optional expiration timeout can be specified. If not all the
+ * fences have been signalled correctly before the timeout expires the Request
+ * is cancelled.
+ *
+ * The function immediately emits the prepared signal if all the prepare
+ * operations have been completed synchronously. If instead the prepare
+ * operations require to wait the completion of asynchronous events, such as
+ * fences notifications or timer expiration, the prepared signal is emitted upon
+ * the asynchronous event completion.
+ *
+ * As we currently only handle fences, the function emits the prepared signal
+ * immediately if there are no fences to wait on. Otherwise the prepared signal
+ * is emitted when all fences have been signalled or the optional timeout has
+ * expired.
+ *
+ * If not all the fences have been correctly signalled or the optional timeout
+ * has expired the Request will emit the Request::prepared signal, but will
+ * be set in error state by setting the Request::cancelled_ flag to true.
+ *
+ * The intended user of this function is the PipelineHandler base class, which
+ * 'prepares' a Request before queuing it to the hardware device. A Request is
+ * ready for being queued to the hardware if the Request::prepared_ flag is set
+ * to true and if Request::cancelled_ is set to false.
+ */
+void Request::Private::prepare(std::chrono::milliseconds timeout)
+{
+	prepared_ = false;
+
+	/* Create and connect one notifier for each synchronization fence. */
+	for (FrameBuffer *buffer : pending_) {
+
+		if (!buffer->fence())
+			continue;
+
+		notifiers_[buffer] = std::make_unique<EventNotifier>(buffer->fence()->fd()->get(),
+								     EventNotifier::Read);
+	}
+
+	if (notifiers_.empty()) {
+		prepared_ = true;
+		prepared.emit();
+		return;
+	}
+
+	for (auto &it : notifiers_) {
+		FrameBuffer *buffer = it.first;
+		std::unique_ptr<EventNotifier> &notifier = it.second;
+
+		notifier->activated.connect(this, [this, buffer] {
+							notifierActivated(buffer);
+					        });
+	}
+
+	/*
+	 * In case a timeout is specified, create a timer and set it up.
+	 *
+	 * The timer must be created here instead of in the Request constructor,
+	 * in order to be bound to the pipeline handler thread.
+	 */
+	if (timeout != 0ms) {
+		timer_ = std::make_unique<Timer>();
+		timer_->timeout.connect(this, &Request::Private::timeout);
+		timer_->start(timeout);
+	}
+}
+
+/**
+ * \var Request::Private::prepared
+ * \brief Request preparation completed Signal
+ *
+ * The signal is emitted once the request preparation has completed (prepared_
+ * == true) and is ready for being queued. The Request might complete with
+ * errors in which case the cancelled_ flag it is set to true and the Request
+ * is cancelled by the slot associated with this signal.
+ *
+ * The intended slot for this signal is the PipelineHandler::doQueueRequests()
+ * function which queues Request after they have been prepared or cancel them
+ * if they have failed preparing.
+ */
+
+void Request::Private::notifierActivated(FrameBuffer *buffer)
+{
+	/* Close the fence if successfully signalled. */
+	ASSERT(buffer);
+	buffer->resetFence();
+
+	/* Remove the entry from the map and check if other fences are pending. */
+	auto it = notifiers_.find(buffer);
+	ASSERT(it != notifiers_.end());
+	notifiers_.erase(it);
+
+	Request *request = _o<Request>();
+	LOG(Request, Debug)
+		<< "Request " << request->cookie() << " buffer " << buffer
+		<< " fence signalled";
+
+	if (!notifiers_.empty())
+		return;
+
+	/* All fences completed, delete the timer and move to state Ready. */
+	timer_.reset();
+	prepared_ = true;
+	prepared.emit();
+}
+
+void Request::Private::timeout()
+{
+	/* A timeout can only happen if there are fences not yet signalled. */
+	ASSERT(!notifiers_.empty());
+	notifiers_.clear();
+
+	Request *request = _o<Request>();
+	LOG(Request, Debug) << "Request prepare timeout: " << request->cookie();
+
+	cancelled_ = true;
+	prepared_ = true;
+	prepared.emit();
 }
 
 /**
