diff --git a/src/libcamera/include/v4l2_videodevice.h b/src/libcamera/include/v4l2_videodevice.h
index 27ec77cdcc3c07ff..9d22754a39a75621 100644
--- a/src/libcamera/include/v4l2_videodevice.h
+++ b/src/libcamera/include/v4l2_videodevice.h
@@ -11,7 +11,9 @@
 #include <vector>
 
 #include <linux/videodev2.h>
+#include <memory>
 
+#include <libcamera/buffer.h>
 #include <libcamera/geometry.h>
 #include <libcamera/pixelformats.h>
 #include <libcamera/signal.h>
@@ -22,9 +24,6 @@
 
 namespace libcamera {
 
-class Buffer;
-class BufferMemory;
-class BufferPool;
 class EventNotifier;
 class FileDescriptor;
 class MediaDevice;
@@ -106,6 +105,46 @@ struct V4L2Capability final : v4l2_capability {
 	}
 };
 
+class V4L2BufferCache
+{
+public:
+	V4L2BufferCache(unsigned int numEntries);
+	V4L2BufferCache(const std::vector<std::unique_ptr<FrameBuffer>> &buffers);
+	~V4L2BufferCache();
+
+	int get(const FrameBuffer &buffer);
+	void put(unsigned int index);
+
+private:
+	class Entry
+	{
+	public:
+		Entry();
+		Entry(bool free, const FrameBuffer &buffer);
+
+		bool operator==(const FrameBuffer &buffer);
+
+		bool free;
+
+	private:
+		struct Plane {
+			Plane(const FrameBuffer::Plane &plane)
+				: fd(plane.fd.fd()), length(plane.length)
+			{
+			}
+
+			int fd;
+			unsigned int length;
+		};
+
+		std::vector<Plane> planes_;
+	};
+
+	std::vector<Entry> cache_;
+	/* \todo Expose the miss counter through an instrumentation API. */
+	unsigned int missCounter_;
+};
+
 class V4L2DeviceFormat
 {
 public:
diff --git a/src/libcamera/v4l2_videodevice.cpp b/src/libcamera/v4l2_videodevice.cpp
index d22655c676bef1ae..84c45dbcb85c8638 100644
--- a/src/libcamera/v4l2_videodevice.cpp
+++ b/src/libcamera/v4l2_videodevice.cpp
@@ -20,7 +20,6 @@
 
 #include <linux/drm_fourcc.h>
 
-#include <libcamera/buffer.h>
 #include <libcamera/event_notifier.h>
 #include <libcamera/file_descriptor.h>
 
@@ -136,6 +135,141 @@ LOG_DECLARE_CATEGORY(V4L2)
  * \return True if the video device provides Streaming I/O IOCTLs
  */
 
+/**
+ * \class V4L2BufferCache
+ * \brief Hot cache of associations between V4L2 buffer indexes and FrameBuffer
+ *
+ * When importing buffers, V4L2 performs lazy mapping of dmabuf instances at
+ * VIDIOC_QBUF (or VIDIOC_PREPARE_BUF) time and keeps the mapping associated
+ * with the V4L2 buffer, as identified by its index. If the same V4L2 buffer is
+ * then reused and queued with different dmabufs, the old dmabufs will be
+ * unmapped and the new ones mapped. To keep this process efficient, it is
+ * crucial to consistently use the same V4L2 buffer for given dmabufs through
+ * the whole duration of a capture cycle.
+ *
+ * The V4L2BufferCache class keeps a map of previous dmabufs to V4L2 buffer
+ * index associations to help selecting V4L2 buffers. It tracks, for every
+ * entry, if the V4L2 buffer is in use, and offers lookup of the best free V4L2
+ * buffer for a set of dmabufs.
+ */
+
+/**
+ * \brief Create an empty cache with \a numEntries entries
+ * \param[in] numEntries Number of entries to reserve in the cache
+ *
+ * Create a cache with \a numEntries entries all marked as unused. The entries
+ * will be populated as the cache is used. This is typically used to implement
+ * buffer import, with buffers added to the cache as they are queued.
+ */
+V4L2BufferCache::V4L2BufferCache(unsigned int numEntries)
+	: missCounter_(0)
+{
+	cache_.resize(numEntries);
+}
+
+/**
+ * \brief Create a pre-populated cache
+ * \param[in] buffers Array of buffers to pre-populated with
+ *
+ * Create a cache pre-populated with \a buffers. This is typically used to
+ * implement buffer export, with all buffers added to the cache when they are
+ * allocated.
+ */
+V4L2BufferCache::V4L2BufferCache(const std::vector<std::unique_ptr<FrameBuffer>> &buffers)
+	: missCounter_(0)
+{
+	for (const std::unique_ptr<FrameBuffer> &buffer : buffers)
+		cache_.emplace_back(true, buffer->planes());
+}
+
+V4L2BufferCache::~V4L2BufferCache()
+{
+	if (missCounter_ > cache_.size())
+		LOG(V4L2, Debug) << "Cache misses: " << missCounter_;
+}
+
+/**
+ * \brief Find the best V4L2 buffer for a FrameBuffer
+ * \param[in] buffer The FrameBuffer
+ *
+ * Find the best V4L2 buffer index to be used for the FrameBuffer \a buffer
+ * based on previous mappings of frame buffers to V4L2 buffers. If a free V4L2
+ * buffer previously used with the same dmabufs as \a buffer is found in the
+ * cache, return its index. Otherwise return the index of the first free V4L2
+ * buffer and record its association with the dmabufs of \a buffer.
+ *
+ * \return The index of the best V4L2 buffer, or -ENOENT if no free V4L2 buffer
+ * is available
+ */
+int V4L2BufferCache::get(const FrameBuffer &buffer)
+{
+	bool hit = false;
+	int use = -1;
+
+	for (unsigned int index = 0; index < cache_.size(); index++) {
+		const Entry &entry = cache_[index];
+
+		if (!entry.free)
+			continue;
+
+		if (use < 0)
+			use = index;
+
+		/* Try to find a cache hit by comparing the planes. */
+		if (cache_[index] == buffer) {
+			hit = true;
+			use = index;
+			break;
+		}
+	}
+
+	if (!hit)
+		missCounter_++;
+
+	if (use < 0)
+		return -ENOENT;
+
+	cache_[use] = Entry(false, buffer);
+
+	return use;
+}
+
+/**
+ * \brief Mark buffer \a index as free in the cache
+ * \param[in] index The V4L2 buffer index
+ */
+void V4L2BufferCache::put(unsigned int index)
+{
+	ASSERT(index < cache_.size());
+	cache_[index].free = true;
+}
+
+V4L2BufferCache::Entry::Entry()
+	: free(true)
+{
+}
+
+V4L2BufferCache::Entry::Entry(bool free, const FrameBuffer &buffer)
+	: free(free)
+{
+	for (const FrameBuffer::Plane &plane : buffer.planes())
+		planes_.emplace_back(plane);
+}
+
+bool V4L2BufferCache::Entry::operator==(const FrameBuffer &buffer)
+{
+	const std::vector<FrameBuffer::Plane> &planes = buffer.planes();
+
+	if (planes_.size() != planes.size())
+		return false;
+
+	for (unsigned int i = 0; i < planes.size(); i++)
+		if (planes_[i].fd != planes[i].fd.fd() ||
+		    planes_[i].length != planes[i].length)
+			return false;
+	return true;
+}
+
 /**
  * \class V4L2DeviceFormat
  * \brief The V4L2 video device image format and sizes
