diff --git a/include/libcamera/internal/pipeline_handler.h b/include/libcamera/internal/pipeline_handler.h
index 5c3c0bc5a8b3..5b98011ac4f6 100644
--- a/include/libcamera/internal/pipeline_handler.h
+++ b/include/libcamera/internal/pipeline_handler.h
@@ -29,6 +29,7 @@ class CameraConfiguration;
 class CameraManager;
 class DeviceEnumerator;
 class DeviceMatch;
+class Fence;
 class FrameBuffer;
 class MediaDevice;
 class PipelineHandler;
@@ -79,6 +80,7 @@ private:
 	void mediaDeviceDisconnected(MediaDevice *media);
 	virtual void disconnect();
 
+	void fenceCompleted(Request *request, FrameBuffer *buffer, Fence *fence);
 	void doQueueRequest();
 
 	std::vector<std::shared_ptr<MediaDevice>> mediaDevices_;
diff --git a/src/libcamera/pipeline_handler.cpp b/src/libcamera/pipeline_handler.cpp
index 0f9fec1b618f..39cb680e5386 100644
--- a/src/libcamera/pipeline_handler.cpp
+++ b/src/libcamera/pipeline_handler.cpp
@@ -18,6 +18,7 @@
 #include <libcamera/framebuffer.h>
 
 #include "libcamera/internal/camera.h"
+#include "libcamera/internal/framebuffer.h"
 #include "libcamera/internal/device_enumerator.h"
 #include "libcamera/internal/media_device.h"
 #include "libcamera/internal/request.h"
@@ -336,11 +337,70 @@ void PipelineHandler::queueRequest(Request *request)
 {
 	LIBCAMERA_TRACEPOINT(request_queue, request);
 
+	Request::Private *data = request->_d();
+
 	{
 		MutexLocker lock(waitingRequestsMutex_);
 		waitingRequests_.push_back(request);
 	}
 
+	for (FrameBuffer *buffer : request->pending_) {
+		if (buffer->fence() == -1)
+			continue;
+
+		/*
+		 * Move the Fence into the Request::Private list of
+		 * fences. This resets the file descriptor fence in the
+		 * FrameBuffer to -1.
+		 */
+		data->addFence(std::move(buffer->_d()->fence()));
+
+		Fence *fence = &data->fences().back();
+		fence->complete.connect(this,
+					[this, request, buffer, fence]() {
+						fenceCompleted(request, buffer, fence);
+					});
+	}
+
+	/* If no fences to wait on, we can queue the request immediately. */
+	if (!data->pendingFences()) {
+		doQueueRequest();
+
+		return;
+	}
+
+	/*
+	 * Now that we have added all fences, enable them one by one.
+	 * Enabling fences while adding them to the Request would race on the
+	 * number of pending fences.
+	 */
+	for (Fence &fence : data->fences())
+		fence.enable();
+}
+
+void PipelineHandler::fenceCompleted(Request *request, FrameBuffer *buffer,
+				     Fence *fence)
+{
+	Request::Private *data = request->_d();
+
+	if (fence->expired()) {
+		/*
+		 * Move the fence back to the framebuffer, so that it doesn't
+		 * get closed and the file descriptor number is made
+		 * available again to applications.
+		 */
+		FrameBuffer::Private *bufferData = buffer->_d();
+		bufferData->fence() = std::move(*fence);
+
+		data->fenceExpired();
+	} else {
+		data->fenceCompleted();
+	}
+
+	if (data->pendingFences())
+		return;
+
+	data->clearFences();
 	doQueueRequest();
 }
 
@@ -357,23 +417,35 @@ void PipelineHandler::doQueueRequest()
 			return;
 
 		request = waitingRequests_.front();
+		if (request->_d()->pendingFences())
+			return;
+
 		waitingRequests_.pop_front();
 	}
 
-	/* Queue Request to the pipeline handler. */
 	Camera *camera = request->_d()->camera();
 	Camera::Private *camData = camera->_d();
 
 	request->sequence_ = camData->requestSequence_++;
-	camData->queuedRequests_.push_back(request);
 
+	/* Cancel the request if one of the fences has failed. */
+	if (request->_d()->expiredFences()) {
+		request->cancel();
+		completeRequest(request);
+
+		doQueueRequest();
+
+		return;
+	}
+
+	/* Queue Request to the pipeline handler. */
+	camData->queuedRequests_.push_back(request);
 	int ret = queueRequestDevice(camera, request);
 	if (ret) {
 		request->cancel();
 		completeRequest(request);
 	}
 
-	/* Try to queue the next Request in the queue, if ready. */
 	doQueueRequest();
 }
 
