diff --git a/src/libcamera/include/v4l2_device.h b/src/libcamera/include/v4l2_device.h
index 510b74f12d05..30a8f77d9772 100644
--- a/src/libcamera/include/v4l2_device.h
+++ b/src/libcamera/include/v4l2_device.h
@@ -7,15 +7,19 @@
 #ifndef __LIBCAMERA_V4L2_DEVICE_H__
 #define __LIBCAMERA_V4L2_DEVICE_H__
 
+#include <atomic>
 #include <string>
 #include <vector>
 
 #include <linux/videodev2.h>
 
+#include <libcamera/signal.h>
+
 namespace libcamera {
 
 class Buffer;
 class BufferPool;
+class EventNotifier;
 class MediaEntity;
 
 struct V4L2Capability final : v4l2_capability {
@@ -96,6 +100,9 @@ public:
 	int exportBuffers(unsigned int count, BufferPool *pool);
 	int releaseBuffers();
 
+	int queueBuffer(Buffer *buffer);
+	Signal<Buffer *> bufferReady;
+
 private:
 	int getFormatSingleplane(V4L2DeviceFormat *format);
 	int setFormatSingleplane(V4L2DeviceFormat *format);
@@ -107,6 +114,9 @@ private:
 	int createPlane(Buffer *buffer, unsigned int plane,
 			unsigned int length);
 
+	Buffer *dequeueBuffer();
+	void bufferAvailable(EventNotifier *notifier);
+
 	std::string deviceNode_;
 	int fd_;
 	V4L2Capability caps_;
@@ -115,6 +125,9 @@ private:
 	enum v4l2_memory memoryType_;
 
 	BufferPool *bufferPool_;
+	std::atomic<unsigned int> queuedBuffersCount_;
+
+	EventNotifier *fdEvent_;
 };
 
 } /* namespace libcamera */
diff --git a/src/libcamera/v4l2_device.cpp b/src/libcamera/v4l2_device.cpp
index 2d0a1cb6abbe..134a468c4236 100644
--- a/src/libcamera/v4l2_device.cpp
+++ b/src/libcamera/v4l2_device.cpp
@@ -13,6 +13,7 @@
 #include <vector>
 
 #include <libcamera/buffer.h>
+#include <libcamera/event_notifier.h>
 
 #include "log.h"
 #include "media_object.h"
@@ -203,6 +204,10 @@ LOG_DEFINE_CATEGORY(V4L2)
  * No API call other than open(), isOpen() and close() shall be called on an
  * unopened device instance.
  *
+ * The V4L2Device class tracks queued buffers and handles buffer events. It
+ * automatically dequeues completed buffers and emits the \ref bufferReady
+ * signal.
+ *
  * Upon destruction any device left open will be closed, and any resources
  * released.
  */
@@ -212,7 +217,8 @@ LOG_DEFINE_CATEGORY(V4L2)
  * \param deviceNode The file-system path to the video device node
  */
 V4L2Device::V4L2Device(const std::string &deviceNode)
-	: deviceNode_(deviceNode), fd_(-1), bufferPool_(nullptr)
+	: deviceNode_(deviceNode), fd_(-1), bufferPool_(nullptr),
+	  queuedBuffersCount_(0), fdEvent_(nullptr)
 {
 	/*
 	 * We default to an MMAP based CAPTURE device, however this will be
@@ -251,7 +257,7 @@ int V4L2Device::open()
 		return -EBUSY;
 	}
 
-	ret = ::open(deviceNode_.c_str(), O_RDWR);
+	ret = ::open(deviceNode_.c_str(), O_RDWR | O_NONBLOCK);
 	if (ret < 0) {
 		ret = -errno;
 		LOG(V4L2, Error)
@@ -294,6 +300,10 @@ int V4L2Device::open()
 			    ? V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE
 			    : V4L2_BUF_TYPE_VIDEO_OUTPUT;
 
+	fdEvent_ = new EventNotifier(fd_, EventNotifier::Read);
+	fdEvent_->activated.connect(this, &V4L2Device::bufferAvailable);
+	fdEvent_->setEnabled(false);
+
 	return 0;
 }
 
@@ -315,6 +325,7 @@ void V4L2Device::close()
 		return;
 
 	releaseBuffers();
+	delete fdEvent_;
 
 	::close(fd_);
 	fd_ = -1;
@@ -631,4 +642,118 @@ int V4L2Device::releaseBuffers()
 	return 0;
 }
 
+/**
+ * \brief Queue a buffer into the device
+ * \param[in] buffer The buffer to be queued
+ *
+ * For capture devices the \a buffer will be filled with data by the device.
+ * For output devices the \a buffer shall contain valid data and will be
+ * processed by the device. Once the device has finished processing the buffer,
+ * it will be available for dequeue.
+ *
+ * \todo Support output devices (bytesused, ...)
+ * \todo Support imported buffers (dmabuf fd)
+ *
+ * \return 0 on success or a negative error number otherwise
+ */
+int V4L2Device::queueBuffer(Buffer *buffer)
+{
+	struct v4l2_buffer buf = {};
+	struct v4l2_plane planes[VIDEO_MAX_PLANES] = {};
+	int ret;
+
+	buf.index = buffer->index();
+	buf.type = bufferType_;
+	buf.memory = memoryType_;
+
+	if (V4L2_TYPE_IS_MULTIPLANAR(buf.type)) {
+		buf.length = buffer->planes().size();
+		buf.m.planes = planes;
+	}
+
+	LOG(V4L2, Debug) << "Queueing buffer " << buf.index;
+
+	ret = ioctl(fd_, VIDIOC_QBUF, &buf);
+	if (ret < 0) {
+		ret = -errno;
+		LOG(V4L2, Error)
+			<< "Failed to queue buffer " << buf.index << ": "
+			<< strerror(-ret);
+		return ret;
+	}
+
+	if (queuedBuffersCount_++ == 0)
+		fdEvent_->setEnabled(true);
+
+	return 0;
+}
+
+/**
+ * \brief Dequeue the next available buffer from the device
+ *
+ * This method dequeues the next available buffer from the device. If no buffer
+ * is available to be dequeued it will return nullptr immediately.
+ *
+ * \return A pointer to the dequeued buffer on succcess, or nullptr otherwise
+ */
+Buffer *V4L2Device::dequeueBuffer()
+{
+	struct v4l2_buffer buf = {};
+	struct v4l2_plane planes[VIDEO_MAX_PLANES] = {};
+	int ret;
+
+	buf.type = bufferType_;
+	buf.memory = memoryType_;
+
+	if (V4L2_TYPE_IS_MULTIPLANAR(buf.type)) {
+		buf.length = VIDEO_MAX_PLANES;
+		buf.m.planes = planes;
+	}
+
+	ret = ioctl(fd_, VIDIOC_DQBUF, &buf);
+	if (ret < 0) {
+		ret = -errno;
+		LOG(V4L2, Error)
+			<< "Failed to dequeue buffer: " << strerror(-ret);
+		return nullptr;
+	}
+
+	ASSERT(buf.index < bufferPool_->count());
+
+	if (--queuedBuffersCount_ == 0)
+		fdEvent_->setEnabled(false);
+
+	return &bufferPool_->buffers()[buf.index];
+}
+
+/**
+ * \brief Slot to handle completed buffer events from the V4L2 device
+ * \param[in] notifier The event notifier
+ *
+ * When this slot is called, a Buffer has become available from the device, and
+ * will be emitted through the bufferReady Signal.
+ *
+ * For Capture devices the Buffer will contain valid data.
+ * For Output devices the Buffer can be considered empty.
+ */
+void V4L2Device::bufferAvailable(EventNotifier *notifier)
+{
+	Buffer *buffer = dequeueBuffer();
+	if (!buffer)
+		return;
+
+	LOG(V4L2, Debug) << "Buffer " << buffer->index() << " is available";
+
+	/* Notify anyone listening to the device. */
+	bufferReady.emit(buffer);
+
+	/* Notify anyone listening to the buffer specifically. */
+	buffer->completed.emit(buffer);
+}
+
+/**
+ * \var V4L2Device::bufferReady
+ * \brief A Signal emitted when a buffer completes
+ */
+
 } /* namespace libcamera */
