diff --git a/src/libcamera/pipeline/virtual/virtual.cpp b/src/libcamera/pipeline/virtual/virtual.cpp
index f35af97b103d..13a96e6a9412 100644
--- a/src/libcamera/pipeline/virtual/virtual.cpp
+++ b/src/libcamera/pipeline/virtual/virtual.cpp
@@ -135,8 +135,14 @@ VirtualCameraData::VirtualCameraData(PipelineHandler *pipe,
 	moveToThread(this);
 }
 
-void VirtualCameraData::processRequest(Request *request)
+void VirtualCameraData::processRequest(Request *request,
+				       uint64_t targetTimestamp)
 {
+	const auto targetTime = std::chrono::steady_clock::time_point{
+		std::chrono::nanoseconds(targetTimestamp)
+	};
+	std::this_thread::sleep_until(targetTime);
+
 	for (const auto &[stream, buffer] : request->buffers()) {
 		bool found = false;
 		/* map buffer and fill test patterns */
@@ -146,7 +152,7 @@ void VirtualCameraData::processRequest(Request *request)
 
 				fmd.status = FrameMetadata::Status::FrameSuccess;
 				fmd.sequence = streamConfig.seq++;
-				fmd.timestamp = currentTimestamp();
+				fmd.timestamp = targetTimestamp;
 
 				std::span<const FrameBuffer::Plane> planes = buffer->planes();
 				for (const auto [i, p] : utils::enumerate(planes))
@@ -348,11 +354,34 @@ int PipelineHandlerVirtual::exportFrameBuffers([[maybe_unused]] Camera *camera,
 	return dmaBufAllocator_.exportBuffers(config.bufferCount, planeSizes, buffers);
 }
 
-int PipelineHandlerVirtual::start([[maybe_unused]] Camera *camera,
-				  [[maybe_unused]] const ControlList *controls)
+int PipelineHandlerVirtual::start(Camera *camera, const ControlList *controls)
 {
 	VirtualCameraData *data = cameraData(camera);
 
+	auto cameraFrameDurationLimits =
+		camera->controls().find(&controls::FrameDurationLimits);
+	ASSERT(cameraFrameDurationLimits != camera->controls().end());
+
+	/* By default target the highest frame rate allowed by the config. */
+	data->minFrameDuration_ =
+		cameraFrameDurationLimits->second.min().get<int64_t>();
+
+	if (controls) {
+		if (auto clientFrameDurationLimits =
+			    controls->get(controls::FrameDurationLimits)) {
+			/*
+			 * Target the highest frame rate allowed by the client,
+			 * clamped to the values allowed by the config.
+			 */
+			data->minFrameDuration_ = std::clamp(
+				clientFrameDurationLimits->front(),
+				data->minFrameDuration_,
+				cameraFrameDurationLimits->second.max().get<int64_t>());
+		}
+	}
+
+	data->targetTimestamp_ = 0;
+
 	for (auto &s : data->streamConfigs_)
 		s.seq = 0;
 
@@ -379,15 +408,26 @@ void PipelineHandlerVirtual::stopDevice(Camera *camera)
 		cancelRequest(data->queuedRequests_.front());
 }
 
-int PipelineHandlerVirtual::queueRequestDevice([[maybe_unused]] Camera *camera,
-					       Request *request)
+int PipelineHandlerVirtual::queueRequestDevice(Camera *camera, Request *request)
 {
 	VirtualCameraData *data = cameraData(camera);
-	const auto timestamp = currentTimestamp();
 
-	request->_d()->metadata().set(controls::SensorTimestamp, timestamp);
+	/*
+	 * TODO: Skip frames if we can't keep up with the maximum frame
+	 * duration.
+	 */
+	data->targetTimestamp_ =
+		std::max(currentTimestamp(),
+			 data->targetTimestamp_ + data->minFrameDuration_ * 1000);
+
+	request->_d()->metadata().set(controls::SensorTimestamp,
+				      data->targetTimestamp_);
+	request->_d()->metadata().set(controls::FrameDuration,
+				      data->minFrameDuration_);
+
 	data->invokeMethod(&VirtualCameraData::processRequest,
-			   ConnectionTypeQueued, request);
+			   ConnectionTypeQueued, request,
+			   data->targetTimestamp_);
 
 	return 0;
 }
diff --git a/src/libcamera/pipeline/virtual/virtual.h b/src/libcamera/pipeline/virtual/virtual.h
index 215e56fa3b9d..40e9095c0c4f 100644
--- a/src/libcamera/pipeline/virtual/virtual.h
+++ b/src/libcamera/pipeline/virtual/virtual.h
@@ -60,12 +60,15 @@ public:
 
 	~VirtualCameraData() = default;
 
-	void processRequest(Request *request);
+	void processRequest(Request *request, uint64_t targetTimestamp);
 
 	Configuration config_;
 
 	std::vector<StreamConfig> streamConfigs_;
 	Signal<FrameBuffer *> bufferCompleted;
+
+	int64_t minFrameDuration_;
+	uint64_t targetTimestamp_;
 };
 
 } /* namespace libcamera */
