feat(server)!: add bounded deferred (long) polling (#4629)
diff --git a/.claude/skills/connector-runtime/SKILL.md b/.claude/skills/connector-runtime/SKILL.md
index b764c31..68a3cc4 100644
--- a/.claude/skills/connector-runtime/SKILL.md
+++ b/.claude/skills/connector-runtime/SKILL.md
@@ -133,7 +133,7 @@
 
 1. Build / ensure consumer group `iggy-connect-sink-{key}` (`connect` matches the docker image name `apache/iggy-connect` - see `runtime/src/sink.rs::default_consumer_group`). Overridable via the stream config's `consumer_group`.
 2. Spawn one task per topic (`spawn_consume_tasks`).
-3. Poll Iggy → batch messages (default `batch_length = 1000`, `poll_interval = 5ms`). A `consumer.next()` `Err` (transport/decode at the Iggy client boundary) bumps `errors` and `continue`s - offset already auto-committed via `AutoCommit::When(AutoCommitWhen::PollingMessages)`, message effectively dropped.
+3. Poll Iggy → batch messages (default `batch_length = 1000`). A `consumer.next()` `Err` (transport/decode at the Iggy client boundary) bumps `errors` and `continue`s. A rejected poll committed nothing, so the consumer retries the same offset. Polls commit via `AutoCommit::When(AutoCommitWhen::PollingMessages)`, so only a reply lost after the server committed can skip its batch.
 4. Decode via `Schema::decoder()`. Decode failure logs + bumps `errors`. `messages_filtered` is reserved for the transform filter contract (`Ok(None)`).
 5. Apply transforms (chain). A transform `Err` is logged, bumps `errors`, and drops only that message (drop-and-continue, mirroring the source) - one bad payload never kills the batch. `Ok(None)` filters the message - bumps `messages_filtered{connector_type="sink"}`.
 6. Postcard-encode batch as `RawMessages`. Headers as a separate postcard blob. Per-message failures (missing field, payload conversion, header serialization) bump `errors` + skip. Batch-level failures (postcard serialization of metadata / `RawMessages`) propagate `Err` -> `spawn_consume_tasks` wrapper bumps `errors` + `set_error`. The total histogram and benchmark emit still fire for the failed batch before the `Err` propagates.
@@ -146,8 +146,9 @@
 
 Gotchas:
 
-- Batch flushes on `current_offset != message_offset` (gap) OR batch full - higher latency for low-volume topics.
-- Auto-commit happens on poll - idempotency at the sink protects against re-delivery on crash before plugin write.
+- Batch flushes when a message is the last one in its partition (`message_offset == current_offset`), when the batch is full, OR before a message of another partition, because the plugin gets one partition id per batch. `max_wait` and `min_count` on the stream make the server collect larger replies.
+- Auto-commit happens on poll, so sink delivery is at-most-once: a crash after the poll and before the plugin write loses that batch.
+- A stop still flushes the batch the loop holds, and the task then awaits `IggyConsumer::shutdown`, which rewinds over the batches the consumer holds. Without either step, the next run skips messages that the polls committed.
 
 ## Logging format
 
diff --git a/Cargo.lock b/Cargo.lock
index cefd525..5e2e889 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7089,7 +7089,7 @@
 
 [[package]]
 name = "iggy"
-version = "0.11.1-edge.4"
+version = "0.11.1-edge.5"
 dependencies = [
  "async-broadcast",
  "async-dropper",
@@ -7127,7 +7127,7 @@
 
 [[package]]
 name = "iggy-bench"
-version = "0.6.1-edge.3"
+version = "0.6.1-edge.4"
 dependencies = [
  "async-trait",
  "bench-report",
@@ -7143,6 +7143,7 @@
  "hostname",
  "human-repr",
  "iggy",
+ "iggy_binary_protocol",
  "nonzero_lit",
  "rand 0.10.3",
  "rayon",
@@ -7185,7 +7186,7 @@
 
 [[package]]
 name = "iggy-cli"
-version = "0.14.1-edge.3"
+version = "0.14.1-edge.4"
 dependencies = [
  "anyhow",
  "apple-native-keyring-store",
@@ -7219,7 +7220,7 @@
 
 [[package]]
 name = "iggy-connectors"
-version = "0.5.1-edge.3"
+version = "0.5.1-edge.4"
 dependencies = [
  "apache-avro 0.22.0",
  "async-trait",
@@ -7319,7 +7320,7 @@
 
 [[package]]
 name = "iggy-mcp"
-version = "0.5.1-edge.3"
+version = "0.5.1-edge.4"
 dependencies = [
  "axum",
  "axum-server",
@@ -7354,7 +7355,7 @@
 
 [[package]]
 name = "iggy_binary_protocol"
-version = "0.11.1-edge.3"
+version = "0.11.1-edge.4"
 dependencies = [
  "aligned-vec",
  "bytemuck",
@@ -7369,7 +7370,7 @@
 
 [[package]]
 name = "iggy_common"
-version = "0.11.1-edge.4"
+version = "0.11.1-edge.5"
 dependencies = [
  "aes-gcm 0.11.1",
  "aes-gcm-siv",
@@ -12982,7 +12983,7 @@
 
 [[package]]
 name = "server"
-version = "0.9.1-edge.3"
+version = "0.9.1-edge.4"
 dependencies = [
  "ahash",
  "arc-swap",
diff --git a/Cargo.toml b/Cargo.toml
index f9528eb..29542a9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -213,11 +213,11 @@
 iceberg = "0.10.1"
 iceberg-catalog-rest = "0.10.1"
 iceberg-storage-opendal = "0.10.1"
-iggy = { path = "core/sdk", version = "0.11.1-edge.4" }
-iggy-cli = { path = "core/cli", version = "0.14.1-edge.3" }
+iggy = { path = "core/sdk", version = "0.11.1-edge.5" }
+iggy-cli = { path = "core/cli", version = "0.14.1-edge.4" }
 iggy-gateway-kafka = { path = "gateways/kafka" }
-iggy_binary_protocol = { path = "core/binary_protocol", version = "0.11.1-edge.3" }
-iggy_common = { path = "core/common", version = "0.11.1-edge.4" }
+iggy_binary_protocol = { path = "core/binary_protocol", version = "0.11.1-edge.4" }
+iggy_common = { path = "core/common", version = "0.11.1-edge.5" }
 iggy_connector_doris_sink = { path = "core/connectors/sinks/doris_sink" }
 iggy_connector_sdk = { path = "core/connectors/sdk", version = "0.5.1-edge.2" }
 indexmap = "2.14.2"
diff --git a/README.md b/README.md
index 871cd18..11c6ebe 100644
--- a/README.md
+++ b/README.md
@@ -198,7 +198,6 @@
 topics = ["records"]
 schema = "json"
 batch_length = 1000
-poll_interval = "5ms"
 consumer_group = "qw_sink_connector"
 
 [transforms.add_fields]
@@ -482,7 +481,6 @@
     .create_consumer_group_if_not_exists()
     .auto_join_consumer_group()
     .polling_strategy(PollingStrategy::next())
-    .poll_interval(IggyDuration::from_str("1ms")?)
     .batch_length(1000)
     .build();
 
diff --git a/bdd/python/uv.lock b/bdd/python/uv.lock
index 10daa23..1133a8f 100644
--- a/bdd/python/uv.lock
+++ b/bdd/python/uv.lock
@@ -8,7 +8,7 @@
 
 [[package]]
 name = "apache-iggy"
-version = "0.9.1.dev4"
+version = "0.9.1.dev5"
 source = { directory = "../../foreign/python" }
 
 [package.metadata]
diff --git a/core/ai/mcp/Cargo.toml b/core/ai/mcp/Cargo.toml
index 763484a..03560c6 100644
--- a/core/ai/mcp/Cargo.toml
+++ b/core/ai/mcp/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy-mcp"
-version = "0.5.1-edge.3"
+version = "0.5.1-edge.4"
 description = "MCP Server for Iggy message streaming platform"
 edition = "2024"
 license = "Apache-2.0"
diff --git a/core/bench/Cargo.toml b/core/bench/Cargo.toml
index 577084e..629f32a 100644
--- a/core/bench/Cargo.toml
+++ b/core/bench/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy-bench"
-version = "0.6.1-edge.3"
+version = "0.6.1-edge.4"
 edition = "2024"
 license = "Apache-2.0"
 repository = "https://github.com/apache/iggy"
@@ -46,6 +46,7 @@
 hostname = { workspace = true }
 human-repr = { workspace = true }
 iggy = { workspace = true }
+iggy_binary_protocol = { workspace = true }
 nonzero_lit = { workspace = true }
 rand = { workspace = true }
 rayon = { workspace = true }
diff --git a/core/bench/src/actors/consumer/benchmark_consumer.rs b/core/bench/src/actors/consumer/benchmark_consumer.rs
index de12aa9..8e9884a 100644
--- a/core/bench/src/actors/consumer/benchmark_consumer.rs
+++ b/core/bench/src/actors/consumer/benchmark_consumer.rs
@@ -483,6 +483,7 @@
                 messages_per_batch: args.messages_per_batch(),
                 warmup_time: args.warmup_time(),
                 polling_kind: PollingKind::Next,
+                poll_options: DeferredPollOptions::default(),
                 origin_timestamp_latency_calculation,
                 pretty: false,
             },
diff --git a/core/bench/src/actors/consumer/client/high_level.rs b/core/bench/src/actors/consumer/client/high_level.rs
index e4e5b32..4b08343 100644
--- a/core/bench/src/actors/consumer/client/high_level.rs
+++ b/core/bench/src/actors/consumer/client/high_level.rs
@@ -26,11 +26,12 @@
 use crate::utils::ClientFactory;
 use futures_util::StreamExt;
 use iggy::prelude::*;
-use std::{sync::Arc, time::Duration};
+use std::sync::Arc;
 use tokio::time::{Instant, timeout};
 use tracing::{error, warn};
 
 const TOPIC_ID: &str = "topic-1";
+const PREFETCH_BATCHES: u32 = 16;
 
 pub struct HighLevelConsumerClient {
     client_factory: Arc<dyn ClientFactory>,
@@ -81,21 +82,41 @@
         };
         Ok(builder
             .batch_length(self.config.messages_per_batch.get())
+            .poll_options(self.config.poll_options)
+            .prefetch_messages(
+                self.config
+                    .messages_per_batch
+                    .get()
+                    .saturating_mul(PREFETCH_BATCHES),
+            )
+            .prefetch_bytes(
+                self.config
+                    .poll_options
+                    .max_bytes
+                    .saturating_mul(PREFETCH_BATCHES),
+            )
             .build())
     }
 }
 
 impl ConsumerClient for HighLevelConsumerClient {
+    #[expect(
+        clippy::significant_drop_tightening,
+        reason = "The borrowed consumer retains reservations until its buffered messages are drained."
+    )]
     async fn consume_batch(&mut self) -> Result<Option<BatchMetrics>, IggyError> {
         let consumer = self.consumer.as_mut().expect("Consumer not initialized");
 
+        // One poll can wait up to its request timeout, so a shorter timeout would restart the
+        // batch clock in the middle of a deliberate wait.
+        let poll_timeout = self.config.poll_options.request_timeout.get_duration();
         let batch_start = Instant::now();
         let mut batch_messages = 0;
         let mut batch_user_bytes = 0;
         let mut batch_total_bytes = 0;
 
         while batch_messages < self.config.messages_per_batch.get() {
-            let timeout_result = timeout(Duration::from_secs(1), consumer.next()).await;
+            let timeout_result = timeout(poll_timeout, consumer.next()).await;
 
             match timeout_result {
                 Ok(Some(message_result)) => match message_result {
@@ -141,6 +162,10 @@
         }
     }
 
+    #[expect(
+        clippy::significant_drop_tightening,
+        reason = "The replacement must finish initialization before taking over the consumer slot"
+    )]
     async fn reset_offsets(&mut self) -> Result<(), IggyError> {
         let client = self.client.as_deref().expect("Client not initialized");
 
@@ -157,6 +182,10 @@
 }
 
 impl BenchmarkInit for HighLevelConsumerClient {
+    #[expect(
+        clippy::significant_drop_tightening,
+        reason = "The initialized consumer moves into self and must retain its prefetch reservations."
+    )]
     async fn setup(&mut self) -> Result<(), IggyError> {
         let client = self.client_factory.create_authenticated_client().await?;
         let mut consumer = self.build_consumer(&client, PollingStrategy::next())?;
diff --git a/core/bench/src/actors/consumer/client/interface.rs b/core/bench/src/actors/consumer/client/interface.rs
index 4f0c15c..6a0e446 100644
--- a/core/bench/src/actors/consumer/client/interface.rs
+++ b/core/bench/src/actors/consumer/client/interface.rs
@@ -28,6 +28,7 @@
     pub messages_per_batch: BenchmarkNumericParameter,
     pub warmup_time: IggyDuration,
     pub polling_kind: PollingKind,
+    pub poll_options: DeferredPollOptions,
     pub origin_timestamp_latency_calculation: bool,
     pub pretty: bool,
 }
diff --git a/core/bench/src/actors/consumer/client/low_level.rs b/core/bench/src/actors/consumer/client/low_level.rs
index 8adae55..b9a651c 100644
--- a/core/bench/src/actors/consumer/client/low_level.rs
+++ b/core/bench/src/actors/consumer/client/low_level.rs
@@ -75,7 +75,7 @@
         };
         let before_poll = Instant::now();
         let polled = client
-            .poll_messages_with_strategy_for(
+            .poll_messages_with_strategy_for_and_options(
                 &self.stream_id,
                 &self.topic_id,
                 self.partition_id,
@@ -83,6 +83,7 @@
                 &strategy_for,
                 messages_to_receive,
                 self.auto_commit,
+                Some(self.config.poll_options),
             )
             .await;
 
@@ -189,8 +190,8 @@
     use crate::utils::client_factory::TcpClientFactory;
     use bench_report::numeric_parameter::BenchmarkNumericParameter;
     use iggy::prelude::{
-        ClientWrapper, Consumer, Identifier, IggyClient, IggyDuration, PollingStrategy, TcpClient,
-        TcpClientConfig,
+        ClientWrapper, Consumer, DeferredPollOptions, Identifier, IggyClient, IggyDuration,
+        PollingStrategy, TcpClient, TcpClientConfig,
     };
     use std::{collections::HashMap, sync::Arc};
 
@@ -213,6 +214,7 @@
                     messages_per_batch: BenchmarkNumericParameter::Value(1),
                     warmup_time: IggyDuration::default(),
                     polling_kind: polling_strategy.kind,
+                    poll_options: DeferredPollOptions::default(),
                     origin_timestamp_latency_calculation: false,
                     pretty: false,
                 },
diff --git a/core/bench/src/actors/consumer/typed_benchmark_consumer.rs b/core/bench/src/actors/consumer/typed_benchmark_consumer.rs
index a7aa1fa..dc16eb3 100644
--- a/core/bench/src/actors/consumer/typed_benchmark_consumer.rs
+++ b/core/bench/src/actors/consumer/typed_benchmark_consumer.rs
@@ -54,6 +54,7 @@
         sampling_time: IggyDuration,
         moving_average_window: u32,
         polling_kind: PollingKind,
+        poll_options: DeferredPollOptions,
         limit_bytes_per_second: Option<IggyByteSize>,
         origin_timestamp_latency_calculation: bool,
         pretty: bool,
@@ -65,6 +66,7 @@
             messages_per_batch,
             warmup_time,
             polling_kind,
+            poll_options,
             origin_timestamp_latency_calculation,
             pretty,
         };
diff --git a/core/bench/src/actors/producing_consumer/typed_benchmark_producing_consumer.rs b/core/bench/src/actors/producing_consumer/typed_benchmark_producing_consumer.rs
index 280d714..93bacc8 100644
--- a/core/bench/src/actors/producing_consumer/typed_benchmark_producing_consumer.rs
+++ b/core/bench/src/actors/producing_consumer/typed_benchmark_producing_consumer.rs
@@ -40,8 +40,8 @@
 use iggy::prelude::*;
 
 pub enum TypedBenchmarkProducingConsumer {
-    High(BenchmarkProducingConsumer<HighLevelProducerClient, HighLevelConsumerClient>),
-    Low(BenchmarkProducingConsumer<LowLevelProducerClient, LowLevelConsumerClient>),
+    High(Box<BenchmarkProducingConsumer<HighLevelProducerClient, HighLevelConsumerClient>>),
+    Low(Box<BenchmarkProducingConsumer<LowLevelProducerClient, LowLevelConsumerClient>>),
 }
 impl TypedBenchmarkProducingConsumer {
     #[allow(clippy::too_many_arguments)]
@@ -62,6 +62,7 @@
         moving_average_window: u32,
         limit_bytes_per_second: Option<IggyByteSize>,
         polling_kind: PollingKind,
+        poll_options: DeferredPollOptions,
         origin_timestamp_latency_calculation: bool,
         pretty: bool,
     ) -> Self {
@@ -82,6 +83,7 @@
             messages_per_batch,
             warmup_time,
             polling_kind,
+            poll_options,
             origin_timestamp_latency_calculation,
             pretty,
         };
@@ -90,7 +92,7 @@
             let producer =
                 HighLevelProducerClient::new(client_factory.clone(), producer_config.clone());
             let consumer = HighLevelConsumerClient::new(client_factory, consumer_config.clone());
-            Self::High(BenchmarkProducingConsumer::new(
+            Self::High(Box::new(BenchmarkProducingConsumer::new(
                 producer,
                 consumer,
                 benchmark_kind,
@@ -101,12 +103,12 @@
                 limit_bytes_per_second,
                 producer_config,
                 consumer_config,
-            ))
+            )))
         } else {
             let producer =
                 LowLevelProducerClient::new(client_factory.clone(), producer_config.clone());
             let consumer = LowLevelConsumerClient::new(client_factory, consumer_config.clone());
-            Self::Low(BenchmarkProducingConsumer::new(
+            Self::Low(Box::new(BenchmarkProducingConsumer::new(
                 producer,
                 consumer,
                 benchmark_kind,
@@ -117,7 +119,7 @@
                 limit_bytes_per_second,
                 producer_config,
                 consumer_config,
-            ))
+            )))
         }
     }
 
diff --git a/core/bench/src/args/common.rs b/core/bench/src/args/common.rs
index 968b51e..0af82cd 100644
--- a/core/bench/src/args/common.rs
+++ b/core/bench/src/args/common.rs
@@ -30,11 +30,18 @@
 use clap::error::ErrorKind;
 use clap::{CommandFactory, Parser};
 use iggy::prelude::{
-    DEFAULT_ROOT_PASSWORD, DEFAULT_ROOT_USERNAME, IggyByteSize, IggyDuration, IggyExpiry,
-    TransportProtocol,
+    DEFAULT_POLL_MAX_BYTES, DEFAULT_ROOT_PASSWORD, DEFAULT_ROOT_USERNAME, DeferredPollOptions,
+    IggyByteSize, IggyDuration, IggyError, IggyExpiry, TransportProtocol,
 };
+use iggy_binary_protocol::batch::{BATCH_HEADER_SIZE, BATCH_MESSAGE_HEADER_SIZE};
+use iggy_binary_protocol::requests::messages::{
+    MAX_DEFERRED_POLL_TIMEOUT_US, MAX_DEFERRED_POLL_WAIT_US, MIN_DEFERRED_POLL_MAX_BYTES,
+    MIN_DEFERRED_POLL_REPLY_MARGIN_US,
+};
+use iggy_binary_protocol::responses::messages::poll_messages::POLL_RESPONSE_HEADER_SIZE;
 use std::num::NonZeroU32;
 use std::str::FromStr;
+use std::time::Duration;
 
 #[derive(Parser, Debug)]
 #[command(author, version, about, long_about = None)]
@@ -52,6 +59,23 @@
     #[arg(long, short = 'P', value_parser = BenchmarkNumericParameter::from_str, default_value_t = BenchmarkNumericParameter::Value(DEFAULT_MESSAGES_PER_BATCH.get()))]
     pub messages_per_batch: BenchmarkNumericParameter,
 
+    /// Maximum deliberate wait for the readiness minimum.
+    #[arg(long, default_value_t = IggyDuration::ONE_SECOND)]
+    pub max_wait: IggyDuration,
+
+    /// Readiness minimum, independent of the maximum messages per batch.
+    #[arg(long, default_value_t = 1)]
+    pub min_count: u32,
+
+    /// Maximum encoded poll reply bytes, including response and batch headers.
+    /// Defaults to 1 MiB, or to one full batch when that is larger.
+    #[arg(long)]
+    pub max_bytes: Option<u32>,
+
+    /// Overall budget for routing, readiness, I/O and response handling.
+    #[arg(long, default_value_t = DeferredPollOptions::default().request_timeout)]
+    pub request_timeout: IggyDuration,
+
     /// Number of message batches per actor (producer / consumer / producing consumer).
     /// This argument is mutually exclusive with `total_messages_size`.
     #[arg(long, short = 'b', group = "data_to_process")]
@@ -118,6 +142,60 @@
 }
 
 impl IggyBenchArgs {
+    pub fn poll_options(&self) -> DeferredPollOptions {
+        DeferredPollOptions {
+            max_wait: self.max_wait,
+            min_count: self.min_count,
+            max_bytes: self
+                .max_bytes
+                .unwrap_or_else(|| self.batch_reply_bytes().max(DEFAULT_POLL_MAX_BYTES)),
+            request_timeout: self.request_timeout,
+        }
+    }
+
+    /// Reply bytes of the largest batch of the largest messages. A poll that starts inside a
+    /// stored batch reads one more stored batch than the batches it covers.
+    fn batch_reply_bytes(&self) -> u32 {
+        let messages = u64::from(self.messages_per_batch.max());
+        let stored_batches = messages.div_ceil(u64::from(self.messages_per_batch.min())) + 1;
+        let bytes = POLL_RESPONSE_HEADER_SIZE as u64
+            + stored_batches * BATCH_HEADER_SIZE as u64
+            + messages * (BATCH_MESSAGE_HEADER_SIZE as u64 + u64::from(self.message_size.max()));
+        u32::try_from(bytes).unwrap_or(u32::MAX)
+    }
+
+    fn poll_options_error(&self, error: &IggyError) -> String {
+        let duration = |micros| IggyDuration::from(Duration::from_micros(micros));
+        match error {
+            IggyError::InvalidMessagesCount => format!(
+                "--min-count {} must be in 1..={}, the smallest --messages-per-batch",
+                self.min_count,
+                self.messages_per_batch.min()
+            ),
+            IggyError::InvalidCommand => format!(
+                "--max-wait {} must be at most {}, and --request-timeout {} must be at least --max-wait plus {} and at most {}",
+                self.max_wait,
+                duration(MAX_DEFERRED_POLL_WAIT_US),
+                self.request_timeout,
+                duration(MIN_DEFERRED_POLL_REPLY_MARGIN_US),
+                duration(MAX_DEFERRED_POLL_TIMEOUT_US)
+            ),
+            IggyError::InvalidSizeBytes => format!(
+                "--max-bytes {} must be at least {MIN_DEFERRED_POLL_MAX_BYTES}",
+                self.poll_options().max_bytes
+            ),
+            error => error.to_string(),
+        }
+    }
+
+    /// Producer-only kinds never poll, so their names leave out the poll options.
+    const fn polls(&self) -> bool {
+        !matches!(
+            self.benchmark_kind,
+            BenchmarkKindCommand::PinnedProducer(_) | BenchmarkKindCommand::BalancedProducer(_)
+        )
+    }
+
     pub fn transport_command(&self) -> &BenchmarkTransportCommand {
         self.benchmark_kind.transport_command()
     }
@@ -138,6 +216,19 @@
     }
 
     pub fn validate(&mut self) {
+        if let Err(error) = self.poll_options().validate(self.messages_per_batch.min()) {
+            Self::command()
+                .error(ErrorKind::ValueValidation, self.poll_options_error(&error))
+                .exit();
+        }
+        if let Some(max_bytes) = self.max_bytes
+            && max_bytes < self.batch_reply_bytes()
+        {
+            println!(
+                "Warning: --max-bytes {max_bytes} holds less than one batch ({} B), so polls return partial batches.",
+                self.batch_reply_bytes()
+            );
+        }
         if self.output_dir().is_none()
             && (self.gitref().is_some()
                 || self.identifier().is_some()
@@ -411,6 +502,16 @@
             transport.to_string(),
         ];
 
+        if self.polls() {
+            parts.push(format!(
+                "wait{}_min{}_bytes{}_timeout{}",
+                self.max_wait,
+                self.min_count,
+                self.poll_options().max_bytes,
+                self.request_timeout
+            ));
+        }
+
         if let Some(remark) = &self.remark() {
             parts.push(remark.clone());
         }
@@ -462,6 +563,16 @@
             self.messages_per_batch(),
         );
 
+        if self.polls() {
+            name = format!(
+                "{name}, wait {}, min {}, max {}B, timeout {}",
+                self.max_wait,
+                self.min_count,
+                self.poll_options().max_bytes,
+                self.request_timeout
+            );
+        }
+
         if let Some(remark) = &self.remark() {
             name = format!("{name} ({remark})");
         }
@@ -469,3 +580,66 @@
         name
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn parse(arguments: &[&str]) -> IggyBenchArgs {
+        parse_kind("pinned-consumer", arguments)
+    }
+
+    fn parse_kind(kind: &str, arguments: &[&str]) -> IggyBenchArgs {
+        let mut command = vec!["iggy-bench"];
+        command.extend_from_slice(arguments);
+        command.extend_from_slice(&[kind, "tcp"]);
+        IggyBenchArgs::try_parse_from(command).unwrap()
+    }
+
+    fn poll_error(arguments: &[&str]) -> String {
+        let args = parse(arguments);
+        let error = args
+            .poll_options()
+            .validate(args.messages_per_batch.min())
+            .unwrap_err();
+        args.poll_options_error(&error)
+    }
+
+    #[test]
+    fn default_max_bytes_should_fit_one_full_batch() {
+        assert_eq!(parse(&[]).poll_options().max_bytes, DEFAULT_POLL_MAX_BYTES);
+
+        let larger = parse(&["-m", "1024"]);
+        let max_bytes = larger.poll_options().max_bytes;
+        assert!(max_bytes > DEFAULT_POLL_MAX_BYTES);
+        assert!(u64::from(max_bytes) >= 1000 * (BATCH_MESSAGE_HEADER_SIZE as u64 + 1024));
+
+        let explicit = parse(&["-m", "1024", "--max-bytes", "4096"]);
+        assert_eq!(explicit.poll_options().max_bytes, 4096);
+    }
+
+    #[test]
+    fn poll_option_errors_should_name_the_flag() {
+        assert!(poll_error(&["-P", "10", "--min-count", "11"]).starts_with("--min-count 11 "));
+        assert!(
+            poll_error(&["--max-wait", "5s", "--request-timeout", "5s"])
+                .contains("--request-timeout 5s must be at least --max-wait plus 1s")
+        );
+        assert!(poll_error(&["--max-bytes", "1"]).starts_with("--max-bytes 1 "));
+    }
+
+    #[test]
+    fn producer_only_names_should_leave_out_poll_options() {
+        let producer = parse_kind("pinned-producer", &["-b", "10"]);
+        assert!(!producer.generate_dir_name().contains("_wait"));
+        assert!(!producer.generate_pretty_name().contains("wait"));
+
+        let consumer = parse(&["-b", "10"]);
+        assert!(consumer.generate_dir_name().contains("_wait1s_min1_"));
+        assert!(
+            consumer
+                .generate_pretty_name()
+                .contains(", wait 1s, min 1,")
+        );
+    }
+}
diff --git a/core/bench/src/benchmarks/common.rs b/core/bench/src/benchmarks/common.rs
index a941e39..2b00e39 100644
--- a/core/bench/src/benchmarks/common.rs
+++ b/core/bench/src/benchmarks/common.rs
@@ -167,6 +167,7 @@
     let moving_average_window = args.moving_average_window();
     let kind = args.kind();
     let pretty = args.pretty;
+    let poll_options = args.poll_options();
     let polling_kind = if cg_count > 0 {
         PollingKind::Next
     } else {
@@ -235,6 +236,7 @@
                     sampling_time,
                     moving_average_window,
                     polling_kind,
+                    poll_options,
                     rate_limit,
                     origin_timestamp_latency_calculation,
                     pretty,
@@ -299,6 +301,7 @@
                     args_clone.moving_average_window(),
                     rate_limit,
                     polling_kind,
+                    args_clone.poll_options(),
                     origin_timestamp_latency_calculation,
                     args_clone.pretty,
                 );
@@ -402,6 +405,7 @@
                     args_clone.moving_average_window(),
                     rate_limit,
                     polling_kind,
+                    args_clone.poll_options(),
                     origin_timestamp_latency_calculation,
                     args_clone.pretty,
                 );
diff --git a/core/bench/src/utils/mod.rs b/core/bench/src/utils/mod.rs
index 2b9a1c5..4fca179 100644
--- a/core/bench/src/utils/mod.rs
+++ b/core/bench/src/utils/mod.rs
@@ -248,6 +248,20 @@
     if args.warmup_time().to_string() != DEFAULT_WARMUP_TIME {
         parts.push(format!("--warmup-time \'{}\'", args.warmup_time()));
     }
+
+    let poll_defaults = DeferredPollOptions::default();
+    if args.max_wait != poll_defaults.max_wait {
+        parts.push(format!("--max-wait \'{}\'", args.max_wait));
+    }
+    if args.min_count != poll_defaults.min_count {
+        parts.push(format!("--min-count {}", args.min_count));
+    }
+    if let Some(max_bytes) = args.max_bytes {
+        parts.push(format!("--max-bytes {max_bytes}"));
+    }
+    if args.request_timeout != poll_defaults.request_timeout {
+        parts.push(format!("--request-timeout \'{}\'", args.request_timeout));
+    }
 }
 
 fn add_benchmark_kind_arguments(parts: &mut Vec<String>, args: &IggyBenchArgs) {
@@ -419,6 +433,48 @@
     }
 
     #[test]
+    fn reproduced_commands_preserve_poll_options() {
+        let original = IggyBenchArgs::try_parse_from([
+            "iggy-bench",
+            "--max-wait",
+            "1m 30s",
+            "--min-count",
+            "100",
+            "--max-bytes",
+            "65536",
+            "--request-timeout",
+            "2m",
+            "pinned-consumer",
+            "tcp",
+        ])
+        .unwrap();
+        let command = recreate_bench_command(&original);
+        let reproduced = IggyBenchArgs::try_parse_from(shell_words(&command)).unwrap();
+        assert_eq!(
+            reproduced.poll_options(),
+            original.poll_options(),
+            "{command}"
+        );
+    }
+
+    /// Splits on spaces outside single quotes, as a shell does for these commands.
+    fn shell_words(command: &str) -> Vec<String> {
+        let mut words = vec![String::new()];
+        let mut quoted = false;
+        for character in command.chars() {
+            match character {
+                '\'' => quoted = !quoted,
+                ' ' if !quoted => words.push(String::new()),
+                character => words.last_mut().unwrap().push(character),
+            }
+        }
+        words
+            .into_iter()
+            .skip_while(|word| word != "iggy-bench")
+            .collect()
+    }
+
+    #[test]
     fn reproduced_websocket_commands_preserve_independent_topic_policies() {
         for messages in ["replicated", "persisted"] {
             for offsets in ["replicated", "persisted"] {
diff --git a/core/binary_protocol/Cargo.toml b/core/binary_protocol/Cargo.toml
index ee64d47..316e5e3 100644
--- a/core/binary_protocol/Cargo.toml
+++ b/core/binary_protocol/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy_binary_protocol"
-version = "0.11.1-edge.3"
+version = "0.11.1-edge.4"
 description = "Wire protocol types and codec for the Iggy binary protocol. Shared between server and SDK."
 edition = "2024"
 rust-version.workspace = true
diff --git a/core/binary_protocol/src/codes.rs b/core/binary_protocol/src/codes.rs
index ac84481..d2ff3ac 100644
--- a/core/binary_protocol/src/codes.rs
+++ b/core/binary_protocol/src/codes.rs
@@ -61,6 +61,14 @@
 /// The route of one partition: its incarnation, the group view and the primary
 /// of that view. Sends, polls and consumer offsets all route with it.
 pub const GET_PARTITION_ROUTE_CODE: u32 = 105;
+pub const POLL_MESSAGES_DEFERRED_CODE: u32 = 106;
+pub const WAIT_FOR_MESSAGES_CODE: u32 = 107;
+/// Cancels a deferred request on the same authenticated connection.
+///
+/// The body is empty, `request` identifies the original request, and no reply
+/// is sent. A connection that is not authenticated gets an `Unauthenticated`
+/// deny instead.
+pub const CANCEL_DEFERRED_REQUEST_CODE: u32 = 108;
 
 // -- Consumer Offsets --
 pub const GET_CONSUMER_OFFSET_CODE: u32 = 120;
@@ -144,6 +152,9 @@
         LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE,
         POLL_MESSAGES_CODE,
         GET_PARTITION_ROUTE_CODE,
+        POLL_MESSAGES_DEFERRED_CODE,
+        WAIT_FOR_MESSAGES_CODE,
+        CANCEL_DEFERRED_REQUEST_CODE,
         SEND_MESSAGES_CODE,
         GET_CONSUMER_OFFSET_CODE,
         STORE_CONSUMER_OFFSET_CODE,
diff --git a/core/binary_protocol/src/consensus/request.rs b/core/binary_protocol/src/consensus/request.rs
index 02a8446..b6576db 100644
--- a/core/binary_protocol/src/consensus/request.rs
+++ b/core/binary_protocol/src/consensus/request.rs
@@ -15,7 +15,10 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::codes::{LOGIN_REGISTER_CODE, LOGIN_REGISTER_WITH_PAT_CODE, LOGOUT_USER_CODE};
+use crate::codes::{
+    CANCEL_DEFERRED_REQUEST_CODE, LOGIN_REGISTER_CODE, LOGIN_REGISTER_WITH_PAT_CODE,
+    LOGOUT_USER_CODE, POLL_MESSAGES_DEFERRED_CODE, WAIT_FOR_MESSAGES_CODE,
+};
 use crate::consensus::{Command, HEADER_SIZE, Operation, RequestHeader};
 use crate::error::WireError;
 use ring::rand::{SecureRandom, SystemRandom};
@@ -42,6 +45,29 @@
 }
 
 impl RequestHeader {
+    /// A fire-and-forget cancellation for this deferred request. It must follow
+    /// the original frame on the same connection, so the server sees the request
+    /// queued or admitted when the cancellation arrives.
+    #[must_use]
+    pub fn deferred_cancellation(&self) -> Option<Self> {
+        if self.operation != Operation::NonReplicated
+            || !matches!(
+                u32::from_le_bytes(self.reserved),
+                POLL_MESSAGES_DEFERRED_CODE | WAIT_FOR_MESSAGES_CODE
+            )
+        {
+            return None;
+        }
+        Self::for_request(
+            CANCEL_DEFERRED_REQUEST_CODE,
+            self.client,
+            self.request,
+            self.session,
+            &[],
+        )
+        .ok()
+    }
+
     /// Builds the header a client sends for `code` with `payload`, given the
     /// identity the session assigned: `client`, `request` and `session`.
     ///
diff --git a/core/binary_protocol/src/dispatch.rs b/core/binary_protocol/src/dispatch.rs
index b5106cb..cece30c 100644
--- a/core/binary_protocol/src/dispatch.rs
+++ b/core/binary_protocol/src/dispatch.rs
@@ -189,6 +189,10 @@
     ),
     // Change log of committed metadata operations
     CommandMeta::non_replicated(GET_METADATA_CHANGES_CODE, "metadata.changes"),
+    // Deferred messages
+    CommandMeta::non_replicated(POLL_MESSAGES_DEFERRED_CODE, "message.poll_deferred"),
+    CommandMeta::non_replicated(WAIT_FOR_MESSAGES_CODE, "message.wait"),
+    CommandMeta::non_replicated(CANCEL_DEFERRED_REQUEST_CODE, "message.cancel_deferred"),
 ];
 
 /// Lookup command metadata by command code.
@@ -249,6 +253,9 @@
         GET_PARTITION_ROUTE_CODE => 46,
         UPDATE_RESOURCE_CODE => 47,
         GET_METADATA_CHANGES_CODE => 48,
+        POLL_MESSAGES_DEFERRED_CODE => 49,
+        WAIT_FOR_MESSAGES_CODE => 50,
+        CANCEL_DEFERRED_REQUEST_CODE => 51,
         _ => return None,
     };
     Some(&COMMAND_TABLE[idx])
diff --git a/core/binary_protocol/src/requests/messages/mod.rs b/core/binary_protocol/src/requests/messages/mod.rs
index 434c4bb..befceaa 100644
--- a/core/binary_protocol/src/requests/messages/mod.rs
+++ b/core/binary_protocol/src/requests/messages/mod.rs
@@ -18,7 +18,13 @@
 pub mod get_partition_route;
 pub mod poll_messages;
 pub mod send_messages;
+pub mod wait_for_messages;
 
 pub use get_partition_route::GetPartitionRouteRequest;
-pub use poll_messages::PollMessagesRequest;
+pub use poll_messages::{
+    DeferredPollMessagesRequest, MAX_DEFERRED_POLL_TIMEOUT_US, MAX_DEFERRED_POLL_WAIT_US,
+    MIN_DEFERRED_POLL_MAX_BYTES, MIN_DEFERRED_POLL_REPLY_MARGIN_US, PollMessagesRequest,
+    deferred_timeout_covers_wait,
+};
 pub use send_messages::{RawMessage, SendMessagesEncoder, SendMessagesHeader};
+pub use wait_for_messages::{WaitForMessagesRequest, WaitForMessagesTarget};
diff --git a/core/binary_protocol/src/requests/messages/poll_messages.rs b/core/binary_protocol/src/requests/messages/poll_messages.rs
index 9fef377..57406c0 100644
--- a/core/binary_protocol/src/requests/messages/poll_messages.rs
+++ b/core/binary_protocol/src/requests/messages/poll_messages.rs
@@ -17,9 +17,10 @@
 
 use crate::WireError;
 use crate::WireIdentifier;
-use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le};
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le, read_u64_le};
 use crate::primitives::consumer::WireConsumer;
 use crate::primitives::polling_strategy::WirePollingStrategy;
+use crate::responses::messages::poll_messages::POLL_RESPONSE_HEADER_SIZE;
 use bytes::{BufMut, BytesMut};
 
 /// `PollMessages` request.
@@ -45,10 +46,34 @@
 
 const PARTITION_FLAG_SIZE: usize = 1;
 const PARTITION_VALUE_SIZE: usize = 4;
-const STRATEGY_SIZE: usize = 9;
+pub(crate) const STRATEGY_SIZE: usize = 9;
 const COUNT_SIZE: usize = 4;
 const AUTO_COMMIT_SIZE: usize = 1;
 
+/// Finite protocol ceiling; servers may configure a lower admission limit.
+pub const MAX_DEFERRED_POLL_WAIT_US: u64 = 600_000_000;
+/// Request timeout room above the longest wait.
+const DEFERRED_POLL_TIMEOUT_HEADROOM_US: u64 = 30_000_000;
+/// Finite ceiling of a deferred request timeout: the longest wait plus headroom.
+pub const MAX_DEFERRED_POLL_TIMEOUT_US: u64 =
+    MAX_DEFERRED_POLL_WAIT_US + DEFERRED_POLL_TIMEOUT_HEADROOM_US;
+/// Least time a waiting request leaves between the end of its wait and its request timeout.
+///
+/// The server spends it on the final read and the reply, so an idle wait answers before the
+/// client gives up. A zero wait answers at once and needs no margin.
+pub const MIN_DEFERRED_POLL_REPLY_MARGIN_US: u64 = 1_000_000;
+/// Smallest byte limit that can return a message: the reply header, one batch
+/// header and one message header.
+pub const MIN_DEFERRED_POLL_MAX_BYTES: usize = POLL_RESPONSE_HEADER_SIZE
+    + crate::batch::BATCH_HEADER_SIZE
+    + crate::batch::BATCH_MESSAGE_HEADER_SIZE;
+
+/// Whether `request_timeout_us` covers a wait of `wait_us` and its reply margin.
+#[must_use]
+pub const fn deferred_timeout_covers_wait(wait_us: u64, request_timeout_us: u64) -> bool {
+    wait_us == 0 || request_timeout_us >= wait_us.saturating_add(MIN_DEFERRED_POLL_REPLY_MARGIN_US)
+}
+
 impl PollMessagesRequest {
     /// Read the strategy and auto-commit flag without allocating identifiers.
     ///
@@ -143,6 +168,69 @@
     }
 }
 
+/// Readiness-based polling with explicit message and response-byte limits.
+/// New command codes prevent older servers from silently ignoring the wait.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DeferredPollMessagesRequest {
+    pub poll: PollMessagesRequest,
+    pub wait_us: u64,
+    pub min_count: u32,
+    pub max_bytes: u32,
+    pub request_timeout_us: u64,
+}
+
+impl WireEncode for DeferredPollMessagesRequest {
+    fn encoded_size(&self) -> usize {
+        self.poll.encoded_size() + 2 * size_of::<u64>() + 2 * size_of::<u32>()
+    }
+
+    fn encode(&self, buf: &mut BytesMut) {
+        self.poll.encode(buf);
+        buf.put_u64_le(self.wait_us);
+        buf.put_u32_le(self.min_count);
+        buf.put_u32_le(self.max_bytes);
+        buf.put_u64_le(self.request_timeout_us);
+    }
+}
+
+impl WireDecode for DeferredPollMessagesRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (poll, consumed) = PollMessagesRequest::decode(buf)?;
+        if buf.len() - consumed != 2 * size_of::<u64>() + 2 * size_of::<u32>() {
+            return Err(WireError::Validation(
+                "deferred poll requires wait, minimum count, byte limit and request timeout".into(),
+            ));
+        }
+        let wait_us = read_u64_le(buf, consumed)?;
+        let min_count = read_u32_le(buf, consumed + size_of::<u64>())?;
+        let max_bytes = read_u32_le(buf, consumed + size_of::<u64>() + size_of::<u32>())?;
+        let request_timeout_us =
+            read_u64_le(buf, consumed + size_of::<u64>() + 2 * size_of::<u32>())?;
+        if wait_us > MAX_DEFERRED_POLL_WAIT_US
+            || min_count == 0
+            || min_count > poll.count
+            || request_timeout_us == 0
+            || !deferred_timeout_covers_wait(wait_us, request_timeout_us)
+            || request_timeout_us > MAX_DEFERRED_POLL_TIMEOUT_US
+            || (max_bytes as usize) < MIN_DEFERRED_POLL_MAX_BYTES
+        {
+            return Err(WireError::Validation(
+                "invalid deferred poll wait, message counts or byte limit".into(),
+            ));
+        }
+        Ok((
+            Self {
+                poll,
+                wait_us,
+                min_count,
+                max_bytes,
+                request_timeout_us,
+            },
+            buf.len(),
+        ))
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -240,4 +328,102 @@
             );
         }
     }
+    #[test]
+    fn deferred_preserves_legacy_prefix_and_validates_limits() {
+        let request = DeferredPollMessagesRequest {
+            poll: PollMessagesRequest {
+                consumer: WireConsumer::consumer_group(WireIdentifier::named("group").unwrap()),
+                stream_id: WireIdentifier::named("stream").unwrap(),
+                topic_id: WireIdentifier::numeric(2),
+                partition_id: Some(3),
+                strategy: WirePollingStrategy::offset(42),
+                count: 5,
+                auto_commit: true,
+            },
+            wait_us: MAX_DEFERRED_POLL_WAIT_US,
+            min_count: 1,
+            max_bytes: 1024,
+            request_timeout_us: MAX_DEFERRED_POLL_TIMEOUT_US,
+        };
+        for wait_us in [0, 1, MAX_DEFERRED_POLL_WAIT_US] {
+            let request = DeferredPollMessagesRequest {
+                wait_us,
+                ..request.clone()
+            };
+            let bytes = request.to_bytes();
+            let prefix = request.poll.to_bytes();
+            assert_eq!(&bytes[..prefix.len()], prefix.as_ref());
+            assert_eq!(bytes.len(), prefix.len() + 24);
+            assert_eq!(
+                &bytes[prefix.len()..prefix.len() + 8],
+                &wait_us.to_le_bytes()
+            );
+            assert_eq!(
+                DeferredPollMessagesRequest::decode(&bytes).unwrap(),
+                (request, bytes.len())
+            );
+            for end in 0..bytes.len() {
+                assert!(DeferredPollMessagesRequest::decode(&bytes[..end]).is_err());
+            }
+            let mut extra = bytes.to_vec();
+            extra.push(0);
+            assert!(DeferredPollMessagesRequest::decode(&extra).is_err());
+        }
+        for invalid in [
+            DeferredPollMessagesRequest {
+                wait_us: MAX_DEFERRED_POLL_WAIT_US + 1,
+                ..request.clone()
+            },
+            DeferredPollMessagesRequest {
+                min_count: 0,
+                ..request.clone()
+            },
+            DeferredPollMessagesRequest {
+                min_count: 6,
+                ..request.clone()
+            },
+            DeferredPollMessagesRequest {
+                max_bytes: 15,
+                ..request.clone()
+            },
+            DeferredPollMessagesRequest {
+                request_timeout_us: 0,
+                ..request.clone()
+            },
+            DeferredPollMessagesRequest {
+                request_timeout_us: MAX_DEFERRED_POLL_WAIT_US - 1,
+                ..request.clone()
+            },
+            DeferredPollMessagesRequest {
+                request_timeout_us: MAX_DEFERRED_POLL_TIMEOUT_US + 1,
+                ..request.clone()
+            },
+            DeferredPollMessagesRequest {
+                wait_us: 1,
+                request_timeout_us: MIN_DEFERRED_POLL_REPLY_MARGIN_US,
+                ..request.clone()
+            },
+            DeferredPollMessagesRequest {
+                max_bytes: u32::try_from(MIN_DEFERRED_POLL_MAX_BYTES).unwrap() - 1,
+                ..request.clone()
+            },
+        ] {
+            assert!(
+                DeferredPollMessagesRequest::decode(&invalid.to_bytes()).is_err(),
+                "accepted {invalid:?}"
+            );
+        }
+        for (wait_us, request_timeout_us) in [(0, 1), (1, 1 + MIN_DEFERRED_POLL_REPLY_MARGIN_US)] {
+            let valid = DeferredPollMessagesRequest {
+                wait_us,
+                request_timeout_us,
+                max_bytes: u32::try_from(MIN_DEFERRED_POLL_MAX_BYTES).unwrap(),
+                ..request.clone()
+            };
+            assert!(
+                DeferredPollMessagesRequest::decode(&valid.to_bytes()).is_ok(),
+                "rejected {valid:?}"
+            );
+        }
+    }
 }
diff --git a/core/binary_protocol/src/requests/messages/wait_for_messages.rs b/core/binary_protocol/src/requests/messages/wait_for_messages.rs
new file mode 100644
index 0000000..4c6eb63
--- /dev/null
+++ b/core/binary_protocol/src/requests/messages/wait_for_messages.rs
@@ -0,0 +1,261 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::codec::{WireDecode, WireEncode, read_u32_le, read_u64_le};
+use crate::primitives::consumer::WireConsumer;
+use crate::primitives::polling_strategy::WirePollingStrategy;
+use crate::requests::messages::poll_messages::{
+    MAX_DEFERRED_POLL_TIMEOUT_US, MAX_DEFERRED_POLL_WAIT_US, STRATEGY_SIZE,
+    deferred_timeout_covers_wait,
+};
+use crate::{MAX_PARTITIONS_PER_REQUEST, WireError, WireIdentifier};
+use bytes::{BufMut, BytesMut};
+use std::collections::HashSet;
+
+const TARGET_SIZE: usize = size_of::<u32>() + STRATEGY_SIZE;
+
+/// Waits until at least one partition holds a message at its start point.
+/// Reads and commits nothing.
+///
+/// Wire format:
+/// ```text
+/// [consumer][stream_id][topic_id][wait_us:8 LE][request_timeout_us:8 LE]
+/// [partitions_count:4 LE] partitions_count x [partition_id:4 LE][strategy:9]
+/// ```
+///
+/// Partition ids are unique, and there are 1 to `MAX_PARTITIONS_PER_REQUEST`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WaitForMessagesRequest {
+    pub consumer: WireConsumer,
+    pub stream_id: WireIdentifier,
+    pub topic_id: WireIdentifier,
+    pub wait_us: u64,
+    pub request_timeout_us: u64,
+    pub partitions: Vec<WaitForMessagesTarget>,
+}
+
+/// The server accepts the offset and next strategies only.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WaitForMessagesTarget {
+    pub partition_id: u32,
+    pub strategy: WirePollingStrategy,
+}
+
+impl WireEncode for WaitForMessagesRequest {
+    fn encoded_size(&self) -> usize {
+        self.consumer.encoded_size()
+            + self.stream_id.encoded_size()
+            + self.topic_id.encoded_size()
+            + 2 * size_of::<u64>()
+            + size_of::<u32>()
+            + self.partitions.len() * TARGET_SIZE
+    }
+
+    #[allow(clippy::cast_possible_truncation)]
+    fn encode(&self, buf: &mut BytesMut) {
+        self.consumer.encode(buf);
+        self.stream_id.encode(buf);
+        self.topic_id.encode(buf);
+        buf.put_u64_le(self.wait_us);
+        buf.put_u64_le(self.request_timeout_us);
+        buf.put_u32_le(self.partitions.len() as u32);
+        for target in &self.partitions {
+            buf.put_u32_le(target.partition_id);
+            target.strategy.encode(buf);
+        }
+    }
+}
+
+impl WireDecode for WaitForMessagesRequest {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let (consumer, mut pos) = WireConsumer::decode(buf)?;
+        let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+        pos += n;
+        let wait_us = read_u64_le(buf, pos)?;
+        pos += size_of::<u64>();
+        let request_timeout_us = read_u64_le(buf, pos)?;
+        pos += size_of::<u64>();
+        let count = read_u32_le(buf, pos)?;
+        pos += size_of::<u32>();
+        // Bound the allocation before `validate` sees the partitions.
+        if count > MAX_PARTITIONS_PER_REQUEST || buf.len() - pos != count as usize * TARGET_SIZE {
+            return Err(WireError::Validation(
+                "invalid readiness wait partitions".into(),
+            ));
+        }
+        let mut partitions = Vec::with_capacity(count as usize);
+        for _ in 0..count {
+            let partition_id = read_u32_le(buf, pos)?;
+            let (strategy, n) = WirePollingStrategy::decode(&buf[pos + size_of::<u32>()..])?;
+            pos += size_of::<u32>() + n;
+            partitions.push(WaitForMessagesTarget {
+                partition_id,
+                strategy,
+            });
+        }
+        let request = Self {
+            consumer,
+            stream_id,
+            topic_id,
+            wait_us,
+            request_timeout_us,
+            partitions,
+        };
+        request.validate()?;
+        Ok((request, pos))
+    }
+}
+
+impl WaitForMessagesRequest {
+    /// The limits that decode enforces, for a sender that builds the request.
+    ///
+    /// # Errors
+    /// Returns `WireError::Validation` for a wait or request timeout out of
+    /// range, no partitions, too many partitions or a duplicate partition.
+    pub fn validate(&self) -> Result<(), WireError> {
+        if self.wait_us > MAX_DEFERRED_POLL_WAIT_US
+            || self.request_timeout_us == 0
+            || !deferred_timeout_covers_wait(self.wait_us, self.request_timeout_us)
+            || self.request_timeout_us > MAX_DEFERRED_POLL_TIMEOUT_US
+            || self.partitions.is_empty()
+            || self.partitions.len() > MAX_PARTITIONS_PER_REQUEST as usize
+        {
+            return Err(WireError::Validation(
+                "invalid readiness wait, request timeout or partitions".into(),
+            ));
+        }
+        let mut seen = HashSet::with_capacity(self.partitions.len());
+        if !self
+            .partitions
+            .iter()
+            .all(|target| seen.insert(target.partition_id))
+        {
+            return Err(WireError::Validation(
+                "duplicate partition in readiness wait".into(),
+            ));
+        }
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::requests::messages::poll_messages::MIN_DEFERRED_POLL_REPLY_MARGIN_US;
+
+    fn request(partitions: Vec<WaitForMessagesTarget>) -> WaitForMessagesRequest {
+        WaitForMessagesRequest {
+            consumer: WireConsumer::consumer_group(WireIdentifier::named("group").unwrap()),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::named("topic").unwrap(),
+            wait_us: 1_000,
+            request_timeout_us: 1_000 + MIN_DEFERRED_POLL_REPLY_MARGIN_US,
+            partitions,
+        }
+    }
+
+    fn target(partition_id: u32) -> WaitForMessagesTarget {
+        WaitForMessagesTarget {
+            partition_id,
+            strategy: WirePollingStrategy::offset(u64::from(partition_id) * 10),
+        }
+    }
+
+    #[test]
+    fn roundtrip_and_reject_truncation() {
+        let request = request(vec![
+            target(0),
+            WaitForMessagesTarget {
+                partition_id: 7,
+                strategy: WirePollingStrategy::next(),
+            },
+        ]);
+        let bytes = request.to_bytes();
+        assert_eq!(bytes.len(), request.encoded_size());
+        assert_eq!(
+            WaitForMessagesRequest::decode(&bytes).unwrap(),
+            (request, bytes.len())
+        );
+        for end in 0..bytes.len() {
+            assert!(
+                WaitForMessagesRequest::decode(&bytes[..end]).is_err(),
+                "accepted truncation at {end}"
+            );
+        }
+    }
+
+    #[test]
+    fn partition_count_limits() {
+        let full = request(
+            (0..MAX_PARTITIONS_PER_REQUEST)
+                .map(target)
+                .collect::<Vec<_>>(),
+        );
+        assert!(WaitForMessagesRequest::decode(&full.to_bytes()).is_ok());
+        for partitions in [
+            Vec::new(),
+            (0..=MAX_PARTITIONS_PER_REQUEST).map(target).collect(),
+            vec![target(3), target(3)],
+        ] {
+            let count = partitions.len();
+            assert!(
+                WaitForMessagesRequest::decode(&request(partitions).to_bytes()).is_err(),
+                "accepted {count} partitions"
+            );
+        }
+    }
+
+    #[test]
+    fn wait_and_timeout_limits() {
+        for (wait_us, request_timeout_us, valid) in [
+            (0, 1, true),
+            (
+                MAX_DEFERRED_POLL_WAIT_US,
+                MAX_DEFERRED_POLL_TIMEOUT_US,
+                true,
+            ),
+            (0, 0, false),
+            (2, 1, false),
+            (1, 1 + MIN_DEFERRED_POLL_REPLY_MARGIN_US, true),
+            (1, MIN_DEFERRED_POLL_REPLY_MARGIN_US, false),
+            (
+                MAX_DEFERRED_POLL_WAIT_US + 1,
+                MAX_DEFERRED_POLL_TIMEOUT_US,
+                false,
+            ),
+            (1, MAX_DEFERRED_POLL_TIMEOUT_US + 1, false),
+        ] {
+            let mut request = request(vec![target(1)]);
+            request.wait_us = wait_us;
+            request.request_timeout_us = request_timeout_us;
+            assert_eq!(
+                WaitForMessagesRequest::decode(&request.to_bytes()).is_ok(),
+                valid,
+                "wait {wait_us}, timeout {request_timeout_us}"
+            );
+        }
+    }
+
+    #[test]
+    fn trailing_bytes_are_rejected() {
+        let mut bytes = BytesMut::from(&request(vec![target(1)]).to_bytes()[..]);
+        bytes.put_u8(0);
+        assert!(WaitForMessagesRequest::decode(&bytes).is_err());
+    }
+}
diff --git a/core/binary_protocol/src/responses/messages/mod.rs b/core/binary_protocol/src/responses/messages/mod.rs
index 325f878..4f8337a 100644
--- a/core/binary_protocol/src/responses/messages/mod.rs
+++ b/core/binary_protocol/src/responses/messages/mod.rs
@@ -18,7 +18,9 @@
 pub mod partition_route;
 pub mod poll_messages;
 pub mod send_messages;
+pub mod wait_for_messages;
 
 pub use partition_route::PartitionRouteResponse;
 pub use poll_messages::{PollMessagesResponse, PollMessagesResponseHeader};
 pub use send_messages::{SendMessagesConfirmationResponse, SendMessagesResponse};
+pub use wait_for_messages::WaitForMessagesResponse;
diff --git a/core/binary_protocol/src/responses/messages/wait_for_messages.rs b/core/binary_protocol/src/responses/messages/wait_for_messages.rs
new file mode 100644
index 0000000..06d46bd
--- /dev/null
+++ b/core/binary_protocol/src/responses/messages/wait_for_messages.rs
@@ -0,0 +1,89 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::codec::{WireDecode, WireEncode, read_u32_le};
+use crate::{MAX_PARTITIONS_PER_REQUEST, WireError};
+use bytes::{BufMut, BytesMut};
+
+/// Partitions that hold a message at their start point. An empty list means
+/// that the wait expired.
+///
+/// Wire format: `[partitions_count:4 LE] partitions_count x [partition_id:4 LE]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WaitForMessagesResponse {
+    pub partitions: Vec<u32>,
+}
+
+impl WireEncode for WaitForMessagesResponse {
+    fn encoded_size(&self) -> usize {
+        size_of::<u32>() * (1 + self.partitions.len())
+    }
+
+    #[allow(clippy::cast_possible_truncation)]
+    fn encode(&self, buf: &mut BytesMut) {
+        buf.put_u32_le(self.partitions.len() as u32);
+        for partition_id in &self.partitions {
+            buf.put_u32_le(*partition_id);
+        }
+    }
+}
+
+impl WireDecode for WaitForMessagesResponse {
+    fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+        let count = read_u32_le(buf, 0)?;
+        if count > MAX_PARTITIONS_PER_REQUEST {
+            return Err(WireError::Validation(
+                "readiness reply names too many partitions".into(),
+            ));
+        }
+        let partitions = (1..=count as usize)
+            .map(|index| read_u32_le(buf, index * size_of::<u32>()))
+            .collect::<Result<Vec<_>, _>>()?;
+        Ok((Self { partitions }, size_of::<u32>() * (1 + count as usize)))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn roundtrip_and_reject_truncation() {
+        for partitions in [Vec::new(), vec![0, 7, u32::MAX]] {
+            let response = WaitForMessagesResponse { partitions };
+            let bytes = response.to_bytes();
+            assert_eq!(bytes.len(), response.encoded_size());
+            assert_eq!(
+                WaitForMessagesResponse::decode(&bytes).unwrap(),
+                (response, bytes.len())
+            );
+            for end in 0..bytes.len() {
+                assert!(
+                    WaitForMessagesResponse::decode(&bytes[..end]).is_err(),
+                    "accepted truncation at {end}"
+                );
+            }
+        }
+    }
+
+    #[test]
+    fn rejects_counts_above_the_request_limit() {
+        let mut bytes = BytesMut::new();
+        bytes.put_u32_le(MAX_PARTITIONS_PER_REQUEST + 1);
+        assert!(WaitForMessagesResponse::decode(&bytes).is_err());
+    }
+}
diff --git a/core/cli/Cargo.toml b/core/cli/Cargo.toml
index c7d365c..64ac1dd 100644
--- a/core/cli/Cargo.toml
+++ b/core/cli/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy-cli"
-version = "0.14.1-edge.3"
+version = "0.14.1-edge.4"
 edition = "2024"
 rust-version.workspace = true
 authors = ["bartosz.ciesla@gmail.com"]
diff --git a/core/common/Cargo.toml b/core/common/Cargo.toml
index b7f8c81..02e93e3 100644
--- a/core/common/Cargo.toml
+++ b/core/common/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy_common"
-version = "0.11.1-edge.4"
+version = "0.11.1-edge.5"
 description = "Iggy is the persistent message streaming platform written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of processing millions of messages per second."
 edition = "2024"
 rust-version.workspace = true
diff --git a/core/common/src/consumer_group_client_state.rs b/core/common/src/consumer_group_client_state.rs
index 80ecd6d..05d2632 100644
--- a/core/common/src/consumer_group_client_state.rs
+++ b/core/common/src/consumer_group_client_state.rs
@@ -29,6 +29,7 @@
 use crate::{Identifier, IggyError};
 use iggy_binary_protocol::primitives::partition_history::PartitionContext;
 use std::collections::HashMap;
+use std::sync::atomic::{AtomicU64, Ordering};
 use std::sync::{Mutex, MutexGuard, PoisonError};
 
 #[derive(Debug, Default, Clone)]
@@ -49,9 +50,20 @@
 
 /// Per-transport cache of group assignments, producer cursors and send
 /// contexts. Topic lookups borrow identifiers without formatting keys.
+/// The session and assignment generations under which a client read a group
+/// assignment.
+#[doc(hidden)]
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+pub struct AssignmentVersion {
+    pub session: u64,
+    pub generation: u64,
+}
+
 #[derive(Debug, Default)]
 pub struct ConsumerGroupClientState {
     groups: Mutex<GroupState>,
+    /// Bumped under the lock before each membership or assignment change.
+    assignment_changes: AtomicU64,
     topic_partitions: Mutex<HashMap<Identifier, HashMap<Identifier, TopicPartitions>>>,
 }
 
@@ -77,6 +89,44 @@
 }
 
 impl ConsumerGroupClientState {
+    /// Snapshot of the assignment version and partitions.
+    #[doc(hidden)]
+    pub fn assignment_snapshot(&self, key: &str) -> (AssignmentVersion, Vec<u32>) {
+        let groups = self.groups();
+        let assignment = groups.assignments.get(key);
+        let version = AssignmentVersion {
+            session: groups.generation,
+            generation: assignment.map_or(0, |assignment| assignment.generation),
+        };
+        let partitions =
+            assignment.map_or_else(Vec::new, |assignment| assignment.partitions.clone());
+        (version, partitions)
+    }
+
+    /// Changes before any answer of [`Self::is_current_assignment`] can change,
+    /// so a caller that sees the same count can keep its earlier answer.
+    #[doc(hidden)]
+    pub fn assignment_changes(&self) -> u64 {
+        self.assignment_changes.load(Ordering::Acquire)
+    }
+
+    /// Validate a buffered batch against the latest locally observed assignment.
+    #[doc(hidden)]
+    pub fn is_current_assignment(
+        &self,
+        key: &str,
+        version: AssignmentVersion,
+        partition: u32,
+    ) -> bool {
+        let groups = self.groups();
+        groups.joined_groups.contains_key(key)
+            && groups.generation == version.session
+            && groups.assignments.get(key).is_some_and(|assignment| {
+                assignment.generation == version.generation
+                    && assignment.partitions.contains(&partition)
+            })
+    }
+
     #[must_use]
     pub fn new() -> Self {
         Self::default()
@@ -94,13 +144,14 @@
     /// Replace the cached assignment for a group. A generation change (a
     /// rebalance) resets the round-robin cursor so selection restarts cleanly.
     pub fn set_assignment(&self, key: String, generation: u64, partitions: Vec<u32>) {
-        self.groups().set_assignment(key, generation, partitions);
+        self.groups_mut()
+            .set_assignment(key, generation, partitions);
     }
 
     /// Drop a group's cached assignment (after a fence rejection) so the next
     /// poll re-syncs.
     pub fn invalidate_assignment(&self, key: &str) {
-        self.groups().assignments.remove(key);
+        self.groups_mut().assignments.remove(key);
     }
 
     /// The next assigned partition for a group poll, advancing the cursor.
@@ -248,14 +299,14 @@
         topic_id: Identifier,
         group_id: Identifier,
     ) {
-        self.groups()
+        self.groups_mut()
             .joined_groups
             .insert(key, (stream_id, topic_id, group_id));
     }
 
     /// Forget a group (after leave / delete) so the heartbeat stops re-syncing.
     pub fn deregister_group(&self, key: &str) {
-        self.groups().joined_groups.remove(key);
+        self.groups_mut().joined_groups.remove(key);
     }
 
     /// True if the last assignment sync observed this client as a member. A
@@ -279,7 +330,7 @@
     /// and clearing the balanced cursors would restart the produce round-robin
     /// at partition 0 on every reconnect.
     pub fn clear_session_scoped(&self) {
-        let mut groups = self.groups();
+        let mut groups = self.groups_mut();
         groups.generation += 1;
         groups.assignments.clear();
         groups.joined_groups.clear();
@@ -299,7 +350,7 @@
         identifiers: (Identifier, Identifier, Identifier),
         assignment: Option<(u64, Vec<u32>)>,
     ) -> Result<(), IggyError> {
-        let mut groups = self.groups();
+        let mut groups = self.groups_mut();
         if groups.generation != generation {
             return Err(IggyError::TransientNotAccepted);
         }
@@ -316,6 +367,14 @@
     fn groups(&self) -> MutexGuard<'_, GroupState> {
         self.groups.lock().unwrap_or_else(PoisonError::into_inner)
     }
+
+    /// A reader that sees the new count then waits for this lock, so it never
+    /// keeps an answer that the change made stale.
+    fn groups_mut(&self) -> MutexGuard<'_, GroupState> {
+        let groups = self.groups();
+        self.assignment_changes.fetch_add(1, Ordering::AcqRel);
+        groups
+    }
 }
 
 #[cfg(test)]
@@ -353,6 +412,48 @@
     }
 
     #[test]
+    fn given_a_membership_or_assignment_change_when_counting_changes_should_move_the_count() {
+        const KEY: &str = "s|t|g";
+        let state = ConsumerGroupClientState::new();
+        let identifier = Identifier::numeric(1).unwrap();
+        let identifiers = (identifier.clone(), identifier.clone(), identifier.clone());
+        let mut count = state.assignment_changes();
+        let mut changed = |state: &ConsumerGroupClientState| {
+            let next = state.assignment_changes();
+            let changed = next != count;
+            count = next;
+            changed
+        };
+
+        state.set_assignment(KEY.to_owned(), 1, vec![0, 1]);
+        assert!(changed(&state));
+        assert_eq!(state.next_group_partition(KEY), Some(0));
+        let version = AssignmentVersion {
+            session: 0,
+            generation: 1,
+        };
+        assert!(!state.is_current_assignment(KEY, version, 0));
+        assert!(!changed(&state), "reads and cursor moves keep the count");
+        state.register_group(
+            KEY.to_owned(),
+            identifier.clone(),
+            identifier.clone(),
+            identifier,
+        );
+        assert!(changed(&state));
+        state.invalidate_assignment(KEY);
+        assert!(changed(&state));
+        state
+            .apply_group_assignment(0, KEY.to_owned(), identifiers, Some((2, vec![1])))
+            .unwrap();
+        assert!(changed(&state));
+        state.deregister_group(KEY);
+        assert!(changed(&state));
+        state.clear_session_scoped();
+        assert!(changed(&state));
+    }
+
+    #[test]
     fn group_partition_round_robins_then_wraps() {
         let state = ConsumerGroupClientState::new();
         state.set_assignment("s|t|g".to_owned(), 1, vec![0, 1, 2]);
@@ -449,7 +550,15 @@
         );
         state.set_topic_partitions(&stream, &topic, &partitions(3));
 
+        let before = state.assignment_snapshot("s|t|g");
         state.clear_session_scoped();
+        state.set_assignment("s|t|g".to_owned(), before.0.generation, before.1.clone());
+        let after = state.assignment_snapshot("s|t|g");
+        assert_ne!(
+            before.0, after.0,
+            "same group generation in a new session must fence queued batches"
+        );
+        state.invalidate_assignment("s|t|g");
 
         assert!(state.registered_groups().is_empty());
         assert!(!state.is_registered("s|t|g"));
diff --git a/core/common/src/error/iggy_error.rs b/core/common/src/error/iggy_error.rs
index 580f7de..33d0dc8 100644
--- a/core/common/src/error/iggy_error.rs
+++ b/core/common/src/error/iggy_error.rs
@@ -194,6 +194,9 @@
     LifecycleBusy = 88,
     #[error("Request not accepted by a backup replica; retry on the primary of its group")]
     NotPrimary = 89,
+    /// No poll progress was applied. Back off without invalidating the partition route.
+    #[error("Deferred poll capacity exceeded; retry after backoff on the same replica")]
+    DeferredPollCapacityExceeded = 90,
     #[error("Client with ID: {0} was not found.")]
     ClientNotFound(u32) = 100,
     #[error("Invalid client ID")]
@@ -768,6 +771,11 @@
         let cases = [
             (IggyError::NotPrimary, 89, "not_primary"),
             (
+                IggyError::DeferredPollCapacityExceeded,
+                90,
+                "deferred_poll_capacity_exceeded",
+            ),
+            (
                 IggyError::OperationUncertain(RETAINED_OPERATION),
                 14006,
                 "operation_uncertain",
@@ -792,6 +800,10 @@
         // `PartialEq` compares codes only, so the payload needs a pattern.
         assert!(matches!(IggyError::from_code(89), IggyError::NotPrimary));
         assert!(matches!(
+            IggyError::from_code(90),
+            IggyError::DeferredPollCapacityExceeded
+        ));
+        assert!(matches!(
             IggyError::from_code(14006),
             IggyError::OperationUncertain(0)
         ));
diff --git a/core/common/src/lib.rs b/core/common/src/lib.rs
index 9bc952d..41ee390 100644
--- a/core/common/src/lib.rs
+++ b/core/common/src/lib.rs
@@ -30,7 +30,7 @@
 // Locking is feature gated, thus only mod level re-export.
 pub mod locking;
 pub use chrono::{DateTime, Duration as ChronoDuration, Utc};
-pub use consumer_group_client_state::ConsumerGroupClientState;
+pub use consumer_group_client_state::{AssignmentVersion, ConsumerGroupClientState};
 
 /// Sentinel `partition_id` in an otherwise-empty poll reply that tells the
 /// client its cached consumer-group assignment is stale (a generation fence
@@ -77,6 +77,10 @@
 pub use http::system::*;
 pub use http::topics::*;
 pub use http::users::*;
+pub use iggy_binary_protocol::requests::messages::{
+    MAX_DEFERRED_POLL_TIMEOUT_US, MAX_DEFERRED_POLL_WAIT_US, MIN_DEFERRED_POLL_MAX_BYTES,
+    MIN_DEFERRED_POLL_REPLY_MARGIN_US,
+};
 pub use iggy_binary_protocol::responses::messages::{
     SendMessagesConfirmationResponse, SendMessagesResponse,
 };
@@ -92,7 +96,6 @@
 pub use traits::cluster_client::ClusterClient;
 pub use traits::consumer_group_client::ConsumerGroupClient;
 pub use traits::consumer_offset_client::ConsumerOffsetClient;
-pub use traits::decode_send_confirmations;
 pub use traits::message_client::MessageClient;
 pub use traits::partition_client::PartitionClient;
 pub use traits::partitioner::Partitioner;
@@ -104,9 +107,11 @@
 pub use traits::topic_client::TopicClient;
 pub use traits::user_client::UserClient;
 pub use traits::validatable::Validatable;
+pub use traits::wait_for_messages_request;
 #[doc(hidden)]
 pub use traits::{BinarySendPartitioning, send_binary_messages};
 pub use traits::{binary_sign_in, rust_sdk_version_info, sign_in_request};
+pub use traits::{decode_send_confirmations, group_cache_key, sync_group_assignment};
 pub use types::args::*;
 pub use types::client::client_info::*;
 pub use types::client_state::ClientState;
diff --git a/core/common/src/traits/binary_impls/messages.rs b/core/common/src/traits/binary_impls/messages.rs
index 2eb3aad..736e5ad 100644
--- a/core/common/src/traits/binary_impls/messages.rs
+++ b/core/common/src/traits/binary_impls/messages.rs
@@ -35,22 +35,34 @@
 use iggy_binary_protocol::codes::SYNC_CONSUMER_GROUP_CODE;
 use iggy_binary_protocol::requests::consumer_groups::SyncConsumerGroupRequest;
 use iggy_binary_protocol::requests::messages::{
-    PollMessagesRequest, RawMessage, SendMessagesEncoder,
+    PollMessagesRequest, RawMessage, SendMessagesEncoder, WaitForMessagesRequest,
+    WaitForMessagesTarget,
 };
 use iggy_binary_protocol::responses::consumer_groups::SyncConsumerGroupResponse;
+use iggy_binary_protocol::responses::messages::WaitForMessagesResponse;
 use std::borrow::Cow;
 
 /// Max attempts to resolve a fenced consumer-group poll: one re-sync after the
 /// coordinator rejects a stale assignment, then retry once.
 const GROUP_POLL_MAX_ATTEMPTS: usize = 2;
 
-fn group_cache_key(stream_id: &Identifier, topic_id: &Identifier, group_id: &Identifier) -> String {
+/// The key of a group in [`crate::ConsumerGroupClientState`].
+#[doc(hidden)]
+pub fn group_cache_key(
+    stream_id: &Identifier,
+    topic_id: &Identifier,
+    group_id: &Identifier,
+) -> String {
     format!("{stream_id}|{topic_id}|{group_id}")
 }
 
 /// Sync the requesting member's assignment from the coordinator into the
 /// transport cache. An empty reply means the client is not a member.
-async fn sync_group_assignment<B: BinaryClient>(
+///
+/// # Errors
+/// Propagates transport and assignment-decoding failures.
+#[doc(hidden)]
+pub async fn sync_group_assignment<B: BinaryClient>(
     client: &B,
     stream_id: &Identifier,
     topic_id: &Identifier,
@@ -253,6 +265,7 @@
 /// (round-robin), ask `strategy_for` where to read it from and send an
 /// explicit-partition poll. A coordinator fence rejection (stale assignment
 /// after a rebalance) triggers one re-sync + retry.
+#[allow(clippy::too_many_arguments)]
 async fn poll_group_messages<B: BinaryClient>(
     client: &B,
     stream_id: &Identifier,
@@ -261,7 +274,9 @@
     strategy_for: &(dyn Fn(u32) -> PollingStrategy + Send + Sync),
     count: u32,
     auto_commit: bool,
+    options: Option<crate::DeferredPollOptions>,
 ) -> Result<PolledMessages, IggyError> {
+    let started = std::time::Instant::now();
     let key = group_cache_key(stream_id, topic_id, &consumer.id);
     if !client.consumer_group_state().has_assignment(&key) {
         sync_group_assignment(client, stream_id, topic_id, &consumer.id).await?;
@@ -296,8 +311,11 @@
             count,
             auto_commit,
         };
+        let remaining = options
+            .map(|options| options.remaining(started.elapsed()))
+            .transpose()?;
         match client
-            .send_poll_with_response(&request, strategy.context)
+            .send_poll_with_response_and_options(&request, strategy.context, remaining)
             .await
         {
             Ok(response) => {
@@ -330,9 +348,39 @@
     // empty poll rather than `ConsumerGroupPartitionNotOwned(0, 0)`: the (0, 0)
     // ids are fabricated and a normal rebalance must not look like a hard error
     // to a CG app that doesn't special-case 5009. The caller just re-polls.
+    if options.is_some() {
+        return Err(IggyError::TransientNotAccepted);
+    }
     Ok(PolledMessages::empty())
 }
 
+/// The wire form of a readiness wait. The SDK also routes it by partition.
+#[doc(hidden)]
+pub fn wait_for_messages_request(
+    stream_id: &Identifier,
+    topic_id: &Identifier,
+    partitions: &[(u32, PollingStrategy)],
+    consumer: &Consumer,
+    options: crate::DeferredPollOptions,
+) -> Result<WaitForMessagesRequest, IggyError> {
+    let request = WaitForMessagesRequest {
+        consumer: consumer_to_wire(consumer)?,
+        stream_id: identifier_to_wire(stream_id)?,
+        topic_id: identifier_to_wire(topic_id)?,
+        wait_us: options.max_wait.as_micros(),
+        request_timeout_us: options.request_timeout.as_micros(),
+        partitions: partitions
+            .iter()
+            .map(|(partition_id, strategy)| WaitForMessagesTarget {
+                partition_id: *partition_id,
+                strategy: polling_strategy_to_wire(strategy),
+            })
+            .collect(),
+    };
+    request.validate().map_err(|_| IggyError::InvalidCommand)?;
+    Ok(request)
+}
+
 /// Map a raw `SendMessages` reply body to its confirmation payload. An empty
 /// body means the batch was accepted but no offsets were reported: the legacy
 /// server answers that way, so absence must never surface as a decode failure.
@@ -398,9 +446,43 @@
         count: u32,
         auto_commit: bool,
     ) -> Result<PolledMessages, IggyError> {
+        self.poll_messages_with_strategy_for_and_options(
+            stream_id,
+            topic_id,
+            partition_id,
+            consumer,
+            strategy_for,
+            count,
+            auto_commit,
+            None,
+        )
+        .await
+    }
+
+    async fn poll_messages_with_strategy_for_and_options(
+        &self,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        partition_id: Option<u32>,
+        consumer: &Consumer,
+        strategy_for: &(dyn Fn(u32) -> PollingStrategy + Send + Sync),
+        count: u32,
+        auto_commit: bool,
+        options: Option<crate::DeferredPollOptions>,
+    ) -> Result<PolledMessages, IggyError> {
+        if let Some(options) = options {
+            options.validate(count)?;
+        }
+        let deadline = if let Some(options) = options {
+            tokio::time::Instant::now()
+                .checked_add(options.request_timeout.get_duration())
+                .ok_or(IggyError::InvalidCommand)?
+        } else {
+            binary_operation_deadline()
+        };
         with_binary_lineage(
             self.operation_lineage(),
-            with_binary_deadline(binary_operation_deadline(), async {
+            with_binary_deadline(deadline, async {
                 fail_if_not_authenticated(self).await?;
                 // VSR: a consumer-group poll without an explicit partition is resolved
                 // client-side from the member's cached assignment (the broker routes
@@ -414,6 +496,7 @@
                         strategy_for,
                         count,
                         auto_commit,
+                        options,
                     )
                     .await;
                 }
@@ -427,13 +510,41 @@
                     count,
                     auto_commit,
                 };
-                let response = self.send_poll_with_response(&req, strategy.context).await?;
+                let response = self
+                    .send_poll_with_response_and_options(&req, strategy.context, options)
+                    .await?;
                 PolledMessages::from_bytes(response)
             }),
         )
         .await
     }
 
+    async fn wait_for_messages(
+        &self,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        partitions: &[(u32, PollingStrategy)],
+        consumer: &Consumer,
+        options: crate::DeferredPollOptions,
+    ) -> Result<Vec<u32>, IggyError> {
+        let request =
+            wait_for_messages_request(stream_id, topic_id, partitions, consumer, options)?;
+        let deadline = tokio::time::Instant::now()
+            .checked_add(options.request_timeout.get_duration())
+            .ok_or(IggyError::InvalidCommand)?;
+        with_binary_lineage(
+            self.operation_lineage(),
+            with_binary_deadline(deadline, async {
+                fail_if_not_authenticated(self).await?;
+                let response = self.send_wait_with_response(&request).await?;
+                Ok(WaitForMessagesResponse::decode_from(&response)
+                    .map_err(|_| IggyError::InvalidCommand)?
+                    .partitions)
+            }),
+        )
+        .await
+    }
+
     async fn send_messages(
         &self,
         stream_id: &Identifier,
diff --git a/core/common/src/traits/binary_impls/mod.rs b/core/common/src/traits/binary_impls/mod.rs
index 627bbca..4826cd1 100644
--- a/core/common/src/traits/binary_impls/mod.rs
+++ b/core/common/src/traits/binary_impls/mod.rs
@@ -28,6 +28,7 @@
 mod users;
 
 pub use messages::{BinarySendPartitioning, decode_send_confirmations, send_binary_messages};
+pub use messages::{group_cache_key, sync_group_assignment, wait_for_messages_request};
 
 use crate::IggyError;
 use crate::OptionsPatch;
diff --git a/core/common/src/traits/binary_transport.rs b/core/common/src/traits/binary_transport.rs
index 2a95c3a..aeb52e4 100644
--- a/core/common/src/traits/binary_transport.rs
+++ b/core/common/src/traits/binary_transport.rs
@@ -26,7 +26,7 @@
 use iggy_binary_protocol::codes::{
     DELETE_CONSUMER_OFFSET_CODE, POLL_MESSAGES_CODE, STORE_CONSUMER_OFFSET_CODE,
 };
-use iggy_binary_protocol::requests::messages::PollMessagesRequest;
+use iggy_binary_protocol::requests::messages::{PollMessagesRequest, WaitForMessagesRequest};
 use iggy_binary_protocol::requests::system::{BindSessionRequest, SessionIdentity};
 use iggy_binary_protocol::requests::users::login_register::BindSecret;
 use iggy_binary_protocol::responses::users::LoginRegisterResponse;
@@ -201,6 +201,31 @@
     ) -> Option<u32> {
         None
     }
+    /// Route a poll with bounded server wait and response size.
+    async fn send_poll_with_response_and_options(
+        &self,
+        request: &PollMessagesRequest,
+        context: Option<PartitionContext>,
+        options: Option<crate::DeferredPollOptions>,
+    ) -> Result<Bytes, IggyError>
+    where
+        Self: Sync,
+    {
+        if options.is_none() {
+            self.send_poll_with_response(request, context).await
+        } else {
+            Err(IggyError::FeatureUnavailable)
+        }
+    }
+
+    /// A readiness wait shares a multiplexed connection, like a positive poll wait.
+    /// A client runs at most `MAX_DEFERRED_REQUESTS` deferred requests at once.
+    async fn send_wait_with_response(
+        &self,
+        _request: &WaitForMessagesRequest,
+    ) -> Result<Bytes, IggyError> {
+        Err(IggyError::FeatureUnavailable)
+    }
 
     fn get_heartbeat_interval(&self) -> NonZeroIggyDuration;
 
diff --git a/core/common/src/traits/message_client.rs b/core/common/src/traits/message_client.rs
index f1d49b1..ac773b5 100644
--- a/core/common/src/traits/message_client.rs
+++ b/core/common/src/traits/message_client.rs
@@ -120,6 +120,95 @@
         .await
     }
 
+    /// Wait for the readiness minimum and return up to `count` messages.
+    /// Expiry returns an available partial; response bytes are independently bounded.
+    /// A lost response can follow progress admission; see `poll_messages`.
+    ///
+    /// A consumer group poll without a partition waits on one assigned partition, chosen
+    /// round-robin, so messages on the other partitions wait up to `max_wait`. To wait on the
+    /// whole assignment, call `wait_for_messages` first and poll a partition that it lists.
+    #[allow(clippy::too_many_arguments)]
+    async fn poll_messages_deferred(
+        &self,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        partition_id: Option<u32>,
+        consumer: &Consumer,
+        strategy: &PollingStrategy,
+        count: u32,
+        auto_commit: bool,
+        options: crate::DeferredPollOptions,
+    ) -> Result<PolledMessages, IggyError> {
+        self.poll_messages_with_strategy_for_and_options(
+            stream_id,
+            topic_id,
+            partition_id,
+            consumer,
+            &|_| *strategy,
+            count,
+            auto_commit,
+            Some(options),
+        )
+        .await
+    }
+
+    /// Deferred polling with the strategy resolved after choosing a partition.
+    /// Custom transports must opt in; positive waits never silently downgrade.
+    #[allow(clippy::too_many_arguments)]
+    async fn poll_messages_with_strategy_for_and_options(
+        &self,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        partition_id: Option<u32>,
+        consumer: &Consumer,
+        strategy_for: &(dyn Fn(u32) -> PollingStrategy + Send + Sync),
+        count: u32,
+        auto_commit: bool,
+        options: Option<crate::DeferredPollOptions>,
+    ) -> Result<PolledMessages, IggyError> {
+        if options.is_some() {
+            return Err(IggyError::FeatureUnavailable);
+        }
+        self.poll_messages_with_strategy_for(
+            stream_id,
+            topic_id,
+            partition_id,
+            consumer,
+            strategy_for,
+            count,
+            auto_commit,
+        )
+        .await
+    }
+
+    /// Wait until at least one of `partitions` holds a message at its start point, and return
+    /// the ids of the partitions that do. An empty list means that `options.max_wait` expired.
+    /// The wait reads and commits no message. Only `max_wait` and `request_timeout` apply.
+    ///
+    /// Each start point is [`PollingStrategy::offset`] or [`PollingStrategy::next`] of
+    /// `consumer`, and each partition appears once. A reported partition returns at least one
+    /// message to a poll with the same consumer and strategy, unless a purge or retention
+    /// removed the messages, or another commit moved `next` past them, in the meantime. A
+    /// reply can leave out a `next` partition whose stored offset moved back during the wait.
+    ///
+    /// The wait runs on the primary of each partition. If the partitions have more than one
+    /// primary, the call waits on each of them and returns the first reply that lists a
+    /// partition. It then cancels the other waits on their shared connections.
+    ///
+    /// A consumer group member that does not own one of the partitions gets
+    /// `ConsumerGroupPartitionNotOwned`. The default implementation is for transports without
+    /// the command, such as HTTP: it fails with `FeatureUnavailable`.
+    async fn wait_for_messages(
+        &self,
+        _stream_id: &Identifier,
+        _topic_id: &Identifier,
+        _partitions: &[(u32, PollingStrategy)],
+        _consumer: &Consumer,
+        _options: crate::DeferredPollOptions,
+    ) -> Result<Vec<u32>, IggyError> {
+        Err(IggyError::FeatureUnavailable)
+    }
+
     /// Send messages using specified partitioning strategy to the given stream and topic by unique IDs or names.
     ///
     /// Authentication is required, and the permission to send the messages.
diff --git a/core/common/src/traits/mod.rs b/core/common/src/traits/mod.rs
index 72a2cc2..a5ae3db 100644
--- a/core/common/src/traits/mod.rs
+++ b/core/common/src/traits/mod.rs
@@ -37,3 +37,4 @@
 
 pub use binary_impls::{BinarySendPartitioning, decode_send_confirmations, send_binary_messages};
 pub use binary_impls::{binary_sign_in, rust_sdk_version_info, sign_in_request};
+pub use binary_impls::{group_cache_key, sync_group_assignment, wait_for_messages_request};
diff --git a/core/common/src/types/message/deferred_poll_options.rs b/core/common/src/types/message/deferred_poll_options.rs
new file mode 100644
index 0000000..da983f7
--- /dev/null
+++ b/core/common/src/types/message/deferred_poll_options.rs
@@ -0,0 +1,159 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::time::Duration;
+
+use crate::{
+    IggyDuration, IggyError, MAX_DEFERRED_POLL_TIMEOUT_US, MAX_DEFERRED_POLL_WAIT_US,
+    MIN_DEFERRED_POLL_MAX_BYTES,
+};
+use iggy_binary_protocol::requests::messages::{
+    DeferredPollMessagesRequest, deferred_timeout_covers_wait,
+};
+
+pub const DEFAULT_POLL_MAX_BYTES: u32 = 1024 * 1024;
+pub const DEFAULT_POLL_REQUEST_TIMEOUT: Duration = Duration::from_secs(11);
+
+/// Readiness and response limits, independent of the maximum message count.
+/// The wait bounds batching delay; the transport's request timeout also allows
+/// bounded storage and network work. A request that waits must leave
+/// [`crate::MIN_DEFERRED_POLL_REPLY_MARGIN_US`] between its wait and its
+/// timeout for the final read and the reply. Byte limits include the binary
+/// response metadata and batch framing, including for HTTP's equivalent
+/// selection.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct DeferredPollOptions {
+    pub max_wait: IggyDuration,
+    pub min_count: u32,
+    pub max_bytes: u32,
+    pub request_timeout: IggyDuration,
+}
+
+impl Default for DeferredPollOptions {
+    fn default() -> Self {
+        Self {
+            max_wait: IggyDuration::ONE_SECOND,
+            min_count: 1,
+            max_bytes: DEFAULT_POLL_MAX_BYTES,
+            request_timeout: DEFAULT_POLL_REQUEST_TIMEOUT.into(),
+        }
+    }
+}
+
+impl DeferredPollOptions {
+    pub fn validate(&self, count: u32) -> Result<(), IggyError> {
+        if self.min_count == 0 || self.min_count > count {
+            return Err(IggyError::InvalidMessagesCount);
+        }
+        // Compare durations first: microseconds above u64 wrap on the wire.
+        let max_wait = self.max_wait.get_duration();
+        let request_timeout = self.request_timeout.get_duration();
+        if max_wait > Duration::from_micros(MAX_DEFERRED_POLL_WAIT_US)
+            || request_timeout < Duration::from_micros(1)
+            || request_timeout > Duration::from_micros(MAX_DEFERRED_POLL_TIMEOUT_US)
+            || !deferred_timeout_covers_wait(
+                self.max_wait.as_micros(),
+                self.request_timeout.as_micros(),
+            )
+        {
+            return Err(IggyError::InvalidCommand);
+        }
+        if (self.max_bytes as usize) < MIN_DEFERRED_POLL_MAX_BYTES {
+            return Err(IggyError::InvalidSizeBytes);
+        }
+        Ok(())
+    }
+
+    /// Preserve one client budget across assignment refreshes and routing retries.
+    /// Both limits shrink by `elapsed`, so a wait keeps its reply margin.
+    ///
+    /// # Errors
+    /// Returns `TransientNotAccepted` once the budget is spent, because this
+    /// attempt never left the client.
+    pub fn remaining(self, elapsed: Duration) -> Result<Self, IggyError> {
+        let request_timeout = self.request_timeout.get_duration().saturating_sub(elapsed);
+        if request_timeout.as_micros() == 0 {
+            return Err(IggyError::TransientNotAccepted);
+        }
+        Ok(Self {
+            max_wait: self.max_wait.get_duration().saturating_sub(elapsed).into(),
+            request_timeout: request_timeout.into(),
+            ..self
+        })
+    }
+}
+
+impl From<&DeferredPollMessagesRequest> for DeferredPollOptions {
+    fn from(request: &DeferredPollMessagesRequest) -> Self {
+        Self {
+            max_wait: request.wait_us.into(),
+            min_count: request.min_count,
+            max_bytes: request.max_bytes,
+            request_timeout: request.request_timeout_us.into(),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::MIN_DEFERRED_POLL_REPLY_MARGIN_US;
+
+    fn options(max_wait: Duration, request_timeout: Duration) -> DeferredPollOptions {
+        DeferredPollOptions {
+            max_wait: max_wait.into(),
+            request_timeout: request_timeout.into(),
+            ..Default::default()
+        }
+    }
+
+    #[test]
+    fn given_wait_when_validating_should_require_the_reply_margin_before_the_timeout() {
+        let margin = Duration::from_micros(MIN_DEFERRED_POLL_REPLY_MARGIN_US);
+        let wait = Duration::from_secs(2);
+        assert!(DeferredPollOptions::default().validate(1).is_ok());
+        assert!(options(wait, wait + margin).validate(1).is_ok());
+        assert!(
+            options(Duration::ZERO, Duration::from_micros(1))
+                .validate(1)
+                .is_ok()
+        );
+        for invalid in [
+            options(wait, wait + margin - Duration::from_micros(1)),
+            options(wait, wait),
+            options(Duration::ZERO, Duration::from_nanos(999)),
+            options(Duration::from_secs(u64::MAX), Duration::from_secs(u64::MAX)),
+        ] {
+            assert!(
+                matches!(invalid.validate(1), Err(IggyError::InvalidCommand)),
+                "accepted {invalid:?}"
+            );
+        }
+    }
+
+    #[test]
+    fn given_spent_budget_when_retrying_should_report_a_request_that_never_left() {
+        let options = options(Duration::from_secs(1), Duration::from_secs(3));
+        let remaining = options.remaining(Duration::from_secs(2)).unwrap();
+        assert_eq!(remaining.max_wait.get_duration(), Duration::ZERO);
+        assert!(remaining.validate(1).is_ok());
+        assert!(matches!(
+            options.remaining(Duration::from_secs(3)),
+            Err(IggyError::TransientNotAccepted)
+        ));
+    }
+}
diff --git a/core/common/src/types/message/mod.rs b/core/common/src/types/message/mod.rs
index 86f2360..1ec9250 100644
--- a/core/common/src/types/message/mod.rs
+++ b/core/common/src/types/message/mod.rs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+mod deferred_poll_options;
 mod iggy_message;
 mod index;
 mod index_view;
@@ -35,6 +36,8 @@
 
 pub const INDEX_SIZE: usize = 16;
 
+pub use deferred_poll_options::{DEFAULT_POLL_MAX_BYTES, DeferredPollOptions};
+
 pub use crate::http::messages::poll_messages::PollMessages;
 pub use crate::http::messages::send_messages::{
     SendMessages, SendMessagesConfirmation, SendMessagesConfirmations,
diff --git a/core/common/src/types/message/polled_messages.rs b/core/common/src/types/message/polled_messages.rs
index d1ad991..5fb0e26 100644
--- a/core/common/src/types/message/polled_messages.rs
+++ b/core/common/src/types/message/polled_messages.rs
@@ -18,7 +18,9 @@
 use crate::{IggyMessage, IggyMessageHeader, error::IggyError};
 use bytes::Bytes;
 use iggy_binary_protocol::WireDecode;
-use iggy_binary_protocol::batch::{BATCH_HEADER_SIZE, BatchHeader, BatchMessageHeader};
+use iggy_binary_protocol::batch::{
+    BATCH_HEADER_SIZE, BATCH_MESSAGE_HEADER_SIZE, BatchHeader, BatchMessageHeader,
+};
 use iggy_binary_protocol::primitives::partition_history::PartitionContext;
 use iggy_binary_protocol::responses::messages::PollMessagesResponseHeader;
 use serde::{Deserialize, Serialize};
@@ -67,7 +69,8 @@
     ///
     /// # Errors
     /// [`IggyError::InvalidNumberEncoding`] on a short prefix;
-    /// [`IggyError::InvalidMessagePayloadLength`] on a malformed record.
+    /// [`IggyError::InvalidMessagePayloadLength`] on a malformed record;
+    /// [`IggyError::InvalidMessagesCount`] when the records hold another count than the prefix.
     pub fn from_bytes(bytes: Bytes) -> Result<Self, IggyError> {
         let (header, consumed) = PollMessagesResponseHeader::decode(&bytes)
             .map_err(|_| IggyError::InvalidNumberEncoding)?;
@@ -93,9 +96,10 @@
 /// values. Payload and user-header `Bytes` are zero-copy slices of the
 /// response buffer.
 fn messages_from_batches(buffer: Bytes, count: u32) -> Result<Vec<IggyMessage>, IggyError> {
-    let mut messages = Vec::with_capacity(
-        (count as usize).min(buffer.len() / iggy_binary_protocol::batch::BATCH_MESSAGE_HEADER_SIZE),
-    );
+    if count as usize > buffer.len() / BATCH_MESSAGE_HEADER_SIZE {
+        return Err(IggyError::InvalidMessagesCount);
+    }
+    let mut messages = Vec::with_capacity(count as usize);
     let mut position = 0usize;
     while position < buffer.len() {
         let batch = BatchHeader::decode(&buffer[position..]).map_err(|decode_error| {
@@ -138,6 +142,9 @@
             } else {
                 None
             };
+            if messages.len() == count as usize {
+                return Err(IggyError::InvalidMessagesCount);
+            }
             messages.push(IggyMessage {
                 header,
                 payload,
@@ -149,7 +156,28 @@
     }
 
     if messages.len() != count as usize {
-        return Err(IggyError::InvalidMessagePayloadLength);
+        return Err(IggyError::InvalidMessagesCount);
     }
     Ok(messages)
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use iggy_binary_protocol::WireEncode;
+
+    #[test]
+    fn rejects_impossible_count_before_allocating_messages() {
+        let prefix = PollMessagesResponseHeader {
+            partition_id: 0,
+            current_offset: 0,
+            messages_count: u32::MAX,
+            context: PartitionContext::default(),
+        }
+        .to_bytes();
+        assert!(matches!(
+            PolledMessages::from_bytes(prefix),
+            Err(IggyError::InvalidMessagesCount)
+        ));
+    }
+}
diff --git a/core/configs/src/server_config/sharding.rs b/core/configs/src/server_config/sharding.rs
index b7d768c..3f75e55 100644
--- a/core/configs/src/server_config/sharding.rs
+++ b/core/configs/src/server_config/sharding.rs
@@ -20,7 +20,7 @@
 
 use std::time::Duration;
 
-use iggy_common::{IggyByteSize, IggyDuration, Validatable};
+use iggy_common::{IggyByteSize, IggyDuration, MAX_DEFERRED_POLL_WAIT_US, Validatable};
 use serde::{Deserialize, Serialize};
 use serde_with::{DisplayFromStr, serde_as};
 use server_common::sharding::PARTITION_IO_CAPACITY_MAX;
@@ -63,6 +63,10 @@
 /// production values.
 pub const RECONCILE_PERIODIC_INTERVAL_MAX: Duration = Duration::from_secs(30);
 
+/// Hard upper bound on `deferred_poll_max_inflight_bytes`, the read memory
+/// that each shard can reserve for deferred polls.
+pub const DEFERRED_POLL_INFLIGHT_BYTES_MAX: u64 = 1024 * 1024 * 1024;
+
 // Every omitted field falls back to the frozen `Default`, so a partial
 // `[sharding]` table resolves each key independently instead of
 // failing on the first missing one (parity with the legacy type).
@@ -111,6 +115,27 @@
     /// the compiled file-job layout.
     #[config_env(leaf)]
     pub partition_io_bytes_max: Option<IggyByteSize>,
+    /// Longest wait of a deferred poll or readiness wait. A longer requested
+    /// wait is shortened to it. At most 10 minutes.
+    #[serde_as(as = "DisplayFromStr")]
+    #[config_env(leaf)]
+    pub deferred_poll_max_wait: IggyDuration,
+    /// Deferred polls and readiness waits parked on one shard. A request
+    /// above the limit is refused with a retryable error.
+    pub deferred_poll_max_pending: usize,
+    /// The part of [`Self::deferred_poll_max_pending`] that one session may
+    /// hold. An HTTP request without a binary session counts against its user.
+    pub deferred_poll_max_pending_per_session: usize,
+    /// The most disk bytes that one deferred poll reserves for its read: three
+    /// times its `max_bytes`, and at least three times the largest disk chunk
+    /// (1 MiB), up to this value. Its reply carries no more than this value, unless the
+    /// first message alone is wider.
+    #[config_env(leaf)]
+    pub deferred_poll_max_read_bytes: IggyByteSize,
+    /// Disk bytes that the deferred reads of one shard reserve together. A
+    /// read that finds no room waits for one. At most 1 GiB.
+    #[config_env(leaf)]
+    pub deferred_poll_max_inflight_bytes: IggyByteSize,
     /// Wall-clock budget for a single shard's bus drain on shutdown.
     /// Drives `IggyMessageBus::shutdown(..)` from the per-shard watchdog
     /// and the parallel-join survivor path. Sized larger than typical
@@ -161,6 +186,26 @@
             poll_completion_capacity: SERVER_CONFIG.sharding.poll_completion_capacity as usize,
             partition_io_capacity: SERVER_CONFIG.sharding.partition_io_capacity as usize,
             partition_io_bytes_max: None,
+            deferred_poll_max_wait: SERVER_CONFIG
+                .sharding
+                .deferred_poll_max_wait
+                .parse()
+                .unwrap(),
+            deferred_poll_max_pending: SERVER_CONFIG.sharding.deferred_poll_max_pending as usize,
+            deferred_poll_max_pending_per_session: SERVER_CONFIG
+                .sharding
+                .deferred_poll_max_pending_per_session
+                as usize,
+            deferred_poll_max_read_bytes: SERVER_CONFIG
+                .sharding
+                .deferred_poll_max_read_bytes
+                .parse()
+                .unwrap(),
+            deferred_poll_max_inflight_bytes: SERVER_CONFIG
+                .sharding
+                .deferred_poll_max_inflight_bytes
+                .parse()
+                .unwrap(),
             shutdown_drain_timeout: SERVER_CONFIG
                 .sharding
                 .shutdown_drain_timeout
@@ -253,6 +298,48 @@
             return Err(ConfigurationError::InvalidConfigurationValue);
         }
 
+        let max_wait = IggyDuration::from(Duration::from_micros(MAX_DEFERRED_POLL_WAIT_US));
+        if self.deferred_poll_max_wait.is_zero()
+            || self.deferred_poll_max_wait.get_duration() > max_wait.get_duration()
+        {
+            eprintln!(
+                "Invalid sharding.deferred_poll_max_wait: {} must be above 0 and at most {max_wait}",
+                self.deferred_poll_max_wait
+            );
+            return Err(ConfigurationError::InvalidConfigurationValue);
+        }
+        let pending = self.deferred_poll_max_pending;
+        if pending == 0 || pending > INBOX_CAPACITY_MAX {
+            eprintln!(
+                "Invalid sharding.deferred_poll_max_pending: {pending} must be in 1..={INBOX_CAPACITY_MAX}"
+            );
+            return Err(ConfigurationError::InvalidConfigurationValue);
+        }
+        let per_session = self.deferred_poll_max_pending_per_session;
+        if per_session == 0 || per_session > pending {
+            eprintln!(
+                "Invalid sharding.deferred_poll_max_pending_per_session: {per_session} must be in \
+                 1..={pending} (deferred_poll_max_pending)"
+            );
+            return Err(ConfigurationError::InvalidConfigurationValue);
+        }
+        let read_bytes = self.deferred_poll_max_read_bytes.as_bytes_u64();
+        let inflight_bytes = self.deferred_poll_max_inflight_bytes.as_bytes_u64();
+        if read_bytes == 0 || read_bytes > inflight_bytes {
+            eprintln!(
+                "Invalid sharding.deferred_poll_max_read_bytes: {read_bytes} bytes must be in \
+                 1..={inflight_bytes} (deferred_poll_max_inflight_bytes)"
+            );
+            return Err(ConfigurationError::InvalidConfigurationValue);
+        }
+        if inflight_bytes > DEFERRED_POLL_INFLIGHT_BYTES_MAX {
+            eprintln!(
+                "Invalid sharding.deferred_poll_max_inflight_bytes: {inflight_bytes} bytes must be \
+                 at most {DEFERRED_POLL_INFLIGHT_BYTES_MAX}"
+            );
+            return Err(ConfigurationError::InvalidConfigurationValue);
+        }
+
         let drain = self.shutdown_drain_timeout.get_duration();
         if drain.is_zero() {
             eprintln!(
diff --git a/core/connectors/runtime/Cargo.toml b/core/connectors/runtime/Cargo.toml
index c568ef2..ef5b855 100644
--- a/core/connectors/runtime/Cargo.toml
+++ b/core/connectors/runtime/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy-connectors"
-version = "0.5.1-edge.3"
+version = "0.5.1-edge.4"
 description = "Connectors runtime for Iggy message streaming platform"
 edition = "2024"
 license = "Apache-2.0"
diff --git a/core/connectors/runtime/README.md b/core/connectors/runtime/README.md
index bf4f920..d7c2f26 100644
--- a/core/connectors/runtime/README.md
+++ b/core/connectors/runtime/README.md
@@ -14,7 +14,7 @@
 
 The [docker image](https://hub.docker.com/r/apache/iggy-connect) is available, and can be fetched via `docker pull apache/iggy-connect:edge`.
 
-The runtime opens two Iggy TCP clients, one for producers and one for consumers. Set credentials matching the broker and a connector configuration provider. Omitted settings use the embedded defaults, including file-based source state storage. Save this example as `connectors.toml` in the repository root and replace `path/to/connectors` with your connector configuration directory.
+The runtime opens one Iggy TCP client that all sources share, and each sink opens its own client every time it starts. Set credentials matching the broker and a connector configuration provider. Omitted settings use the embedded defaults, including file-based source state storage. Save this example as `connectors.toml` in the repository root and replace `path/to/connectors` with your connector configuration directory.
 
 ```toml
 [iggy]
@@ -53,6 +53,26 @@
 
 Source destination topics must persist every acknowledged batch before the runtime checkpoints the source or invokes its Ack hook. Missing topics are therefore created with `durability = "persisted"` and `messages_required_to_save = 1`. An existing topic must use `durability = "persisted"`; its save threshold may differ because persisted acknowledgments already wait for durable storage.
 
+Sink consumers use long polling. A poll returns when `min_count` messages are
+ready (default 1), when its reply reaches 1 MiB, or when `max_wait` expires
+(default `1s`). Set both on a sink `[[streams]]` entry to batch messages, for
+example `max_wait = "3s"` with `min_count` equal to `batch_length`. `min_count`
+must not exceed `batch_length`. `max_wait` must be above zero and at most 10
+minutes, and the server shortens a wait above its `deferred_poll_max_wait`.
+Use `max_wait` and `min_count` in place of `poll_interval`. If a sink
+`[[streams]]` entry sets `poll_interval`, that sink fails to start. The server
+commits the offset of each sink poll when it answers the poll, before the
+plugin consumes the messages. Sinks need a server that supports deferred polls
+(binary commands 106 and 107).
+
+At each start, a sink connects an Iggy client of its own. After the last
+consumer of that start stops, the sink logs the client out and shuts it down.
+A poll of one sink that waits for messages thus does not delay the polls or
+offset stores of another sink. The consumers inside one sink share its client.
+They share the progress lane of each partition, which orders their polls and
+offset calls. They also share the limit of 64 deferred requests per client.
+The [SDK README](../../sdk/README.md) describes both.
+
 ## State storage
 
 Source plugins can supply optional checkpoint bytes. The runtime stores these opaque bytes using the backend selected by `state.storage`:
diff --git a/core/connectors/runtime/example_config/connectors/bigquery_sink.toml b/core/connectors/runtime/example_config/connectors/bigquery_sink.toml
index 188b118..b7433f8 100644
--- a/core/connectors/runtime/example_config/connectors/bigquery_sink.toml
+++ b/core/connectors/runtime/example_config/connectors/bigquery_sink.toml
@@ -28,7 +28,6 @@
 topics = ["example_topic"]
 schema = "json"
 batch_length = 1000
-poll_interval = "5ms"
 consumer_group = "bigquery_sink_connector"
 
 [plugin_config]
diff --git a/core/connectors/runtime/example_config/connectors/clickhouse_sink.toml b/core/connectors/runtime/example_config/connectors/clickhouse_sink.toml
index f118deb..c3df139 100644
--- a/core/connectors/runtime/example_config/connectors/clickhouse_sink.toml
+++ b/core/connectors/runtime/example_config/connectors/clickhouse_sink.toml
@@ -28,7 +28,6 @@
 topics = ["example_topic"]
 schema = "json"
 batch_length = 1000
-poll_interval = "5ms"
 consumer_group = "clickhouse_sink_connector"
 
 [plugin_config]
diff --git a/core/connectors/runtime/example_config/connectors/delta_sink.toml b/core/connectors/runtime/example_config/connectors/delta_sink.toml
index 319a967..225b8f4 100644
--- a/core/connectors/runtime/example_config/connectors/delta_sink.toml
+++ b/core/connectors/runtime/example_config/connectors/delta_sink.toml
@@ -28,7 +28,6 @@
 topics = ["delta_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "delta_sink_connector"
 
 [plugin_config]
diff --git a/core/connectors/runtime/example_config/connectors/doris_sink.toml b/core/connectors/runtime/example_config/connectors/doris_sink.toml
index eccb82f..aebed0c 100644
--- a/core/connectors/runtime/example_config/connectors/doris_sink.toml
+++ b/core/connectors/runtime/example_config/connectors/doris_sink.toml
@@ -29,7 +29,6 @@
 topics = ["doris_events"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "doris_sink"
 
 [plugin_config]
diff --git a/core/connectors/runtime/example_config/connectors/iceberg_sink.toml b/core/connectors/runtime/example_config/connectors/iceberg_sink.toml
index 9228be1..0f10796 100644
--- a/core/connectors/runtime/example_config/connectors/iceberg_sink.toml
+++ b/core/connectors/runtime/example_config/connectors/iceberg_sink.toml
@@ -28,7 +28,6 @@
 topics = ["example_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "iceberg_sink_connector"
 
 # Local S3 example
diff --git a/core/connectors/runtime/example_config/connectors/influxdb_sink.toml b/core/connectors/runtime/example_config/connectors/influxdb_sink.toml
index f41cad3..2920985 100644
--- a/core/connectors/runtime/example_config/connectors/influxdb_sink.toml
+++ b/core/connectors/runtime/example_config/connectors/influxdb_sink.toml
@@ -29,7 +29,6 @@
 topics = ["influx_events"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "influxdb_sink"
 
 [plugin_config]
diff --git a/core/connectors/runtime/example_config/connectors/quickwit_sink.toml b/core/connectors/runtime/example_config/connectors/quickwit_sink.toml
index d13d02f..db41010 100644
--- a/core/connectors/runtime/example_config/connectors/quickwit_sink.toml
+++ b/core/connectors/runtime/example_config/connectors/quickwit_sink.toml
@@ -29,7 +29,6 @@
 topics = ["records"]
 schema = "json"
 batch_length = 1000
-poll_interval = "5ms"
 consumer_group = "qw_sink_connector"
 
 [transforms.add_fields]
diff --git a/core/connectors/runtime/example_config/connectors/rabbitmq_sink.toml b/core/connectors/runtime/example_config/connectors/rabbitmq_sink.toml
index 8d09f65..e4c920b 100644
--- a/core/connectors/runtime/example_config/connectors/rabbitmq_sink.toml
+++ b/core/connectors/runtime/example_config/connectors/rabbitmq_sink.toml
@@ -28,7 +28,6 @@
 topics = ["example_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "rabbitmq_sink_connector"
 
 [plugin_config]
diff --git a/core/connectors/runtime/example_config/connectors/stdout_sink.toml b/core/connectors/runtime/example_config/connectors/stdout_sink.toml
index ca9e4f3..4a1a2bf 100644
--- a/core/connectors/runtime/example_config/connectors/stdout_sink.toml
+++ b/core/connectors/runtime/example_config/connectors/stdout_sink.toml
@@ -29,7 +29,6 @@
 topics = ["example_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "stdout_sink_connector"
 
 [plugin_config]
diff --git a/core/connectors/runtime/runtime.http b/core/connectors/runtime/runtime.http
index 3205c7b..ca6440c 100644
--- a/core/connectors/runtime/runtime.http
+++ b/core/connectors/runtime/runtime.http
@@ -71,7 +71,6 @@
         ],
         "schema": "json",
         "batch_length": 100,
-        "poll_interval": "5ms",
         "consumer_group": "stdout_sink_connector"
       }
     ],
diff --git a/core/connectors/runtime/src/api/error.rs b/core/connectors/runtime/src/api/error.rs
index 77125ba..7ee95c1 100644
--- a/core/connectors/runtime/src/api/error.rs
+++ b/core/connectors/runtime/src/api/error.rs
@@ -46,6 +46,7 @@
                     RuntimeError::MissingIggyCredentials => StatusCode::BAD_REQUEST,
                     RuntimeError::InvalidConfiguration(_) => StatusCode::BAD_REQUEST,
                     RuntimeError::InvalidConnectorKey(_) => StatusCode::BAD_REQUEST,
+                    RuntimeError::UnsupportedPollInterval(_) => StatusCode::BAD_REQUEST,
                     RuntimeError::CannotConvertConfiguration => StatusCode::BAD_REQUEST,
                     RuntimeError::SinkNotFound(_) => StatusCode::NOT_FOUND,
                     RuntimeError::SourceNotFound(_) => StatusCode::NOT_FOUND,
diff --git a/core/connectors/runtime/src/api/mod.rs b/core/connectors/runtime/src/api/mod.rs
index 5669ede..cca2746 100644
--- a/core/connectors/runtime/src/api/mod.rs
+++ b/core/connectors/runtime/src/api/mod.rs
@@ -224,7 +224,7 @@
 mod tests {
     use super::*;
     use crate::configs::connectors::create_connectors_config_provider;
-    use crate::configs::runtime::{ConnectorsConfig, LocalConnectorsConfig};
+    use crate::configs::runtime::{ConnectorsConfig, IggyConfig, LocalConnectorsConfig};
     use crate::manager::sink::SinkManager;
     use crate::manager::source::SourceManager;
     use crate::metrics::Metrics;
@@ -375,7 +375,7 @@
             start_time: IggyTimestamp::now(),
             iggy_clients: Arc::new(IggyClients {
                 producer: IggyClient::default(),
-                consumer: IggyClient::default(),
+                config: IggyConfig::default(),
             }),
             state_factory: Arc::new(FileStateFactory::new(
                 directory.path().display().to_string(),
diff --git a/core/connectors/runtime/src/api/sink.rs b/core/connectors/runtime/src/api/sink.rs
index d99e89c..3bf55cb 100644
--- a/core/connectors/runtime/src/api/sink.rs
+++ b/core/connectors/runtime/src/api/sink.rs
@@ -263,7 +263,6 @@
         .restart_connector(
             &key,
             context.config_provider.as_ref(),
-            &context.iggy_clients.consumer,
             &context.metrics,
             &context,
         )
diff --git a/core/connectors/runtime/src/configs/connectors.rs b/core/connectors/runtime/src/configs/connectors.rs
index 2a51f64..5e1ac4b 100644
--- a/core/connectors/runtime/src/configs/connectors.rs
+++ b/core/connectors/runtime/src/configs/connectors.rs
@@ -269,7 +269,11 @@
     #[config_env(leaf)]
     pub avro_schema_path: Option<PathBuf>,
     pub batch_length: Option<u32>,
+    /// Replaced by `max_wait` and `min_count`. Kept so that a sink that still
+    /// sets it fails to start: without the field, serde ignores the setting.
     pub poll_interval: Option<String>,
+    pub max_wait: Option<String>,
+    pub min_count: Option<u32>,
     pub consumer_group: Option<String>,
 }
 
@@ -444,7 +448,7 @@
     fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
         write!(
             f,
-            "{{ stream: {}, topics: {}, schema: {:?}, avro_schema_json: {:?}, avro_schema_path: {:?}, batch_length: {:?}, poll_interval: {:?}, consumer_group: {:?} }}",
+            "{{ stream: {}, topics: {}, schema: {:?}, avro_schema_json: {:?}, avro_schema_path: {:?}, batch_length: {:?}, poll_interval: {:?}, max_wait: {:?}, min_count: {:?}, consumer_group: {:?} }}",
             self.stream,
             self.topics
                 .iter()
@@ -456,6 +460,8 @@
             self.avro_schema_path,
             self.batch_length,
             self.poll_interval,
+            self.max_wait,
+            self.min_count,
             self.consumer_group
         )
     }
diff --git a/core/connectors/runtime/src/error.rs b/core/connectors/runtime/src/error.rs
index 9d415a7..35950b4 100644
--- a/core/connectors/runtime/src/error.rs
+++ b/core/connectors/runtime/src/error.rs
@@ -26,6 +26,10 @@
         max_length = crate::configs::connectors::ConnectorKey::MAX_LENGTH
     )]
     InvalidConnectorKey(String),
+    #[error(
+        "Sink stream {0:?} sets poll_interval, which is no longer supported. Set max_wait and min_count to batch messages"
+    )]
+    UnsupportedPollInterval(String),
     #[error("Failed to serialize topic metadata")]
     FailedToSerializeTopicMetadata,
     #[error("Failed to serialize messages metadata")]
@@ -93,6 +97,7 @@
             RuntimeError::MissingIggyCredentials => "invalid_configuration",
             RuntimeError::InvalidConfiguration(_) => "invalid_configuration",
             RuntimeError::InvalidConnectorKey(_) => "invalid_connector_key",
+            RuntimeError::UnsupportedPollInterval(_) => "invalid_configuration",
             RuntimeError::HttpRequestFailed(_) => "http_request_failed",
             RuntimeError::StateLoadFailed { .. } => "state_load_failed",
             RuntimeError::TokenFileNotFound(_) => "invalid_configuration",
diff --git a/core/connectors/runtime/src/main.rs b/core/connectors/runtime/src/main.rs
index b4c92d1..ad91890 100644
--- a/core/connectors/runtime/src/main.rs
+++ b/core/connectors/runtime/src/main.rs
@@ -32,7 +32,7 @@
 use dotenvy::dotenv;
 use error::RuntimeError;
 use figlet_rs::FIGlet;
-use iggy::prelude::{Client, IggyConsumer, IggyProducer};
+use iggy::prelude::{Client, IggyClient, IggyConsumer, IggyProducer};
 use iggy_connector_sdk::{
     StreamDecoder, StreamEncoder,
     api::ConnectorStatus,
@@ -247,7 +247,7 @@
     .await?;
 
     let sinks_config = connectors_config.sinks();
-    let (sinks, failed_sinks) = sink::init(sinks_config.clone(), &iggy_clients.consumer).await?;
+    let (sinks, failed_sinks) = sink::init(sinks_config.clone(), &iggy_clients).await?;
 
     let mut sink_wrappers = vec![];
     let mut sink_containers_by_key: HashMap<String, Arc<Container<SinkApi>>> = HashMap::new();
@@ -386,7 +386,6 @@
     }
 
     iggy_clients.producer.shutdown().await?;
-    iggy_clients.consumer.shutdown().await?;
     info!("All connectors closed. Runtime shutdown complete.");
     Ok(())
 }
@@ -521,6 +520,7 @@
     version: String,
     config_format: Option<ConfigFormat>,
     consumers: Vec<SinkConnectorConsumer>,
+    client: Option<Arc<IggyClient>>,
     error: Option<String>,
     verbose: bool,
     benchmark: bool,
diff --git a/core/connectors/runtime/src/manager/sink.rs b/core/connectors/runtime/src/manager/sink.rs
index 07e9452..74a4ead 100644
--- a/core/connectors/runtime/src/manager/sink.rs
+++ b/core/connectors/runtime/src/manager/sink.rs
@@ -24,7 +24,6 @@
 use crate::sink;
 use dashmap::DashMap;
 use dlopen2::wrapper::Container;
-use iggy::prelude::IggyClient;
 use iggy_connector_sdk::api::{ConnectorError, ConnectorStatus};
 use std::collections::HashMap;
 use std::fmt;
@@ -183,7 +182,6 @@
         &self,
         key: &str,
         config: &SinkConfig,
-        iggy_client: &IggyClient,
         metrics: &Arc<Metrics>,
         context: &Arc<RuntimeContext>,
     ) -> Result<(), RuntimeError> {
@@ -202,20 +200,34 @@
 
         let plugin_id = PLUGIN_ID.fetch_add(1, Ordering::SeqCst);
 
-        sink::init_sink(
-            &container,
-            &config.plugin_config.clone().unwrap_or_default(),
-            plugin_id,
-        )?;
-        info!("Sink connector with ID: {plugin_id} for plugin: {key} initialized successfully.");
-
-        let consumers = sink::setup_sink_consumers(key, config, iggy_client).await?;
+        // Connected before the plugin starts, so a refused connection leaves no
+        // plugin instance behind.
+        let client = context.iggy_clients.connect_sink_client().await?;
+        let setup = async {
+            sink::init_sink(
+                &container,
+                &config.plugin_config.clone().unwrap_or_default(),
+                plugin_id,
+            )?;
+            info!(
+                "Sink connector with ID: {plugin_id} for plugin: {key} initialized successfully."
+            );
+            sink::setup_sink_consumers(key, config, &client).await
+        };
+        let consumers = match setup.await {
+            Ok(consumers) => consumers,
+            Err(error) => {
+                sink::release_sink_client(client, plugin_id).await;
+                return Err(error);
+            }
+        };
 
         let callback = container.iggy_sink_consume;
         let (shutdown_tx, task_handles) = sink::spawn_consume_tasks(
             plugin_id,
             key,
             consumers,
+            client,
             callback,
             config.verbose,
             config.benchmark,
@@ -241,7 +253,6 @@
         &self,
         key: &str,
         config_provider: &dyn ConnectorsConfigProvider,
-        iggy_client: &IggyClient,
         metrics: &Arc<Metrics>,
         context: &Arc<RuntimeContext>,
     ) -> Result<(), RuntimeError> {
@@ -268,8 +279,7 @@
             .map_err(|e| RuntimeError::InvalidConfiguration(e.to_string()))?
             .ok_or_else(|| RuntimeError::SinkNotFound(key.to_string()))?;
 
-        self.start_connector(key, &config, iggy_client, metrics, context)
-            .await?;
+        self.start_connector(key, &config, metrics, context).await?;
         info!("Sink connector: {key} restarted successfully.");
         Ok(())
     }
diff --git a/core/connectors/runtime/src/sink.rs b/core/connectors/runtime/src/sink.rs
index 49459b8..6da5560 100644
--- a/core/connectors/runtime/src/sink.rs
+++ b/core/connectors/runtime/src/sink.rs
@@ -16,20 +16,21 @@
 // under the License.
 
 use crate::benchmark;
-use crate::configs::connectors::SinkConfig;
+use crate::configs::connectors::{SinkConfig, StreamConsumerConfig};
 use crate::context::RuntimeContext;
 use crate::log::LOG_CALLBACK;
 use crate::metrics::{ConnectorType, Metrics, SinkLabels};
+use crate::stream::IggyClients;
 use crate::{
     FailedPlugin, PLUGIN_ID, RuntimeError, SinkApi, SinkConnector, SinkConnectorConsumer,
     SinkConnectorPlugin, SinkConnectorWrapper, close_plugin_instance, resolve_plugin_path,
     transform,
 };
 use dlopen2::wrapper::Container;
-use futures::StreamExt;
+use futures::{Stream, StreamExt};
 use iggy::prelude::{
-    AutoCommit, AutoCommitWhen, IggyClient, IggyConsumer, IggyDuration, IggyMessage,
-    PollingStrategy,
+    AutoCommit, AutoCommitWhen, Client, DeferredPollOptions, IggyClient, IggyConsumer, IggyError,
+    IggyMessage, NonZeroIggyDuration, PollingStrategy, UserClient,
 };
 use iggy_connector_sdk::decoders::avro::{AvroConfig, AvroStreamDecoder};
 use iggy_connector_sdk::{
@@ -48,8 +49,8 @@
 
 /// Initializes all enabled sink connectors.
 ///
-/// Per-connector failures (path resolution, dlopen, plugin init,
-/// consumer/decoder/transform setup) are captured against the offending
+/// Per-connector failures (path resolution, dlopen, plugin init, client
+/// connect, consumer/decoder/transform setup) are captured against the offending
 /// connector and do not abort the runtime. Connectors that fail before their
 /// FFI container can be loaded are returned in the second tuple element so
 /// they remain visible in health/status output.
@@ -58,7 +59,7 @@
 /// propagated as `Err`.
 pub async fn init(
     sink_configs: HashMap<String, SinkConfig>,
-    iggy_client: &IggyClient,
+    iggy_clients: &IggyClients,
 ) -> Result<(HashMap<String, SinkConnector>, Vec<FailedPlugin>), RuntimeError> {
     let mut sink_connectors: HashMap<String, SinkConnector> = HashMap::new();
     let mut failed_plugins: Vec<FailedPlugin> = Vec::new();
@@ -145,6 +146,7 @@
             version,
             config_format: config.plugin_config_format,
             consumers: vec![],
+            client: None,
             error: init_error.clone(),
             verbose: config.verbose,
             benchmark: config.benchmark,
@@ -155,8 +157,18 @@
             continue;
         }
 
-        match setup_sink_consumers(&key, &config, iggy_client).await {
-            Ok(consumers) => {
+        let setup = async {
+            let client = iggy_clients.connect_sink_client().await?;
+            match setup_sink_consumers(&key, &config, &client).await {
+                Ok(consumers) => Ok((client, consumers)),
+                Err(error) => {
+                    release_sink_client(client, plugin_id).await;
+                    Err(error)
+                }
+            }
+        };
+        match setup.await {
+            Ok((client, consumers)) => {
                 let connector = sink_connectors
                     .get_mut(&path)
                     .expect("sink connector was inserted above");
@@ -165,6 +177,7 @@
                     .iter_mut()
                     .find(|plugin| plugin.id == plugin_id)
                     .expect("sink plugin was pushed above");
+                plugin.client = Some(client);
                 for (consumer, decoder, batch_size, transforms) in consumers {
                     plugin.consumers.push(SinkConnectorConsumer {
                         consumer,
@@ -206,13 +219,15 @@
     let mut handles = Vec::new();
     for sink in sinks {
         for plugin in sink.plugins {
-            if let Some(error) = &plugin.error {
+            // Only a sink whose setup failed has no client, and its error says why.
+            let Some(client) = plugin.client else {
                 error!(
-                    "Failed to initialize sink connector with ID: {}: {error}. Skipping...",
+                    "Failed to initialize sink connector with ID: {}: {}. Skipping...",
                     plugin.id,
+                    plugin.error.as_deref().unwrap_or_default(),
                 );
                 continue;
-            }
+            };
             info!("Starting consume for sink with ID: {}...", plugin.id);
             let consumers = plugin
                 .consumers
@@ -223,6 +238,7 @@
                 plugin.id,
                 &plugin.key,
                 consumers,
+                client,
                 sink.callback,
                 plugin.verbose,
                 plugin.benchmark,
@@ -235,6 +251,10 @@
     handles
 }
 
+/// Every task holds a reference to the `client` of this start, and the task
+/// that ends last logs it out and shuts it down. A consumer shutdown can still
+/// store offsets after a stop stops waiting for its task, so the client must
+/// outlive it.
 #[allow(clippy::type_complexity, clippy::too_many_arguments)]
 pub(crate) fn spawn_consume_tasks(
     plugin_id: u32,
@@ -245,6 +265,7 @@
         u32,
         Vec<Arc<dyn Transform>>,
     )>,
+    client: Arc<IggyClient>,
     callback: ConsumeCallback,
     verbose: bool,
     benchmark: bool,
@@ -258,14 +279,28 @@
         );
     }
     let (shutdown_tx, shutdown_rx) = watch::channel(());
+    if consumers.is_empty() {
+        // Without a task, nothing else shuts the client down.
+        tokio::spawn(release_sink_client(client, plugin_id));
+        return (shutdown_tx, Vec::new());
+    }
+    // Each task gets its own reference and none stays here, so the task that
+    // ends last finds the client unshared.
+    let clients = vec![client; consumers.len()];
     let mut task_handles = Vec::new();
     let labels = Arc::new(SinkLabels::new(plugin_key));
-    for (consumer, decoder, batch_size, transforms) in consumers {
+    for ((mut consumer, decoder, batch_size, transforms), client) in
+        consumers.into_iter().zip(clients)
+    {
         let plugin_key = plugin_key.to_string();
         let metrics = metrics.clone();
         let shutdown_rx = shutdown_rx.clone();
         let context = context.clone();
         let labels = labels.clone();
+        let topic_metadata = TopicMetadata {
+            stream: consumer.stream().to_string(),
+            topic: consumer.topic().to_string(),
+        };
         let handle = tokio::spawn(async move {
             if let Err(error) = consume_messages(
                 plugin_id,
@@ -273,7 +308,8 @@
                 batch_size,
                 callback,
                 transforms,
-                consumer,
+                topic_metadata,
+                &mut consumer,
                 verbose,
                 benchmark,
                 &plugin_key,
@@ -292,12 +328,38 @@
                     .set_error(&plugin_key, &error.to_string(), Some(&metrics))
                     .await;
             }
+            // The polls also committed the batches the consumer still holds,
+            // and only a shutdown moves the offsets back over them.
+            if let Err(error) = consumer.shutdown().await {
+                error!(
+                    "Failed to shut down the consumer of sink connector with ID: {plugin_id}: {error}"
+                );
+            }
+            release_sink_client(client, plugin_id).await;
         });
         task_handles.push(handle);
     }
     (shutdown_tx, task_handles)
 }
 
+/// Logs the client of a sink start out and shuts it down when the caller holds
+/// its last reference. Each holder calls it once it is done with the client. A
+/// closed connection alone keeps its session, and with it the group members,
+/// until the session times out.
+pub(crate) async fn release_sink_client(client: Arc<IggyClient>, plugin_id: u32) {
+    let Some(client) = Arc::into_inner(client) else {
+        return;
+    };
+    if let Err(error) = client.logout_user().await {
+        warn!("Failed to log out the Iggy client of sink connector with ID: {plugin_id}: {error}");
+    }
+    if let Err(error) = client.shutdown().await {
+        error!(
+            "Failed to shut down the Iggy client of sink connector with ID: {plugin_id}: {error}"
+        );
+    }
+}
+
 #[allow(clippy::too_many_arguments)]
 pub(crate) async fn consume_messages(
     plugin_id: u32,
@@ -305,7 +367,8 @@
     batch_size: u32,
     consume: ConsumeCallback,
     transforms: Vec<Arc<dyn Transform>>,
-    mut consumer: IggyConsumer,
+    topic_metadata: TopicMetadata,
+    mut messages: impl Stream<Item = Result<iggy::prelude::ReceivedMessage, IggyError>> + Unpin,
     verbose: bool,
     benchmark: bool,
     plugin_key: &str,
@@ -316,40 +379,59 @@
     info!("Started consuming messages for sink connector with ID: {plugin_id}");
     let batch_size = batch_size as usize;
     let mut batch = Vec::with_capacity(batch_size);
-    let topic_metadata = TopicMetadata {
-        stream: consumer.stream().to_string(),
-        topic: consumer.topic().to_string(),
-    };
+    // The partition of the batch and its current offset at the last poll. The
+    // plugin learns one partition per batch, so another partition flushes it.
+    let mut buffered = None;
+    let mut next = None;
 
     loop {
-        let message = tokio::select! {
-            _ = shutdown_rx.changed() => {
-                info!("Sink connector with ID: {plugin_id} received shutdown signal");
+        let message = match next.take() {
+            Some(message) => Some(message),
+            None => {
+                let message = tokio::select! {
+                    _ = shutdown_rx.changed() => {
+                        info!("Sink connector with ID: {plugin_id} received shutdown signal");
+                        None
+                    }
+                    msg = messages.next() => msg,
+                };
+                match message {
+                    Some(Ok(message)) => Some(message),
+                    Some(Err(_)) => {
+                        error!(
+                            "Failed to receive message for sink connector with ID: {plugin_id} from stream: {}, topic: {}",
+                            topic_metadata.stream, topic_metadata.topic
+                        );
+                        metrics.inc_errors_with_labels(&labels.counter);
+                        continue;
+                    }
+                    None => None,
+                }
+            }
+        };
+
+        // The poll committed the batch held here, so a stop still hands it to
+        // the plugin instead of dropping it.
+        let stopping = message.is_none();
+        if let Some(message) = message {
+            if buffered.is_some_and(|(partition_id, _)| partition_id != message.partition_id) {
+                next = Some(message);
+            } else {
+                let message_offset = message.message.header.offset;
+                buffered = Some((message.partition_id, message.current_offset));
+                batch.push(message.message);
+                if message.current_offset != message_offset && batch.len() < batch_size {
+                    continue;
+                }
+            }
+        }
+        let Some((partition_id, current_offset)) = buffered.take() else {
+            if stopping {
                 break;
             }
-            msg = consumer.next() => msg,
-        };
-
-        let Some(message) = message else {
-            break;
-        };
-        let Ok(message) = message else {
-            error!(
-                "Failed to receive message for sink connector with ID: {plugin_id} from stream: {}, topic: {}",
-                topic_metadata.stream, topic_metadata.topic
-            );
-            metrics.inc_errors_with_labels(&labels.counter);
             continue;
         };
 
-        let partition_id = message.partition_id;
-        let current_offset = message.current_offset;
-        let message_offset = message.message.header.offset;
-        batch.push(message.message);
-        if current_offset != message_offset && batch.len() < batch_size {
-            continue;
-        }
-
         let messages = std::mem::take(&mut batch);
         let messages_count = messages.len();
         metrics.inc_messages_consumed_with_labels(&labels.counter, messages_count as u64);
@@ -437,6 +519,9 @@
                 elapsed
             );
         }
+        if stopping {
+            break;
+        }
     }
     info!("Stopped consuming messages for sink connector with ID: {plugin_id}");
     Ok(())
@@ -499,12 +584,7 @@
 
     let mut consumers = Vec::new();
     for stream in config.streams.iter() {
-        let poll_interval = IggyDuration::from_str(
-            stream.poll_interval.as_deref().unwrap_or("5ms"),
-        )
-        .map_err(|error| {
-            RuntimeError::InvalidConfiguration(format!("Invalid poll interval: {error}"))
-        })?;
+        let poll_options = poll_options(stream)?;
         let default_consumer_group = format!("iggy-connect-sink-{key}");
         let consumer_group = stream
             .consumer_group
@@ -518,8 +598,8 @@
                 .create_consumer_group_if_not_exists()
                 .auto_join_consumer_group()
                 .polling_strategy(PollingStrategy::next())
-                .poll_interval(poll_interval)
                 .batch_length(batch_length)
+                .poll_options(poll_options)
                 .build();
             consumer.init().await?;
             let decoder: Arc<dyn StreamDecoder> = match stream.schema {
@@ -544,6 +624,28 @@
     Ok(consumers)
 }
 
+/// A configured wait extends the default request timeout by its length, so the
+/// final read and the reply keep the default time after the wait.
+fn poll_options(stream: &StreamConsumerConfig) -> Result<DeferredPollOptions, RuntimeError> {
+    if stream.poll_interval.is_some() {
+        return Err(RuntimeError::UnsupportedPollInterval(stream.stream.clone()));
+    }
+    let mut options = DeferredPollOptions::default();
+    if let Some(max_wait) = stream.max_wait.as_deref() {
+        // A zero wait answers at once, so an idle topic would be polled constantly.
+        let max_wait = NonZeroIggyDuration::from_str(max_wait).map_err(|error| {
+            RuntimeError::InvalidConfiguration(format!("Invalid max wait: {error}"))
+        })?;
+        options.request_timeout =
+            (options.request_timeout.get_duration() + max_wait.get_duration()).into();
+        options.max_wait = max_wait.into();
+    }
+    if let Some(min_count) = stream.min_count {
+        options.min_count = min_count;
+    }
+    Ok(options)
+}
+
 #[allow(clippy::too_many_arguments)]
 async fn process_messages(
     plugin_id: u32,
@@ -848,6 +950,7 @@
     static TEST_PLUGIN_ID: AtomicU32 = AtomicU32::new(u32::MAX / 2);
 
     struct ConsumedBatch {
+        partition_id: u32,
         metadata_schema: Schema,
         messages_schema: Schema,
         payloads: Vec<Vec<u8>>,
@@ -881,6 +984,7 @@
         let raw = postcard::from_bytes::<RawMessages>(messages).expect("failed to deserialize");
 
         CONSUMED.entry(plugin_id).or_default().push(ConsumedBatch {
+            partition_id: metadata.partition_id,
             metadata_schema: metadata.schema,
             messages_schema: raw.schema,
             offsets: raw.messages.iter().map(|message| message.offset).collect(),
@@ -1155,6 +1259,119 @@
     }
 
     #[tokio::test]
+    async fn given_replies_of_two_partitions_when_consumed_should_send_each_partition_in_its_own_batch()
+     {
+        // Larger than any batch here, so only a partition head or another
+        // partition flushes one.
+        const BATCH_SIZE: u32 = 16;
+        let plugin_id = next_plugin_id();
+        let received = |partition_id, offset, current_offset| {
+            Ok(iggy::prelude::ReceivedMessage {
+                context: iggy::prelude::PartitionContext::default(),
+                message: test_message(offset, br#"{"id":1}"#.to_vec()),
+                current_offset,
+                partition_id,
+            })
+        };
+        // Byte-limited replies stop each partition short of its head.
+        let messages = futures::stream::iter([
+            received(1, 0, 2),
+            received(1, 1, 2),
+            received(2, 0, 1),
+            received(1, 2, 2),
+            received(2, 1, 1),
+        ]);
+        let (_shutdown, shutdown_rx) = watch::channel(());
+
+        consume_messages(
+            plugin_id,
+            Schema::Json.decoder(),
+            BATCH_SIZE,
+            capturing_consume,
+            Vec::new(),
+            TopicMetadata {
+                stream: "test_stream".to_owned(),
+                topic: "test_topic".to_owned(),
+            },
+            messages,
+            false,
+            false,
+            "test_sink",
+            &Arc::new(Metrics::init()),
+            &SinkLabels::new("test_sink"),
+            shutdown_rx,
+        )
+        .await
+        .expect("consuming the stream should succeed");
+
+        let batches: Vec<_> = captured(plugin_id)
+            .into_iter()
+            .map(|batch| (batch.partition_id, batch.offsets))
+            .collect();
+        assert_eq!(
+            batches,
+            [(1, vec![0, 1]), (2, vec![0]), (1, vec![2]), (2, vec![1])]
+        );
+    }
+
+    #[tokio::test]
+    async fn given_a_batch_short_of_the_partition_head_when_shutdown_arrives_should_still_send_it()
+    {
+        const BATCH_SIZE: u32 = 16;
+        let plugin_id = next_plugin_id();
+        let (shutdown_tx, shutdown_rx) = watch::channel(());
+        let received = |offset| {
+            Ok(iggy::prelude::ReceivedMessage {
+                context: iggy::prelude::PartitionContext::default(),
+                message: test_message(offset, br#"{"id":1}"#.to_vec()),
+                current_offset: TEST_CURRENT_OFFSET,
+                partition_id: 1,
+            })
+        };
+        // A byte-limited reply ends short of the head, so the loop asks for
+        // more. That poll parks, and the shutdown arrives while it waits.
+        let messages = Box::pin(futures::stream::iter([received(0), received(1)]).chain(
+            futures::stream::once(async move {
+                shutdown_tx
+                    .send(())
+                    .expect("the sink loop should still listen for shutdown");
+                std::future::pending().await
+            }),
+        ));
+
+        consume_messages(
+            plugin_id,
+            Schema::Json.decoder(),
+            BATCH_SIZE,
+            capturing_consume,
+            Vec::new(),
+            TopicMetadata {
+                stream: "test_stream".to_owned(),
+                topic: "test_topic".to_owned(),
+            },
+            messages,
+            false,
+            false,
+            "test_sink",
+            &Arc::new(Metrics::init()),
+            &SinkLabels::new("test_sink"),
+            shutdown_rx,
+        )
+        .await
+        .expect("consuming the stream should succeed");
+
+        let batches: Vec<_> = captured(plugin_id)
+            .into_iter()
+            .map(|batch| (batch.partition_id, batch.offsets))
+            .collect();
+        assert_eq!(
+            batches,
+            [(1, vec![0, 1])],
+            "the poll committed this batch, so a shutdown must not drop it"
+        );
+    }
+
+    #[tokio::test]
     async fn given_mixed_payload_variants_when_batch_is_processed_should_split_into_runs() {
         let plugin_id = next_plugin_id();
         let (transform, messages) = split_batch();
@@ -1345,4 +1562,66 @@
                 .expect("the proto run must rebuild");
         assert_eq!(rebuilt.schema(), Schema::Proto);
     }
+
+    #[test]
+    fn given_max_wait_and_min_count_when_building_poll_options_should_wait_for_a_batch() {
+        let default = DeferredPollOptions::default();
+        let unset = poll_options(&StreamConsumerConfig::default()).expect("defaults are valid");
+        assert_eq!(unset, default);
+
+        let stream = StreamConsumerConfig {
+            max_wait: Some("30s".to_owned()),
+            min_count: Some(500),
+            ..StreamConsumerConfig::default()
+        };
+        let options = poll_options(&stream).expect("a 30 s wait is valid");
+
+        assert_eq!(options.max_wait.get_duration(), Duration::from_secs(30));
+        assert_eq!(options.min_count, 500);
+        assert_eq!(
+            options.request_timeout.get_duration(),
+            default.request_timeout.get_duration() + Duration::from_secs(30)
+        );
+        assert_eq!(options.validate(1000), Ok(()));
+    }
+
+    #[test]
+    fn given_a_zero_max_wait_when_building_poll_options_should_refuse_it() {
+        for max_wait in ["0", "unlimited"] {
+            let stream = StreamConsumerConfig {
+                max_wait: Some(max_wait.to_owned()),
+                ..StreamConsumerConfig::default()
+            };
+            assert!(
+                matches!(
+                    poll_options(&stream),
+                    Err(RuntimeError::InvalidConfiguration(_))
+                ),
+                "max_wait {max_wait} would poll an idle topic constantly"
+            );
+        }
+    }
+
+    #[test]
+    fn given_a_poll_interval_when_building_poll_options_should_refuse_it() {
+        let stream = StreamConsumerConfig {
+            stream: "orders".to_owned(),
+            poll_interval: Some("3s".to_owned()),
+            max_wait: Some("3s".to_owned()),
+            min_count: Some(500),
+            ..StreamConsumerConfig::default()
+        };
+
+        let error = poll_options(&stream).expect_err("poll_interval is no longer supported");
+
+        assert!(
+            matches!(&error, RuntimeError::UnsupportedPollInterval(name) if name == "orders"),
+            "unexpected error: {error}"
+        );
+        let message = error.to_string();
+        assert!(
+            message.contains("max_wait") && message.contains("min_count"),
+            "the error must name the settings that replace poll_interval, got: {message}"
+        );
+    }
 }
diff --git a/core/connectors/runtime/src/source.rs b/core/connectors/runtime/src/source.rs
index 6f2c528..85bd678 100644
--- a/core/connectors/runtime/src/source.rs
+++ b/core/connectors/runtime/src/source.rs
@@ -1256,7 +1256,7 @@
 mod tests {
     use super::*;
     use crate::configs::connectors::create_connectors_config_provider;
-    use crate::configs::runtime::{ConnectorsConfig, LocalConnectorsConfig};
+    use crate::configs::runtime::{ConnectorsConfig, IggyConfig, LocalConnectorsConfig};
     use crate::manager::sink::SinkManager;
     use crate::manager::source::{SourceDetails, SourceInfo, SourceManager};
     use crate::metrics::Metrics;
@@ -1354,7 +1354,7 @@
             start_time: IggyTimestamp::now(),
             iggy_clients: Arc::new(IggyClients {
                 producer: IggyClient::default(),
-                consumer: IggyClient::default(),
+                config: IggyConfig::default(),
             }),
             state_factory,
         });
diff --git a/core/connectors/runtime/src/stream.rs b/core/connectors/runtime/src/stream.rs
index 83b2dc6..0b8398f 100644
--- a/core/connectors/runtime/src/stream.rs
+++ b/core/connectors/runtime/src/stream.rs
@@ -17,6 +17,7 @@
 
 use iggy::prelude::{Client, IggyClient, IggyClientBuilder};
 use std::path::PathBuf;
+use std::sync::Arc;
 use tracing::{error, info};
 
 use crate::configs::runtime::IggyConfig;
@@ -74,14 +75,24 @@
 
 pub struct IggyClients {
     pub producer: IggyClient,
-    pub consumer: IggyClient,
+    /// Each sink start connects its own client with it.
+    pub config: IggyConfig,
 }
 
 pub async fn init(config: IggyConfig) -> Result<IggyClients, RuntimeError> {
-    let consumer = create_client(&config).await?;
     let producer = create_client(&config).await?;
-    let iggy_clients = IggyClients { producer, consumer };
-    Ok(iggy_clients)
+    Ok(IggyClients { producer, config })
+}
+
+impl IggyClients {
+    /// A client of its own for one sink start. The consumers of one client
+    /// share its progress lanes and deferred request slots, so on a shared
+    /// client a parked poll of one sink holds back the polls and offset stores
+    /// of another. A stopped start that leaves its group late also cannot touch
+    /// the group membership of the next start.
+    pub async fn connect_sink_client(&self) -> Result<Arc<IggyClient>, RuntimeError> {
+        create_client(&self.config).await.map(Arc::new)
+    }
 }
 
 /// Builds the authenticated connection string for `config`, resolving a
diff --git a/core/connectors/sdk/README.md b/core/connectors/sdk/README.md
index a706051..c3e874a 100644
--- a/core/connectors/sdk/README.md
+++ b/core/connectors/sdk/README.md
@@ -174,7 +174,7 @@
 
 The format-conversion transforms define no per-key defaults. Every non-optional key shown above must be present, or the configuration fails to deserialize (`schema_path`, `message_type`, `field_mappings`, and `descriptor_set` are optional).
 
-The two `[[streams]]` shapes differ: a source produces to a single `topic` and can tune batching via `batch_length` and `linger_time`, while a sink consumes from a list of `topics` and can additionally set `batch_length`, `poll_interval`, and `consumer_group`.
+The two `[[streams]]` shapes differ. A source produces to a single `topic` and can tune batching with `batch_length` and `linger_time`. A sink consumes from a list of `topics` and can set `batch_length` and `consumer_group`.
 
 Transforms are keyed by type, so one connector configures at most one `proto_convert`.
 
diff --git a/core/connectors/sinks/README.md b/core/connectors/sinks/README.md
index 70a2894..37da4ea 100644
--- a/core/connectors/sinks/README.md
+++ b/core/connectors/sinks/README.md
@@ -93,7 +93,6 @@
 topics = ["example_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "stdout_sink_connector"
 
 # Custom configuration for the sink connector, deserialized to type T from `plugin_config` field
diff --git a/core/connectors/sinks/bigquery_sink/README.md b/core/connectors/sinks/bigquery_sink/README.md
index 5c680c4..f7fa1c3 100644
--- a/core/connectors/sinks/bigquery_sink/README.md
+++ b/core/connectors/sinks/bigquery_sink/README.md
@@ -27,7 +27,6 @@
 topics = ["example_topic"]
 schema = "json"
 batch_length = 1000
-poll_interval = "5ms"
 consumer_group = "bigquery_sink_connector"
 
 [plugin_config]
@@ -228,7 +227,6 @@
     topics = ["demo_topic"]
     schema = "json"
     batch_length = 100
-    poll_interval = "100ms"
     consumer_group = "bigquery_sink_test"
 
     [plugin_config]
diff --git a/core/connectors/sinks/bigquery_sink/config.toml b/core/connectors/sinks/bigquery_sink/config.toml
index 188b118..b7433f8 100644
--- a/core/connectors/sinks/bigquery_sink/config.toml
+++ b/core/connectors/sinks/bigquery_sink/config.toml
@@ -28,7 +28,6 @@
 topics = ["example_topic"]
 schema = "json"
 batch_length = 1000
-poll_interval = "5ms"
 consumer_group = "bigquery_sink_connector"
 
 [plugin_config]
diff --git a/core/connectors/sinks/clickhouse_sink/README.md b/core/connectors/sinks/clickhouse_sink/README.md
index 95dc378..fae5ceb 100644
--- a/core/connectors/sinks/clickhouse_sink/README.md
+++ b/core/connectors/sinks/clickhouse_sink/README.md
@@ -24,7 +24,6 @@
 topics = ["example_topic"]
 schema = "json"
 batch_length = 1000
-poll_interval = "5ms"
 consumer_group = "clickhouse_sink_connector"
 
 [plugin_config]
@@ -112,7 +111,6 @@
 topics = ["user_events"]
 schema = "json"
 batch_length = 500
-poll_interval = "10ms"
 consumer_group = "clickhouse_sink"
 
 [plugin_config]
@@ -130,7 +128,6 @@
 topics = ["app_metrics"]
 schema = "json"
 batch_length = 5000
-poll_interval = "5ms"
 consumer_group = "clickhouse_sink"
 
 [plugin_config]
@@ -151,7 +148,6 @@
 topics = ["csv_data"]
 schema = "text"
 batch_length = 1000
-poll_interval = "50ms"
 consumer_group = "clickhouse_sink"
 
 [plugin_config]
diff --git a/core/connectors/sinks/clickhouse_sink/config.toml b/core/connectors/sinks/clickhouse_sink/config.toml
index 3bb217c..ae1f68e 100644
--- a/core/connectors/sinks/clickhouse_sink/config.toml
+++ b/core/connectors/sinks/clickhouse_sink/config.toml
@@ -28,7 +28,6 @@
 topics = ["example_topic"]
 schema = "json"
 batch_length = 1000
-poll_interval = "5ms"
 consumer_group = "clickhouse_sink_connector"
 
 [plugin_config]
diff --git a/core/connectors/sinks/delta_sink/README.md b/core/connectors/sinks/delta_sink/README.md
index 95ffe91..959344e 100644
--- a/core/connectors/sinks/delta_sink/README.md
+++ b/core/connectors/sinks/delta_sink/README.md
@@ -56,9 +56,10 @@
   stream = "your_stream"
   topics = ["topic_inside_of_your_stream"]
   schema = "json"
-  # important: read about batch and poll interval settings below
+  # important: read about the batch and wait settings below
   batch_length = 10000
-  poll_interval = "3s"
+  max_wait = "3s"
+  min_count = 10000
   consumer_group = "delta_sink_connector"
 
   # these settings are specific to each plugin, and in case of Delta sink, to the type of storage used
@@ -73,8 +74,9 @@
 ### `[[streams]]` section
 
 - Find a topic that you need and the corresponding stream that this topic is in and add it into the configuration.
-- `batch_length` and `poll_interval` have to be carefully set for Delta tables. `poll_interval` sets the minimum gap between polls. Each poll returns immediately with whatever messages are currently available, up to `batch_length`. The Delta sink writes and commits exactly what one poll returns, so each Delta commit holds at most `batch_length` rows, and topic intake is capped at roughly `batch_length` messages per `poll_interval` per topic.
-  `batch_length` is a ceiling: it only produces large files when messages arrive fast enough between polls to fill it, so a low-throughput topic produces small, frequent commits regardless of how high `batch_length` is set. For Delta, writing means creating a log entry and a separate Parquet file, so a low `batch_length` (roughly < 1000) or a low-throughput topic results in lots of small files.
+- `batch_length`, `max_wait` and `min_count` have to be carefully set for Delta tables. A poll waits up to `max_wait` until `min_count` messages are ready, and returns earlier when its reply reaches 1 MiB. The runtime keeps polling while the partition holds more messages and the batch holds fewer than `batch_length` messages.
+  Then the Delta sink writes and commits the batch, so each Delta commit holds at most `batch_length` rows. With `min_count` equal to `batch_length`, a low-throughput topic produces one commit per `max_wait`.
+  `batch_length` is a ceiling: it only produces large files when messages arrive fast enough within `max_wait` to fill it, so a low-throughput topic produces small commits regardless of how high `batch_length` is set. For Delta, writing means creating a log entry and a separate Parquet file, so a low `batch_length` (roughly < 1000) or a low-throughput topic results in lots of small files.
   This is highly undesirable for the reader as it will have to read from many small files instead of a few larger ones. For query performance optimization, the system consuming these files will have to apply `OPTIMIZE` query in order to consolidate the files. We recommend setting `batch_length` pretty high based on your workload so that ideally the files are already optimized for reading.
   The guideline recommended by the developers of Delta is to keep individual file sizes between 128 MB and 1 GB. In the context of this document, it means that ideally `batch_length` should be tuned so that each commit's file size falls within the suggested range, assuming your topic has enough throughput to fill it. The given range is only a rough guideline and you need to find a good setting based on the patterns of reading and writing in your systems.
   A larger `batch_length` means a failed write loses more data: offsets commit at poll time, so if the Delta write or commit fails afterward, that whole batch is lost with no retry. It also sits in memory longer and in multiple copies — raw messages, coerced JSON, and the writer's internal buffer — until the commit succeeds.
diff --git a/core/connectors/sinks/delta_sink/config.toml b/core/connectors/sinks/delta_sink/config.toml
index 87d7e1f..af8089c 100644
--- a/core/connectors/sinks/delta_sink/config.toml
+++ b/core/connectors/sinks/delta_sink/config.toml
@@ -28,7 +28,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "delta_sink_connector"
 
 # table_uri: Absolute URI of an existing Delta table; bare paths are not accepted.
diff --git a/core/connectors/sinks/doris_sink/README.md b/core/connectors/sinks/doris_sink/README.md
index 2c786e1..61e50eb 100644
--- a/core/connectors/sinks/doris_sink/README.md
+++ b/core/connectors/sinks/doris_sink/README.md
@@ -70,7 +70,6 @@
 topics = ["doris_events"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "doris_sink"
 
 [plugin_config]
diff --git a/core/connectors/sinks/doris_sink/config.toml b/core/connectors/sinks/doris_sink/config.toml
index 2c595c7..30878eb 100644
--- a/core/connectors/sinks/doris_sink/config.toml
+++ b/core/connectors/sinks/doris_sink/config.toml
@@ -28,7 +28,6 @@
 topics = []
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "doris_sink"
 
 [plugin_config]
diff --git a/core/connectors/sinks/elasticsearch_sink/config.toml b/core/connectors/sinks/elasticsearch_sink/config.toml
index 63e9df7..b89acee 100644
--- a/core/connectors/sinks/elasticsearch_sink/config.toml
+++ b/core/connectors/sinks/elasticsearch_sink/config.toml
@@ -28,7 +28,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "elasticsearch_sink_cg"
 
 [plugin_config]
diff --git a/core/connectors/sinks/http_sink/README.md b/core/connectors/sinks/http_sink/README.md
index 97a9695..b5d2dc7 100644
--- a/core/connectors/sinks/http_sink/README.md
+++ b/core/connectors/sinks/http_sink/README.md
@@ -54,7 +54,6 @@
 topics = ["demo_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "100ms"
 consumer_group = "test_cg"
 [plugin_config]
 url = "http://localhost:9090/ingest"
@@ -105,7 +104,6 @@
 topics = ["notifications"]
 schema = "json"
 batch_length = 50
-poll_interval = "100ms"
 consumer_group = "http_sink"
 
 [plugin_config]
@@ -318,7 +316,6 @@
 topics = ["temperature", "pressure"]
 schema = "raw"
 batch_length = 200
-poll_interval = "50ms"
 consumer_group = "sensor_relay"
 
 [plugin_config]
@@ -342,7 +339,6 @@
 topics = ["application", "infrastructure", "security"]
 schema = "json"
 batch_length = 500
-poll_interval = "200ms"
 consumer_group = "log_forwarder"
 
 [plugin_config]
@@ -464,7 +460,6 @@
 topics = ["clicks", "views"]
 schema = "json"
 batch_length = 100
-poll_interval = "100ms"
 consumer_group = "http_sink_events"
 
 [[streams]]
@@ -472,7 +467,6 @@
 topics = ["created"]
 schema = "json"
 batch_length = 50
-poll_interval = "200ms"
 consumer_group = "http_sink_orders"
 
 [plugin_config]
@@ -534,7 +528,6 @@
 topics = ["clicks"]
 schema = "json"
 batch_length = 500
-poll_interval = "50ms"
 consumer_group = "analytics_sink"
 
 [plugin_config]
@@ -561,7 +554,6 @@
 topics = ["orders"]
 schema = "json"
 batch_length = 50
-poll_interval = "200ms"
 consumer_group = "billing_sink"
 
 [plugin_config]
@@ -590,7 +582,6 @@
 topics = ["alerts"]
 schema = "json"
 batch_length = 1
-poll_interval = "500ms"
 consumer_group = "slack_sink"
 
 [plugin_config]
diff --git a/core/connectors/sinks/http_sink/config.toml b/core/connectors/sinks/http_sink/config.toml
index 83af481..f06ac83 100644
--- a/core/connectors/sinks/http_sink/config.toml
+++ b/core/connectors/sinks/http_sink/config.toml
@@ -28,7 +28,6 @@
 topics = ["my_topic"]
 schema = "json"
 batch_length = 50
-poll_interval = "100ms"
 consumer_group = "http_sink_group"
 
 [plugin_config]
diff --git a/core/connectors/sinks/iceberg_sink/config.toml b/core/connectors/sinks/iceberg_sink/config.toml
index 7ab0612..a4fa55c 100644
--- a/core/connectors/sinks/iceberg_sink/config.toml
+++ b/core/connectors/sinks/iceberg_sink/config.toml
@@ -28,7 +28,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "iceberg_sink_connector"
 
 [plugin_config]
diff --git a/core/connectors/sinks/influxdb_sink/README.md b/core/connectors/sinks/influxdb_sink/README.md
index 361e61d..9c736b3 100644
--- a/core/connectors/sinks/influxdb_sink/README.md
+++ b/core/connectors/sinks/influxdb_sink/README.md
@@ -126,7 +126,6 @@
 topics = ["cpu"]
 schema = "json"
 batch_length = 100
-poll_interval = "10ms"
 consumer_group = "influxdb_sink"
 
 [plugin_config]
diff --git a/core/connectors/sinks/influxdb_sink/config.toml b/core/connectors/sinks/influxdb_sink/config.toml
index 7d32488..aceb229 100644
--- a/core/connectors/sinks/influxdb_sink/config.toml
+++ b/core/connectors/sinks/influxdb_sink/config.toml
@@ -28,7 +28,6 @@
 topics = ["users", "orders"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "influxdb_sink"
 
 [plugin_config]
diff --git a/core/connectors/sinks/meilisearch_sink/config.toml b/core/connectors/sinks/meilisearch_sink/config.toml
index 1d99631..71694a7 100644
--- a/core/connectors/sinks/meilisearch_sink/config.toml
+++ b/core/connectors/sinks/meilisearch_sink/config.toml
@@ -28,7 +28,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "meilisearch_sink"
 
 [plugin_config]
diff --git a/core/connectors/sinks/mongodb_sink/README.md b/core/connectors/sinks/mongodb_sink/README.md
index 05f3d48..8686eb0 100644
--- a/core/connectors/sinks/mongodb_sink/README.md
+++ b/core/connectors/sinks/mongodb_sink/README.md
@@ -44,7 +44,6 @@
 topics = ["demo_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "100ms"
 consumer_group = "test_cg"
 [plugin_config]
 connection_uri = "mongodb://localhost:27017"
@@ -81,7 +80,6 @@
 topics = ["demo_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "100ms"
 consumer_group = "mongodb_cg"
 
 [plugin_config]
diff --git a/core/connectors/sinks/mongodb_sink/config.toml b/core/connectors/sinks/mongodb_sink/config.toml
index 3b47b4a..3d8e33a 100644
--- a/core/connectors/sinks/mongodb_sink/config.toml
+++ b/core/connectors/sinks/mongodb_sink/config.toml
@@ -28,7 +28,6 @@
 topics = ["users", "orders"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "mongodb_sink"
 
 [plugin_config]
diff --git a/core/connectors/sinks/postgres_sink/README.md b/core/connectors/sinks/postgres_sink/README.md
index 3d4b145..33207df 100644
--- a/core/connectors/sinks/postgres_sink/README.md
+++ b/core/connectors/sinks/postgres_sink/README.md
@@ -38,7 +38,6 @@
 topics = ["users", "orders"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "postgres_sink"
 
 [plugin_config]
@@ -185,7 +184,6 @@
 
 - Increase `batch_size` for higher throughput (larger batches = fewer round trips)
 - Adjust `max_connections` based on PostgreSQL's `max_connections` setting
-- Use `poll_interval` to control how often the sink checks for new messages
 - Use `payload_format = "json"` for JSON data to enable native querying
 
 The sink caps each statement at PostgreSQL's 65,535 bind parameters based on
@@ -206,7 +204,6 @@
 topics = ["user_events"]
 schema = "json"
 batch_length = 100
-poll_interval = "10ms"
 consumer_group = "pg_sink"
 
 [plugin_config]
@@ -225,7 +222,6 @@
 topics = ["images", "files"]
 schema = "raw"
 batch_length = 50
-poll_interval = "100ms"
 consumer_group = "pg_sink"
 
 [plugin_config]
@@ -244,7 +240,6 @@
 topics = ["app_logs"]
 schema = "text"
 batch_length = 1000
-poll_interval = "5ms"
 consumer_group = "pg_sink"
 
 [plugin_config]
diff --git a/core/connectors/sinks/postgres_sink/config.toml b/core/connectors/sinks/postgres_sink/config.toml
index 3fe8987..192e2f5 100644
--- a/core/connectors/sinks/postgres_sink/config.toml
+++ b/core/connectors/sinks/postgres_sink/config.toml
@@ -28,7 +28,6 @@
 topics = ["users", "orders"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "postgres_sink"
 
 [plugin_config]
diff --git a/core/connectors/sinks/quickwit_sink/config.toml b/core/connectors/sinks/quickwit_sink/config.toml
index 4e29a09..7df60da 100644
--- a/core/connectors/sinks/quickwit_sink/config.toml
+++ b/core/connectors/sinks/quickwit_sink/config.toml
@@ -28,7 +28,6 @@
 topics = []
 schema = ""
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "quickwit_sink"
 
 [plugin_config]
diff --git a/core/connectors/sinks/rabbitmq_sink/config.toml b/core/connectors/sinks/rabbitmq_sink/config.toml
index 4c6bbfc..e1880ae 100644
--- a/core/connectors/sinks/rabbitmq_sink/config.toml
+++ b/core/connectors/sinks/rabbitmq_sink/config.toml
@@ -28,7 +28,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "rabbitmq_sink_connector"
 
 [plugin_config]
diff --git a/core/connectors/sinks/redshift_sink/README.md b/core/connectors/sinks/redshift_sink/README.md
index 9a1f8ba..4d95323 100644
--- a/core/connectors/sinks/redshift_sink/README.md
+++ b/core/connectors/sinks/redshift_sink/README.md
@@ -28,7 +28,6 @@
 topics = ["users", "orders"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "redshift_sink"
 
 [plugin_config]
diff --git a/core/connectors/sinks/redshift_sink/config.toml b/core/connectors/sinks/redshift_sink/config.toml
index 96dfc56..5dcb9dd 100644
--- a/core/connectors/sinks/redshift_sink/config.toml
+++ b/core/connectors/sinks/redshift_sink/config.toml
@@ -28,7 +28,6 @@
 topics = ["users", "orders"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "redshift_sink"
 
 [plugin_config]
diff --git a/core/connectors/sinks/s3_sink/README.md b/core/connectors/sinks/s3_sink/README.md
index c988c3f..1d3a006 100644
--- a/core/connectors/sinks/s3_sink/README.md
+++ b/core/connectors/sinks/s3_sink/README.md
@@ -33,7 +33,6 @@
 topics = ["api_requests", "errors"]
 schema = "json"
 batch_length = 1000
-poll_interval = "100ms"
 consumer_group = "s3_sink"
 ```
 
diff --git a/core/connectors/sinks/s3_sink/config.toml b/core/connectors/sinks/s3_sink/config.toml
index ed71fc9..d65d4ba 100644
--- a/core/connectors/sinks/s3_sink/config.toml
+++ b/core/connectors/sinks/s3_sink/config.toml
@@ -28,7 +28,6 @@
 topics = ["api_requests", "errors"]
 schema = "json"
 batch_length = 1000
-poll_interval = "100ms"
 consumer_group = "s3_sink"
 
 [plugin_config]
diff --git a/core/connectors/sinks/stdout_sink/config.toml b/core/connectors/sinks/stdout_sink/config.toml
index ae97084..cabfad0 100644
--- a/core/connectors/sinks/stdout_sink/config.toml
+++ b/core/connectors/sinks/stdout_sink/config.toml
@@ -28,7 +28,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "stdout_sink_connector"
 
 [plugin_config]
diff --git a/core/connectors/sinks/surrealdb_sink/README.md b/core/connectors/sinks/surrealdb_sink/README.md
index 8a3f7e2..0fa9a04 100644
--- a/core/connectors/sinks/surrealdb_sink/README.md
+++ b/core/connectors/sinks/surrealdb_sink/README.md
@@ -40,7 +40,6 @@
 topics = ["example_topic"]
 schema = "json"
 batch_length = 1000
-poll_interval = "5ms"
 consumer_group = "surrealdb_sink_connector"
 
 [plugin_config]
diff --git a/core/connectors/sinks/surrealdb_sink/config.toml b/core/connectors/sinks/surrealdb_sink/config.toml
index 5b8a51c..c690150 100644
--- a/core/connectors/sinks/surrealdb_sink/config.toml
+++ b/core/connectors/sinks/surrealdb_sink/config.toml
@@ -29,7 +29,6 @@
 topics = ["example_topic"]
 schema = "json"
 batch_length = 1000
-poll_interval = "5ms"
 consumer_group = "surrealdb_sink_connector"
 
 [plugin_config]
diff --git a/core/integration/tests/cluster/chaos_monkey.rs b/core/integration/tests/cluster/chaos_monkey.rs
index 7f9950e..e07e1db 100644
--- a/core/integration/tests/cluster/chaos_monkey.rs
+++ b/core/integration/tests/cluster/chaos_monkey.rs
@@ -2441,7 +2441,10 @@
             AutoCommitWhen::ConsumingAllMessages,
         ))
         .batch_length(POLL_BATCH)
-        .poll_interval(IggyDuration::from(POLL_PAUSE))
+        .poll_options(DeferredPollOptions {
+            max_wait: IggyDuration::from(POLL_PAUSE),
+            ..Default::default()
+        })
         .build();
     consumer.init().await.expect("init the IggyConsumer");
     consumer
diff --git a/core/integration/tests/cluster/partition_primary_routing.rs b/core/integration/tests/cluster/partition_primary_routing.rs
index 7dd60a1..eb23966 100644
--- a/core/integration/tests/cluster/partition_primary_routing.rs
+++ b/core/integration/tests/cluster/partition_primary_routing.rs
@@ -33,6 +33,7 @@
 use bytes::{Bytes, BytesMut};
 use futures::StreamExt;
 use futures::future::join_all;
+use futures::stream::FuturesUnordered;
 use iggy::prelude::*;
 use iggy_binary_protocol::codes::{
     BIND_SESSION_CODE, GET_CONSUMER_GROUP_CODE, GET_PARTITION_ROUTE_CODE, POLL_MESSAGES_CODE,
@@ -63,7 +64,7 @@
 use integration::harness::disk::{
     leader_node_index_via, read_metadata_superblock_state, read_partition_superblock_state,
 };
-use integration::harness::{TestBinary, TestHarness};
+use integration::harness::{TestBinary, TestHarness, wait_for_consumer_group_assignment};
 use integration::iggy_harness;
 use journal::superblock::{PingPongSuperblock, SuperblockStore};
 use reqwest::StatusCode;
@@ -183,6 +184,8 @@
     .saturating_add(FRESHNESS_MARGIN)
     .saturating_add(FENCE_SLACK);
 const FENCE_PROBE_INTERVAL: Duration = Duration::from_millis(100);
+const DEFERRED_PENDING_METRIC: &str = "deferred_poll_pending";
+const DEFERRED_DELIVERY_BUDGET: Duration = Duration::from_secs(5);
 
 fn message(payload: &str) -> IggyMessage {
     IggyMessage::from_str(payload).expect("build message")
@@ -366,6 +369,252 @@
     );
 }
 
+#[iggy_harness(
+    cluster_nodes = 3,
+    test_client_transport = [Tcp, WebSocket, Quic, Http],
+    server(
+        http.jwt.encoding_secret = "0123456789abcdef0123456789abcdef",
+        http.jwt.decoding_secret = "0123456789abcdef0123456789abcdef"
+    )
+)]
+async fn given_spread_primaries_when_polling_deferred_should_wake_each_partition_independently(
+    harness: &TestHarness,
+) {
+    let (client, stream, topic, observers) =
+        spread_deferred_topic(harness, "deferred-spread").await;
+    let producer = harness.root_client().await.unwrap();
+    let consumer = Consumer::default();
+    let strategy = PollingStrategy::next();
+    let mut polls: FuturesUnordered<_> = (0..SPREAD_PARTITIONS)
+        .map(|partition| {
+            client.poll_messages_deferred(
+                &stream,
+                &topic,
+                Some(partition),
+                &consumer,
+                &strategy,
+                2,
+                true,
+                DeferredPollOptions {
+                    min_count: 2,
+                    ..deferred_test_options()
+                },
+            )
+        })
+        .collect();
+    tokio::select! {
+        result = polls.next() => panic!("empty partition completed before publication: {result:?}"),
+        () = wait_for_deferred_pending(&observers, 1) => {}
+    }
+    client
+        .ping()
+        .await
+        .expect("control traffic remains available");
+
+    for partition in (0..SPREAD_PARTITIONS).rev() {
+        let payloads = [format!("{partition}-first"), format!("{partition}-second")];
+        let mut messages: Vec<_> = payloads.iter().map(|payload| message(payload)).collect();
+        producer
+            .send_messages(
+                &stream,
+                &topic,
+                &Partitioning::partition_id(partition),
+                &mut messages,
+            )
+            .await
+            .unwrap();
+        let polled = timeout(DEFERRED_DELIVERY_BUDGET, polls.next())
+            .await
+            .expect("an empty partition must not delay another primary's ready poll")
+            .unwrap()
+            .unwrap();
+        assert_eq!(polled.partition_id, partition);
+        assert_eq!(polled.messages.len(), payloads.len());
+        for (offset, (received, payload)) in polled.messages.iter().zip(&payloads).enumerate() {
+            assert_eq!(received.header.offset, offset as u64);
+            assert_eq!(received.payload.as_ref(), payload.as_bytes());
+        }
+        let stored = client
+            .get_consumer_offset(&consumer, &stream, &topic, Some(partition))
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!(stored.stored_offset, 1);
+    }
+    assert!(polls.is_empty());
+    wait_for_deferred_pending(&observers, 0).await;
+}
+
+#[iggy_harness(
+    cluster_nodes = 3,
+    test_client_transport = [Tcp, WebSocket, Quic],
+    server(
+        http.jwt.encoding_secret = "0123456789abcdef0123456789abcdef",
+        http.jwt.decoding_secret = "0123456789abcdef0123456789abcdef"
+    )
+)]
+async fn given_spread_primaries_when_consuming_deferred_should_route_every_group_partition(
+    harness: &TestHarness,
+) {
+    let producer = harness.root_client().await.unwrap();
+    for (stream_name, auto_commit) in [
+        ("deferred-manual", AutoCommit::Disabled),
+        (
+            "deferred-on-poll",
+            AutoCommit::When(AutoCommitWhen::PollingMessages),
+        ),
+    ] {
+        let (client, stream, topic, observers) = spread_deferred_topic(harness, stream_name).await;
+        let mut consumer = client
+            .consumer_group(GROUP_NAME, stream_name, TOPIC_NAME)
+            .unwrap()
+            .auto_commit(auto_commit)
+            .polling_strategy(PollingStrategy::next())
+            .batch_length(1)
+            .poll_options(deferred_test_options())
+            .build();
+        consumer.init().await.unwrap();
+        let group = Consumer::group(Identifier::named(GROUP_NAME).unwrap());
+        wait_for_consumer_group_assignment(
+            &client,
+            &stream,
+            &topic,
+            &group.id,
+            1,
+            PRECONDITION_BUDGET,
+        )
+        .await;
+
+        for offset in 0..2 {
+            for partition in (0..SPREAD_PARTITIONS).rev() {
+                let mut next = Box::pin(consumer.next());
+                tokio::select! {
+                    result = &mut next => panic!("no message was published for {stream_name}: {result:?}"),
+                    () = wait_for_deferred_pending(&observers, 1) => {}
+                }
+                let payload = format!("{stream_name}-{partition}-{offset}");
+                producer
+                    .send_messages(
+                        &stream,
+                        &topic,
+                        &Partitioning::partition_id(partition),
+                        &mut [message(&payload)],
+                    )
+                    .await
+                    .unwrap();
+                let received = timeout(DEFERRED_DELIVERY_BUDGET, next)
+                    .await
+                    .expect("a group must receive from any ready partition primary")
+                    .unwrap()
+                    .unwrap();
+                assert_eq!(received.partition_id, partition, "{stream_name}");
+                assert_eq!(received.message.header.offset, offset, "{stream_name}");
+                assert_eq!(received.message.payload.as_ref(), payload.as_bytes());
+                if matches!(auto_commit, AutoCommit::Disabled) {
+                    let stored = client
+                        .get_consumer_offset(&group, &stream, &topic, Some(partition))
+                        .await
+                        .unwrap();
+                    assert_eq!(
+                        stored.map(|stored| stored.stored_offset),
+                        offset.checked_sub(1)
+                    );
+                    consumer
+                        .store_offset(offset, Some(partition))
+                        .await
+                        .unwrap();
+                }
+                let stored = client
+                    .get_consumer_offset(&group, &stream, &topic, Some(partition))
+                    .await
+                    .unwrap()
+                    .unwrap();
+                assert_eq!(stored.stored_offset, offset, "{stream_name}/{partition}");
+            }
+        }
+        consumer.shutdown().await.unwrap();
+        wait_for_deferred_pending(&observers, 0).await;
+    }
+}
+
+fn deferred_test_options() -> DeferredPollOptions {
+    DeferredPollOptions {
+        max_wait: Duration::from_secs(30).into(),
+        request_timeout: Duration::from_secs(40).into(),
+        ..Default::default()
+    }
+}
+
+async fn spread_deferred_topic(
+    harness: &TestHarness,
+    stream_name: &str,
+) -> (IggyClient, Identifier, Identifier, Vec<HttpClient>) {
+    let client = harness.root_client().await.unwrap();
+    client
+        .create_stream(stream_name, &StreamCreateOptions::default())
+        .await
+        .unwrap();
+    let stream = Identifier::named(stream_name).unwrap();
+    let topic = Identifier::named(TOPIC_NAME).unwrap();
+    client
+        .create_topic(
+            &stream,
+            TOPIC_NAME,
+            &TopicCreateOptions {
+                partitions_count: Some(SPREAD_PARTITIONS),
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    let probe = connect_without_login(harness.node(0).tcp_addr().unwrap()).await;
+    probe
+        .login_user(DEFAULT_ROOT_USERNAME, DEFAULT_ROOT_PASSWORD)
+        .await
+        .unwrap();
+    assert_initial_spread(
+        &probe,
+        &stream,
+        &[(TOPIC_NAME, SPREAD_PARTITIONS)],
+        harness.cluster_size(),
+    )
+    .await;
+    let observer = HttpClient::login_root(harness).await;
+    let observers = (0..harness.cluster_size())
+        .map(|node| HttpClient {
+            client: observer.client.clone(),
+            base_url: node_url(harness, node),
+            token: observer.token.clone(),
+        })
+        .collect();
+    (client, stream, topic, observers)
+}
+
+async fn wait_for_deferred_pending(observers: &[HttpClient], expected: u64) {
+    let deadline = Instant::now() + DEFERRED_DELIVERY_BUDGET;
+    loop {
+        let counts = join_all(observers.iter().map(async |observer| {
+            let text = metrics(observer).await;
+            let values: Vec<u64> = text
+                .lines()
+                .filter(|line| line.starts_with(DEFERRED_PENDING_METRIC))
+                .map(|line| line.rsplit_once(' ').unwrap().1.parse().unwrap())
+                .collect();
+            assert!(!values.is_empty(), "deferred poll metrics missing: {text}");
+            values.into_iter().sum::<u64>()
+        }))
+        .await;
+        if counts.iter().all(|count| *count == expected) {
+            return;
+        }
+        assert!(
+            Instant::now() < deadline,
+            "expected {expected} pending waits on each node, got {counts:?}"
+        );
+        sleep(FENCE_PROBE_INTERVAL).await;
+    }
+}
+
 async fn assert_partition_payload(
     client: &TcpClient,
     stream: &Identifier,
@@ -2633,9 +2882,11 @@
         loop {
             match receiver.next().await.expect("the consumer must stay open") {
                 Ok(message) => break message,
-                // The fixture killed the old owner before polling, so the
-                // uncertain reply cannot hide admission of this new message.
-                Err(IggyError::TransientNotCommitted) => {}
+                // A readiness wait sent to the killed owner times out as not
+                // accepted, and an auto-commit poll as uncertain. The fixture
+                // killed it before polling, so the uncertain reply cannot hide
+                // admission of this new message.
+                Err(IggyError::TransientNotAccepted | IggyError::TransientNotCommitted) => {}
                 Err(error) => panic!("poll could not recover its cached data route: {error:?}"),
             }
         }
diff --git a/core/integration/tests/connectors/clickhouse/proto_text_config/clickhouse_sink.toml b/core/integration/tests/connectors/clickhouse/proto_text_config/clickhouse_sink.toml
index 107820e..4cd48c4 100644
--- a/core/integration/tests/connectors/clickhouse/proto_text_config/clickhouse_sink.toml
+++ b/core/integration/tests/connectors/clickhouse/proto_text_config/clickhouse_sink.toml
@@ -35,7 +35,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 1000
-poll_interval = "5ms"
 consumer_group = "clickhouse_sink_connector"
 
 # No descriptor is configured, so `json_to_protobuf` falls back to proto text
diff --git a/core/integration/tests/connectors/delta/malformed_table_uri_config/config.toml b/core/integration/tests/connectors/delta/malformed_table_uri_config/config.toml
index 8952324..545f073 100644
--- a/core/integration/tests/connectors/delta/malformed_table_uri_config/config.toml
+++ b/core/integration/tests/connectors/delta/malformed_table_uri_config/config.toml
@@ -31,7 +31,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "delta_malformed_uri_sink_connector"
 
 [plugin_config]
diff --git a/core/integration/tests/connectors/delta/missing_secret_key_config/config.toml b/core/integration/tests/connectors/delta/missing_secret_key_config/config.toml
index 14f1bd4..e8ea96c 100644
--- a/core/integration/tests/connectors/delta/missing_secret_key_config/config.toml
+++ b/core/integration/tests/connectors/delta/missing_secret_key_config/config.toml
@@ -31,7 +31,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "delta_missing_secret_key_sink_connector"
 
 [plugin_config]
diff --git a/core/integration/tests/connectors/doris/proto_text_config/doris_sink.toml b/core/integration/tests/connectors/doris/proto_text_config/doris_sink.toml
index f6b8ff9..b6d98ff 100644
--- a/core/integration/tests/connectors/doris/proto_text_config/doris_sink.toml
+++ b/core/integration/tests/connectors/doris/proto_text_config/doris_sink.toml
@@ -34,7 +34,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "doris_sink"
 
 # No descriptor is configured, so `json_to_protobuf` falls back to proto text
diff --git a/core/integration/tests/connectors/elasticsearch/proto_text_config/elasticsearch_sink.toml b/core/integration/tests/connectors/elasticsearch/proto_text_config/elasticsearch_sink.toml
index e9fa194..f740bb3 100644
--- a/core/integration/tests/connectors/elasticsearch/proto_text_config/elasticsearch_sink.toml
+++ b/core/integration/tests/connectors/elasticsearch/proto_text_config/elasticsearch_sink.toml
@@ -33,7 +33,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "elasticsearch_sink_cg"
 
 # No descriptor is configured, so `json_to_protobuf` falls back to proto text
diff --git a/core/integration/tests/connectors/http_config_provider/direct_responses.rs b/core/integration/tests/connectors/http_config_provider/direct_responses.rs
index d1b748f..8bd9970 100644
--- a/core/integration/tests/connectors/http_config_provider/direct_responses.rs
+++ b/core/integration/tests/connectors/http_config_provider/direct_responses.rs
@@ -197,7 +197,6 @@
     assert_eq!(topics[0].as_str().unwrap(), "test_topic");
     assert_eq!(stream["schema"].as_str().unwrap(), "json");
     assert!(stream["batch_length"].as_u64().is_some());
-    assert!(stream["poll_interval"].as_str().is_some());
     assert!(stream["consumer_group"].as_str().is_some());
 
     assert!(v0["plugin_config_format"].is_null());
diff --git a/core/integration/tests/connectors/http_config_provider/wiremock/__files/direct/connectors-config.json b/core/integration/tests/connectors/http_config_provider/wiremock/__files/direct/connectors-config.json
index d2e3e13..86da47c 100644
--- a/core/integration/tests/connectors/http_config_provider/wiremock/__files/direct/connectors-config.json
+++ b/core/integration/tests/connectors/http_config_provider/wiremock/__files/direct/connectors-config.json
@@ -25,7 +25,6 @@
           "topics": ["test_topic"],
           "schema": "json",
           "batch_length": 100,
-          "poll_interval": "5ms",
           "consumer_group": "stdout_sink_connector"
         }
       ],
diff --git a/core/integration/tests/connectors/http_config_provider/wiremock/__files/direct/stdout-sink-configs.json b/core/integration/tests/connectors/http_config_provider/wiremock/__files/direct/stdout-sink-configs.json
index f61ff7a..365c66b 100644
--- a/core/integration/tests/connectors/http_config_provider/wiremock/__files/direct/stdout-sink-configs.json
+++ b/core/integration/tests/connectors/http_config_provider/wiremock/__files/direct/stdout-sink-configs.json
@@ -24,7 +24,6 @@
         "topics": ["test_topic"],
         "schema": "json",
         "batch_length": 100,
-        "poll_interval": "5ms",
         "consumer_group": "stdout_sink_connector"
       }
     ],
diff --git a/core/integration/tests/connectors/http_config_provider/wiremock/__files/direct/stdout-sink-v0.json b/core/integration/tests/connectors/http_config_provider/wiremock/__files/direct/stdout-sink-v0.json
index a3264ca..b62ce48 100644
--- a/core/integration/tests/connectors/http_config_provider/wiremock/__files/direct/stdout-sink-v0.json
+++ b/core/integration/tests/connectors/http_config_provider/wiremock/__files/direct/stdout-sink-v0.json
@@ -23,7 +23,6 @@
       "topics": ["test_topic"],
       "schema": "json",
       "batch_length": 100,
-      "poll_interval": "5ms",
       "consumer_group": "stdout_sink_connector"
     }
   ],
diff --git a/core/integration/tests/connectors/http_config_provider/wiremock/__files/wrapped/connectors-config.json b/core/integration/tests/connectors/http_config_provider/wiremock/__files/wrapped/connectors-config.json
index 689ffdb..c477888 100644
--- a/core/integration/tests/connectors/http_config_provider/wiremock/__files/wrapped/connectors-config.json
+++ b/core/integration/tests/connectors/http_config_provider/wiremock/__files/wrapped/connectors-config.json
@@ -26,7 +26,6 @@
             "topics": ["test_topic"],
             "schema": "json",
             "batch_length": 100,
-            "poll_interval": "5ms",
             "consumer_group": "stdout_sink_connector"
           }
         ],
diff --git a/core/integration/tests/connectors/http_config_provider/wiremock/__files/wrapped/stdout-sink-configs.json b/core/integration/tests/connectors/http_config_provider/wiremock/__files/wrapped/stdout-sink-configs.json
index 909ebd0..08f569c 100644
--- a/core/integration/tests/connectors/http_config_provider/wiremock/__files/wrapped/stdout-sink-configs.json
+++ b/core/integration/tests/connectors/http_config_provider/wiremock/__files/wrapped/stdout-sink-configs.json
@@ -25,7 +25,6 @@
           "topics": ["test_topic"],
           "schema": "json",
           "batch_length": 100,
-          "poll_interval": "5ms",
           "consumer_group": "stdout_sink_connector"
         }
       ],
diff --git a/core/integration/tests/connectors/http_config_provider/wiremock/__files/wrapped/stdout-sink-v0.json b/core/integration/tests/connectors/http_config_provider/wiremock/__files/wrapped/stdout-sink-v0.json
index c806484..90e85c6 100644
--- a/core/integration/tests/connectors/http_config_provider/wiremock/__files/wrapped/stdout-sink-v0.json
+++ b/core/integration/tests/connectors/http_config_provider/wiremock/__files/wrapped/stdout-sink-v0.json
@@ -24,7 +24,6 @@
         "topics": ["test_topic"],
         "schema": "json",
         "batch_length": 100,
-        "poll_interval": "5ms",
         "consumer_group": "stdout_sink_connector"
       }
     ],
diff --git a/core/integration/tests/connectors/http_config_provider/wrapped_responses.rs b/core/integration/tests/connectors/http_config_provider/wrapped_responses.rs
index 847c639..f24e348 100644
--- a/core/integration/tests/connectors/http_config_provider/wrapped_responses.rs
+++ b/core/integration/tests/connectors/http_config_provider/wrapped_responses.rs
@@ -197,7 +197,6 @@
     assert_eq!(topics[0].as_str().unwrap(), "test_topic");
     assert_eq!(stream["schema"].as_str().unwrap(), "json");
     assert!(stream["batch_length"].as_u64().is_some());
-    assert!(stream["poll_interval"].as_str().is_some());
     assert!(stream["consumer_group"].as_str().is_some());
 
     assert!(v0["plugin_config_format"].is_null());
diff --git a/core/integration/tests/connectors/iceberg/default_credentials_config/config.toml b/core/integration/tests/connectors/iceberg/default_credentials_config/config.toml
index 5c6ffc6..b90aa08 100644
--- a/core/integration/tests/connectors/iceberg/default_credentials_config/config.toml
+++ b/core/integration/tests/connectors/iceberg/default_credentials_config/config.toml
@@ -28,7 +28,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "iceberg_sink_connector"
 
 # Notice: This configuration deliberately omits 'store_access_key_id' and 'store_secret_access_key'.
diff --git a/core/integration/tests/connectors/influxdb/sink_v3_config/config.toml b/core/integration/tests/connectors/influxdb/sink_v3_config/config.toml
index e24a2f0..beaa01e 100644
--- a/core/integration/tests/connectors/influxdb/sink_v3_config/config.toml
+++ b/core/integration/tests/connectors/influxdb/sink_v3_config/config.toml
@@ -32,7 +32,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "influxdb3_sink_cg"
 
 [plugin_config]
diff --git a/core/integration/tests/connectors/influxdb_sink.toml b/core/integration/tests/connectors/influxdb_sink.toml
index 7894320..ddb2484 100644
--- a/core/integration/tests/connectors/influxdb_sink.toml
+++ b/core/integration/tests/connectors/influxdb_sink.toml
@@ -28,7 +28,6 @@
 topics = ["connector_topic"]
 schema = "json"
 batch_length = 10
-poll_interval = "100ms"
 consumer_group = "influxdb_sink_test"
 
 [plugin_config]
diff --git a/core/integration/tests/connectors/runtime/benchmark_config/stdout_sink.toml b/core/integration/tests/connectors/runtime/benchmark_config/stdout_sink.toml
index 89451d7..ea747a4 100644
--- a/core/integration/tests/connectors/runtime/benchmark_config/stdout_sink.toml
+++ b/core/integration/tests/connectors/runtime/benchmark_config/stdout_sink.toml
@@ -28,7 +28,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 50
-poll_interval = "5ms"
 consumer_group = "stdout_bench_sink_connector"
 
 [plugin_config]
diff --git a/core/integration/tests/connectors/runtime/benchmark_disabled_config/stdout_sink.toml b/core/integration/tests/connectors/runtime/benchmark_disabled_config/stdout_sink.toml
index 0622590..eeb9e5a 100644
--- a/core/integration/tests/connectors/runtime/benchmark_disabled_config/stdout_sink.toml
+++ b/core/integration/tests/connectors/runtime/benchmark_disabled_config/stdout_sink.toml
@@ -28,7 +28,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 50
-poll_interval = "5ms"
 consumer_group = "stdout_off_sink_connector"
 
 [plugin_config]
diff --git a/core/integration/tests/connectors/runtime/benchmark_json_config/stdout_sink.toml b/core/integration/tests/connectors/runtime/benchmark_json_config/stdout_sink.toml
index c2c9548..9b7cb92 100644
--- a/core/integration/tests/connectors/runtime/benchmark_json_config/stdout_sink.toml
+++ b/core/integration/tests/connectors/runtime/benchmark_json_config/stdout_sink.toml
@@ -28,7 +28,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 50
-poll_interval = "5ms"
 consumer_group = "stdout_json_sink_connector"
 
 [plugin_config]
diff --git a/core/integration/tests/connectors/runtime/error_isolation.rs b/core/integration/tests/connectors/runtime/error_isolation.rs
index 7c918ab..d9a28ee 100644
--- a/core/integration/tests/connectors/runtime/error_isolation.rs
+++ b/core/integration/tests/connectors/runtime/error_isolation.rs
@@ -28,7 +28,7 @@
 //! `source::init` and `sink::init`:
 //!   * plugin path resolution failure (missing `.so`),
 //!   * source state-load failure (unreachable state file),
-//!   * post-container setup failure (invalid duration in stream config),
+//!   * post-container setup failure (invalid transform config),
 //!   * post-container setup failure surfaced as an Iggy error (missing stream).
 
 use iggy_common::{Identifier, IggyMessage, MessageClient, Partitioning};
@@ -107,8 +107,10 @@
         .as_ref()
         .expect("Invalid sink should expose a last_error");
     assert!(
-        last_error.message.contains("poll interval"),
-        "last_error should mention the misconfigured poll_interval, got: {}",
+        last_error
+            .message
+            .contains("Failed to parse transform config"),
+        "last_error should mention the misconfigured transform, got: {}",
         last_error.message
     );
 
diff --git a/core/integration/tests/connectors/runtime/mod.rs b/core/integration/tests/connectors/runtime/mod.rs
index 3c74f48..b0baaf4 100644
--- a/core/integration/tests/connectors/runtime/mod.rs
+++ b/core/integration/tests/connectors/runtime/mod.rs
@@ -19,3 +19,4 @@
 mod error_isolation;
 mod http_state;
 mod schema_tagging;
+mod sink_clients;
diff --git a/core/integration/tests/connectors/runtime/schema_tagging_config/stdout_sink.toml b/core/integration/tests/connectors/runtime/schema_tagging_config/stdout_sink.toml
index 3e21620..e9e64f1 100644
--- a/core/integration/tests/connectors/runtime/schema_tagging_config/stdout_sink.toml
+++ b/core/integration/tests/connectors/runtime/schema_tagging_config/stdout_sink.toml
@@ -29,7 +29,6 @@
 schema = "avro"
 avro_schema_json = '{"type":"record","name":"Event","fields":[{"name":"id","type":"long"},{"name":"name","type":"string"}]}'
 batch_length = 10
-poll_interval = "5ms"
 consumer_group = "schema_tagging_sink_connector"
 
 [plugin_config]
diff --git a/core/integration/tests/connectors/runtime/sink_clients.rs b/core/integration/tests/connectors/runtime/sink_clients.rs
new file mode 100644
index 0000000..43276a8
--- /dev/null
+++ b/core/integration/tests/connectors/runtime/sink_clients.rs
@@ -0,0 +1,218 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Each sink start connects an Iggy client of its own, so a parked poll of one
+//! sink cannot hold back the polls and offset stores of another. A member id
+//! is the lowest free slot of its group, so it repeats across groups and after
+//! a member leaves. These tests therefore tell the members apart by the
+//! clients that the server lists, each with the number of groups it is in.
+
+use iggy::prelude::{Identifier, IggyClient, SystemClient};
+use iggy_common::ClientInfo;
+use iggy_connector_sdk::api::{ConnectorStatus, SinkInfoResponse};
+use integration::harness::{seeds, wait_for_consumer_group_assignment};
+use integration::iggy_harness;
+use reqwest::Client;
+use std::time::Duration;
+use tokio::time::{Instant, sleep};
+
+const FIRST_SINK_KEY: &str = "stdout_first";
+const SECOND_SINK_KEY: &str = "stdout_second";
+const RESTART_SINK_KEY: &str = "stdout_restart";
+const PARTIAL_SETUP_SINK_KEY: &str = "stdout_partial_setup";
+const DEFAULT_SINK_GROUP_PREFIX: &str = "iggy-connect-sink-";
+/// A stopped consumer can finish its shutdown after the 5 s that a sink stop
+/// waits for it.
+const MEMBERSHIP_BUDGET: Duration = Duration::from_secs(30);
+/// Well under the 36 s session timeout of the test server, 1.2 x its 30 s
+/// heartbeat interval. A closed connection alone would pass after its session
+/// expires, so only a logout passes within this budget.
+const LOGOUT_BUDGET: Duration = Duration::from_secs(10);
+const CLIENTS_POLL_INTERVAL: Duration = Duration::from_millis(100);
+
+#[iggy_harness(
+    server(connectors_runtime(config_path = "tests/connectors/runtime/sink_clients.toml")),
+    seed = seeds::connector_stream
+)]
+async fn given_two_started_sinks_when_clients_listed_should_show_one_client_per_sink_group(
+    harness: &TestHarness,
+) {
+    let client = harness
+        .root_client()
+        .await
+        .expect("root client should connect");
+    for key in [FIRST_SINK_KEY, SECOND_SINK_KEY] {
+        wait_for_sink_group(&client, key, 1, MEMBERSHIP_BUDGET).await;
+    }
+
+    let clients = client
+        .get_clients()
+        .await
+        .expect("clients should be listed");
+    assert_eq!(
+        single_group_clients(&clients).len(),
+        2,
+        "each sink group should have a client of its own as its member, got: {clients:?}"
+    );
+}
+
+#[iggy_harness(
+    server(connectors_runtime(config_path = "tests/connectors/runtime/sink_restart.toml")),
+    seed = seeds::connector_stream
+)]
+async fn given_running_sink_when_restarted_through_api_should_rejoin_group_with_new_client(
+    harness: &TestHarness,
+) {
+    let client = harness
+        .root_client()
+        .await
+        .expect("root client should connect");
+    wait_for_sink_group(&client, RESTART_SINK_KEY, 1, MEMBERSHIP_BUDGET).await;
+    let before = client
+        .get_clients()
+        .await
+        .expect("clients should be listed");
+    let [old_member] = single_group_clients(&before)[..] else {
+        panic!("the sink client should be the only group member, got: {before:?}");
+    };
+
+    let api_url = harness
+        .connectors_runtime()
+        .expect("connector runtime should be available")
+        .http_url();
+    let response = Client::new()
+        .post(format!("{api_url}/sinks/{RESTART_SINK_KEY}/restart"))
+        .send()
+        .await
+        .expect("restart request should complete");
+    assert_eq!(response.status().as_u16(), 204, "restart should succeed");
+
+    let old_client_gone =
+        |clients: &[ClientInfo]| clients.iter().all(|info| info.client_id != old_member);
+    let after = wait_for_clients(&client, old_client_gone).await;
+    assert!(
+        old_client_gone(&after),
+        "the restart should shut down the old sink client {old_member}, got: {after:?}"
+    );
+    assert_eq!(
+        after.len(),
+        before.len(),
+        "the restart should replace the sink client, not add one: {before:?} -> {after:?}"
+    );
+    assert_eq!(
+        single_group_clients(&after).len(),
+        1,
+        "the new sink client should be the only group member, got: {after:?}"
+    );
+    wait_for_sink_group(&client, RESTART_SINK_KEY, 1, MEMBERSHIP_BUDGET).await;
+}
+
+#[iggy_harness(
+    server(connectors_runtime(config_path = "tests/connectors/runtime/sink_partial_setup.toml")),
+    seed = seeds::connector_stream
+)]
+async fn given_sink_with_failing_second_stream_when_runtime_starts_should_leave_no_group_member(
+    harness: &TestHarness,
+) {
+    let api_url = harness
+        .connectors_runtime()
+        .expect("connector runtime should be available")
+        .http_url();
+    let sink: SinkInfoResponse = Client::new()
+        .get(format!("{api_url}/sinks/{PARTIAL_SETUP_SINK_KEY}"))
+        .send()
+        .await
+        .expect("sink request should complete")
+        .error_for_status()
+        .expect("sink endpoint should succeed")
+        .json()
+        .await
+        .expect("sink response should decode");
+    assert_eq!(sink.status, ConnectorStatus::Error);
+    let last_error = sink
+        .last_error
+        .expect("the failed sink should expose a last_error");
+    assert!(
+        last_error.message.contains("no_such_stream")
+            && last_error.message.contains("was not found"),
+        "last_error should name the missing second stream, got: {}",
+        last_error.message
+    );
+
+    let client = harness
+        .root_client()
+        .await
+        .expect("root client should connect");
+    wait_for_sink_group(&client, PARTIAL_SETUP_SINK_KEY, 0, LOGOUT_BUDGET).await;
+    let clients = client
+        .get_clients()
+        .await
+        .expect("clients should be listed");
+    assert!(
+        clients.iter().all(|info| info.consumer_groups_count == 0),
+        "no client should stay a group member after the failed setup, got: {clients:?}"
+    );
+}
+
+async fn wait_for_sink_group(
+    client: &IggyClient,
+    sink_key: &str,
+    members_count: u32,
+    budget: Duration,
+) {
+    let stream_id = Identifier::named(seeds::names::STREAM).expect("valid stream name");
+    let topic_id = Identifier::named(seeds::names::TOPIC).expect("valid topic name");
+    let group_id = Identifier::named(&format!("{DEFAULT_SINK_GROUP_PREFIX}{sink_key}"))
+        .expect("valid group name");
+    wait_for_consumer_group_assignment(
+        client,
+        &stream_id,
+        &topic_id,
+        &group_id,
+        members_count,
+        budget,
+    )
+    .await;
+}
+
+/// Ids of the clients that belong to exactly one consumer group.
+fn single_group_clients(clients: &[ClientInfo]) -> Vec<u32> {
+    clients
+        .iter()
+        .filter(|info| info.consumer_groups_count == 1)
+        .map(|info| info.client_id)
+        .collect()
+}
+
+/// Lists the clients until `ready` holds or the budget runs out, and returns
+/// the last list.
+async fn wait_for_clients(
+    client: &IggyClient,
+    ready: impl Fn(&[ClientInfo]) -> bool,
+) -> Vec<ClientInfo> {
+    let deadline = Instant::now() + MEMBERSHIP_BUDGET;
+    loop {
+        let clients = client
+            .get_clients()
+            .await
+            .expect("clients should be listed");
+        if ready(&clients) || Instant::now() >= deadline {
+            return clients;
+        }
+        sleep(CLIENTS_POLL_INTERVAL).await;
+    }
+}
diff --git a/core/integration/tests/connectors/runtime/sink_clients.toml b/core/integration/tests/connectors/runtime/sink_clients.toml
new file mode 100644
index 0000000..4a8721b
--- /dev/null
+++ b/core/integration/tests/connectors/runtime/sink_clients.toml
@@ -0,0 +1,20 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[connectors]
+config_type = "local"
+config_dir = "tests/connectors/runtime/sink_clients_config"
diff --git a/core/integration/tests/connectors/runtime/sink_clients_config/stdout_first.toml b/core/integration/tests/connectors/runtime/sink_clients_config/stdout_first.toml
new file mode 100644
index 0000000..60cc0a8
--- /dev/null
+++ b/core/integration/tests/connectors/runtime/sink_clients_config/stdout_first.toml
@@ -0,0 +1,36 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# No consumer_group, so the sink joins its default group,
+# iggy-connect-sink-stdout_first.
+
+type = "sink"
+key = "stdout_first"
+enabled = true
+version = 0
+name = "Stdout first sink"
+path = "../../target/debug/libiggy_connector_stdout_sink"
+verbose = true
+
+[[streams]]
+stream = "test_stream"
+topics = ["test_topic"]
+schema = "json"
+batch_length = 100
+
+[plugin_config]
+print_payload = true
diff --git a/core/integration/tests/connectors/runtime/sink_clients_config/stdout_second.toml b/core/integration/tests/connectors/runtime/sink_clients_config/stdout_second.toml
new file mode 100644
index 0000000..6cf3854
--- /dev/null
+++ b/core/integration/tests/connectors/runtime/sink_clients_config/stdout_second.toml
@@ -0,0 +1,36 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# No consumer_group, so the sink joins its default group,
+# iggy-connect-sink-stdout_second.
+
+type = "sink"
+key = "stdout_second"
+enabled = true
+version = 0
+name = "Stdout second sink"
+path = "../../target/debug/libiggy_connector_stdout_sink"
+verbose = true
+
+[[streams]]
+stream = "test_stream"
+topics = ["test_topic"]
+schema = "json"
+batch_length = 100
+
+[plugin_config]
+print_payload = true
diff --git a/core/integration/tests/connectors/runtime/sink_invalid_config/stdout_invalid.toml b/core/integration/tests/connectors/runtime/sink_invalid_config/stdout_invalid.toml
index b4230b8..2e67581 100644
--- a/core/integration/tests/connectors/runtime/sink_invalid_config/stdout_invalid.toml
+++ b/core/integration/tests/connectors/runtime/sink_invalid_config/stdout_invalid.toml
@@ -15,7 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
-# Deliberately broken sink: `poll_interval` cannot be parsed as a duration.
+# Deliberately broken sink: `enabled` of the transform is not a boolean.
 # Used to assert that the runtime stays alive and reports this connector
 # with `ConnectorStatus::Error` instead of aborting startup.
 
@@ -32,8 +32,10 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "not-a-valid-duration"
 consumer_group = "stdout_invalid_sink_connector"
 
 [plugin_config]
 print_payload = true
+
+[transforms.add_fields]
+enabled = "yes"
diff --git a/core/integration/tests/connectors/runtime/sink_invalid_config/stdout_valid.toml b/core/integration/tests/connectors/runtime/sink_invalid_config/stdout_valid.toml
index 0670d68..e5f872a 100644
--- a/core/integration/tests/connectors/runtime/sink_invalid_config/stdout_valid.toml
+++ b/core/integration/tests/connectors/runtime/sink_invalid_config/stdout_valid.toml
@@ -32,7 +32,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "stdout_valid_sink_connector"
 
 [plugin_config]
diff --git a/core/integration/tests/connectors/runtime/sink_missing_plugin_config/stdout_missing.toml b/core/integration/tests/connectors/runtime/sink_missing_plugin_config/stdout_missing.toml
index 608f34e..d33b17e 100644
--- a/core/integration/tests/connectors/runtime/sink_missing_plugin_config/stdout_missing.toml
+++ b/core/integration/tests/connectors/runtime/sink_missing_plugin_config/stdout_missing.toml
@@ -33,7 +33,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "stdout_missing_plugin_sink_connector"
 
 [plugin_config]
diff --git a/core/integration/tests/connectors/runtime/sink_missing_plugin_config/stdout_valid.toml b/core/integration/tests/connectors/runtime/sink_missing_plugin_config/stdout_valid.toml
index f9a52dd..1177764 100644
--- a/core/integration/tests/connectors/runtime/sink_missing_plugin_config/stdout_valid.toml
+++ b/core/integration/tests/connectors/runtime/sink_missing_plugin_config/stdout_valid.toml
@@ -32,7 +32,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "stdout_valid_sink_connector"
 
 [plugin_config]
diff --git a/core/integration/tests/connectors/runtime/sink_missing_stream_config/stdout_missing_stream.toml b/core/integration/tests/connectors/runtime/sink_missing_stream_config/stdout_missing_stream.toml
index 344b618..ab86322 100644
--- a/core/integration/tests/connectors/runtime/sink_missing_stream_config/stdout_missing_stream.toml
+++ b/core/integration/tests/connectors/runtime/sink_missing_stream_config/stdout_missing_stream.toml
@@ -32,7 +32,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "stdout_missing_stream_sink_connector"
 
 [plugin_config]
diff --git a/core/integration/tests/connectors/runtime/sink_missing_stream_config/stdout_valid.toml b/core/integration/tests/connectors/runtime/sink_missing_stream_config/stdout_valid.toml
index 0670d68..e5f872a 100644
--- a/core/integration/tests/connectors/runtime/sink_missing_stream_config/stdout_valid.toml
+++ b/core/integration/tests/connectors/runtime/sink_missing_stream_config/stdout_valid.toml
@@ -32,7 +32,6 @@
 topics = ["test_topic"]
 schema = "json"
 batch_length = 100
-poll_interval = "5ms"
 consumer_group = "stdout_valid_sink_connector"
 
 [plugin_config]
diff --git a/core/integration/tests/connectors/runtime/sink_partial_setup.toml b/core/integration/tests/connectors/runtime/sink_partial_setup.toml
new file mode 100644
index 0000000..c4369d6
--- /dev/null
+++ b/core/integration/tests/connectors/runtime/sink_partial_setup.toml
@@ -0,0 +1,20 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[connectors]
+config_type = "local"
+config_dir = "tests/connectors/runtime/sink_partial_setup_config"
diff --git a/core/integration/tests/connectors/runtime/sink_partial_setup_config/stdout_partial_setup.toml b/core/integration/tests/connectors/runtime/sink_partial_setup_config/stdout_partial_setup.toml
new file mode 100644
index 0000000..740e5c8
--- /dev/null
+++ b/core/integration/tests/connectors/runtime/sink_partial_setup_config/stdout_partial_setup.toml
@@ -0,0 +1,42 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# The second stream does not exist, so setup fails after the consumer of the
+# first stream joined the default group, iggy-connect-sink-stdout_partial_setup.
+
+type = "sink"
+key = "stdout_partial_setup"
+enabled = true
+version = 0
+name = "Stdout partial setup sink"
+path = "../../target/debug/libiggy_connector_stdout_sink"
+verbose = true
+
+[[streams]]
+stream = "test_stream"
+topics = ["test_topic"]
+schema = "json"
+batch_length = 100
+
+[[streams]]
+stream = "no_such_stream"
+topics = ["test_topic"]
+schema = "json"
+batch_length = 100
+
+[plugin_config]
+print_payload = true
diff --git a/core/integration/tests/connectors/runtime/sink_restart.toml b/core/integration/tests/connectors/runtime/sink_restart.toml
new file mode 100644
index 0000000..87b6755
--- /dev/null
+++ b/core/integration/tests/connectors/runtime/sink_restart.toml
@@ -0,0 +1,20 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[connectors]
+config_type = "local"
+config_dir = "tests/connectors/runtime/sink_restart_config"
diff --git a/core/integration/tests/connectors/runtime/sink_restart_config/stdout_restart.toml b/core/integration/tests/connectors/runtime/sink_restart_config/stdout_restart.toml
new file mode 100644
index 0000000..41d371d
--- /dev/null
+++ b/core/integration/tests/connectors/runtime/sink_restart_config/stdout_restart.toml
@@ -0,0 +1,36 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# No consumer_group, so the sink joins its default group,
+# iggy-connect-sink-stdout_restart.
+
+type = "sink"
+key = "stdout_restart"
+enabled = true
+version = 0
+name = "Stdout restart sink"
+path = "../../target/debug/libiggy_connector_stdout_sink"
+verbose = true
+
+[[streams]]
+stream = "test_stream"
+topics = ["test_topic"]
+schema = "json"
+batch_length = 100
+
+[plugin_config]
+print_payload = true
diff --git a/core/integration/tests/connectors/runtime/sink_transform_error_config/stdout.toml b/core/integration/tests/connectors/runtime/sink_transform_error_config/stdout.toml
index b6b1bce..097ffd1 100644
--- a/core/integration/tests/connectors/runtime/sink_transform_error_config/stdout.toml
+++ b/core/integration/tests/connectors/runtime/sink_transform_error_config/stdout.toml
@@ -27,7 +27,6 @@
 topics = ["test_topic"]
 schema = "raw"
 batch_length = 100
-poll_interval = "5ms"
 
 [plugin_config]
 print_payload = true
diff --git a/core/integration/tests/sdk/consumer_group_membership.rs b/core/integration/tests/sdk/consumer_group_membership.rs
index ae1a89e..8c0075b 100644
--- a/core/integration/tests/sdk/consumer_group_membership.rs
+++ b/core/integration/tests/sdk/consumer_group_membership.rs
@@ -109,7 +109,6 @@
         .consumer_group(CONSUMER_GROUP_NAME, STREAM_NAME, TOPIC_NAME)
         .unwrap()
         .batch_length(1)
-        .poll_interval(IggyDuration::new(Duration::from_millis(100)))
         .auto_join_consumer_group()
         .do_not_create_consumer_group_if_not_exists()
         .build();
diff --git a/core/integration/tests/sdk/consumer_offset.rs b/core/integration/tests/sdk/consumer_offset.rs
index ecf03b2..ae4d797 100644
--- a/core/integration/tests/sdk/consumer_offset.rs
+++ b/core/integration/tests/sdk/consumer_offset.rs
@@ -431,7 +431,7 @@
             .create_topic_if_not_exists(true)
             .partitions_count(partitions_count)
             .maybe_partition_id(partition_id)
-            .polling_interval("1ms".parse().unwrap())
+            .poll_options(iggy::prelude::DeferredPollOptions::default())
             .polling_strategy(PollingStrategy::first())
             .polling_retry_interval(NonZeroIggyDuration::ONE_SECOND)
             .init_retries(0)
diff --git a/core/integration/tests/sdk/stream_builder.rs b/core/integration/tests/sdk/stream_builder.rs
index 2632dea..55eaab4 100644
--- a/core/integration/tests/sdk/stream_builder.rs
+++ b/core/integration/tests/sdk/stream_builder.rs
@@ -61,7 +61,6 @@
         .create_stream_if_not_exists(true)
         .create_topic_if_not_exists(true)
         .partitions_count(1)
-        .polling_interval("1ms".parse().unwrap())
         .polling_strategy(PollingStrategy::first())
         .polling_retry_interval(NonZeroIggyDuration::ONE_SECOND)
         .init_retries(0)
diff --git a/core/integration/tests/server/poll_semantics_vsr.rs b/core/integration/tests/server/poll_semantics_vsr.rs
index d031711..5df62c0 100644
--- a/core/integration/tests/server/poll_semantics_vsr.rs
+++ b/core/integration/tests/server/poll_semantics_vsr.rs
@@ -24,8 +24,12 @@
 //! timestamp poll must be at-or-after, including the message stamped exactly at
 //! the queried timestamp (the timestamp replies report per message).
 
+use futures::StreamExt;
 use iggy::prelude::*;
+use integration::harness::wait_for_consumer_group_assignment;
 use integration::iggy_harness;
+use std::str::FromStr;
+use std::sync::Arc;
 use tokio::time::{Duration, sleep};
 
 #[iggy_harness(
@@ -358,3 +362,1127 @@
         "the message at the queried timestamp is the one returned"
     );
 }
+
+#[iggy_harness(test_client_transport = [Tcp, WebSocket, Quic, Http])]
+async fn given_deferred_poll_when_messages_arrive_should_fill_count_and_keep_control_available(
+    harness: &TestHarness,
+) {
+    let client = harness.root_client().await.unwrap();
+    client
+        .create_stream("deferred-stream", &StreamCreateOptions::default())
+        .await
+        .unwrap();
+    let stream = Identifier::from_str_value("deferred-stream").unwrap();
+    client
+        .create_topic(
+            &stream,
+            "deferred-topic",
+            &TopicCreateOptions {
+                partitions_count: Some(1),
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    let topic = Identifier::from_str_value("deferred-topic").unwrap();
+    let consumer = Consumer::default();
+    let strategy = PollingStrategy::first();
+    let mut poll = Box::pin(client.poll_messages_deferred(
+        &stream,
+        &topic,
+        Some(0),
+        &consumer,
+        &strategy,
+        2,
+        false,
+        DeferredPollOptions {
+            max_wait: 2_000_000.into(),
+            min_count: 2,
+            ..Default::default()
+        },
+    ));
+    tokio::select! {
+        result = &mut poll => panic!("empty poll completed before arrivals: {result:?}"),
+        () = sleep(Duration::from_millis(50)) => {}
+    }
+    let mut first = vec![
+        IggyMessage::builder()
+            .payload("first".into())
+            .build()
+            .unwrap(),
+    ];
+    client
+        .send_messages(&stream, &topic, &Partitioning::partition_id(0), &mut first)
+        .await
+        .unwrap();
+    client
+        .ping()
+        .await
+        .expect("control connection remains available");
+    tokio::select! {
+        result = &mut poll => panic!("partial poll completed before count or deadline: {result:?}"),
+        () = sleep(Duration::from_millis(50)) => {}
+    }
+    let mut second = vec![
+        IggyMessage::builder()
+            .payload("second".into())
+            .build()
+            .unwrap(),
+    ];
+    client
+        .send_messages(&stream, &topic, &Partitioning::partition_id(0), &mut second)
+        .await
+        .unwrap();
+    let result = tokio::time::timeout(Duration::from_secs(3), poll)
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(
+        result
+            .messages
+            .iter()
+            .map(|message| message.header.offset)
+            .collect::<Vec<_>>(),
+        [0, 1]
+    );
+    let started = tokio::time::Instant::now();
+    let partial = client
+        .poll_messages_deferred(
+            &stream,
+            &topic,
+            Some(0),
+            &consumer,
+            &strategy,
+            3,
+            false,
+            DeferredPollOptions {
+                max_wait: 200_000.into(),
+                min_count: 3,
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    assert_eq!(partial.messages.len(), 2);
+    assert!(
+        started.elapsed() >= Duration::from_millis(150),
+        "partial result returned before deadline"
+    );
+    let empty = client
+        .poll_messages_deferred(
+            &stream,
+            &topic,
+            Some(0),
+            &consumer,
+            &PollingStrategy::offset(2),
+            1,
+            false,
+            DeferredPollOptions {
+                max_wait: 50_000.into(),
+                min_count: 1,
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    assert!(empty.messages.is_empty());
+    let immediate = client
+        .poll_messages_deferred(
+            &stream,
+            &topic,
+            Some(0),
+            &consumer,
+            &strategy,
+            3,
+            false,
+            DeferredPollOptions {
+                max_wait: 0.into(),
+                min_count: 3,
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    assert_eq!(immediate.messages.len(), 2);
+}
+
+#[iggy_harness(
+    test_client_transport = [Tcp, WebSocket, Quic, Http],
+    cluster_nodes = 3,
+    server(
+        http.jwt.encoding_secret = "0123456789abcdef0123456789abcdef",
+        http.jwt.decoding_secret = "0123456789abcdef0123456789abcdef"
+    )
+)]
+async fn given_deferred_auto_commit_when_cancelled_should_preserve_unseen_messages(
+    harness: &TestHarness,
+) {
+    let client = if harness.transport().unwrap() == TransportProtocol::Http {
+        harness
+            .node(1)
+            .http_client()
+            .unwrap()
+            .with_root_login()
+            .connect()
+            .await
+            .unwrap()
+    } else {
+        harness.root_client().await.unwrap()
+    };
+    client
+        .create_stream("cancel-stream", &StreamCreateOptions::default())
+        .await
+        .unwrap();
+    let stream = Identifier::from_str_value("cancel-stream").unwrap();
+    client
+        .create_topic(
+            &stream,
+            "cancel-topic",
+            &TopicCreateOptions {
+                partitions_count: Some(1),
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    let topic = Identifier::from_str_value("cancel-topic").unwrap();
+    let consumer = Consumer::default();
+    let strategy = PollingStrategy::next();
+    let mut messages = vec![
+        IggyMessage::builder()
+            .payload("unseen".into())
+            .build()
+            .unwrap(),
+    ];
+    client
+        .send_messages(
+            &stream,
+            &topic,
+            &Partitioning::partition_id(0),
+            &mut messages,
+        )
+        .await
+        .unwrap();
+    let mut cancelled = Box::pin(client.poll_messages_deferred(
+        &stream,
+        &topic,
+        Some(0),
+        &consumer,
+        &strategy,
+        2,
+        true,
+        DeferredPollOptions {
+            max_wait: 200_000.into(),
+            min_count: 2,
+            ..Default::default()
+        },
+    ));
+    tokio::select! {
+        result = &mut cancelled => panic!("partial result completed before cancellation: {result:?}"),
+        () = sleep(Duration::from_millis(50)) => {}
+    }
+    drop(cancelled);
+    sleep(Duration::from_millis(250)).await;
+    let next = client
+        .poll_messages_deferred(
+            &stream,
+            &topic,
+            Some(0),
+            &consumer,
+            &strategy,
+            1,
+            false,
+            DeferredPollOptions {
+                max_wait: 200_000.into(),
+                min_count: 1,
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    assert_eq!(
+        next.messages.len(),
+        1,
+        "cancelled poll must not accept its partial result"
+    );
+    assert_eq!(next.messages[0].header.offset, 0);
+
+    let committed = client
+        .poll_messages_deferred(
+            &stream,
+            &topic,
+            Some(0),
+            &consumer,
+            &strategy,
+            1,
+            true,
+            DeferredPollOptions {
+                max_wait: 500_000.into(),
+                min_count: 1,
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    assert_eq!(committed.messages[0].header.offset, 0);
+    let next = client
+        .poll_messages_deferred(
+            &stream,
+            &topic,
+            Some(0),
+            &consumer,
+            &strategy,
+            1,
+            true,
+            DeferredPollOptions {
+                max_wait: 200_000.into(),
+                min_count: 1,
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    assert!(
+        next.messages.is_empty(),
+        "accepted automatic commit advances Next"
+    );
+}
+
+#[iggy_harness(test_client_transport = [Tcp, WebSocket, Quic])]
+async fn given_deferred_group_when_one_partition_is_empty_should_deliver_other_partition(
+    harness: &TestHarness,
+) {
+    let client = harness.root_client().await.unwrap();
+    client
+        .create_stream("group-stream", &StreamCreateOptions::default())
+        .await
+        .unwrap();
+    let stream = Identifier::from_str_value("group-stream").unwrap();
+    client
+        .create_topic(
+            &stream,
+            "group-topic",
+            &TopicCreateOptions {
+                partitions_count: Some(2),
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    let topic = Identifier::from_str_value("group-topic").unwrap();
+    let mut consumer = client
+        .consumer_group("deferred-group", "group-stream", "group-topic")
+        .unwrap()
+        .create_consumer_group_if_not_exists()
+        .auto_join_consumer_group()
+        .auto_commit(AutoCommit::Disabled)
+        .polling_strategy(PollingStrategy::first())
+        .batch_length(1)
+        .poll_options(DeferredPollOptions {
+            max_wait: Duration::from_secs(2).into(),
+            ..Default::default()
+        })
+        .build();
+    consumer.init().await.unwrap();
+    let mut messages = vec![
+        IggyMessage::builder()
+            .payload("ready".into())
+            .build()
+            .unwrap(),
+    ];
+    client
+        .send_messages(
+            &stream,
+            &topic,
+            &Partitioning::partition_id(1),
+            &mut messages,
+        )
+        .await
+        .unwrap();
+    let message = tokio::time::timeout(Duration::from_secs(1), consumer.next())
+        .await
+        .expect("empty partition must not hold up the ready partition")
+        .unwrap()
+        .unwrap();
+    assert_eq!(message.partition_id, 1);
+    consumer.shutdown().await.unwrap();
+}
+
+#[iggy_harness(test_client_transport = [Tcp, WebSocket, Quic])]
+async fn given_readiness_wait_when_a_message_arrives_should_name_its_partition_without_reading_it(
+    harness: &TestHarness,
+) {
+    let client = harness.root_client().await.unwrap();
+    client
+        .create_stream("readiness-stream", &StreamCreateOptions::default())
+        .await
+        .unwrap();
+    let stream = Identifier::from_str_value("readiness-stream").unwrap();
+    client
+        .create_topic(
+            &stream,
+            "readiness-topic",
+            &TopicCreateOptions {
+                partitions_count: Some(2),
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    let topic = Identifier::from_str_value("readiness-topic").unwrap();
+    let consumer = Consumer::default();
+    let next = [(0, PollingStrategy::next()), (1, PollingStrategy::next())];
+    let options = DeferredPollOptions {
+        max_wait: Duration::from_secs(2).into(),
+        ..Default::default()
+    };
+    let mut wait = Box::pin(client.wait_for_messages(&stream, &topic, &next, &consumer, options));
+    tokio::select! {
+        result = &mut wait => panic!("wait on empty partitions completed early: {result:?}"),
+        () = sleep(Duration::from_millis(50)) => {}
+    }
+    let mut messages = vec![
+        IggyMessage::builder()
+            .payload("ready".into())
+            .build()
+            .unwrap(),
+    ];
+    client
+        .send_messages(
+            &stream,
+            &topic,
+            &Partitioning::partition_id(1),
+            &mut messages,
+        )
+        .await
+        .unwrap();
+    let ready = tokio::time::timeout(Duration::from_secs(3), wait)
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(ready, [1]);
+    assert!(
+        client
+            .get_consumer_offset(&consumer, &stream, &topic, Some(1))
+            .await
+            .unwrap()
+            .is_none(),
+        "a wait stores no offset"
+    );
+
+    let now = DeferredPollOptions {
+        max_wait: 0.into(),
+        ..Default::default()
+    };
+    let offsets = [
+        (0, PollingStrategy::offset(0)),
+        (1, PollingStrategy::offset(0)),
+    ];
+    assert_eq!(
+        client
+            .wait_for_messages(&stream, &topic, &offsets, &consumer, now)
+            .await
+            .unwrap(),
+        [1],
+        "a zero wait reports what is ready now"
+    );
+    let past_head = [(1, PollingStrategy::offset(1))];
+    assert!(
+        client
+            .wait_for_messages(&stream, &topic, &past_head, &consumer, now)
+            .await
+            .unwrap()
+            .is_empty()
+    );
+    let polled = client
+        .poll_messages(
+            &stream,
+            &topic,
+            Some(1),
+            &consumer,
+            &PollingStrategy::next(),
+            1,
+            false,
+        )
+        .await
+        .unwrap();
+    assert_eq!(polled.messages[0].header.offset, 0);
+    assert!(matches!(
+        client
+            .wait_for_messages(
+                &stream,
+                &topic,
+                &[(1, PollingStrategy::first())],
+                &consumer,
+                now
+            )
+            .await,
+        Err(IggyError::InvalidCommand)
+    ));
+}
+
+#[iggy_harness(test_client_transport = [Tcp, WebSocket, Quic])]
+async fn given_commit_on_poll_group_when_the_application_pauses_should_commit_only_delivered_messages(
+    harness: &TestHarness,
+) {
+    const SENT: u64 = 5;
+    const READ: u64 = 2;
+    let client = harness.root_client().await.unwrap();
+    client
+        .create_stream("on-demand-stream", &StreamCreateOptions::default())
+        .await
+        .unwrap();
+    let stream = Identifier::from_str_value("on-demand-stream").unwrap();
+    client
+        .create_topic(
+            &stream,
+            "on-demand-topic",
+            &TopicCreateOptions {
+                partitions_count: Some(2),
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    let topic = Identifier::from_str_value("on-demand-topic").unwrap();
+    let mut consumer = client
+        .consumer_group("on-demand-group", "on-demand-stream", "on-demand-topic")
+        .unwrap()
+        .create_consumer_group_if_not_exists()
+        .auto_join_consumer_group()
+        .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
+        .polling_strategy(PollingStrategy::next())
+        .batch_length(1)
+        .poll_options(DeferredPollOptions {
+            max_wait: Duration::from_secs(2).into(),
+            ..Default::default()
+        })
+        .build();
+    consumer.init().await.unwrap();
+    let mut first = Box::pin(consumer.next());
+    tokio::select! {
+        _ = &mut first => panic!("consumer read from empty partitions"),
+        () = sleep(Duration::from_millis(50)) => {}
+    }
+    let mut messages: Vec<_> = (0..SENT)
+        .map(|index| {
+            IggyMessage::builder()
+                .payload(format!("message-{index}").into())
+                .build()
+                .unwrap()
+        })
+        .collect();
+    client
+        .send_messages(
+            &stream,
+            &topic,
+            &Partitioning::partition_id(1),
+            &mut messages,
+        )
+        .await
+        .unwrap();
+    let mut delivered = vec![
+        tokio::time::timeout(Duration::from_secs(3), first)
+            .await
+            .expect("a readiness wait must wake the consumer")
+            .unwrap()
+            .unwrap(),
+    ];
+    while (delivered.len() as u64) < READ {
+        delivered.push(
+            tokio::time::timeout(Duration::from_secs(3), consumer.next())
+                .await
+                .unwrap()
+                .unwrap()
+                .unwrap(),
+        );
+    }
+    assert_eq!(
+        delivered
+            .iter()
+            .map(|message| (message.partition_id, message.message.header.offset))
+            .collect::<Vec<_>>(),
+        [(1, 0), (1, 1)]
+    );
+    sleep(Duration::from_millis(300)).await;
+    let group = Consumer::group(Identifier::from_str_value("on-demand-group").unwrap());
+    let stored = client
+        .get_consumer_offset(&group, &stream, &topic, Some(1))
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(
+        stored.stored_offset,
+        READ - 1,
+        "a paused application leaves the rest of the partition uncommitted"
+    );
+    for offset in READ..SENT {
+        let message = tokio::time::timeout(Duration::from_secs(3), consumer.next())
+            .await
+            .unwrap()
+            .unwrap()
+            .unwrap();
+        assert_eq!(
+            (message.partition_id, message.message.header.offset),
+            (1, offset)
+        );
+    }
+    assert!(
+        tokio::time::timeout(Duration::from_millis(300), consumer.next())
+            .await
+            .is_err(),
+        "every message is delivered once"
+    );
+    consumer.shutdown().await.unwrap();
+}
+
+#[iggy_harness(test_client_transport = [Tcp])]
+async fn given_commit_on_poll_group_when_shut_down_mid_batch_should_store_the_last_delivered_offset(
+    harness: &TestHarness,
+) {
+    const SENT: u32 = 10;
+    const READ: u64 = 3;
+    let client = harness.root_client().await.unwrap();
+    let (stream, topic) = create_partition_with_messages(&client, "shutdown-stream", SENT).await;
+    let mut consumer = client
+        .consumer_group("shutdown-group", "shutdown-stream", TOPIC)
+        .unwrap()
+        .create_consumer_group_if_not_exists()
+        .auto_join_consumer_group()
+        .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
+        .batch_length(SENT)
+        .build();
+    consumer.init().await.unwrap();
+    read_offsets(&mut consumer, READ).await;
+    let group = Consumer::group(Identifier::named("shutdown-group").unwrap());
+    wait_for_stored_offset(&client, &group, &stream, &topic, u64::from(SENT) - 1).await;
+
+    consumer.shutdown().await.unwrap();
+
+    wait_for_stored_offset(&client, &group, &stream, &topic, READ - 1).await;
+}
+
+#[iggy_harness(test_client_transport = [Tcp])]
+async fn given_interval_and_poll_commits_when_the_application_pauses_should_store_the_last_delivered_offset(
+    harness: &TestHarness,
+) {
+    const SENT: u32 = 10;
+    const READ: u64 = 3;
+    let client = harness.root_client().await.unwrap();
+    let (stream, topic) = create_partition_with_messages(&client, "interval-stream", SENT).await;
+    let mut consumer = client
+        .consumer("interval-reader", "interval-stream", TOPIC, 0)
+        .unwrap()
+        .auto_commit(AutoCommit::IntervalOrWhen(
+            NonZeroIggyDuration::new(Duration::from_millis(50)).unwrap(),
+            AutoCommitWhen::PollingMessages,
+        ))
+        .batch_length(SENT)
+        .build();
+    consumer.init().await.unwrap();
+
+    read_offsets(&mut consumer, READ).await;
+
+    let reader = Consumer::new(Identifier::named("interval-reader").unwrap());
+    wait_for_stored_offset(&client, &reader, &stream, &topic, READ - 1).await;
+    consumer.shutdown().await.unwrap();
+    wait_for_stored_offset(&client, &reader, &stream, &topic, READ - 1).await;
+}
+
+#[iggy_harness(
+    test_client_transport = [Tcp],
+    server(heartbeat.enabled = true, heartbeat.interval = "7s", consumer_group.heartbeat_interval = "500ms")
+)]
+async fn given_deferred_poll_when_wait_exceeds_heartbeat_window_should_keep_sessions_alive(
+    harness: &TestHarness,
+) {
+    let client = TcpClient::create(Arc::new(TcpClientConfig {
+        server_address: harness.server().raw_tcp_addr().unwrap(),
+        heartbeat_interval: NonZeroIggyDuration::from_str("100ms").unwrap(),
+        nodelay: true,
+        ..TcpClientConfig::default()
+    }))
+    .unwrap();
+    let client = IggyClient::create(ClientWrapper::Tcp(client), None, None);
+    client.connect().await.unwrap();
+    client
+        .login_user(DEFAULT_ROOT_USERNAME, DEFAULT_ROOT_PASSWORD)
+        .await
+        .unwrap();
+    client
+        .create_stream("live-stream", &StreamCreateOptions::default())
+        .await
+        .unwrap();
+    let stream = Identifier::named("live-stream").unwrap();
+    client
+        .create_topic(
+            &stream,
+            "live-topic",
+            &TopicCreateOptions {
+                partitions_count: Some(1),
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    let topic = Identifier::named("live-topic").unwrap();
+    client
+        .create_consumer_group(&stream, &topic, "live-group")
+        .await
+        .unwrap();
+    let group = Identifier::named("live-group").unwrap();
+    client
+        .join_consumer_group(&stream, &topic, &group)
+        .await
+        .unwrap();
+    wait_for_consumer_group_assignment(&client, &stream, &topic, &group, 1, Duration::from_secs(5))
+        .await;
+
+    let started = tokio::time::Instant::now();
+    let result = client
+        .poll_messages_deferred(
+            &stream,
+            &topic,
+            None,
+            &Consumer::group(group.clone()),
+            &PollingStrategy::first(),
+            1,
+            false,
+            DeferredPollOptions {
+                max_wait: 10_000_000.into(),
+                min_count: 1,
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    assert_eq!(result.partition_id, 0);
+    assert!(result.messages.is_empty());
+    assert!(started.elapsed() >= Duration::from_secs(9));
+    client.ping().await.unwrap();
+    assert_eq!(
+        client
+            .get_consumer_group(&stream, &topic, &group)
+            .await
+            .unwrap()
+            .unwrap()
+            .members_count,
+        1,
+        "the parent session must remain a group member throughout the deferred wait"
+    );
+}
+
+#[iggy_harness(test_client_transport = [Tcp, WebSocket, Quic, Http])]
+async fn given_large_batch_cap_when_one_message_arrives_should_return_without_waiting_for_batch(
+    harness: &TestHarness,
+) {
+    let client = harness.root_client().await.unwrap();
+    client
+        .create_stream("ready-stream", &StreamCreateOptions::default())
+        .await
+        .unwrap();
+    let stream = Identifier::named("ready-stream").unwrap();
+    client
+        .create_topic(
+            &stream,
+            "topic",
+            &TopicCreateOptions {
+                partitions_count: Some(1),
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    let topic = Identifier::named("topic").unwrap();
+    let consumer = Consumer::default();
+    let strategy = PollingStrategy::offset(0);
+    let mut poll = Box::pin(client.poll_messages_deferred(
+        &stream,
+        &topic,
+        Some(0),
+        &consumer,
+        &strategy,
+        100,
+        false,
+        DeferredPollOptions {
+            max_wait: Duration::from_secs(5).into(),
+            ..Default::default()
+        },
+    ));
+    tokio::select! {
+        result = &mut poll => panic!("completed without an arrival: {result:?}"),
+        () = sleep(Duration::from_millis(50)) => {}
+    }
+    let mut messages = vec![IggyMessage::from_str("ready").unwrap()];
+    client
+        .send_messages(
+            &stream,
+            &topic,
+            &Partitioning::partition_id(0),
+            &mut messages,
+        )
+        .await
+        .unwrap();
+    let polled = tokio::time::timeout(Duration::from_secs(2), poll)
+        .await
+        .expect("minimum is one, not 100")
+        .unwrap();
+    assert_eq!(polled.messages.len(), 1);
+}
+
+#[iggy_harness(test_client_transport = [Tcp, WebSocket, Quic, Http])]
+async fn given_byte_limit_when_batch_is_larger_should_return_prefix_without_skipping_offsets(
+    harness: &TestHarness,
+) {
+    const PAYLOAD: &str = "payload";
+    let client = harness.root_client().await.unwrap();
+    client
+        .create_stream("bytes-stream", &StreamCreateOptions::default())
+        .await
+        .unwrap();
+    let stream = Identifier::named("bytes-stream").unwrap();
+    client
+        .create_topic(
+            &stream,
+            "topic",
+            &TopicCreateOptions {
+                partitions_count: Some(1),
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    let topic = Identifier::named("topic").unwrap();
+    let mut messages = (0..3)
+        .map(|_| IggyMessage::from_str(PAYLOAD).unwrap())
+        .collect::<Vec<_>>();
+    client
+        .send_messages(
+            &stream,
+            &topic,
+            &Partitioning::partition_id(0),
+            &mut messages,
+        )
+        .await
+        .unwrap();
+    let max_bytes =
+        (iggy_binary_protocol::responses::messages::poll_messages::POLL_RESPONSE_HEADER_SIZE
+            + iggy_binary_protocol::batch::BATCH_HEADER_SIZE
+            + iggy_binary_protocol::batch::BATCH_MESSAGE_HEADER_SIZE
+            + PAYLOAD.len()) as u32;
+    let options = DeferredPollOptions {
+        min_count: 3,
+        max_bytes,
+        ..Default::default()
+    };
+    for offset in 0..3 {
+        let polled = tokio::time::timeout(
+            Duration::from_millis(700),
+            client.poll_messages_deferred(
+                &stream,
+                &topic,
+                Some(0),
+                &Consumer::default(),
+                &PollingStrategy::next(),
+                3,
+                true,
+                options,
+            ),
+        )
+        .await
+        .unwrap()
+        .unwrap();
+        assert_eq!(polled.messages.len(), 1);
+        assert_eq!(polled.messages[0].header.offset, offset);
+    }
+    let oversized = client
+        .poll_messages_deferred(
+            &stream,
+            &topic,
+            Some(0),
+            &Consumer::default(),
+            &PollingStrategy::offset(0),
+            3,
+            false,
+            DeferredPollOptions {
+                max_bytes: max_bytes - 1,
+                ..options
+            },
+        )
+        .await;
+    let error = oversized.expect_err("an oversized first record must fail, never disappear");
+    let code = if let IggyError::HttpResponseError(_, body) = &error {
+        serde_json::from_str::<serde_json::Value>(body).unwrap()["id"]
+            .as_u64()
+            .unwrap()
+    } else {
+        u64::from(error.as_code())
+    };
+    assert_eq!(
+        code,
+        u64::from(IggyError::InvalidSizeBytes.as_code()),
+        "{error:?}"
+    );
+    let replay = client
+        .poll_messages_deferred(
+            &stream,
+            &topic,
+            Some(0),
+            &Consumer::default(),
+            &PollingStrategy::offset(0),
+            3,
+            false,
+            options,
+        )
+        .await
+        .unwrap();
+    assert_eq!(replay.messages[0].header.offset, 0);
+}
+
+#[iggy_harness(test_client_transport = [Tcp, WebSocket, Quic, Http])]
+async fn given_default_consumer_when_restarted_before_commit_should_replay_unprocessed_messages(
+    harness: &TestHarness,
+) {
+    let client = harness.root_client().await.unwrap();
+    client
+        .create_stream("safe-stream", &StreamCreateOptions::default())
+        .await
+        .unwrap();
+    let stream = Identifier::named("safe-stream").unwrap();
+    client
+        .create_topic(
+            &stream,
+            "topic",
+            &TopicCreateOptions {
+                partitions_count: Some(1),
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    let topic = Identifier::named("topic").unwrap();
+    let mut messages = (0..6)
+        .map(|_| IggyMessage::from_str("uncommitted").unwrap())
+        .collect::<Vec<_>>();
+    client
+        .send_messages(
+            &stream,
+            &topic,
+            &Partitioning::partition_id(0),
+            &mut messages,
+        )
+        .await
+        .unwrap();
+    let mut consumer = client
+        .consumer("safe-reader", "safe-stream", "topic", 0)
+        .unwrap()
+        .batch_length(2)
+        .prefetch_messages(4)
+        .build();
+    consumer.init().await.unwrap();
+    for offset in 0..6 {
+        let received = tokio::time::timeout(Duration::from_secs(2), consumer.next())
+            .await
+            .expect("explicit fetch position must advance without a commit")
+            .unwrap()
+            .unwrap();
+        assert_eq!(received.message.header.offset, offset);
+    }
+    drop(consumer);
+    let identity = Consumer::new(Identifier::named("safe-reader").unwrap());
+    assert!(
+        client
+            .get_consumer_offset(&identity, &stream, &topic, Some(0))
+            .await
+            .unwrap()
+            .is_none()
+    );
+    let mut restarted = client
+        .consumer("safe-reader", "safe-stream", "topic", 0)
+        .unwrap()
+        .batch_length(2)
+        .build();
+    restarted.init().await.unwrap();
+    let first = tokio::time::timeout(Duration::from_secs(2), restarted.next())
+        .await
+        .unwrap()
+        .unwrap()
+        .unwrap();
+    assert_eq!(first.message.header.offset, 0);
+    restarted.store_offset(0, Some(0)).await.unwrap();
+    drop(restarted);
+    let mut committed = client
+        .consumer("safe-reader", "safe-stream", "topic", 0)
+        .unwrap()
+        .build();
+    committed.init().await.unwrap();
+    let next = tokio::time::timeout(Duration::from_secs(2), committed.next())
+        .await
+        .unwrap()
+        .unwrap()
+        .unwrap();
+    assert_eq!(next.message.header.offset, 1);
+    committed.shutdown().await.unwrap();
+}
+
+#[iggy_harness(test_client_transport = [Tcp, WebSocket, Quic])]
+async fn given_buffered_group_batch_when_membership_is_revoked_should_discard_queued_messages(
+    harness: &TestHarness,
+) {
+    let client = harness.root_client().await.unwrap();
+    client
+        .create_stream("revoke-stream", &StreamCreateOptions::default())
+        .await
+        .unwrap();
+    let stream = Identifier::named("revoke-stream").unwrap();
+    client
+        .create_topic(
+            &stream,
+            "topic",
+            &TopicCreateOptions {
+                partitions_count: Some(1),
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    let topic = Identifier::named("topic").unwrap();
+    let group = Identifier::named("readers").unwrap();
+    client
+        .create_consumer_group(&stream, &topic, "readers")
+        .await
+        .unwrap();
+    client
+        .join_consumer_group(&stream, &topic, &group)
+        .await
+        .unwrap();
+    let mut messages = (0..10)
+        .map(|_| IggyMessage::from_str("buffered").unwrap())
+        .collect::<Vec<_>>();
+    client
+        .send_messages(
+            &stream,
+            &topic,
+            &Partitioning::partition_id(0),
+            &mut messages,
+        )
+        .await
+        .unwrap();
+    let mut consumer = client
+        .consumer_group("readers", "revoke-stream", "topic")
+        .unwrap()
+        .do_not_auto_join_consumer_group()
+        .batch_length(10)
+        .prefetch_messages(10)
+        .build();
+    consumer.init().await.unwrap();
+    assert_eq!(
+        consumer
+            .next()
+            .await
+            .unwrap()
+            .unwrap()
+            .message
+            .header
+            .offset,
+        0
+    );
+    client
+        .leave_consumer_group(&stream, &topic, &group)
+        .await
+        .unwrap();
+    sleep(Duration::from_millis(1200)).await;
+    let received = tokio::time::timeout(Duration::from_secs(2), consumer.next())
+        .await
+        .unwrap()
+        .unwrap();
+    assert!(
+        received.is_err(),
+        "revoked prefetched messages must never reach the application"
+    );
+    consumer.shutdown().await.unwrap();
+}
+
+const TOPIC: &str = "topic";
+const RECEIVE_TIMEOUT: Duration = Duration::from_secs(3);
+const STORED_OFFSET_TIMEOUT: Duration = Duration::from_secs(3);
+const STORED_OFFSET_RECHECK: Duration = Duration::from_millis(20);
+
+/// Creates `stream_name` with a one-partition topic that holds `count` messages.
+async fn create_partition_with_messages(
+    client: &IggyClient,
+    stream_name: &str,
+    count: u32,
+) -> (Identifier, Identifier) {
+    client
+        .create_stream(stream_name, &StreamCreateOptions::default())
+        .await
+        .unwrap();
+    let stream = Identifier::named(stream_name).unwrap();
+    client
+        .create_topic(
+            &stream,
+            TOPIC,
+            &TopicCreateOptions {
+                partitions_count: Some(1),
+                ..Default::default()
+            },
+        )
+        .await
+        .unwrap();
+    let topic = Identifier::named(TOPIC).unwrap();
+    let mut messages = (0..count)
+        .map(|index| IggyMessage::from_str(&format!("message-{index}")).unwrap())
+        .collect::<Vec<_>>();
+    client
+        .send_messages(
+            &stream,
+            &topic,
+            &Partitioning::partition_id(0),
+            &mut messages,
+        )
+        .await
+        .unwrap();
+    (stream, topic)
+}
+
+/// Reads offsets `0..count` of partition 0, in order.
+async fn read_offsets(consumer: &mut IggyConsumer, count: u64) {
+    for offset in 0..count {
+        let received = tokio::time::timeout(RECEIVE_TIMEOUT, consumer.next())
+            .await
+            .unwrap()
+            .unwrap()
+            .unwrap();
+        assert_eq!(
+            (received.partition_id, received.message.header.offset),
+            (0, offset)
+        );
+    }
+}
+
+/// Waits until the server stores `expected` for `consumer` on partition 0.
+async fn wait_for_stored_offset(
+    client: &IggyClient,
+    consumer: &Consumer,
+    stream: &Identifier,
+    topic: &Identifier,
+    expected: u64,
+) {
+    let deadline = tokio::time::Instant::now() + STORED_OFFSET_TIMEOUT;
+    loop {
+        let stored = client
+            .get_consumer_offset(consumer, stream, topic, Some(0))
+            .await
+            .unwrap()
+            .map(|offset| offset.stored_offset);
+        if stored == Some(expected) {
+            return;
+        }
+        assert!(
+            tokio::time::Instant::now() < deadline,
+            "stored offset {stored:?}, expected {expected}"
+        );
+        sleep(STORED_OFFSET_RECHECK).await;
+    }
+}
diff --git a/core/integration/tests/server/scenarios/authentication_scenario.rs b/core/integration/tests/server/scenarios/authentication_scenario.rs
index 02f7d42..147632b 100644
--- a/core/integration/tests/server/scenarios/authentication_scenario.rs
+++ b/core/integration/tests/server/scenarios/authentication_scenario.rs
@@ -32,11 +32,16 @@
 use iggy_binary_protocol::WireEncode;
 use iggy_binary_protocol::codes::*;
 use iggy_binary_protocol::dispatch::COMMAND_TABLE;
-use iggy_binary_protocol::requests::messages::GetPartitionRouteRequest;
+use iggy_binary_protocol::requests::messages::{
+    DeferredPollMessagesRequest, GetPartitionRouteRequest, PollMessagesRequest,
+    WaitForMessagesRequest, WaitForMessagesTarget,
+};
 use iggy_binary_protocol::requests::system::{BindSessionRequest, SessionIdentity};
 use iggy_binary_protocol::requests::users::login_register::BindSecret;
 use iggy_binary_protocol::{ClientVersionInfo, IGGY_PROTOCOL_VERSION, WireName};
-use iggy_common::wire_conversions::identifier_to_wire;
+use iggy_common::wire_conversions::{
+    consumer_to_wire, identifier_to_wire, polling_strategy_to_wire,
+};
 use iggy_common::{MetadataChangeGroups, VsrSessionControl};
 use integration::harness::{TestHarness, login_root};
 
@@ -159,6 +164,11 @@
         ) {
             continue;
         }
+        // The SDK refuses a raw cancel, because a cancel names a parked
+        // request by its id. The server `classify` test covers its auth gate.
+        if code == CANCEL_DEFERRED_REQUEST_CODE {
+            continue;
+        }
         // Special setup required
         if matches!(
             code,
@@ -323,6 +333,39 @@
                 )
                 .await
                 .map(|_| ()),
+            POLL_MESSAGES_DEFERRED_CODE => {
+                let options = DeferredPollOptions::default();
+                let request = DeferredPollMessagesRequest {
+                    poll: poll_request(&ctx),
+                    wait_us: options.max_wait.as_micros(),
+                    min_count: options.min_count,
+                    max_bytes: options.max_bytes,
+                    request_timeout_us: options.request_timeout.as_micros(),
+                };
+                client
+                    .send_binary_request(code, request.to_bytes())
+                    .await
+                    .map(|_| ())
+            }
+            WAIT_FOR_MESSAGES_CODE => {
+                let options = DeferredPollOptions::default();
+                let poll = poll_request(&ctx);
+                let request = WaitForMessagesRequest {
+                    consumer: poll.consumer,
+                    stream_id: poll.stream_id,
+                    topic_id: poll.topic_id,
+                    wait_us: options.max_wait.as_micros(),
+                    request_timeout_us: options.request_timeout.as_micros(),
+                    partitions: vec![WaitForMessagesTarget {
+                        partition_id: 0,
+                        strategy: poll.strategy,
+                    }],
+                };
+                client
+                    .send_binary_request(code, request.to_bytes())
+                    .await
+                    .map(|_| ())
+            }
             // Consumer Offsets
             GET_CONSUMER_OFFSET_CODE => client
                 .get_consumer_offset(&ctx.consumer, &ctx.stream_id, &ctx.topic_id, Some(0))
@@ -365,6 +408,18 @@
     }
 }
 
+fn poll_request(ctx: &TestContext) -> PollMessagesRequest {
+    PollMessagesRequest {
+        consumer: consumer_to_wire(&ctx.consumer).unwrap(),
+        stream_id: identifier_to_wire(&ctx.stream_id).unwrap(),
+        topic_id: identifier_to_wire(&ctx.topic_id).unwrap(),
+        partition_id: Some(0),
+        strategy: polling_strategy_to_wire(&PollingStrategy::offset(0)),
+        count: 1,
+        auto_commit: true,
+    }
+}
+
 fn assert_unauthenticated(result: Result<(), IggyError>, name: &str) {
     match result {
         Err(e) if e.as_code() == IggyError::FeatureUnavailable.as_code() => {}
diff --git a/core/integration/tests/server/scenarios/consumer_group_auto_commit_reconnection_scenario.rs b/core/integration/tests/server/scenarios/consumer_group_auto_commit_reconnection_scenario.rs
index 6215146..5a0e174 100644
--- a/core/integration/tests/server/scenarios/consumer_group_auto_commit_reconnection_scenario.rs
+++ b/core/integration/tests/server/scenarios/consumer_group_auto_commit_reconnection_scenario.rs
@@ -140,7 +140,6 @@
         .consumer_group(CONSUMER_GROUP_NAME, STREAM_NAME, TOPIC_NAME)
         .unwrap()
         .batch_length(10)
-        .poll_interval(IggyDuration::from_str("100ms").expect("Invalid duration"))
         .polling_strategy(PollingStrategy::next())
         .auto_join_consumer_group()
         .auto_commit(AutoCommit::IntervalOrAfter(
diff --git a/core/integration/tests/server/scenarios/consumer_group_new_messages_after_restart_scenario.rs b/core/integration/tests/server/scenarios/consumer_group_new_messages_after_restart_scenario.rs
index 7aaac37..eecdb97 100644
--- a/core/integration/tests/server/scenarios/consumer_group_new_messages_after_restart_scenario.rs
+++ b/core/integration/tests/server/scenarios/consumer_group_new_messages_after_restart_scenario.rs
@@ -223,7 +223,6 @@
         .consumer_group(CONSUMER_GROUP_NAME, STREAM_NAME, TOPIC_NAME)
         .unwrap()
         .batch_length(10)
-        .poll_interval(IggyDuration::from_str("100ms").unwrap())
         .polling_strategy(PollingStrategy::next())
         .auto_join_consumer_group()
         .create_consumer_group_if_not_exists()
diff --git a/core/integration/tests/server/scenarios/consumer_group_offset_cleanup_scenario.rs b/core/integration/tests/server/scenarios/consumer_group_offset_cleanup_scenario.rs
index b807f58..9ffe576 100644
--- a/core/integration/tests/server/scenarios/consumer_group_offset_cleanup_scenario.rs
+++ b/core/integration/tests/server/scenarios/consumer_group_offset_cleanup_scenario.rs
@@ -156,7 +156,6 @@
         .consumer_group(consumer_group_name, STREAM_NAME, TOPIC_NAME)
         .unwrap()
         .batch_length(10)
-        .poll_interval(IggyDuration::from_str("10ms").expect("Invalid duration"))
         .polling_strategy(PollingStrategy::next())
         .auto_join_consumer_group()
         .auto_commit(AutoCommit::IntervalOrAfter(
diff --git a/core/integration/tests/server/scenarios/consumer_timestamp_polling_scenario.rs b/core/integration/tests/server/scenarios/consumer_timestamp_polling_scenario.rs
index 5bdefa4..495a74c 100644
--- a/core/integration/tests/server/scenarios/consumer_timestamp_polling_scenario.rs
+++ b/core/integration/tests/server/scenarios/consumer_timestamp_polling_scenario.rs
@@ -186,7 +186,6 @@
             AutoCommitWhen::ConsumingAllMessages,
         ))
         .polling_strategy(strategy)
-        .poll_interval(IggyDuration::from_str("2ms").unwrap())
         .batch_length(BATCH_LENGTH)
         .build();
 
diff --git a/core/integration/tests/server/scenarios/reconnect_after_restart_scenario.rs b/core/integration/tests/server/scenarios/reconnect_after_restart_scenario.rs
index 6f71817..3b48ec6 100644
--- a/core/integration/tests/server/scenarios/reconnect_after_restart_scenario.rs
+++ b/core/integration/tests/server/scenarios/reconnect_after_restart_scenario.rs
@@ -164,7 +164,6 @@
         .expect("Failed to create consumer builder")
         .polling_strategy(PollingStrategy::next())
         .batch_length(10)
-        .poll_interval(IggyDuration::from_str("100ms").unwrap())
         .polling_retry_interval(NonZeroIggyDuration::from_str("500ms").unwrap())
         .build();
 
diff --git a/core/integration/tests/server/scenarios/stale_client_consumer_group_scenario.rs b/core/integration/tests/server/scenarios/stale_client_consumer_group_scenario.rs
index e94f299..4f34079 100644
--- a/core/integration/tests/server/scenarios/stale_client_consumer_group_scenario.rs
+++ b/core/integration/tests/server/scenarios/stale_client_consumer_group_scenario.rs
@@ -556,7 +556,6 @@
         .consumer_group(CONSUMER_GROUP_NAME, STREAM_NAME, TOPIC_NAME)
         .unwrap()
         .batch_length(1)
-        .poll_interval(IggyDuration::from_str("100ms").unwrap())
         .polling_strategy(PollingStrategy::next())
         .auto_join_consumer_group()
         .create_consumer_group_if_not_exists()
diff --git a/core/message_bus/src/lib.rs b/core/message_bus/src/lib.rs
index c574d34..0b95e05 100644
--- a/core/message_bus/src/lib.rs
+++ b/core/message_bus/src/lib.rs
@@ -112,8 +112,8 @@
 use std::cell::{Cell, OnceCell, RefCell};
 use std::net::SocketAddr;
 use std::rc::Rc;
-use std::sync::Arc;
 use std::sync::atomic::{AtomicU16, Ordering};
+use std::sync::{Arc, OnceLock};
 use std::time::{Duration, Instant};
 
 /// Maximum number of replicas a single cluster supports. Replica ids are
@@ -528,6 +528,13 @@
 ///
 /// A bus impl must preserve this divergence - see each method.
 pub trait MessageBus {
+    /// Process-wide monotonic microseconds, on the same timeline as `sleep`.
+    /// Virtual-time implementations must override both methods together.
+    fn monotonic_micros(&self) -> u64 {
+        static ORIGIN: OnceLock<Instant> = OnceLock::new();
+        u64::try_from(ORIGIN.get_or_init(Instant::now).elapsed().as_micros()).unwrap_or(u64::MAX)
+    }
+
     /// Queue one frame for `client_id`. Takes anything that converts into a
     /// [`BusMessage`]: a single `Frozen<MESSAGE_ALIGN>` buffer or an
     /// already fragmented frame. [`IggyMessageBus`] refuses every such frame
@@ -1099,11 +1106,16 @@
     fn finish_local_reply(&self, client_id: u128, route: ReplyRoute) -> Result<(), SendError> {
         match route {
             ReplyRoute::Delivered(send_result) => send_result.map_err(map_try_send_err),
-            ReplyRoute::InProcess(message) => {
+            ReplyRoute::InProcess(mut message) => {
                 let request = reply_request_id(&message);
+                let receipt = message.take_write_receipt();
                 self.clients
                     .fire_in_process(client_id, request, message)
-                    .map_err(|_| SendError::ClientNotFound(client_id))
+                    .map_err(|_| SendError::ClientNotFound(client_id))?;
+                if let Some(receipt) = receipt {
+                    receipt.complete();
+                }
+                Ok(())
             }
             ReplyRoute::NoSlot(_message) => Err(SendError::ClientNotFound(client_id)),
             ReplyRoute::Stalled => Err(SendError::Backpressure),
@@ -1431,6 +1443,10 @@
     // spawn queue, and virtual clock) would be silently reverted to the defaults
     // through the wrapper. No-op for production, whose `IggyMessageBus` uses those
     // defaults anyway.
+    fn monotonic_micros(&self) -> u64 {
+        (**self).monotonic_micros()
+    }
+
     fn sleep(&self, duration: Duration) -> impl Future<Output = ()> {
         (**self).sleep(duration)
     }
diff --git a/core/message_bus/src/transports/quic.rs b/core/message_bus/src/transports/quic.rs
index 04fdbea..390a587 100644
--- a/core/message_bus/src/transports/quic.rs
+++ b/core/message_bus/src/transports/quic.rs
@@ -76,7 +76,13 @@
 use futures::FutureExt;
 use futures::channel::oneshot;
 use futures::stream::{FuturesUnordered, StreamExt};
-use iggy_binary_protocol::{Command, GenericHeader, ReplyHeader, RequestHeader};
+use iggy_binary_protocol::codes::{POLL_MESSAGES_DEFERRED_CODE, WAIT_FOR_MESSAGES_CODE};
+use iggy_binary_protocol::requests::messages::{
+    DeferredPollMessagesRequest, WaitForMessagesRequest,
+};
+use iggy_binary_protocol::{
+    Command, GenericHeader, HEADER_SIZE, ReplyHeader, RequestHeader, WireDecode,
+};
 use server_common::Message;
 use std::cell::RefCell;
 use std::collections::{HashMap, VecDeque};
@@ -325,6 +331,24 @@
     read_u64(request.as_slice(), offset_of!(RequestHeader, request))
 }
 
+/// The request timeout that the body of a deferred request of `code` declares.
+fn deferred_timeout(code: u32, body: &[u8]) -> Option<Duration> {
+    let micros = match code {
+        POLL_MESSAGES_DEFERRED_CODE => {
+            DeferredPollMessagesRequest::decode_from(body)
+                .ok()?
+                .request_timeout_us
+        }
+        WAIT_FOR_MESSAGES_CODE => {
+            WaitForMessagesRequest::decode_from(body)
+                .ok()?
+                .request_timeout_us
+        }
+        _ => return None,
+    };
+    Some(Duration::from_micros(micros))
+}
+
 fn read_u64(bytes: &[u8], offset: usize) -> Option<u64> {
     bytes
         .get(offset..offset + size_of::<u64>())
@@ -471,6 +495,21 @@
             debug!(%label, %peer, "quic: frame shorter than a request header");
             return ControlFlow::Continue(());
         };
+        let header =
+            bytemuck::checked::try_from_bytes::<RequestHeader>(&request.as_slice()[..HEADER_SIZE])
+                .ok();
+        let cancellation = header.and_then(RequestHeader::deferred_cancellation);
+        // Only a cancellable request waits, so one predicate decides both.
+        let deferred_timeout = header
+            .filter(|_| cancellation.is_some())
+            .and_then(|header| {
+                deferred_timeout(
+                    u32::from_le_bytes(header.reserved),
+                    &request.as_slice()[HEADER_SIZE..],
+                )
+            })
+            .unwrap_or_default();
+        let reply_backstop = reply_backstop.max(deferred_timeout);
         let mut reply = router.borrow_mut().register(request_id);
         if in_tx.send(request).await.is_err() {
             debug!(%label, %peer, "quic: inbound queue dropped");
@@ -491,14 +530,26 @@
                 }
             }
         };
-        let Some(frame) = replied else {
+        let Some(mut frame) = replied else {
             drop(reply);
             router.borrow_mut().release(request_id);
+            if let Some(header) = cancellation {
+                let mut cancel = server_common::Message::<RequestHeader>::new(HEADER_SIZE);
+                cancel
+                    .as_mut_slice()
+                    .copy_from_slice(bytemuck::bytes_of(&header));
+                // Both frames enter the same connection drain in order, including
+                // a stream stopped before its original request reaches dispatch.
+                if in_tx.send(cancel.into_generic()).await.is_err() {
+                    return ControlFlow::Break(());
+                }
+            }
             if let Err(e) = send.reset(VarInt::from_u32(QUIC_NO_REPLY)) {
                 debug!(%label, %peer, error = ?e, "quic: stream reset failed");
             }
             return ControlFlow::Continue(());
         };
+        let receipt = frame.take_write_receipt();
         for fragment in frame.into_fragments() {
             let BufResult(result, _frozen) = send.write_all(fragment).await;
             if let Err(e) = result {
@@ -510,6 +561,10 @@
         // reply is complete.
         if let Err(e) = send.finish() {
             debug!(%label, %peer, error = ?e, "quic: send.finish() failed");
+            return ControlFlow::Continue(());
+        }
+        if let Some(receipt) = receipt {
+            receipt.complete();
         }
         // Keep the reply alive through graceful shutdown until the peer has
         // acknowledged it. Closing the connection after FIN alone can discard it.
diff --git a/core/message_bus/src/transports/tcp.rs b/core/message_bus/src/transports/tcp.rs
index 01eabd8..0ff6caf 100644
--- a/core/message_bus/src/transports/tcp.rs
+++ b/core/message_bus/src/transports/tcp.rs
@@ -408,6 +408,7 @@
     let mut iovecs: Vec<Frozen<MESSAGE_ALIGN>> = Vec::with_capacity(max_batch);
     // Sized by the first wide batch; most connections never need it.
     let mut scratch: Vec<Frozen<MESSAGE_ALIGN>> = Vec::new();
+    let mut write_receipts = Vec::new();
     let mut shutdown_fut = Box::pin(shutdown.wait().fuse());
 
     loop {
@@ -439,7 +440,8 @@
 
         // `drain(..)` keeps the batch allocation for the next round.
         #[allow(clippy::iter_with_drain)]
-        for frame in batch.drain(..) {
+        for mut frame in batch.drain(..) {
+            write_receipts.extend(frame.take_write_receipt());
             iovecs.extend(frame.into_fragments());
         }
         let result = write_iovecs(&mut write_half, &mut iovecs, &mut scratch).await;
@@ -454,6 +456,10 @@
             );
             return;
         }
+        #[allow(clippy::iter_with_drain)]
+        for receipt in write_receipts.drain(..) {
+            receipt.complete();
+        }
     }
 }
 
diff --git a/core/message_bus/src/transports/tcp_tls.rs b/core/message_bus/src/transports/tcp_tls.rs
index c015599..d7d548d 100644
--- a/core/message_bus/src/transports/tcp_tls.rs
+++ b/core/message_bus/src/transports/tcp_tls.rs
@@ -573,6 +573,10 @@
                     }
                 }
                 let drained = batch.len();
+                let write_receipts: Vec<_> = batch
+                    .iter_mut()
+                    .filter_map(BusMessage::take_write_receipt)
+                    .collect();
                 // `drain(..)` consumes the batch in FIFO order while
                 // preserving the Vec's allocation for the next
                 // iteration; `into_iter()` would move the buffer out.
@@ -602,6 +606,9 @@
                     );
                     return;
                 }
+                for receipt in write_receipts {
+                    receipt.complete();
+                }
             }
             PumpAction::Recv(Ok(Some(frame))) => {
                 if in_tx.send(frame).await.is_err() {
diff --git a/core/message_bus/src/transports/ws.rs b/core/message_bus/src/transports/ws.rs
index 15b5a96..91c94d6 100644
--- a/core/message_bus/src/transports/ws.rs
+++ b/core/message_bus/src/transports/ws.rs
@@ -233,6 +233,10 @@
                     }
                 }
                 let drained = batch.len();
+                let write_receipts: Vec<_> = batch
+                    .iter_mut()
+                    .filter_map(BusMessage::take_write_receipt)
+                    .collect();
                 #[allow(clippy::iter_with_drain)]
                 // One WS frame per message: tungstenite takes a single payload
                 // buffer, so a multi-fragment frame is joined here (the only
@@ -248,6 +252,9 @@
                     warn!(%label, %peer, error = ?e, batch_len = drained, "ws writer: flush failed");
                     return;
                 }
+                for receipt in write_receipts {
+                    receipt.complete();
+                }
             }
             PumpAction::Recv(Ok(msg)) => match msg {
                 WsMessage::Binary(bytes) => {
diff --git a/core/message_bus/src/transports/wss.rs b/core/message_bus/src/transports/wss.rs
index b28b93e..cc00c8d 100644
--- a/core/message_bus/src/transports/wss.rs
+++ b/core/message_bus/src/transports/wss.rs
@@ -381,6 +381,10 @@
                     }
                 }
                 let drained = batch.len();
+                let write_receipts: Vec<_> = batch
+                    .iter_mut()
+                    .filter_map(BusMessage::take_write_receipt)
+                    .collect();
                 // `drain(..)` consumes the batch in FIFO order while
                 // preserving the Vec's allocation for the next
                 // iteration; `into_iter()` would move the buffer out.
@@ -399,6 +403,9 @@
                     warn!(%label, %peer, error = ?e, batch_len = drained, "wss writer: flush failed");
                     return;
                 }
+                for receipt in write_receipts {
+                    receipt.complete();
+                }
             }
             PumpAction::Recv(Ok(msg)) => match msg {
                 WsMessage::Binary(bytes) => {
diff --git a/core/partitions/src/iggy_partition.rs b/core/partitions/src/iggy_partition.rs
index 0d96630..0b88532 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -168,6 +168,8 @@
     /// Widest batch committed here, the floor for a disk poll's first read.
     /// See [`Self::widest_committed_batch`].
     widest_batch_bytes: Cell<u64>,
+    /// See [`PollVisibility::committed_bytes`].
+    committed_bytes: u64,
     pub created_at: IggyTimestamp,
     pub revision_id: u64,
     pub(crate) offset_space: OffsetSpace,
@@ -279,6 +281,7 @@
     poll_history: PollHistoryId,
     incarnation: crate::PartitionIncarnation,
     workerless_offset_files: Rc<crate::offset_storage::RetainedOffsetFiles<compio::fs::File>>,
+    poll_notifier: Option<Rc<dyn Fn()>>,
     /// Committed consumer-offset membership and values. This is deliberately
     /// separate from the eager poll maps because follower-local and uncommitted
     /// auto-commit progress must never consume a durable slot or enter a state
@@ -484,6 +487,17 @@
     reservation: AutoCommitReservation,
 }
 
+/// What a parked poll compares between two reads of a partition.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct PollVisibility {
+    /// The committed offset, `None` until the offset space is seeded.
+    pub offset: Option<u64>,
+    /// Encoded bytes, batch framing included, of the batches that commits
+    /// made visible since the partition was opened. Only its growth between
+    /// two reads has a meaning.
+    pub committed_bytes: u64,
+}
+
 /// Post-preflight dispatch in `on_request`: replicate via VSR or take the
 /// `NoAck` leader-local fast path. `RoutedRequestHeader` is boxed to avoid the
 /// 277-byte inline variant tripping clippy's `large_enum_variant`.
@@ -879,6 +893,7 @@
             history_transition: None,
             stats,
             widest_batch_bytes: Cell::new(0),
+            committed_bytes: 0,
             created_at: IggyTimestamp::now(),
             revision_id: 0,
             offset_space: OffsetSpace::default(),
@@ -920,6 +935,7 @@
             poll_history: PollHistoryId::default(),
             incarnation: crate::PartitionIncarnation::default(),
             workerless_offset_files: Rc::default(),
+            poll_notifier: None,
             durable_consumer_offsets: DurableConsumerOffsets::default(),
             consumer_offset_capacities: ConsumerKind::ALL
                 .map(|kind| ConsumerOffsetCapacity::new(kind, crate::DEFAULT_CONSUMER_OFFSETS_MAX)),
@@ -2869,6 +2885,7 @@
         // the pre-first-flush shape, where the committed counter still seeds
         // nothing.
         self.offset_space.committed_seeded |= frontier > 0;
+        self.publish_poll_visibility();
     }
 
     /// Path of the anchor whose lifecycle is this segment's.
@@ -2888,11 +2905,12 @@
     /// because a segment on disk holds committed messages only). Everything on
     /// the live path moves ONE bit -- see [`Self::note_append_live`] and
     /// [`Self::note_committed_seeded`].
-    pub const fn set_offset_space_used(&mut self, used: bool) {
+    pub fn set_offset_space_used(&mut self, used: bool) {
         self.offset_space = OffsetSpace {
             append_live: used,
             committed_seeded: used,
         };
+        self.publish_poll_visibility();
     }
 
     /// The append counter is live: an offset has been journaled, so the next
@@ -2913,9 +2931,10 @@
     /// Implies the append counter is live too -- nothing commits that was not
     /// journaled first -- but the reverse does not hold, which is the whole
     /// reason the two bits are separate.
-    pub const fn note_committed_seeded(&mut self) {
+    pub fn note_committed_seeded(&mut self) {
         self.offset_space.append_live = true;
         self.offset_space.committed_seeded = true;
+        self.publish_poll_visibility();
     }
 
     /// Whether this partition ever stamped an offset, i.e. whether its offset
@@ -3066,6 +3085,7 @@
             .max(durable)
             .max(self.dirty_offset.load(Ordering::Relaxed));
         self.offset.store(durable, Ordering::Release);
+        self.publish_poll_visibility();
         self.dirty_offset.store(dirty, Ordering::Relaxed);
         self.offset_space = offset_space;
         self.recovered_durable_offset = offset_space.committed_seeded.then_some(durable);
@@ -4315,10 +4335,39 @@
     pub(crate) fn invalidate_poll_history(&mut self) {
         self.workerless_offset_files.clear();
         self.poll_history = PollHistoryId::default();
+        self.publish_poll_visibility();
         self.discard_queued_auto_commits();
         self.transfer_plan.borrow_mut().take();
     }
 
+    /// The served history and what commits made visible in it. A change of
+    /// either can make a parked poll ready.
+    pub fn poll_visibility(&self) -> (PollHistoryId, PollVisibility) {
+        (
+            self.poll_history,
+            PollVisibility {
+                offset: self
+                    .offset_space
+                    .committed_seeded
+                    .then(|| self.offset.load(Ordering::Acquire)),
+                committed_bytes: self.committed_bytes,
+            },
+        )
+    }
+
+    /// The notifier runs inside partition mutations whenever [`Self::poll_visibility`]
+    /// can change. It must only record the change, and it must not re-enter the
+    /// partition.
+    pub fn set_poll_notifier(&mut self, notifier: Option<Rc<dyn Fn()>>) {
+        self.poll_notifier = notifier;
+    }
+
+    pub(crate) fn publish_poll_visibility(&self) {
+        if let Some(notify) = &self.poll_notifier {
+            notify();
+        }
+    }
+
     fn discard_queued_auto_commits(&self) {
         self.consensus.with_pipeline_mut(|pipeline| {
             pipeline.retain_requests(|request| request.auto_commit().is_none());
@@ -5546,9 +5595,10 @@
 
         if serve_journal_first {
             let tier = match self.journal_get_sync(&query) {
-                Some((fragments, last_matching_offset)) => PollTier::Resident {
+                Some((fragments, last_matching_offset, message_count)) => PollTier::Resident {
                     fragments,
                     last_matching_offset,
+                    message_count,
                 },
                 None => PollTier::Empty,
             };
@@ -11376,6 +11426,7 @@
             .is_none_or(|durable| end_offset > durable)
         {
             self.offset.store(end_offset, Ordering::Release);
+            self.committed_bytes = self.committed_bytes.saturating_add(batch_stats.size_bytes);
             self.stats.set_current_offset(end_offset);
             // Advance the aggregate stats with the visible offset. Disk
             // persistence is threshold-gated in `commit_messages`, which
@@ -13520,7 +13571,7 @@
     use crate::iggy_index::{IGGY_INDEX_SIZE, IggyIndex, IggyIndexCache};
     use crate::iggy_index_reader::IggyIndexReader;
     use crate::offset_storage::{OffsetRecord, decode_offset_record, persist_offset};
-    use crate::poll_plan::{DiskReadOutcome, SealedSegmentHandle};
+    use crate::poll_plan::{DiskFault, DiskReadOutcome, SealedSegmentHandle};
     use crate::{IggyIndexWriter, MessagesWriter};
     use bytes::{Bytes, BytesMut};
     use compio::io::AsyncWriteAtExt;
@@ -27106,6 +27157,9 @@
             fragments,
             commit_offset: partition.offsets().commit_offset,
             last_matching_offset,
+            message_count: u32::from(last_matching_offset.is_some()),
+            truncated: false,
+            read_error: None,
         }
     }
 
@@ -30205,7 +30259,7 @@
             .await;
 
         assert!(
-            matches!(outcome, DiskReadOutcome::Faulted),
+            matches!(outcome, DiskReadOutcome::Faulted(DiskFault::Retryable)),
             "unreadable first segment must fault-close, not skip forward to the later segment",
         );
 
@@ -30369,7 +30423,7 @@
             .await;
 
         assert!(
-            matches!(outcome, DiskReadOutcome::Faulted),
+            matches!(outcome, DiskReadOutcome::Faulted(DiskFault::Unreadable)),
             "corrupt earlier segment must fault-close, not skip forward to the later segment",
         );
 
@@ -30437,7 +30491,7 @@
 
         let outcome = plan(true).read_disk(query).await;
         assert!(
-            matches!(outcome, DiskReadOutcome::Faulted),
+            matches!(outcome, DiskReadOutcome::Faulted(DiskFault::Unreadable)),
             "a batch that fails its own checksum must fault-close"
         );
 
@@ -30505,7 +30559,7 @@
             .await;
 
         assert!(
-            matches!(outcome, DiskReadOutcome::Faulted),
+            matches!(outcome, DiskReadOutcome::Faulted(DiskFault::Unreadable)),
             "flushed offsets of a file-less partition must fault-close, not serve the journal",
         );
     }
@@ -30540,7 +30594,7 @@
             .await;
 
         assert!(
-            matches!(outcome, DiskReadOutcome::Faulted),
+            matches!(outcome, DiskReadOutcome::Faulted(DiskFault::Retryable)),
             "unresolvable dir over file-backed data must fault-close, not serve the journal",
         );
     }
diff --git a/core/partitions/src/iggy_partitions.rs b/core/partitions/src/iggy_partitions.rs
index ab103a5..9b0d176 100644
--- a/core/partitions/src/iggy_partitions.rs
+++ b/core/partitions/src/iggy_partitions.rs
@@ -1217,7 +1217,7 @@
         // D = 0 (last disk match), so a straddle would continue at offset 1.
         // A raw snapshot walk from offset 1 falls through to the next resident
         // op (offset 3): exactly the silent skip the gate prevents.
-        let (_, leaky_offset) = crate::journal::select_resident(
+        let (_, leaky_offset, _) = crate::journal::select_resident(
             &entries,
             MessageLookup::Offset {
                 offset: 1,
@@ -1245,7 +1245,7 @@
         let from_offset = 3u64;
         let contiguous = oldest_resident.is_some_and(|oldest| oldest <= from_offset);
         assert!(contiguous, "offset 3 is resident; gate must allow it");
-        let (_, contiguous_offset) = crate::journal::select_resident(
+        let (_, contiguous_offset, _) = crate::journal::select_resident(
             &entries,
             MessageLookup::Offset {
                 offset: from_offset,
@@ -1338,7 +1338,7 @@
         }
 
         let entries = partition.log.journal().inner.resident_entries();
-        let (_, last) = crate::journal::select_resident(
+        let (_, last, _) = crate::journal::select_resident(
             &entries,
             MessageLookup::Offset {
                 offset: 0,
diff --git a/core/partitions/src/journal.rs b/core/partitions/src/journal.rs
index 4458c8d..0a01c0f 100644
--- a/core/partitions/src/journal.rs
+++ b/core/partitions/src/journal.rs
@@ -530,7 +530,7 @@
     ) -> PollQueryResult<4096> {
         let count = query.count();
         if count == 0 {
-            return (PollFragments::new(), None);
+            return (PollFragments::new(), None, 0);
         }
 
         // Disjoint `UnsafeCell`s: this borrows `op_to_storage_offset` while the
@@ -564,7 +564,7 @@
             );
         }
 
-        (fragments, last_matching_offset)
+        (fragments, last_matching_offset, matched_messages)
     }
 
     /// Drain all accumulated batches, matching the legacy `PartitionJournal` API.
@@ -1496,7 +1496,7 @@
         );
     }
 
-    (!fragments.is_empty()).then_some((fragments, last_matching_offset))
+    (!fragments.is_empty()).then_some((fragments, last_matching_offset, matched_messages))
 }
 
 #[cfg(test)]
@@ -2216,8 +2216,9 @@
             "fixture must select a sparse fraction of the prepare"
         );
 
-        let (fragments, last_matching_offset) =
+        let (fragments, last_matching_offset, matched) =
             select_resident(std::slice::from_ref(&prepare), query).expect("one record matches");
+        assert_eq!(matched, 1);
         assert_eq!(last_matching_offset, Some(500));
         assert_eq!(fragments.len(), 2, "rewritten header plus body slice");
         let (header, body) = (&fragments[0], &fragments[1]);
@@ -2239,9 +2240,10 @@
     fn resident_whole_batch_selection_keeps_the_zero_copy_slice() {
         let prepare = build_resident_prepare(1_000, 300);
 
-        let (fragments, last_matching_offset) =
+        let (fragments, last_matching_offset, matched) =
             select_resident(std::slice::from_ref(&prepare), offset_lookup(0, 1_000))
                 .expect("whole batch matches");
+        assert_eq!(matched, 1_000);
         assert_eq!(last_matching_offset, Some(999));
         assert_eq!(fragments.len(), 1, "a whole batch ships its original bytes");
         assert!(
@@ -2267,7 +2269,7 @@
             "fixture must select a sparse fraction that is over the copy cap"
         );
 
-        let (fragments, _) =
+        let (fragments, _, _) =
             select_resident(std::slice::from_ref(&prepare), query).expect("records match");
         assert_eq!(fragments.len(), 2, "rewritten header plus body slice");
         assert!(
@@ -2321,4 +2323,23 @@
             "dense slice keeps the zero-copy path"
         );
     }
+    #[test]
+    fn selected_message_count_does_not_include_offset_holes() {
+        let entries = vec![
+            build_message_prepare(1, 0, 1, 8),
+            build_message_prepare(2, 100, 1, 8),
+        ];
+        let (fragments, last, count) = select_resident(
+            &entries,
+            MessageLookup::Offset {
+                offset: 0,
+                count: 3,
+                ceiling: 100,
+            },
+        )
+        .unwrap();
+        assert!(!fragments.is_empty());
+        assert_eq!(last, Some(100));
+        assert_eq!(count, 2);
+    }
 }
diff --git a/core/partitions/src/lib.rs b/core/partitions/src/lib.rs
index 67c10a6..1ead193 100644
--- a/core/partitions/src/lib.rs
+++ b/core/partitions/src/lib.rs
@@ -92,7 +92,7 @@
 
 pub(crate) const FILE_SYNC_CONCURRENCY: usize = 16;
 
-pub use iggy_partition::{PollCompletion, PollReplication};
+pub use iggy_partition::{PollCompletion, PollReplication, PollVisibility};
 pub use messages_writer::MessagesWriter;
 pub use offset_storage::delete_persisted_offset;
 pub use partition_storage::{
@@ -101,7 +101,7 @@
     hydrate_partition_log, read_created_revision, read_revision_record, write_created_revision,
     write_revision_record,
 };
-pub use poll_plan::{PollPlan, PollReadResult};
+pub use poll_plan::{DISK_POLL_CHUNK_MAX, PollPlan, PollReadResult};
 pub use segment::Segment;
 pub use segment_recovery::{
     PartitionRecoveryError, PartitionRecoveryRefusal, RecoveredSegment, load_persisted_segments,
diff --git a/core/partitions/src/poll_plan.rs b/core/partitions/src/poll_plan.rs
index 5045aa5..7f837e4 100644
--- a/core/partitions/src/poll_plan.rs
+++ b/core/partitions/src/poll_plan.rs
@@ -26,14 +26,17 @@
 use crate::journal::{
     MessageLookup, push_selected_batch_fragments, select_batch_slice, unpin_sparse_source,
 };
-use crate::{PollFragments, PollingConsumer};
+use crate::{Fragment, PollFragments, PollingConsumer};
 use compio::io::AsyncReadAtExt;
+use iggy_binary_protocol::batch::{BATCH_HEADER_SIZE, BatchHeader, BatchRef};
+use iggy_binary_protocol::responses::messages::poll_messages::POLL_RESPONSE_HEADER_SIZE;
 use iggy_binary_protocol::{WireError, batch};
 use iggy_common::{ConsumerKind, IggyError};
 use server_common::iobuf::{Frozen, Owned};
 use server_common::poll::PollHistoryId;
-use server_common::send_messages::{BatchIntegrity, COMMAND_HEADER_SIZE};
+use server_common::send_messages::{BatchIntegrity, COMMAND_HEADER_SIZE, frozen_batch_header};
 use std::cell::{Cell, RefCell};
+use std::collections::HashSet;
 use std::rc::Rc;
 use std::sync::Arc;
 use std::sync::atomic::AtomicBool;
@@ -196,9 +199,169 @@
     pub(crate) commit_offset: u64,
     /// Inclusive offset of the last selected message, or `None` for no match.
     pub(crate) last_matching_offset: Option<u64>,
+    pub(crate) message_count: u32,
+    /// A byte limit or a fault stopped the read before the requested count,
+    /// so later offsets may exist past this prefix.
+    pub(crate) truncated: bool,
+    pub(crate) read_error: Option<IggyError>,
 }
 
 impl PollReadResult {
+    /// Cap the encoded selection at `budget` before the owner accepts its
+    /// final offset. The first message passes the budget, so a poll always
+    /// makes progress, but nothing passes `max_bytes`. A sliced batch retains
+    /// its original offset base and receives a new checksum.
+    ///
+    /// # Errors
+    /// Returns `InvalidSizeBytes` when the first message does not fit in
+    /// `max_bytes`, or `CannotReadMessage` if the selected batch framing is
+    /// invalid.
+    pub fn limit_bytes(
+        mut self,
+        max_bytes: usize,
+        budget: usize,
+    ) -> Result<(Self, bool), IggyError> {
+        let ceiling = max_bytes
+            .checked_sub(POLL_RESPONSE_HEADER_SIZE)
+            .ok_or(IggyError::InvalidSizeBytes)?;
+        let mut remaining = ceiling.min(budget.saturating_sub(POLL_RESPONSE_HEADER_SIZE));
+        let mut fragments = std::mem::take(&mut self.fragments).into_iter();
+        self.message_count = 0;
+        self.last_matching_offset = None;
+        let mut limited = false;
+        while let Some(fragment) = fragments.next() {
+            let source = fragment.into_frozen();
+            let mut header =
+                BatchHeader::decode(&source).map_err(|_| IggyError::CannotReadMessage)?;
+            let body = if source.len() == BATCH_HEADER_SIZE {
+                fragments
+                    .next()
+                    .ok_or(IggyError::CannotReadMessage)?
+                    .into_frozen()
+            } else {
+                source.slice(BATCH_HEADER_SIZE..source.len())
+            };
+            if body.len()
+                != header
+                    .blob_len()
+                    .map_err(|_| IggyError::CannotReadMessage)?
+            {
+                return Err(IggyError::CannotReadMessage);
+            }
+            let batch = BatchRef::new(header, &body);
+            let mut selected = 0;
+            let mut end = 0;
+            for record in batch.iter_with_offsets() {
+                let limit = if self.message_count == 0 && selected == 0 {
+                    ceiling
+                } else {
+                    remaining
+                };
+                if BATCH_HEADER_SIZE.saturating_add(record.end) > limit {
+                    limited = true;
+                    break;
+                }
+                selected += 1;
+                end = record.end;
+                self.last_matching_offset = Some(
+                    header
+                        .base_offset
+                        .checked_add(u64::from(record.message.header.offset_delta))
+                        .ok_or(IggyError::CannotReadMessage)?,
+                );
+            }
+            if selected == 0 {
+                if self.message_count == 0 {
+                    return Err(IggyError::InvalidSizeBytes);
+                }
+                break;
+            }
+            self.message_count += selected;
+            remaining = remaining.saturating_sub(BATCH_HEADER_SIZE + end);
+            limited |= remaining == 0;
+            if selected == header.message_count && source.len() != BATCH_HEADER_SIZE {
+                self.fragments.push(Fragment::whole(source));
+            } else {
+                header.message_count = selected;
+                header.batch_length = (BATCH_HEADER_SIZE + end) as u64;
+                header.batch_checksum = header.checksum_for_blob(&body[..end]);
+                self.fragments
+                    .push(Fragment::whole(frozen_batch_header(&header)));
+                self.fragments.push(Fragment::whole(body.slice(..end)));
+            }
+            if limited {
+                break;
+            }
+        }
+        Ok((self, limited))
+    }
+
+    /// Memory this result keeps alive, counting every backing allocation once
+    /// however many fragments slice it.
+    #[must_use]
+    pub fn retained_bytes(&self) -> usize {
+        self.fragments
+            .capacity()
+            .saturating_mul(size_of::<crate::Fragment>())
+            .saturating_add(charge_allocations(&mut HashSet::new(), &self.fragments))
+    }
+
+    /// Copy the selection into one exact-size allocation and drop the borrowed
+    /// buffers. A reply that slices a few bytes out of segment-sized journal
+    /// buffers pins all of them, so the copy trades one pass over the reply for
+    /// a charge that tracks the reply itself. Fragment boundaries survive, which
+    /// keeps batch framing intact.
+    #[must_use]
+    pub fn compacted(mut self) -> Self {
+        let mut buffer = Owned::with_capacity(self.encoded_bytes());
+        for fragment in &self.fragments {
+            buffer.extend_from_slice(fragment.as_slice());
+        }
+        let compacted = Frozen::from(buffer);
+        let mut start = 0;
+        self.fragments = self
+            .fragments
+            .iter()
+            .map(|fragment| {
+                let end = start + fragment.len();
+                let piece = Fragment::slice(compacted.clone(), start, end);
+                start = end;
+                piece
+            })
+            .collect();
+        self
+    }
+
+    #[must_use]
+    pub const fn message_count(&self) -> u32 {
+        self.message_count
+    }
+
+    /// The selected bytes with their batch framing, which is what
+    /// [`Self::limit_bytes`] counts against its limits.
+    #[must_use]
+    pub fn encoded_bytes(&self) -> usize {
+        self.fragments.iter().map(Fragment::len).sum()
+    }
+
+    /// Whether a byte limit or a fault stopped the read early. Waiting for
+    /// more messages cannot grow such a result, because the next read stops
+    /// at the same place.
+    #[must_use]
+    pub const fn truncated(&self) -> bool {
+        self.truncated
+    }
+
+    /// Reject a read that failed before it matched anything. A valid prefix
+    /// before a fault is served, and the next poll, which starts at the
+    /// fault, reports it.
+    ///
+    /// # Errors
+    /// Returns the underlying read failure.
+    pub fn checked(mut self) -> Result<Self, IggyError> {
+        self.read_error.take().map_or_else(|| Ok(self), Err)
+    }
+
     #[must_use]
     pub const fn consumer_kind(&self) -> ConsumerKind {
         match self.context.consumer {
@@ -260,20 +423,48 @@
     }
 
     /// Read the captured snapshot without changing consumer progress.
-    /// The result still requires owner validation, including when it is empty.
+    /// The owner decides whether legacy prefix results or strict errors apply.
+    ///
+    /// TODO(partitions): the legacy poll reply has no error channel, so a disk
+    /// fault reaches the consumer as an ordinary empty poll. Fair for a
+    /// transient IO fault, wrong for a batch that failed its own checksum: data
+    /// damaged at rest never reads again, so the consumer waits forever.
+    /// Surfacing it needs a status on the poll reply, an SDK-visible change on
+    /// every client. Deferred polls report it through [`PollReadResult::checked`].
     pub async fn execute(self) -> PollReadResult {
-        let commit_offset = self.commit_offset;
-        let (fragments, last_matching_offset) = match self.tier {
-            PollTier::Empty => (PollFragments::new(), None),
+        self.execute_with_limit(usize::MAX, usize::MAX).await
+    }
+
+    /// Read the captured snapshot like [`Self::execute`], holding at most
+    /// `max_bytes` of read buffers once the walk matched a message. Reads are
+    /// sized to what a selection of `max_selected_bytes`, counted as
+    /// [`PollReadResult::limit_bytes`] counts them, can still keep, and stop
+    /// once it is full. The batch that holds the first match is read whatever
+    /// its width, so a poll always makes progress. A walk that a limit or a
+    /// fault stops early returns its contiguous prefix, marked
+    /// [`PollReadResult::truncated`].
+    pub async fn execute_with_limit(
+        self,
+        max_bytes: usize,
+        max_selected_bytes: usize,
+    ) -> PollReadResult {
+        let mut read_error = None;
+        let mut truncated = false;
+        let (fragments, last_matching_offset, message_count) = match self.tier {
+            PollTier::Empty => (PollFragments::new(), None, 0),
             PollTier::Resident {
                 fragments,
                 last_matching_offset,
-            } => (fragments, last_matching_offset),
+                message_count,
+            } => (fragments, last_matching_offset, message_count),
             PollTier::Disk {
                 disk,
                 query,
                 resident_tail,
-            } => match disk.read_disk(query).await {
+            } => match disk
+                .read_disk_with_limit(query, max_bytes, max_selected_bytes)
+                .await
+            {
                 // Disk walked cleanly and matched nothing: the query offset is
                 // below disk retention too, so the match (if any) is journal-
                 // resident. Serve the journal forward (retention-recovery) from
@@ -281,29 +472,32 @@
                 // timestamp); no contiguity gate, this is not a straddle.
                 DiskReadOutcome::Empty => {
                     crate::journal::select_resident(&resident_tail.entries, query)
-                        .unwrap_or_else(|| (PollFragments::new(), None))
+                        .unwrap_or_else(|| (PollFragments::new(), None, 0))
                 }
-                // Disk read stopped on a fault. Fail-closed: return an empty poll
-                // WITHOUT the journal-forward fallback. Falling forward here would
-                // splice the next resident op over the unreadable run and silently
-                // skip live data.
-                //
-                // TODO(partitions): the poll reply has no error channel, so this
-                // reaches the consumer as an ordinary empty poll. Fair for a transient
-                // IO fault, wrong for a batch that failed its own checksum: data
-                // damaged at rest never reads again, so the consumer waits forever.
-                // Surfacing it needs a status on the poll reply, an SDK-visible change
-                // on every client. Until then the ERROR in `walk_disk_chunk` is the
-                // only signal, and it is server-side only.
-                DiskReadOutcome::Faulted => (PollFragments::new(), None),
+                // Disk read stopped on a fault before any match. Fail-closed:
+                // falling forward to the journal here would splice the next
+                // resident op over the unreadable run and silently skip live data.
+                DiskReadOutcome::Faulted(fault) => {
+                    read_error = Some(fault.error());
+                    (PollFragments::new(), None, 0)
+                }
                 // Straddle: continue past the last disk match into the resident
                 // tail (gate + race argument live on `straddle_continuation`).
                 DiskReadOutcome::Matched {
                     mut fragments,
                     last_matching_offset,
                     matched,
+                    faulted,
+                    limited,
                 } => {
-                    let remaining = query.count().saturating_sub(matched);
+                    // A limited prefix stops here. Splicing the journal onto
+                    // it would both skip the offsets still on disk and spend
+                    // bytes past the limit that stopped it.
+                    let remaining = if limited {
+                        0
+                    } else {
+                        query.count().saturating_sub(matched)
+                    };
                     let continuation = last_matching_offset
                         .and_then(|last_offset| {
                             resident_tail.straddle_continuation(
@@ -315,22 +509,28 @@
                         .and_then(|query| {
                             crate::journal::select_resident(&resident_tail.entries, query)
                         });
-                    match continuation {
-                        Some((journal_fragments, journal_last)) => {
-                            fragments.extend(journal_fragments);
-                            (fragments, journal_last.or(last_matching_offset))
-                        }
-                        None => (fragments, last_matching_offset),
+                    if let Some((journal_fragments, journal_last, journal_count)) = continuation {
+                        fragments.extend(journal_fragments);
+                        (
+                            fragments,
+                            journal_last.or(last_matching_offset),
+                            matched + journal_count,
+                        )
+                    } else {
+                        truncated = limited || faulted;
+                        (fragments, last_matching_offset, matched)
                     }
                 }
             },
         };
-
         PollReadResult {
             context: self.context,
-            commit_offset,
+            commit_offset: self.commit_offset,
             fragments,
             last_matching_offset,
+            message_count,
+            truncated,
+            read_error,
         }
     }
 
@@ -341,24 +541,25 @@
     /// Panics if [`Self::needs_off_pump_io`] is true.
     #[must_use]
     pub fn execute_resident(self) -> PollReadResult {
-        let commit_offset = self.commit_offset;
-        let (fragments, last_matching_offset) = match self.tier {
-            PollTier::Empty => (PollFragments::new(), None),
+        let (fragments, last_matching_offset, message_count) = match self.tier {
+            PollTier::Empty => (PollFragments::new(), None, 0),
             PollTier::Resident {
                 fragments,
                 last_matching_offset,
-            } => (fragments, last_matching_offset),
-            // `needs_off_pump_io` is true for every Disk tier, so the dispatch
-            // gate never routes one here.
+                message_count,
+            } => (fragments, last_matching_offset, message_count),
             PollTier::Disk { .. } => {
                 unreachable!("execute_resident on Disk tier; needs_off_pump_io guards this")
             }
         };
         PollReadResult {
             context: self.context,
-            commit_offset,
+            commit_offset: self.commit_offset,
             fragments,
             last_matching_offset,
+            message_count,
+            truncated: false,
+            read_error: None,
         }
     }
 }
@@ -368,6 +569,7 @@
     Resident {
         fragments: PollFragments<4096>,
         last_matching_offset: Option<u64>,
+        message_count: u32,
     },
     Disk {
         disk: DiskReadPlan,
@@ -378,8 +580,8 @@
     },
 }
 
-/// Outcome of [`DiskReadPlan::read_disk`], distinguishing a benign empty walk
-/// from an IO fault so the caller can fail-closed.
+/// Outcome of [`DiskReadPlan::read_disk_with_limit`], distinguishing a benign
+/// empty walk from an IO fault so the caller can fail-closed.
 ///
 /// A faulted segment may hold data that is present-but-unreadable right now;
 /// the disk walk stops at the fault (never advancing to later segments) so a
@@ -387,25 +589,57 @@
 /// a `Faulted` result, or it would splice the next resident op over the
 /// unreadable run and silently skip live messages.
 pub enum DiskReadOutcome {
-    /// Walk produced matches (possibly a partial prefix if a fault stopped it).
+    /// Walk produced matches (possibly a partial prefix if a fault or a byte
+    /// limit stopped it).
     Matched {
         fragments: PollFragments<4096>,
         last_matching_offset: Option<u64>,
         matched: u32,
+        faulted: bool,
+        /// A byte limit stopped the walk. Later offsets exist but are not in
+        /// this result, so nothing may be appended to it.
+        limited: bool,
     },
     /// Walk completed with no fault and matched nothing. The query offset is
     /// below disk retention too, so the caller may serve the journal forward
     /// (retention-recovery) without skipping anything.
     Empty,
     /// Walk stopped on an IO fault before matching anything. Fail-closed: the
-    /// caller returns an empty poll so the consumer cursor does not advance
-    /// past data that may still be present-but-unreadable.
-    Faulted,
+    /// caller returns an empty poll or an error, so the consumer cursor does
+    /// not advance past data that may still be present-but-unreadable.
+    Faulted(DiskFault),
+}
+
+/// Why a disk walk stopped on a fault.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DiskFault {
+    /// Open or read retries ran out, or the directory was mid-rotation. A
+    /// later poll may read the same bytes.
+    Retryable,
+    /// A batch failed its checksum or layout, or its bytes are gone. Every
+    /// later poll of the offset fails the same way.
+    Unreadable,
+}
+
+impl DiskFault {
+    /// A retryable fault changed nothing, so the poll is refused like one that
+    /// no replica accepted, and clients retry it without an error.
+    const fn error(self) -> IggyError {
+        match self {
+            Self::Retryable => IggyError::TransientNotAccepted,
+            Self::Unreadable => IggyError::CannotReadMessage,
+        }
+    }
 }
 
 /// Ceiling for ordinary disk reads. An incomplete batch may require one
 /// larger re-read, without widening subsequent chunks or segments.
-const DISK_POLL_CHUNK_MAX: u64 = 1 << 20;
+pub const DISK_POLL_CHUNK_MAX: u64 = 1 << 20;
+
+/// Budget charge per byte a chunk read holds: the read buffer itself, a sparse
+/// copy of its selection, and headroom for the fragment headers and
+/// descriptors that the selection adds.
+const READ_ALLOCATION_FACTOR: usize = 4;
 
 /// Smallest first read of a disk poll. Below this the syscall and the segment
 /// walk cost more than the bytes the smaller read saves, and a poll for a
@@ -419,11 +653,26 @@
     Done,
     /// Fail-closed: the segment may hold present-but-unreadable or corrupt
     /// data, so no later segment may be served over it.
-    Faulted,
+    Faulted(DiskFault),
+    /// The byte budget is spent or the selection fills the reply. Whatever
+    /// matched is a contiguous prefix and is serveable, but the walk may not
+    /// continue.
+    Limited,
 }
 
 /// The state one disk walk carries across its segments.
 struct DiskWalk {
+    max_bytes: usize,
+    /// What the reply keeps. Reading past it only fetches bytes that the next
+    /// poll reads again.
+    max_selected_bytes: usize,
+    selected_bytes: usize,
+    retained_bytes: usize,
+    /// Allocations already in `retained_bytes`, so slices of one chunk or one
+    /// sparse copy are charged once.
+    charged: HashSet<usize>,
+    /// Set only after a match, so a limited walk always has a prefix to serve.
+    limited: bool,
     /// Byte offset into the segment being walked; reset at each boundary.
     position: u64,
     /// Messages between the resolved index entry and the requested offset,
@@ -448,6 +697,21 @@
         (count - self.matched).saturating_add(self.skipped)
     }
 
+    /// Bytes the next read can still use: the rest of the reply, plus the run
+    /// in front of the first match at `bytes_per_message` each. Without a mean
+    /// size that run has no estimate, so nothing caps the read.
+    fn usable_bytes(&self, bytes_per_message: Option<u32>) -> u64 {
+        let reply = self.max_selected_bytes.saturating_sub(self.selected_bytes) as u64;
+        if self.skipped == 0 {
+            return reply;
+        }
+        bytes_per_message.map_or(u64::MAX, |bytes_per_message| {
+            reply.saturating_add(
+                u64::from(bytes_per_message).saturating_mul(u64::from(self.skipped)),
+            )
+        })
+    }
+
     fn starting_at(position: u64, skipped: u32) -> Self {
         Self {
             position,
@@ -456,6 +720,12 @@
             fragments: PollFragments::new(),
             last_matching_offset: None,
             batch_read_floor: 0,
+            max_bytes: usize::MAX,
+            max_selected_bytes: usize::MAX,
+            selected_bytes: 0,
+            retained_bytes: 0,
+            charged: HashSet::new(),
+            limited: false,
             #[cfg(feature = "poll-diagnostics")]
             requested_bytes: 0,
             #[cfg(feature = "poll-diagnostics")]
@@ -465,7 +735,17 @@
 }
 
 impl DiskReadPlan {
-    /// Bytes to read for the next `remaining` messages.
+    /// The next read of `walk`, never under a batch width it already learned.
+    fn next_chunk_len(&self, walk: &DiskWalk, count: u32) -> u64 {
+        self.chunk_len(
+            walk.remaining_to_read(count),
+            walk.usable_bytes(self.bytes_per_message),
+        )
+        .max(walk.batch_read_floor)
+    }
+
+    /// Bytes to read for the next `remaining` messages, of which the walk can
+    /// use no more than `usable`.
     ///
     /// A poll asks for a message count and the walk reads bytes, so the two are
     /// bridged by the partition's own mean encoded size. Reading a fixed
@@ -486,13 +766,17 @@
     /// wider than the ceiling still grows through the re-read path. The ceiling
     /// is what every poll read before it was sized at all, so no poll reads
     /// more than it used to.
-    fn chunk_len(&self, remaining: u32) -> u64 {
-        let Some(bytes_per_message) = self.bytes_per_message else {
-            return DISK_POLL_CHUNK_MAX;
-        };
-        u64::from(bytes_per_message)
-            .saturating_mul(u64::from(remaining))
-            .saturating_add(COMMAND_HEADER_SIZE as u64)
+    ///
+    /// `usable` caps the estimate under the same floors, since a smaller read
+    /// costs more than the bytes it saves.
+    fn chunk_len(&self, remaining: u32, usable: u64) -> u64 {
+        self.bytes_per_message
+            .map_or(DISK_POLL_CHUNK_MAX, |bytes_per_message| {
+                u64::from(bytes_per_message)
+                    .saturating_mul(u64::from(remaining))
+                    .saturating_add(COMMAND_HEADER_SIZE as u64)
+            })
+            .min(usable)
             .max(self.widest_batch_bytes)
             .clamp(DISK_POLL_CHUNK_MIN, DISK_POLL_CHUNK_MAX)
     }
@@ -502,7 +786,18 @@
     /// the file IO. Walks stamped `[256B BatchHeader][blob]` batches in
     /// chunked reads, re-reading a batch split across a chunk boundary in the
     /// next chunk.
+    #[cfg(test)]
     pub(crate) async fn read_disk(self, query: MessageLookup) -> DiskReadOutcome {
+        self.read_disk_with_limit(query, usize::MAX, usize::MAX)
+            .await
+    }
+
+    async fn read_disk_with_limit(
+        self,
+        query: MessageLookup,
+        max_bytes: usize,
+        max_selected_bytes: usize,
+    ) -> DiskReadOutcome {
         let count = query.count();
         if count == 0 || self.segments.is_empty() {
             return DiskReadOutcome::Empty;
@@ -516,7 +811,7 @@
             // read back, so the journal-forward would skip them: fail closed.
             PartitionDirResolution::NoFiles => {
                 return if self.segments.iter().any(|segment| segment.persisted > 0) {
-                    DiskReadOutcome::Faulted
+                    DiskReadOutcome::Faulted(DiskFault::Unreadable)
                 } else {
                     DiskReadOutcome::Empty
                 };
@@ -533,7 +828,7 @@
                     segment_count = self.segments.len(),
                     "disk poll: file-backed partition has no resolvable dir; failing closed"
                 );
-                return DiskReadOutcome::Faulted;
+                return DiskReadOutcome::Faulted(DiskFault::Retryable);
             }
         };
 
@@ -548,7 +843,10 @@
         // full-scan fallback). An active first segment keeps its
         // resident-index-resolved `start_position` untouched.
         let resolved = match self.segments.first() {
-            Some(first) => self.resolve_sealed_start(first, query, partition_dir).await,
+            Some(first) => {
+                self.resolve_sealed_start(first, query, partition_dir, max_bytes)
+                    .await
+            }
             None => None,
         };
         let position = resolved.map_or(self.start_position, |(position, _)| position);
@@ -566,10 +864,12 @@
             _ => 0,
         };
         let mut walk = DiskWalk::starting_at(position, skipped);
+        walk.max_bytes = max_bytes;
+        walk.max_selected_bytes = max_selected_bytes;
         // Set when an open/read retry exhausts. The walk breaks immediately so
         // later segments are never read into the result (which would leave a
         // gap at the faulted segment). Pre-fault matches are still served.
-        let mut faulted = false;
+        let mut fault = None;
 
         for segment in &self.segments {
             if walk.matched >= count {
@@ -586,19 +886,21 @@
             let Some(file) = self.resolve_segment_file(segment, &path).await else {
                 // Open exhausted retries: the segment may hold present-but-
                 // unreadable data. Stop here rather than walking past it.
-                faulted = true;
+                fault = Some(DiskFault::Retryable);
                 break;
             };
 
-            if matches!(
-                self.walk_segment(&file, query, count, persisted, &mut walk)
-                    .await,
-                SegmentWalk::Faulted
-            ) {
-                faulted = true;
-                break;
+            match self
+                .walk_segment(&file, query, count, persisted, &mut walk)
+                .await
+            {
+                SegmentWalk::Done => walk.position = 0,
+                SegmentWalk::Limited => break,
+                SegmentWalk::Faulted(kind) => {
+                    fault = Some(kind);
+                    break;
+                }
             }
-            walk.position = 0;
         }
 
         // The three ratios a read-sizing change is judged on: bytes asked of
@@ -624,9 +926,11 @@
                 fragments: walk.fragments,
                 last_matching_offset: walk.last_matching_offset,
                 matched: walk.matched,
+                limited: walk.limited,
+                faulted: fault.is_some(),
             }
-        } else if faulted {
-            DiskReadOutcome::Faulted
+        } else if let Some(fault) = fault {
+            DiskReadOutcome::Faulted(fault)
         } else {
             DiskReadOutcome::Empty
         }
@@ -648,15 +952,28 @@
         persisted: u64,
         walk: &mut DiskWalk,
     ) -> SegmentWalk {
-        let mut chunk_len = self
-            .chunk_len(walk.remaining_to_read(count))
-            .max(walk.batch_read_floor);
+        let mut chunk_len = self.next_chunk_len(walk, count);
         while walk.matched < count && walk.position < persisted {
             let len = (persisted - walk.position).min(chunk_len) as usize;
+            // The batch that holds the first match is read whatever its width,
+            // as the legacy poll does: refusing it would refuse the same offset
+            // on every retry. Later reads stop at the budget or once the
+            // selection fills the reply, and the walk serves the prefix it
+            // holds.
+            if walk.matched > 0
+                && (walk.selected_bytes >= walk.max_selected_bytes
+                    || len
+                        .saturating_mul(READ_ALLOCATION_FACTOR)
+                        .saturating_add(walk.retained_bytes)
+                        > walk.max_bytes)
+            {
+                walk.limited = true;
+                return SegmentWalk::Limited;
+            }
             let Some(chunk) = self.read_chunk_with_retry(file, len, walk).await else {
                 // Chunk read exhausted retries: same fail-closed reason as
                 // a failed open.
-                return SegmentWalk::Faulted;
+                return SegmentWalk::Faulted(DiskFault::Retryable);
             };
             let fragments_before_chunk = walk.fragments.len();
             let ChunkWalk {
@@ -684,11 +1001,23 @@
                 &chunk,
                 usize::MAX,
             );
+            let added = &walk.fragments[fragments_before_chunk..];
+            walk.retained_bytes = walk
+                .retained_bytes
+                .saturating_add(charge_allocations(&mut walk.charged, added))
+                .saturating_add(added.len().saturating_mul(size_of::<crate::Fragment>() * 2));
+            walk.selected_bytes = walk
+                .selected_bytes
+                .saturating_add(added.iter().map(Fragment::len).sum());
+            if walk.matched > 0 && walk.retained_bytes > walk.max_bytes {
+                walk.limited = true;
+                return SegmentWalk::Limited;
+            }
             if corrupt {
                 // A batch that does not match its own checksum. Fail closed like
                 // an IO fault: serving it hands a consumer data provably not what
                 // was written, and skipping ahead punches a silent gap.
-                return SegmentWalk::Faulted;
+                return SegmentWalk::Faulted(DiskFault::Unreadable);
             }
             if consumed == 0 {
                 if (len as u64) >= persisted - walk.position {
@@ -696,13 +1025,13 @@
                     // corrupt batch in this segment. Fail-closed like an IO
                     // fault so a later segment is never served over the corrupt
                     // run, which would punch a silent gap into the poll.
-                    return SegmentWalk::Faulted;
+                    return SegmentWalk::Faulted(DiskFault::Unreadable);
                 }
                 // A single batch larger than the chunk. Its own header says
                 // how wide it is, so re-read exactly that; only a header this
                 // read could not reach leaves the old quadrupling.
                 if needed as u64 > persisted - walk.position {
-                    return SegmentWalk::Faulted;
+                    return SegmentWalk::Faulted(DiskFault::Unreadable);
                 }
                 chunk_len = if needed > len {
                     walk.batch_read_floor = walk
@@ -717,9 +1046,7 @@
             if walk.matched > 0 {
                 walk.skipped = 0;
             }
-            chunk_len = self
-                .chunk_len(walk.remaining_to_read(count))
-                .max(walk.batch_read_floor);
+            chunk_len = self.next_chunk_len(walk, count);
             walk.position += consumed as u64;
         }
         SegmentWalk::Done
@@ -765,6 +1092,7 @@
         segment: &DiskSegment,
         query: MessageLookup,
         partition_dir: &str,
+        max_bytes: usize,
     ) -> Option<(u64, u64)> {
         // The active segment grows under the reader, so neither the shared
         // sparse index nor the offset memo can describe it; its own resident
@@ -807,7 +1135,12 @@
                 return None;
             }
         };
-        if entry_count.saturating_mul(IGGY_INDEX_SIZE as u64) <= SEALED_INDEX_RESIDENT_MAX_BYTES {
+        if entry_count.saturating_mul(IGGY_INDEX_SIZE as u64) <= SEALED_INDEX_RESIDENT_MAX_BYTES
+            && entry_count
+                .saturating_mul(IGGY_INDEX_SIZE as u64)
+                .saturating_mul(2)
+                <= max_bytes as u64
+        {
             let index = match reader.load_all().await {
                 Ok(index) => index,
                 Err(error) => {
@@ -949,6 +1282,19 @@
     .map(|entry| (entry.position, entry.offset))
 }
 
+/// Bytes of the allocations `fragments` keep alive that `charged` did not
+/// count yet, so slices of one allocation charge it once.
+fn charge_allocations<'a>(
+    charged: &mut HashSet<usize>,
+    fragments: impl IntoIterator<Item = &'a Fragment>,
+) -> usize {
+    fragments
+        .into_iter()
+        .filter(|fragment| charged.insert(fragment.allocation_id()))
+        .map(Fragment::allocation_bytes)
+        .fold(0, usize::saturating_add)
+}
+
 /// Walk stamped `[256B BatchHeader][blob]` batches in one disk
 /// chunk, pushing matching fragments. Returns bytes consumed: the start
 /// of the first batch that did not fully fit in the chunk (the caller
@@ -1050,17 +1396,94 @@
 mod tests {
     use super::*;
     use crate::iggy_index::IggyIndex;
-    #[cfg(feature = "poll-diagnostics")]
     use bytes::Bytes;
     use compio::io::AsyncWriteAtExt;
     use server_common::iobuf::Owned;
-    #[cfg(feature = "poll-diagnostics")]
     use server_common::send_messages::{
         IggyMessage, IggyMessageHeader, IggyMessages, SendMessagesOwned,
     };
-    #[cfg(feature = "poll-diagnostics")]
     use server_common::sharding::IggyNamespace;
 
+    #[test]
+    fn byte_limit_rewrites_whole_and_split_batches_with_valid_checksums() {
+        const PAYLOAD: &[u8] = b"payload";
+        let mut messages = IggyMessages::with_capacity(2);
+        for _ in 0..2 {
+            messages.push(IggyMessage {
+                header: IggyMessageHeader {
+                    payload_length: u32::try_from(PAYLOAD.len()).unwrap(),
+                    ..Default::default()
+                },
+                payload: Bytes::from_static(PAYLOAD),
+                user_headers: None,
+            });
+        }
+        let mut batch =
+            SendMessagesOwned::from_messages(IggyNamespace::new(1, 1, 0), &messages).unwrap();
+        batch.header.base_offset = 42;
+        batch.header.batch_checksum = batch.header.checksum_for_blob(&batch.blob);
+        let mut bytes = vec![0; batch.header.total_size()];
+        batch.header.encode_into(&mut bytes);
+        bytes[BATCH_HEADER_SIZE..].copy_from_slice(&batch.blob);
+        let one_message = POLL_RESPONSE_HEADER_SIZE
+            + BATCH_HEADER_SIZE
+            + batch::BATCH_MESSAGE_HEADER_SIZE
+            + PAYLOAD.len();
+        // The first message passes any budget, but never `max_bytes`.
+        let limits = [
+            (one_message, usize::MAX),
+            (usize::MAX, one_message),
+            (usize::MAX, 0),
+        ];
+        for split in [false, true] {
+            for (max_bytes, budget) in limits {
+                let source: Frozen<4096> = Owned::copy_from_slice(&bytes).into();
+                let mut fragments = PollFragments::new();
+                if split {
+                    fragments.push(Fragment::whole(source.slice(..BATCH_HEADER_SIZE)));
+                    fragments.push(Fragment::whole(source.slice(BATCH_HEADER_SIZE..)));
+                } else {
+                    fragments.push(Fragment::whole(source));
+                }
+                let result = PollPlan {
+                    context: PollContext {
+                        history: PollHistoryId::default(),
+                        consumer: PollingConsumer::Consumer(0, 0),
+                        auto_commit: true,
+                        owner: None,
+                        _group_poll: None,
+                    },
+                    commit_offset: 99,
+                    tier: PollTier::Resident {
+                        fragments,
+                        last_matching_offset: Some(43),
+                        message_count: 2,
+                    },
+                }
+                .execute_resident();
+                let (result, limited) = result.limit_bytes(max_bytes, budget).unwrap();
+                assert!(limited);
+                assert_eq!(result.message_count(), 1);
+                assert_eq!(result.last_matching_offset, Some(42));
+                assert_eq!(result.commit_offset, 99);
+                let bytes: Vec<u8> = result
+                    .fragments
+                    .into_iter()
+                    .flat_map(|fragment| fragment.into_frozen().to_vec())
+                    .collect();
+                assert_eq!(bytes.len() + POLL_RESPONSE_HEADER_SIZE, one_message);
+                let header = BatchHeader::decode(&bytes).unwrap();
+                assert_eq!(header.base_offset, 42);
+                assert_eq!(header.message_count, 1);
+                assert_eq!(header.total_size(), bytes.len());
+                assert_eq!(
+                    header.batch_checksum,
+                    header.checksum_for_blob(&bytes[BATCH_HEADER_SIZE..])
+                );
+            }
+        }
+    }
+
     /// Write a sealed-segment index file too large to materialize
     /// (`entry_count * IGGY_INDEX_SIZE > SEALED_INDEX_RESIDENT_MAX_BYTES`), so
     /// `resolve_sealed_start` takes the file-backed lookup path the offset
@@ -1107,17 +1530,17 @@
         let mean = u32::try_from(batch / 1000).expect("mean fits");
         let plan = sizing_plan(Some(mean), batch);
 
-        assert!(plan.chunk_len(999) >= batch);
-        assert!(plan.chunk_len(500) >= batch);
-        assert!(plan.chunk_len(1) >= batch);
+        assert!(plan.chunk_len(999, u64::MAX) >= batch);
+        assert!(plan.chunk_len(500, u64::MAX) >= batch);
+        assert!(plan.chunk_len(1, u64::MAX) >= batch);
         // The floor never pushes a read above what an unsized poll would take.
         assert_eq!(
-            sizing_plan(Some(mean), 4 << 20).chunk_len(1),
+            sizing_plan(Some(mean), 4 << 20).chunk_len(1, u64::MAX),
             DISK_POLL_CHUNK_MAX
         );
         // A count wide enough to matter still wins over the floor.
         assert_eq!(
-            sizing_plan(Some(10), 2048).chunk_len(1000),
+            sizing_plan(Some(10), 2048).chunk_len(1000, u64::MAX),
             DISK_POLL_CHUNK_MIN
         );
     }
@@ -1127,19 +1550,43 @@
         let plan = |bytes_per_message| sizing_plan(bytes_per_message, 0);
 
         // Nothing committed yet, so nothing bridges a count to bytes.
-        assert_eq!(plan(None).chunk_len(1000), DISK_POLL_CHUNK_MAX);
+        assert_eq!(plan(None).chunk_len(1000, u64::MAX), DISK_POLL_CHUNK_MAX);
         // A thousand small messages used to read a megabyte to return 150 KB.
         assert_eq!(
-            plan(Some(150)).chunk_len(1000),
+            plan(Some(150)).chunk_len(1000, u64::MAX),
             150 * 1000 + COMMAND_HEADER_SIZE as u64
         );
         // The floor keeps a poll for a few messages off a read per batch, the
         // ceiling is what every poll read before it was sized at all.
-        assert_eq!(plan(Some(150)).chunk_len(1), DISK_POLL_CHUNK_MIN);
-        assert_eq!(plan(Some(64 << 10)).chunk_len(1000), DISK_POLL_CHUNK_MAX);
+        assert_eq!(plan(Some(150)).chunk_len(1, u64::MAX), DISK_POLL_CHUNK_MIN);
+        assert_eq!(
+            plan(Some(64 << 10)).chunk_len(1000, u64::MAX),
+            DISK_POLL_CHUNK_MAX
+        );
         // Wide messages and a wide count must clamp, never wrap.
         assert_eq!(
-            plan(Some(u32::MAX)).chunk_len(u32::MAX),
+            plan(Some(u32::MAX)).chunk_len(u32::MAX, u64::MAX),
+            DISK_POLL_CHUNK_MAX
+        );
+    }
+
+    #[test]
+    fn a_reply_byte_limit_caps_the_first_read_but_not_the_run_in_front_of_it() {
+        const REPLY_BYTES: usize = 100 << 10;
+        let plan = sizing_plan(Some(1000), 0);
+        let mut walk = DiskWalk::starting_at(0, 0);
+        walk.max_selected_bytes = REPLY_BYTES;
+        // A thousand messages of a kilobyte do not fit the reply.
+        assert_eq!(plan.next_chunk_len(&walk, 1000), REPLY_BYTES as u64);
+        // The sparse index resolved an entry 500 messages before the target.
+        walk.skipped = 500;
+        assert_eq!(
+            plan.next_chunk_len(&walk, 1000),
+            REPLY_BYTES as u64 + 500 * 1000
+        );
+        // Without a mean size the run has no width, so the reply caps nothing.
+        assert_eq!(
+            sizing_plan(None, 0).next_chunk_len(&walk, 1000),
             DISK_POLL_CHUNK_MAX
         );
     }
@@ -1266,7 +1713,6 @@
         }
     }
 
-    #[cfg(feature = "poll-diagnostics")]
     fn disk_batch(payload_length: u32, offset: u64) -> Vec<u8> {
         let mut messages = IggyMessages::with_capacity(1);
         messages.push(IggyMessage {
@@ -1330,7 +1776,7 @@
         // First poll pays the on-file lookup and memoizes entry 2's interval
         // [20, 30): offset 25 resolves to entry 2 (offset 20 -> position 200).
         let first = plan
-            .resolve_sealed_start(&segment, offset_query(25), &partition_dir)
+            .resolve_sealed_start(&segment, offset_query(25), &partition_dir, usize::MAX)
             .await;
         // The entry offset rides along so the caller can size its first read
         // to cover the run between that entry and the requested offset.
@@ -1345,14 +1791,14 @@
         // (proof the cursor answered with zero index-file reads)...
         std::fs::remove_dir_all(&dir).expect("remove dir");
         let in_interval = plan
-            .resolve_sealed_start(&segment, offset_query(29), &partition_dir)
+            .resolve_sealed_start(&segment, offset_query(29), &partition_dir, usize::MAX)
             .await;
         assert_eq!(in_interval, Some((200, 20)));
 
         // ...while an offset past the interval misses the cursor, reaches for
         // the (now gone) file, and falls back to the byte-0 scan.
         let past_interval = plan
-            .resolve_sealed_start(&segment, offset_query(30), &partition_dir)
+            .resolve_sealed_start(&segment, offset_query(30), &partition_dir, usize::MAX)
             .await;
         assert_eq!(past_interval, None);
     }
@@ -1386,6 +1832,7 @@
             tier: PollTier::Resident {
                 fragments: placeholder_fragments(),
                 last_matching_offset: Some(last_selected_offset),
+                message_count: 1,
             },
         };
         assert!(!resident_plan.needs_off_pump_io());
@@ -1421,4 +1868,359 @@
         assert!(read_result.fragments.is_empty());
         assert_eq!(read_result.last_matching_offset, None);
     }
+    #[compio::test]
+    async fn deferred_disk_result_enforces_byte_limits_and_reports_faults_at_their_offset() {
+        for corrupt_tail in [false, true] {
+            let directory = tempfile::tempdir().unwrap();
+            let mut bytes = disk_batch(10, 0);
+            let first_batch_bytes = bytes.len();
+            let mut tail = disk_batch(10, 9);
+            if corrupt_tail {
+                *tail.last_mut().unwrap() ^= 1;
+            }
+            bytes.extend(tail);
+            std::fs::write(directory.path().join("00000000000000000000.log"), &bytes).unwrap();
+            let plan = |offset, count| PollPlan {
+                context: PollContext {
+                    history: PollHistoryId::default(),
+                    consumer: PollingConsumer::Consumer(0, 0),
+                    auto_commit: true,
+                    owner: None,
+                    _group_poll: None,
+                },
+                commit_offset: 9,
+                tier: PollTier::Disk {
+                    query: MessageLookup::Offset {
+                        offset,
+                        count,
+                        ceiling: 9,
+                    },
+                    resident_tail: ResidentTailSnapshot {
+                        entries: Vec::new(),
+                        oldest_resident: None,
+                    },
+                    disk: DiskReadPlan {
+                        partition_dir: PartitionDirResolution::Resolved(
+                            directory.path().display().to_string(),
+                        ),
+                        bytes_per_message: None,
+                        widest_batch_bytes: 0,
+                        segments: vec![DiskSegment {
+                            start_offset: 0,
+                            persisted: bytes.len() as u64,
+                            read_state: Rc::default(),
+                            sealed: false,
+                        }],
+                        start_position: 0,
+                        start_index_offset: None,
+                        namespace_raw: 0,
+                        validate_checksum: true,
+                    },
+                },
+            };
+            let result = plan(0, 2).execute_with_limit(1 << 20, usize::MAX).await;
+            if corrupt_tail {
+                // The valid prefix is served, and the poll that starts at the
+                // corrupt batch reports it, so the error names its offset.
+                assert_eq!(result.message_count(), 1);
+                assert!(result.truncated());
+                let result = result.checked().expect("a prefix before a fault is served");
+                assert_eq!(result.last_matching_offset, Some(0));
+                assert!(matches!(
+                    plan(9, 1)
+                        .execute_with_limit(1 << 20, usize::MAX)
+                        .await
+                        .checked(),
+                    Err(IggyError::CannotReadMessage)
+                ));
+                std::fs::remove_file(directory.path().join("00000000000000000000.log")).unwrap();
+                assert!(
+                    matches!(
+                        plan(0, 2)
+                            .execute_with_limit(1 << 20, usize::MAX)
+                            .await
+                            .checked(),
+                        Err(IggyError::TransientNotAccepted)
+                    ),
+                    "a segment that cannot be opened now is retried, not reported"
+                );
+            } else {
+                assert_eq!(
+                    result.message_count(),
+                    2,
+                    "sparse offsets count as two messages"
+                );
+                assert!(!result.truncated());
+                let (result, limited) = result
+                    .checked()
+                    .unwrap()
+                    .limit_bytes(POLL_RESPONSE_HEADER_SIZE + first_batch_bytes, usize::MAX)
+                    .unwrap();
+                assert!(limited);
+                assert_eq!(result.message_count(), 1);
+                assert_eq!(result.last_matching_offset, Some(0));
+                assert_eq!(result.commit_offset, 9);
+            }
+        }
+    }
+
+    #[compio::test]
+    async fn deferred_disk_budget_admits_the_batch_that_holds_the_first_match() {
+        // The batch is wider than the chunk and the whole budget. Refusing it
+        // failed the same offset on every retry, so the consumer stalled.
+        let directory = tempfile::tempdir().unwrap();
+        let bytes = disk_batch(2 << 20, 0);
+        let path = directory.path().join("batch.log");
+        std::fs::write(&path, &bytes).unwrap();
+        let file = compio::fs::File::open(path).await.unwrap();
+        let plan = sizing_plan(Some(1), 0);
+        let mut walk = DiskWalk::starting_at(0, 0);
+        walk.max_bytes = 1 << 20;
+        plan.walk_segment(&file, offset_query(0), 1, bytes.len() as u64, &mut walk)
+            .await;
+        assert_eq!(walk.matched, 1);
+        assert_eq!(walk.last_matching_offset, Some(0));
+        assert_eq!(
+            walk.fragments.iter().map(Fragment::len).sum::<usize>(),
+            bytes.len()
+        );
+    }
+
+    #[compio::test]
+    async fn a_byte_limited_disk_walk_serves_the_prefix_it_already_read() {
+        // The budget admits the first chunk and stops the next one. Before this
+        // was fixed the walk threw away every batch it had matched, so the
+        // consumer polled the same offset again and was refused again.
+        const BATCHES: u32 = 128;
+        let directory = tempfile::tempdir().unwrap();
+        let mut bytes = Vec::new();
+        for offset in 0..u64::from(BATCHES) {
+            bytes.extend(disk_batch(16 << 10, offset));
+        }
+        std::fs::write(directory.path().join("00000000000000000000.log"), &bytes).unwrap();
+        let plan = PollPlan {
+            context: PollContext {
+                history: PollHistoryId::default(),
+                consumer: PollingConsumer::Consumer(0, 0),
+                auto_commit: true,
+                owner: None,
+                _group_poll: None,
+            },
+            commit_offset: u64::from(BATCHES) - 1,
+            tier: PollTier::Disk {
+                query: MessageLookup::Offset {
+                    offset: 0,
+                    count: BATCHES,
+                    ceiling: u64::from(BATCHES) - 1,
+                },
+                resident_tail: ResidentTailSnapshot {
+                    entries: Vec::new(),
+                    oldest_resident: None,
+                },
+                disk: DiskReadPlan {
+                    partition_dir: PartitionDirResolution::Resolved(
+                        directory.path().display().to_string(),
+                    ),
+                    bytes_per_message: None,
+                    widest_batch_bytes: 0,
+                    segments: vec![DiskSegment {
+                        start_offset: 0,
+                        persisted: bytes.len() as u64,
+                        read_state: Rc::default(),
+                        sealed: false,
+                    }],
+                    start_position: 0,
+                    start_index_offset: None,
+                    namespace_raw: 0,
+                    validate_checksum: true,
+                },
+            },
+        };
+        // Chunks are 1 MiB here, and the walk refuses a chunk whose buffer
+        // would put four times its length over the budget. This admits the
+        // first chunk and stops the second, which is the case under test.
+        let result = plan.execute_with_limit(4608 << 10, usize::MAX).await;
+        let served = result.message_count();
+        assert!(
+            served > 0 && served < BATCHES,
+            "expected a partial prefix, got {served} of {BATCHES}"
+        );
+        assert_eq!(result.last_matching_offset, Some(u64::from(served) - 1));
+        assert!(result.truncated());
+        let result = result
+            .checked()
+            .expect("a byte-limited prefix is serveable");
+        assert_eq!(result.message_count(), served);
+    }
+
+    #[compio::test]
+    async fn deferred_disk_read_reads_at_most_one_record_past_max_bytes() {
+        // The walk budget alone admitted a second chunk, so a read fetched
+        // nearly twice what its reply kept and the next poll read it again.
+        const BATCHES: u32 = 64;
+        let directory = tempfile::tempdir().unwrap();
+        let record = disk_batch(128 << 10, 0).len();
+        let mut bytes = Vec::with_capacity(record * BATCHES as usize);
+        for offset in 0..u64::from(BATCHES) {
+            bytes.extend(disk_batch(128 << 10, offset));
+        }
+        std::fs::write(directory.path().join("00000000000000000000.log"), &bytes).unwrap();
+        let max_selected_bytes = DEFAULT_MAX_BYTES - POLL_RESPONSE_HEADER_SIZE;
+        let result = deferred_disk_poll(directory.path(), bytes.len(), BATCHES)
+            .execute_with_limit(DEFAULT_WALK_BUDGET, max_selected_bytes)
+            .await;
+        assert!(result.truncated());
+        assert!(
+            result.encoded_bytes() <= max_selected_bytes + record,
+            "read {} bytes for a reply that keeps {max_selected_bytes}",
+            result.encoded_bytes()
+        );
+        let (reply, limited) = result
+            .limit_bytes(DEFAULT_MAX_BYTES, DEFAULT_MAX_READ_BYTES)
+            .unwrap();
+        assert!(limited);
+        assert_eq!(reply.message_count() as usize, max_selected_bytes / record);
+    }
+
+    #[compio::test]
+    async fn deferred_disk_read_returns_a_record_above_max_bytes_alone() {
+        // The reply refuses such a record with `InvalidSizeBytes`, and the SDK
+        // then fetches it alone with no byte limit. Both reads must hold it
+        // whole, or its partition stalls.
+        const BATCHES: u32 = 16;
+        let directory = tempfile::tempdir().unwrap();
+        let mut bytes = disk_batch(2 << 20, 0);
+        let oversized = bytes.len();
+        for offset in 1..u64::from(BATCHES) {
+            bytes.extend(disk_batch(16 << 10, offset));
+        }
+        std::fs::write(directory.path().join("00000000000000000000.log"), &bytes).unwrap();
+
+        let result = deferred_disk_poll(directory.path(), bytes.len(), BATCHES)
+            .execute_with_limit(
+                DEFAULT_WALK_BUDGET,
+                DEFAULT_MAX_BYTES - POLL_RESPONSE_HEADER_SIZE,
+            )
+            .await;
+        assert_eq!(result.message_count(), 1);
+        assert_eq!(result.encoded_bytes(), oversized);
+        assert!(matches!(
+            result.limit_bytes(DEFAULT_MAX_BYTES, DEFAULT_MAX_READ_BYTES),
+            Err(IggyError::InvalidSizeBytes)
+        ));
+
+        let (alone, _) = deferred_disk_poll(directory.path(), bytes.len(), 1)
+            .execute_with_limit(
+                DEFAULT_WALK_BUDGET,
+                DEFAULT_MAX_READ_BYTES - POLL_RESPONSE_HEADER_SIZE,
+            )
+            .await
+            .limit_bytes(u32::MAX as usize, DEFAULT_MAX_READ_BYTES)
+            .unwrap();
+        assert_eq!(alone.message_count(), 1);
+        assert_eq!(alone.encoded_bytes(), oversized);
+    }
+
+    /// The SDK's default `max_bytes`, the server's default read reservation,
+    /// and the third of it that the walk of a deferred disk read may hold.
+    const DEFAULT_MAX_BYTES: usize = 1 << 20;
+    const DEFAULT_MAX_READ_BYTES: usize = 16 << 20;
+    const DEFAULT_WALK_BUDGET: usize = DEFAULT_MAX_READ_BYTES / 3;
+
+    /// A poll from offset 0 over the one segment in `directory`, for a
+    /// partition without stats.
+    fn deferred_disk_poll(directory: &std::path::Path, persisted: usize, count: u32) -> PollPlan {
+        PollPlan {
+            context: PollContext {
+                history: PollHistoryId::default(),
+                consumer: PollingConsumer::Consumer(0, 0),
+                auto_commit: true,
+                owner: None,
+                _group_poll: None,
+            },
+            commit_offset: u64::MAX,
+            tier: PollTier::Disk {
+                query: MessageLookup::Offset {
+                    offset: 0,
+                    count,
+                    ceiling: u64::MAX,
+                },
+                resident_tail: ResidentTailSnapshot {
+                    entries: Vec::new(),
+                    oldest_resident: None,
+                },
+                disk: DiskReadPlan {
+                    partition_dir: PartitionDirResolution::Resolved(
+                        directory.display().to_string(),
+                    ),
+                    bytes_per_message: None,
+                    widest_batch_bytes: 0,
+                    segments: vec![DiskSegment {
+                        start_offset: 0,
+                        persisted: persisted as u64,
+                        read_state: Rc::default(),
+                        sealed: false,
+                    }],
+                    start_position: 0,
+                    start_index_offset: None,
+                    namespace_raw: 0,
+                    validate_checksum: true,
+                },
+            },
+        }
+    }
+
+    fn resident(fragments: PollFragments) -> PollReadResult {
+        PollPlan {
+            context: PollContext {
+                history: PollHistoryId::default(),
+                consumer: PollingConsumer::Consumer(0, 0),
+                auto_commit: true,
+                owner: None,
+                _group_poll: None,
+            },
+            commit_offset: 0,
+            tier: PollTier::Resident {
+                fragments,
+                last_matching_offset: None,
+                message_count: 0,
+            },
+        }
+        .execute_resident()
+    }
+
+    fn windows(source: &Frozen<4096>, count: usize) -> PollFragments {
+        (0..count)
+            .map(|window| Fragment::slice(source.clone(), window * 4096, window * 4096 + 512))
+            .collect()
+    }
+
+    #[test]
+    fn retained_bytes_counts_one_allocation_once_however_many_slices_borrow_it() {
+        let source: Frozen<4096> = Owned::copy_from_slice(&vec![7; 1 << 20]).into();
+        let one = resident(windows(&source, 1)).retained_bytes();
+        let four = resident(windows(&source, 4)).retained_bytes();
+        assert_eq!(one, four);
+        assert!(four < 2 * source.allocation_bytes());
+    }
+
+    #[test]
+    fn compacting_releases_the_borrowed_allocation_and_preserves_the_selection() {
+        let source: Frozen<4096> = Owned::copy_from_slice(&vec![7; 1 << 20]).into();
+        let borrowed = resident(windows(&source, 4));
+        let selection: Vec<Vec<u8>> = borrowed
+            .fragments
+            .iter()
+            .map(|fragment| fragment.as_slice().to_vec())
+            .collect();
+        let charge = borrowed.retained_bytes();
+        let compacted = borrowed.compacted();
+        assert!(compacted.retained_bytes() < charge);
+        let kept: Vec<Vec<u8>> = compacted
+            .fragments
+            .iter()
+            .map(|fragment| fragment.as_slice().to_vec())
+            .collect();
+        assert_eq!(kept, selection);
+    }
 }
diff --git a/core/partitions/src/types.rs b/core/partitions/src/types.rs
index 78affa1..39e42fd 100644
--- a/core/partitions/src/types.rs
+++ b/core/partitions/src/types.rs
@@ -49,6 +49,11 @@
 
 impl<const ALIGN: usize> Fragment<ALIGN> {
     #[must_use]
+    pub fn allocation_bytes(&self) -> usize {
+        self.source.allocation_bytes()
+    }
+
+    #[must_use]
     pub fn whole(source: Frozen<ALIGN>) -> Self {
         let end = source.len();
         Self {
@@ -98,6 +103,13 @@
     pub fn borrows_from(&self, source: &Frozen<ALIGN>) -> bool {
         self.source.shares_allocation(source)
     }
+
+    /// Identity of the allocation this fragment keeps alive, so a byte charge
+    /// counts a shared allocation once instead of once per slice.
+    #[must_use]
+    pub fn allocation_id(&self) -> usize {
+        self.source.allocation_id()
+    }
 }
 
 /// Arguments for polling messages from a partition.
@@ -110,7 +122,7 @@
 }
 
 pub type PollFragments<const ALIGN: usize = 4096> = SmallVec<[Fragment<ALIGN>; 4]>;
-pub type PollQueryResult<const ALIGN: usize = 4096> = (PollFragments<ALIGN>, Option<u64>);
+pub type PollQueryResult<const ALIGN: usize = 4096> = (PollFragments<ALIGN>, Option<u64>, u32);
 
 impl PollingArgs {
     #[must_use]
diff --git a/core/sdk/Cargo.toml b/core/sdk/Cargo.toml
index 5dbf22d..e8d8f00 100644
--- a/core/sdk/Cargo.toml
+++ b/core/sdk/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy"
-version = "0.11.1-edge.4"
+version = "0.11.1-edge.5"
 description = "Iggy is the persistent message streaming platform written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of processing millions of messages per second."
 edition = "2024"
 rust-version.workspace = true
diff --git a/core/sdk/README.md b/core/sdk/README.md
index 2aa9163..ce1b7f3 100644
--- a/core/sdk/README.md
+++ b/core/sdk/README.md
@@ -211,14 +211,9 @@
 
     let mut consumer = client
         .consumer_group(CONSUMER_GROUP, STREAM, TOPIC)?
-        .auto_commit(AutoCommit::IntervalOrWhen(
-            NonZeroIggyDuration::from_str("1s")?,
-            AutoCommitWhen::ConsumingAllMessages,
-        ))
         .create_consumer_group_if_not_exists()
         .auto_join_consumer_group()
         .polling_strategy(PollingStrategy::next())
-        .poll_interval(IggyDuration::from_str("1ms")?)
         .batch_length(1000)
         .build();
     consumer.init().await?;
@@ -239,6 +234,9 @@
                         println!("  header {key} = {value:?}");
                     }
                 }
+                consumer
+                    .store_offset(message.message.header.offset, Some(message.partition_id))
+                    .await?;
             }
             Err(error) => eprintln!("poll error: {error}"),
         }
@@ -251,7 +249,7 @@
 
 `IggyClient::send_binary_request_to_replica(code, payload, replica_id)` sends a raw command to one node of a cluster, named by the replica id that cluster metadata returns.
 
-Each node answers non-replicated commands such as `GetStats` from its own state, so this reads the state of that node. The request carries the session's metadata watermark. Replicated commands, polls, consumer-offset reads and session-control codes fail with `InvalidCommand` before anything is sent.
+Each node answers non-replicated commands such as `GetStats` from its own state, so this reads the state of that node. The request carries the session's metadata watermark. Replicated commands, polls, readiness waits, deferred-request cancels, consumer-offset reads and session-control codes fail with `InvalidCommand` before anything is sent.
 
 An id that cluster metadata does not name fails with `InvalidIdentifier`. An unreachable node is retried until the request deadline, and a `NotPrimary` reply is returned rather than sent to another node.
 
@@ -259,6 +257,134 @@
 
 For `IggyConsumerConfig`, `partitions_count` controls topic creation only. An ordinary consumer uses partition `0` unless the builder's `partition_id` or the config's `with_partition_id` selects another partition. Code that previously used `partitions_count` to select an existing partition must set `partition_id` explicitly. Consumer-group assignment ignores `partition_id`.
 
+## Deferred polling
+
+High-level consumers use long polling by default. `DeferredPollOptions` separates
+readiness from batch limits: `min_count = 1`, `max_wait = 1s`, `max_bytes = 1 MiB`
+and `request_timeout = 11s`. `batch_length` remains the maximum message count.
+Use `.poll_options(options)` on either consumer builder, or pass options to the
+low-level `poll_messages_deferred` method. A zero `max_wait` skips readiness
+waiting while preserving the same byte limits and request budget.
+
+The server responds when the minimum is readable, the byte cap is reached, or the
+readiness wait expires. Expiry allows a final bounded read; the separate request
+timeout bounds routing, waiting, I/O and response handling. It returns partial or
+empty data when appropriate. Read failures and exhausted request budgets return
+errors. `max_bytes` covers the binary body, including response metadata and batch
+framing; HTTP applies the same selection limit before JSON encoding. The server
+also stops a reply at `sharding.deferred_poll_max_read_bytes`, unless its first
+record alone is wider. A first record that cannot fit returns `InvalidSizeBytes`,
+without advancing its offset.
+The high-level consumer then fetches that record alone, without the byte limit.
+
+Both builders default to `Next` and manual commits. Process messages in partition order,
+then call `store_offset(offset, Some(partition_id))`. Fetch positions advance
+independently of stored offsets. An initial `Next` is resolved once per partition.
+A partition that a rebalance leaves with its group member continues after the last
+message that the application took. The member resolves `Next` again for a partition
+that it gets back from another member. After the client starts a new session, the
+member resolves `Next` again for every partition. Restarting before a commit can replay work.
+Explicit auto-commit policies remain available and can commit messages before processing.
+
+This default is incompatible with earlier releases. The builder used to commit
+with `AutoCommit::IntervalOrWhen(1s, PollingMessages)`, and `IggyConsumerConfig`
+used `AutoCommit::When(PollingMessages)` with `Last`. Code that relied on those
+defaults must set `auto_commit` explicitly, or each restart reads the same
+messages again. `IggyConsumerConfig::with_auto_commit` sets it on a `default` or
+`from_stream_topic` config. A consumer that delivered messages but stored no
+offset logs a warning at shutdown. With `AutoCommit::Disabled` and `Next`,
+`consume_messages` also warns at its start, because its message consumer cannot
+store offsets.
+
+Under `PollingMessages` the server commits every batch that it reads, so the
+consumer does not prefetch. It fetches one batch when the application asks for a
+message and its buffer is empty. A group member first waits until one of its
+partitions holds a message. It sends one readiness wait (binary command 107) per
+partition primary, which watches every assigned partition, and then fetches from
+a ready partition.
+
+Prefetch reserves both byte and message capacity before each request and holds
+it through queued and partially consumed batches. Defaults are 16 MiB of encoded
+response bytes and 16,000 messages, or `batch_length` messages when that is
+larger; use `.prefetch_bytes(...)` and
+`.prefetch_messages(...)` to change them. Each must fit at least one maximum
+response. Decoded message metadata and bounded HTTP JSON overhead are additional;
+these limits are not process RSS limits. Memory handed to application code is
+outside the consumer budget. Assignment refresh, reconnection and cancellation
+continue while the application stalls. Revoked generations are discarded before
+delivery. The normal `poll_interval` setting has been removed; error backoff is
+still configurable.
+
+Binary data requests share multiplexed connections with control traffic.
+A client admits at most 64 deferred requests, and each high-level consumer admits
+at most 16 across fetches and readiness waits, including waits split across replicas.
+Requests waiting for admission spend their original request budget. A group member with more
+partitions than prefetch slots fetches only the partitions that hold a message,
+and one readiness wait per partition primary watches the others. Cancellation
+sends command 108 for the original request on the
+same authenticated connection. QUIC stream cancellation is translated into that
+command by the server transport. It stops a poll still waiting for readiness.
+The binary transport retries a poll that does not commit and does not use
+`next` with the same request identity, reducing its wait and timeout before each attempt.
+A poll that can move an offset is not resent after a lost reply: it fails with
+`TransientNotCommitted`, and the high-level worker retries its explicit
+position. With opt-in server auto-commit, a lost reply, timeout or cancellation
+may race an accepted offset update. `TransientNotCommitted` means the offset may
+already have advanced; it does not prove that the server rejected the poll.
+Timing margins cannot remove this uncertainty. Use manual commits after processing
+when a lost response must not skip messages.
+
+Progress polls (`Next` or auto-commit), offset reads, stores and deletes share a
+progress lane per partition. Sends use the separate mutation lane, so a parked
+poll does not delay a producer sharing the client. Offset stores and deletes
+hold both lanes; an unresolved store or delete blocks later progress until it is
+resolved or abandoned. Different consumers on one partition still share the
+progress lane.
+
+A parked progress poll holds the progress lane of its partition for up to
+`max_wait`. Offset reads, stores and deletes of the same client on that
+partition wait for it. Each of these calls waits for at most the 30-second
+operation timeout. If the poll holds the lane longer, the call fails with
+`TransientNotAccepted`. The client did not send the call, so it is safe to
+retry.
+
+HTTP uses `GET /streams/{stream}/topics/{topic}/messages/deferred` with the ordinary
+poll fields plus `wait_us`, `min_count`, `max_bytes` and `request_timeout_us`.
+Omitted options use the SDK defaults. Binary command 106 carries the same
+contract. `wait_for_messages` (binary command 107) waits until any of several
+partitions holds a message and reads nothing. It has no HTTP route. Upgrade
+servers before SDKs; unsupported servers reject these requests.
+SDK retries, server forwarding and the high-level worker deduct the elapsed
+time from the original budgets. Routing and admission waits also spend that
+budget, and the caller keeps its original request timeout. Proxy timeouts
+should exceed the configured request timeout. The low-level immediate poll API retains
+its existing command and route.
+
+The server defaults to a 30-second maximum readiness wait, 1024 pending polls per
+shard, 64 per logical session (or HTTP user), 16 MiB per read reservation and
+64 MiB of in-flight read reservations. A longer requested wait is shortened to
+the server maximum. A disk read stops after the messages that fill `max_bytes`,
+capped at the read reservation. If its buffers fill their share of that
+reservation first, it stops there. It returns what it read, even below
+`min_count`. Configure
+`sharding.deferred_poll_*` accordingly. The response cap is applied to the selected
+result; it does not cap all storage work. Capacity refusal returns
+`DeferredPollCapacityExceeded` (90); clients back off on the same route. Over
+HTTP, a partition primary with no free relay slot refuses a relayed deferred poll
+with the same code. If the caller's credential holds its whole share of slots,
+the status is 429. In all other cases, it is 503. A
+high-level consumer whose poll is still refused when its request timeout ends
+returns the error to the application and polls that partition again after
+`polling_retry_interval`. A replica
+that cannot serve a partition still returns `TransientNotAccepted` (58) or
+`NotPrimary` (89), prompting route refresh. Upgrade clients with code 90 support
+before enabling deferred polling against this server. Metrics expose pending polls, read reservations and
+completion outcomes.
+
+Bench exposes `--max-wait`, `--min-count`, `--max-bytes` and `--request-timeout`
+for both consumer APIs and records them in report names. Intentional batching
+waits contribute to measured latency.
+
 ## Metadata changes
 
 `get_metadata_changes` reads the node's change log: one record per public result of a committed metadata operation, such as a stream created, a topic renamed, a user's permissions changed or a session closed. A mirror keeps a cursor, asks for the records after it, and stores the returned `next` cursor. The call needs root or the global `manage_servers` permission.
diff --git a/core/sdk/src/binary/mod.rs b/core/sdk/src/binary/mod.rs
index f71eb63..fc9a5d3 100644
--- a/core/sdk/src/binary/mod.rs
+++ b/core/sdk/src/binary/mod.rs
@@ -138,6 +138,24 @@
                 self.driver.poll(request, context).await
             }
 
+            async fn send_wait_with_response(
+                &self,
+                request: &iggy_binary_protocol::requests::messages::WaitForMessagesRequest,
+            ) -> Result<bytes::Bytes, iggy_common::IggyError> {
+                self.driver.wait_for_messages(request).await
+            }
+
+            async fn send_poll_with_response_and_options(
+                &self,
+                request: &iggy_binary_protocol::requests::messages::PollMessagesRequest,
+                context: Option<iggy_common::PartitionContext>,
+                options: Option<iggy_common::DeferredPollOptions>,
+            ) -> Result<bytes::Bytes, iggy_common::IggyError> {
+                self.driver
+                    .poll_with_options(request, context, options)
+                    .await
+            }
+
             fn routed_partition_count(
                 &self,
                 stream_id: &iggy_common::Identifier,
diff --git a/core/sdk/src/client_wrappers/binary_message_client.rs b/core/sdk/src/client_wrappers/binary_message_client.rs
index 3de42ca..098f1e0 100644
--- a/core/sdk/src/client_wrappers/binary_message_client.rs
+++ b/core/sdk/src/client_wrappers/binary_message_client.rs
@@ -18,8 +18,8 @@
 use crate::client_wrappers::client_wrapper::ClientWrapper;
 use async_trait::async_trait;
 use iggy_common::{
-    Consumer, Identifier, IggyError, IggyMessage, MessageClient, Partitioning, PolledMessages,
-    PollingStrategy, SendMessagesResponse,
+    Consumer, DeferredPollOptions, Identifier, IggyError, IggyMessage, MessageClient, Partitioning,
+    PolledMessages, PollingStrategy, SendMessagesResponse,
 };
 
 #[async_trait]
@@ -56,21 +56,61 @@
         count: u32,
         auto_commit: bool,
     ) -> Result<PolledMessages, IggyError> {
+        self.poll_messages_with_strategy_for_and_options(
+            stream_id,
+            topic_id,
+            partition_id,
+            consumer,
+            strategy_for,
+            count,
+            auto_commit,
+            None,
+        )
+        .await
+    }
+
+    async fn poll_messages_with_strategy_for_and_options(
+        &self,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        partition_id: Option<u32>,
+        consumer: &Consumer,
+        strategy_for: &(dyn Fn(u32) -> PollingStrategy + Send + Sync),
+        count: u32,
+        auto_commit: bool,
+        options: Option<DeferredPollOptions>,
+    ) -> Result<PolledMessages, IggyError> {
         super::dispatch_client!(
             self,
-            MessageClient::poll_messages_with_strategy_for(
+            MessageClient::poll_messages_with_strategy_for_and_options(
                 stream_id,
                 topic_id,
                 partition_id,
                 consumer,
                 strategy_for,
                 count,
-                auto_commit
+                auto_commit,
+                options
             )
         )
         .await
     }
 
+    async fn wait_for_messages(
+        &self,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        partitions: &[(u32, PollingStrategy)],
+        consumer: &Consumer,
+        options: DeferredPollOptions,
+    ) -> Result<Vec<u32>, IggyError> {
+        super::dispatch_client!(
+            self,
+            MessageClient::wait_for_messages(stream_id, topic_id, partitions, consumer, options)
+        )
+        .await
+    }
+
     async fn send_messages(
         &self,
         stream_id: &Identifier,
diff --git a/core/sdk/src/client_wrappers/client_wrapper.rs b/core/sdk/src/client_wrappers/client_wrapper.rs
index e221f39..fc533ec 100644
--- a/core/sdk/src/client_wrappers/client_wrapper.rs
+++ b/core/sdk/src/client_wrappers/client_wrapper.rs
@@ -23,9 +23,10 @@
 use crate::websocket::websocket_client::WebSocketClient;
 use iggy_common::locking::IggyRwLockFn;
 use iggy_common::{
-    BinaryOperationLineage, BinarySendPartitioning, BinaryTransport, ClientState, Identifier,
-    IggyError, IggyMessage, MessageClient, SendMessagesResponse, SystemClient,
-    send_binary_messages,
+    AssignmentVersion, BinaryClient, BinaryOperationLineage, BinarySendPartitioning,
+    BinaryTransport, ClientState, Identifier, IggyError, IggyMessage, MessageClient,
+    SendMessagesResponse, SystemClient, group_cache_key, send_binary_messages,
+    sync_group_assignment,
 };
 
 #[allow(clippy::large_enum_variant)]
@@ -124,4 +125,102 @@
             Self::WebSocket(client) => client.can_recover(),
         }
     }
+
+    pub(crate) async fn deferred_group_state(
+        &self,
+    ) -> Result<std::sync::Arc<iggy_common::ConsumerGroupClientState>, IggyError> {
+        match self {
+            Self::Tcp(client) => Ok(client.consumer_group_state()),
+            Self::Quic(client) => Ok(client.consumer_group_state()),
+            Self::WebSocket(client) => Ok(client.consumer_group_state()),
+            Self::Http(_) => Err(IggyError::FeatureUnavailable),
+            Self::Iggy(client) => Box::pin(client.client.read().await.deferred_group_state()).await,
+        }
+    }
+
+    /// The partitions grouped by primary endpoint and split to fit one wait
+    /// request each, so that a consumer can keep one readiness wait in flight
+    /// per group.
+    pub(crate) async fn readiness_groups(
+        &self,
+        stream: &Identifier,
+        topic: &Identifier,
+        partitions: &[u32],
+    ) -> Result<Vec<Vec<u32>>, IggyError> {
+        match self {
+            Self::Tcp(client) => {
+                client
+                    .driver
+                    .readiness_groups(stream, topic, partitions)
+                    .await
+            }
+            Self::Quic(client) => {
+                client
+                    .driver
+                    .readiness_groups(stream, topic, partitions)
+                    .await
+            }
+            Self::WebSocket(client) => {
+                client
+                    .driver
+                    .readiness_groups(stream, topic, partitions)
+                    .await
+            }
+            Self::Http(_) => Err(IggyError::FeatureUnavailable),
+            Self::Iggy(client) => {
+                Box::pin(
+                    client
+                        .client
+                        .read()
+                        .await
+                        .readiness_groups(stream, topic, partitions),
+                )
+                .await
+            }
+        }
+    }
+
+    pub(crate) async fn deferred_poll_partitions(
+        &self,
+        stream: &Identifier,
+        topic: &Identifier,
+        group: &Identifier,
+    ) -> Result<(AssignmentVersion, Vec<u32>), IggyError> {
+        match self {
+            Self::Tcp(client) => group_assignment(client, stream, topic, group).await,
+            Self::Quic(client) => group_assignment(client, stream, topic, group).await,
+            Self::WebSocket(client) => group_assignment(client, stream, topic, group).await,
+            Self::Http(_) => Err(IggyError::FeatureUnavailable),
+            Self::Iggy(client) => {
+                Box::pin(
+                    client
+                        .client
+                        .read()
+                        .await
+                        .deferred_poll_partitions(stream, topic, group),
+                )
+                .await
+            }
+        }
+    }
+}
+
+/// Syncs the assignment, then reads it.
+async fn group_assignment<B: BinaryClient>(
+    client: &B,
+    stream: &Identifier,
+    topic: &Identifier,
+    group: &Identifier,
+) -> Result<(AssignmentVersion, Vec<u32>), IggyError> {
+    sync_group_assignment(client, stream, topic, group).await?;
+    let state = client.consumer_group_state();
+    let key = group_cache_key(stream, topic, group);
+    if !state.is_registered(&key) {
+        return Err(IggyError::ConsumerGroupMemberNotFound(
+            0,
+            group.clone(),
+            topic.clone(),
+        ));
+    }
+    Ok(state.assignment_snapshot(&key))
 }
diff --git a/core/sdk/src/clients/binary_message.rs b/core/sdk/src/clients/binary_message.rs
index a8c62d8..e80dae7 100644
--- a/core/sdk/src/clients/binary_message.rs
+++ b/core/sdk/src/clients/binary_message.rs
@@ -21,8 +21,8 @@
 use iggy_common::locking::IggyRwLockFn;
 use iggy_common::{BinarySendPartitioning, MessageClient};
 use iggy_common::{
-    Consumer, Identifier, IggyError, IggyMessage, Partitioning, PolledMessages, PollingStrategy,
-    SendMessagesResponse,
+    Consumer, DeferredPollOptions, Identifier, IggyError, IggyMessage, Partitioning,
+    PolledMessages, PollingStrategy, SendMessagesResponse,
 };
 
 #[async_trait]
@@ -59,11 +59,35 @@
         count: u32,
         auto_commit: bool,
     ) -> Result<PolledMessages, IggyError> {
+        self.poll_messages_with_strategy_for_and_options(
+            stream_id,
+            topic_id,
+            partition_id,
+            consumer,
+            strategy_for,
+            count,
+            auto_commit,
+            None,
+        )
+        .await
+    }
+
+    async fn poll_messages_with_strategy_for_and_options(
+        &self,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        partition_id: Option<u32>,
+        consumer: &Consumer,
+        strategy_for: &(dyn Fn(u32) -> PollingStrategy + Send + Sync),
+        count: u32,
+        auto_commit: bool,
+        options: Option<iggy_common::DeferredPollOptions>,
+    ) -> Result<PolledMessages, IggyError> {
         if count == 0 {
             return Err(IggyError::InvalidMessagesCount);
         }
 
-        let mut polled_messages = super::with_client!(self, client => client.poll_messages_with_strategy_for(
+        let mut polled_messages = super::with_client!(self, client => client.poll_messages_with_strategy_for_and_options(
                 stream_id,
                 topic_id,
                 partition_id,
@@ -71,6 +95,7 @@
                 strategy_for,
                 count,
                 auto_commit,
+                options,
             ).await)?;
 
         if let Some(ref encryptor) = self.encryptor {
@@ -90,6 +115,21 @@
         Ok(polled_messages)
     }
 
+    async fn wait_for_messages(
+        &self,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        partitions: &[(u32, PollingStrategy)],
+        consumer: &Consumer,
+        options: DeferredPollOptions,
+    ) -> Result<Vec<u32>, IggyError> {
+        self.client
+            .read()
+            .await
+            .wait_for_messages(stream_id, topic_id, partitions, consumer, options)
+            .await
+    }
+
     async fn send_messages(
         &self,
         stream_id: &Identifier,
diff --git a/core/sdk/src/clients/client.rs b/core/sdk/src/clients/client.rs
index 5fc5196..6e55c8d 100644
--- a/core/sdk/src/clients/client.rs
+++ b/core/sdk/src/clients/client.rs
@@ -180,7 +180,6 @@
 ///     .consumer("consumer_name", "stream_name", "topic_name", 1)?
 ///     .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
 ///     .polling_strategy(PollingStrategy::next())
-///     .poll_interval(IggyDuration::ONE_SECOND)
 ///     .batch_length(1000)
 ///     .build();
 /// consumer.init().await?;
@@ -649,7 +648,6 @@
     ///     .consumer("consumer_name", "stream_name", "topic_name", 1)? // returns IggyConsumerBuilder from IggyClient
     ///     .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
     ///     .polling_strategy(PollingStrategy::next())
-    ///     .poll_interval(IggyDuration::ONE_SECOND)
     ///     .batch_length(1000)
     ///     .build(); // returns IggyConsumer from IggyConsumerBuilder
     /// # let _ = consumer;
@@ -679,7 +677,6 @@
             Identifier::named(topic)?,
             Some(partition),
             self.encryptor.clone(),
-            None,
         ))
     }
 
@@ -721,7 +718,6 @@
     ///     .consumer_group("group_name", "stream_name", "topic_name")? // returns IggyConsumerBuilder from IggyClient
     ///     .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
     ///     .polling_strategy(PollingStrategy::next())
-    ///     .poll_interval(IggyDuration::ONE_SECOND)
     ///     .batch_length(1000)
     ///     .build(); // returns IggyConsumer from IggyConsumerBuilder
     /// # let _ = consumer;
@@ -750,7 +746,6 @@
             Identifier::named(topic)?,
             None,
             self.encryptor.clone(),
-            None,
         ))
     }
 
@@ -958,8 +953,8 @@
     ///
     /// # Errors
     /// [`IggyError::InvalidCommand`] before anything is sent if `code` is a
-    /// session-control code, a replicated command, a poll, or a consumer-offset
-    /// read. Writes and partition reads reach their primary through the typed
+    /// session-control code, a replicated command, a poll, a readiness wait, a
+    /// deferred-request cancel, or a consumer-offset read. Writes and partition reads reach their primary through the typed
     /// API and [`send_binary_request`](IggyClient::send_binary_request).
     /// [`IggyError::InvalidIdentifier`] if the roster does not name
     /// `replica_id`, or names it without an endpoint for this transport.
diff --git a/core/sdk/src/clients/consumer.rs b/core/sdk/src/clients/consumer.rs
index 9600643..9aad250 100644
--- a/core/sdk/src/clients/consumer.rs
+++ b/core/sdk/src/clients/consumer.rs
@@ -18,37 +18,31 @@
 use crate::client_wrappers::client_wrapper::ClientWrapper;
 use crate::driver::{operation_deadline, with_deadline};
 use crate::links::OPERATION_TIMEOUT;
-use bytes::Bytes;
 use dashmap::DashMap;
 use futures::Stream;
-use futures_util::{FutureExt, StreamExt};
+use futures_util::StreamExt;
 use iggy_binary_protocol::primitives::partition_history::PartitionContext;
 use iggy_common::ConsumerPosition;
 use iggy_common::locking::{IggyRwLock, IggyRwLockFn};
 use iggy_common::{
-    Client, ConsumerGroupClient, ConsumerOffsetClient, MessageClient, StreamClient, TopicClient,
+    AssignmentVersion, Consumer, ConsumerGroupClientState, ConsumerKind, DeferredPollOptions,
+    DiagnosticEvent, EncryptorKind, IdKind, Identifier, IggyDuration, IggyError, IggyMessage,
+    NonZeroIggyDuration, PolledMessages, PollingKind, PollingStrategy, group_cache_key,
 };
-use iggy_common::{
-    Consumer, ConsumerKind, DiagnosticEvent, EncryptorKind, IdKind, Identifier, IggyDuration,
-    IggyError, IggyMessage, IggyTimestamp, NO_ASSIGNED_PARTITION, NonZeroIggyDuration,
-    PolledMessages, PollingKind, PollingStrategy,
-};
-use std::collections::VecDeque;
+use iggy_common::{Client, ConsumerGroupClient, ConsumerOffsetClient, StreamClient, TopicClient};
+use std::collections::{BTreeSet, VecDeque};
 use std::fmt::{self, Debug, Formatter};
-use std::future::Future;
 use std::pin::Pin;
 use std::sync::Arc;
 use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
 use std::task::{Context, Poll};
-use std::time::Duration;
 use tokio::sync::{Mutex, Notify};
 use tokio::task::JoinHandle;
 use tokio::time;
-use tokio::time::{Instant, sleep};
+use tokio::time::Instant;
 use tracing::{debug, error, info, trace, warn};
 
 const ORDERING: std::sync::atomic::Ordering = std::sync::atomic::Ordering::SeqCst;
-type PollMessagesFuture = Pin<Box<dyn Future<Output = Result<PolledMessages, IggyError>> + Send>>;
 
 #[derive(Clone, Copy)]
 enum OffsetMutation {
@@ -347,6 +341,103 @@
             .map(|entry| *entry.value())
             .collect()
     }
+
+    /// Stores the delivered position of every partition where it differs from the record.
+    ///
+    /// Only for auto-commit-on-poll. The poll commits each whole batch, so the record holds
+    /// the batch end while the application can still be behind it, and the store must bypass
+    /// the skip rule to move the server offset back. `assignment` limits a group member to
+    /// the partitions it still owns; each store also carries the captured partition context.
+    async fn store_delivered_offsets(
+        &self,
+        assignment: Option<&(Arc<ConsumerGroupClientState>, String)>,
+    ) {
+        let owned = assignment.map(|(groups, key)| groups.assignment_snapshot(key).1);
+        for position in self.unstored_delivered_offsets(owned.as_deref()) {
+            if let Err(error) = self.store_consumer_position(position, true).await {
+                error!("Failed to store delivered position: {position:?}. {error}");
+            }
+        }
+    }
+
+    /// The server committed `undelivered`, but the application never took it.
+    /// Recording each commit lets [`Self::store_delivered_offsets`] move the
+    /// offset back to the last delivered message. Without a delivered message,
+    /// a `Next` consumer stores the offset before the first undelivered one,
+    /// because its next run resumes after the stored offset.
+    async fn rewind_undelivered_offsets(
+        &self,
+        undelivered: Vec<PolledMessages>,
+        assignment: Option<&(Arc<ConsumerGroupClientState>, String)>,
+        resumes_next: bool,
+    ) {
+        let owned = assignment.map(|(groups, key)| groups.assignment_snapshot(key).1);
+        let mut rewound = BTreeSet::new();
+        for polled in undelivered {
+            let partition_id = polled.partition_id;
+            let (Some(first), Some(last)) = (polled.messages.first(), polled.messages.last())
+            else {
+                continue;
+            };
+            if owned
+                .as_ref()
+                .is_some_and(|owned| !owned.contains(&partition_id))
+            {
+                continue;
+            }
+            if self.last_consumed_offsets.contains_key(&partition_id) {
+                self.last_stored_offsets.insert(
+                    partition_id,
+                    ConsumerPosition {
+                        partition_id,
+                        offset: last.header.offset,
+                        context: polled.context,
+                    },
+                );
+                continue;
+            }
+            // Batches arrive in offset order, so the first one of a partition
+            // holds the oldest undelivered message.
+            if !resumes_next || !rewound.insert(partition_id) {
+                continue;
+            }
+            let result = match first.header.offset.checked_sub(1) {
+                Some(offset) => {
+                    let position = ConsumerPosition {
+                        partition_id,
+                        offset,
+                        context: polled.context,
+                    };
+                    self.store_consumer_position(position, true).await
+                }
+                None => {
+                    self.last_stored_offsets.remove(&partition_id);
+                    self.mutate_offset(OffsetMutation::Delete {
+                        partition_id: Some(partition_id),
+                    })
+                    .await
+                }
+            };
+            if let Err(error) = result {
+                error!(
+                    "Failed to rewind the offset before undelivered messages in partition: {partition_id}. {error}"
+                );
+            }
+        }
+    }
+
+    fn unstored_delivered_offsets(&self, owned: Option<&[u32]>) -> Vec<ConsumerPosition> {
+        self.last_consumed_positions()
+            .into_iter()
+            .filter(|position| {
+                owned.is_none_or(|owned| owned.contains(&position.partition_id))
+                    && self
+                        .last_stored_offsets
+                        .get(&position.partition_id)
+                        .is_none_or(|stored| *stored != *position)
+            })
+            .collect()
+    }
 }
 
 /// Offsets are comparable only within one partition incarnation and owner. The metadata frontier
@@ -356,112 +447,52 @@
     left.incarnation == right.incarnation && left.owner_generation == right.owner_generation
 }
 
-// SAFETY: IggyConsumer is Sync because:
-// 1. The only non-Sync field is `poll_future: Option<PollMessagesFuture>`
-// 2. `poll_future` is only accessed through `poll_next()` which requires `Pin<&mut Self>`
-//    (exclusive mutable access), so concurrent access to `poll_future` is impossible
-// 3. All other fields are inherently Sync (Arc<AtomicX>, Arc<DashMap>, etc.) or
-//    only accessed through `&mut self` methods
-// 4. All `&self` methods only access Sync-safe fields
-unsafe impl Sync for IggyConsumer {}
-
-/// Reads messages from the partitions of one topic and yields them one at a time.
+/// Reads a topic as a stream of messages, using long polling and bounded prefetch.
 ///
-/// A topic is split into partitions, and a partition is an ordered log that producers append to.
-/// Every message sits at an *offset*, its position in that log. Reading is therefore always the
-/// same three decisions: which partition to read, where in it to start, and how to keep track of
-/// how far you got so the next run can continue there.
+/// Call [`Self::init`] before reading and [`Self::shutdown`] before dropping the consumer.
+/// A standalone consumer reads one partition. A group member reads its assignment, and its
+/// assignment refresh continues even when the application stops reading. Fetches share the
+/// client's multiplexed connections with control traffic. At most 16 fetches and readiness
+/// waits run concurrently per consumer, within the client's limit of 64 deferred requests.
 ///
-/// `IggyConsumer` handles all three. It fetches batches of messages from the server, keeps them in
-/// an in-memory buffer, decrypts them when it has an encryptor, and records how far it has read.
-/// **It implements [`Stream`], so consuming is a loop over [`StreamExt::next`].**
+/// A poll returns when at least `min_count` messages are available (one by default),
+/// its byte limit is reached, or `max_wait` expires. `batch_length` is the maximum
+/// message count. The separate request timeout covers routing, waiting and reading.
+/// There is no normal polling interval; errors and reconnects retain retry backoff.
+/// A poll that is still refused when its request timeout ends, for example by a
+/// server at its deferred poll capacity, reaches the application as an error, and
+/// the consumer polls that partition again after
+/// [`polling_retry_interval`](crate::prelude::IggyConsumerBuilder::polling_retry_interval).
 ///
-/// You can use a consumer as a worker draining a topic, a reader that replays a
-/// partition from a chosen point, and a pool of consumers sharing a workload through a consumer
-/// group.
+/// # Positions and commits
 ///
-/// # Creating a consumer
+/// The initial polling strategy is resolved separately for each partition. `Next`
+/// starts after the stored offset, or at zero when no offset exists. Subsequent
+/// fetches use explicit offsets, so a lost reply can be retried without advancing
+/// past unseen messages. Reassignment discards prefetched data. A partition that a
+/// rebalance leaves with its group member continues after the last message that the
+/// application took. The member resolves the starting position again for a partition
+/// that it gets back from another member. After the client starts a new session, the
+/// member resolves it again for every partition. Messages already delivered are
+/// filtered unless replay is enabled. Delivery and durable processing progress are
+/// separate.
 ///
-/// Easiest way is to use the [`IggyClient`] with a configured connection. Then:
-/// - [`IggyClient::consumer()`] builds a standalone consumer, bound to the one partition passed
-///   in.
-/// - [`IggyClient::consumer_group()`] builds a member of a consumer group. The server gives every
-///   partition to exactly one member, so several consumers using the same group name split the
-///   topic between them and share one set of offsets.
+/// The default is [`AutoCommit::Disabled`]. Store offsets only after successfully
+/// processing messages, in partition order. Fetching ahead never commits under this
+/// default. Opting into auto-commit can acknowledge messages before processing.
 ///
-/// Note, building never talks to the server. [`init()`](Self::init) must be awaited once before the
-/// first message is read.
-///
-/// # Examples
-///
-/// A standalone consumer reading partition 1 with the defaults:
-///
-/// ```rust,no_run
-/// use futures_util::StreamExt;
-/// use iggy::prelude::*;
-///
-/// # async fn example() -> Result<(), IggyError> {
-/// let client = IggyClient::from_connection_string("iggy://iggy:iggy@localhost:8090")?;
-/// client.connect().await?;
-///
-/// let mut consumer = client
-///     .consumer("my-consumer", "my-stream", "my-topic", 1)?
-///     .batch_length(100)
-///     .poll_interval(IggyDuration::new_from_secs(1))
-///     .build();
-/// consumer.init().await?;
-///
-/// while let Some(received) = consumer.next().await {
-///     match received {
-///         Ok(received) => println!("Offset: {}", received.message.header.offset),
-///         Err(error) => eprintln!("Failed to read a message: {error}"),
-///     }
-/// }
-/// # Ok(())
-/// # }
-/// ```
-///
-/// A group member that queues a commit for every message just before handing it over, and shuts
-/// down cleanly:
-///
-/// ```rust,no_run
-/// use futures_util::StreamExt;
-/// use iggy::prelude::*;
-///
-/// # async fn handle(message: &IggyMessage) {}
-/// # async fn example() -> Result<(), IggyError> {
-/// let client = IggyClient::from_connection_string("iggy://iggy:iggy@localhost:8090")?;
-/// client.connect().await?;
-///
-/// let mut consumer = client
-///     .consumer_group("order-workers", "my-stream", "my-topic")?
-///     .auto_commit(AutoCommit::When(AutoCommitWhen::ConsumingEachMessage))
-///     .polling_strategy(PollingStrategy::next())
-///     .build();
-/// consumer.init().await?;
-///
-/// let mut consumed = 0;
-/// while let Some(received) = consumer.next().await {
-///     match received {
-///         Ok(received) => {
-///             handle(&received.message).await;
-///             consumed += 1;
-///         }
-///         Err(error) => eprintln!("Failed to read a message: {error}"),
-///     }
-///     if consumed == 100 {
-///         break;
-///     }
-/// }
-///
-/// consumer.shutdown().await?;
-/// # Ok(())
-/// # }
-/// ```
-///
-/// Committing by hand, so that a message the handler could not process comes back on the next
-/// run. Every commit is one round trip, and no auto-commit setting substitutes, since each of them
-/// also commits a message whose handler failed:
+/// Under [`AutoCommitWhen::PollingMessages`] the server commits every batch that it
+/// reads, so the consumer does not fetch ahead. It fetches one batch when the
+/// application asks for a message and the buffer is empty. A group member first waits
+/// until one of its partitions holds a message, with one readiness wait per partition
+/// primary. A batch that the server committed reaches the application even after a
+/// reassignment. After its first batch, a partition continues from an explicit offset,
+/// so the consumer retries a lost reply from the same offset. If the consumer shuts down
+/// first, [`shutdown`](Self::shutdown) moves the offset back over the messages that the
+/// application did not take, also for a fetch in flight. A lost reply to the first `Next`
+/// poll of a partition can still skip its batch. So can a lost reply whose partition gets
+/// a new owner before the retry. A new session of the client gives every partition a new
+/// owner.
 ///
 /// ```rust,no_run
 /// use futures_util::StreamExt;
@@ -471,14 +502,8 @@
 /// # async fn example() -> Result<(), IggyError> {
 /// let client = IggyClient::from_connection_string("iggy://iggy:iggy@localhost:8090")?;
 /// client.connect().await?;
-///
-/// let mut consumer = client
-///     .consumer("my-consumer", "my-stream", "my-topic", 1)?
-///     .auto_commit(AutoCommit::Disabled)
-///     .polling_strategy(PollingStrategy::next())
-///     .build();
+/// let mut consumer = client.consumer("reader", "stream", "topic", 0)?.build();
 /// consumer.init().await?;
-///
 /// while let Some(received) = consumer.next().await {
 ///     let received = match received {
 ///         Ok(received) => received,
@@ -493,254 +518,45 @@
 ///     }
 ///     consumer.store_position(received.position()).await?;
 /// }
-///
 /// consumer.shutdown().await?;
 /// # Ok(())
 /// # }
 /// ```
 ///
-/// # Which partitions are read
+/// # Buffer bounds
 ///
-/// A **standalone consumer** reads exactly one partition, the one passed to
-/// [`IggyClient::consumer()`]. Covering a whole topic with several partitions
-/// means running one consumer per partition and dividing the work yourself.
-///
-/// A **consumer group member** does not choose. The server hands every partition of the topic to
-/// exactly one member, so consumers sharing a group name split the topic between them without
-/// coordinating. [`ReceivedMessage::partition_id`] tells where a message came from.
-///
-/// What to know when working with consumer groups:
-/// - With [`auto_join_consumer_group()`] (the default) a member joins during [`init()`](Self::init),
-///   creating the group first if [`create_consumer_group_if_not_exists()`] is set (the default).
-///   It rejoins on its own after a reconnect and whenever the server reports that its membership
-///   is gone.
-/// - Such a member does not poll until it is in the group. A join that fails is yielded as
-///   `Some(Err(..))` after [`polling_retry_interval()`], and the next call tries again.
-/// - Partitions are redistributed whenever members join or leave, so a member reads different
-///   partitions over time and messages from several partitions interleave in its stream.
-/// - More members than partitions leaves the surplus members without partitions. Such a member
-///   keeps asking the server for an assignment, parking for [`polling_retry_interval()`] between
-///   attempts. The partition count of the topic is the ceiling on how far one group can be
-///   scaled out.
-/// - The group shares one set of stored offsets, kept under the group name. Thus,
-///   a partition taken over by another member continues where the previous one
-///   committed.
-///
-/// # How messages are read
-///
-/// Reading is done by polling. One request fetches up to [`batch_length()`] messages. The consumer
-/// passes the first one to the caller and buffers the rest. The next request is sent once that buffer
-/// is empty.
-///
-/// [`poll_interval()`] sets the smallest gap between two requests, measured from one send to the
-/// next. Without it the next request goes out as soon as the previous one is answered, which is
-/// the fastest option but keeps a busy loop running against an idle topic.
-///
-/// [`polling_strategy()`] decides **where** in the partition reading begins:
-///
-/// | Strategy | Starts at |
-/// | --- | --- |
-/// | [`PollingStrategy::next()`] (default) | the message after the offset stored on the server, or the first message when nothing is stored yet |
-/// | [`PollingStrategy::first()`] | the oldest message in the partition |
-/// | [`PollingStrategy::last()`] | the end of the partition (returns up to [`batch_length()`] of the most recent messages) |
-/// | [`PollingStrategy::offset()`] | a custom offset |
-/// | [`PollingStrategy::timestamp()`] | the first message at or after a given point in time |
-///
-/// Only [`PollingStrategy::next()`] consults the offset stored on the server.
-/// Use this if you want to resume where a previous run stopped. The other four are the starting
-/// point for the first request to each partition. From then on the consumer asks that partition
-/// for whatever follows the last message it handed over from it, and it keeps that position per
-/// partition. A partition that moves to another member and back therefore continues from this
-/// consumer's own position, not from where the other member got to, so a rebalance can repeat
-/// messages under these strategies, which ignore the group's stored offsets by definition.
-///
-/// [`StreamExt::next`] yields `None` once [`shutdown()`](Self::shutdown) has been called, and never
-/// otherwise: not when the topic is empty and not while the client is disconnected. A request that
-/// comes back empty is not an error and not the end of the stream, it just means nothing new has
-/// arrived yet.
-///
-/// A failed poll request waits [`polling_retry_interval()`] before yielding `Some(Err(..))` and leaves
-/// the consumer usable for the next call. This delay also applies to terminal server errors, to a lost
-/// link, to an authentication failure, and when the ordinary poll interval is disabled. Hence,
-/// deciding when to give up on repeated errors is up to you.
-///
-/// Polling pauses while the client is not signed in, for example after a sign-out, or once every
-/// replica link has used up its reconnect retries, until it has signed in again. The heartbeat of the
-/// [`IggyClient`] restarts such links. The consumer handles this automatically; the next call parks
-/// for [`polling_retry_interval()`] while polling is paused. Polling does not pause for a client that
-/// only the caller can bring back: one shut down or disconnected by the caller, or one that lost every
-/// link with reconnection disabled. Each call then fails after [`polling_retry_interval()`], and
-/// [`IggyConsumerMessageExt::consume_messages`] returns the error.
-///
-/// After another client deletes and recreates a partition, one poll of it can fail with
-/// [`IggyError::HistoryUnavailable`], or [`IggyError::ConsumerGroupPartitionNotOwned`] for a group
-/// member, because the request still carries the context of the deleted partition. That context is
-/// dropped with the failed request, so the next poll reads the new partition. Commits of positions
-/// read before the change keep their context and are refused the same way.
-///
-/// With [`AutoCommitWhen::PollingMessages`], a missing response can leave the server's
-/// cursor ahead of messages this consumer received. Continuing with
-/// [`PollingStrategy::next()`] can skip those messages. See the
-/// [poll recovery contract](MessageClient::poll_messages) for recovery from explicit
-/// checkpoints for each partition.
-///
-/// For a boilerplate implementation of such a loop Iggy provides [`IggyConsumerMessageExt::consume_messages`].
-///
-/// # Tracking what has been read
-///
-/// An offset is the index tracking what has been already read from a partition by the consumer.
-/// Managing the offset has implications on where consumers resume reading messages.
-///
-/// There are two positions (offsets) tracked in two different places:
-/// - The **reading position** is held by the consumer, one per partition, and is the offset of the
-///   last message handed over
-///   ([`get_last_consumed_offset()`](Self::get_last_consumed_offset)). It dies with the process.
-/// - The **stored offset** is committed to the server under the consumer or group name.
-///   Its restart guarantees depend on the topic's consumer-offset durability policy.
-///
-/// Committing matters because [`PollingStrategy::next()`] resumes from the stored offset. A
-/// consumer that never commits starts over from the same place on every run. Within a run it
-/// stalls instead: the server serves the same messages again on every request, messages at or
-/// below the reading position are dropped (see [Guarantees](#guarantees)), so the stream goes
-/// quiet once the reading position is [`batch_length()`] or more ahead of the stored offset. Under
-/// [`PollingStrategy::next()`], keep commits within [`batch_length()`] of the reading position.
-///
-/// [`auto_commit()`] decides when the consumer commits by itself:
-///
-/// | Setting | Commits |
-/// | --- | --- |
-/// | [`AutoCommit::Disabled`] | never, not even on [`shutdown()`](Self::shutdown). Commit with [`store_position()`](Self::store_position) |
-/// | [`AutoCommit::Interval`] | on every tick, the reading position of every partition read so far |
-/// | [`AutoCommitWhen::PollingMessages`] | sends the commit with the poll request itself, before your code sees the batch |
-/// | [`AutoCommitWhen::ConsumingEachMessage`] | queued just before every message is handed over to the calling code. Commits queued faster than they are sent collapse into the latest one per partition |
-/// | [`AutoCommitWhen::ConsumingEveryNthMessage`] | queued just before a message whose offset divides by `n` is handed over |
-/// | [`AutoCommitWhen::ConsumingAllMessages`] | queued when the buffer of the current batch runs empty |
-/// | [`AutoCommitAfter`] variants | once the handler returned, `Ok` or `Err`, and only under [`IggyConsumerMessageExt::consume_messages`], see below |
-///
-/// [`AutoCommit::IntervalOrWhen`] and [`AutoCommit::IntervalOrAfter`] combine an interval with a
-/// message trigger. The default is [`AutoCommit::IntervalOrWhen`] with one second and
-/// [`AutoCommitWhen::PollingMessages`].
-/// Important implications of these settings:
-/// - [`AutoCommitWhen::PollingMessages`] marks a batch as consumed while it is being delivered,
-///   before your code has seen any of it. For a crash-safe option configure with [`AutoCommit::Disabled`]
-///   and manually store the position of each handled message with [`Self::store_position()`].
-/// - [`AutoCommitWhen::ConsumingEveryNthMessage`] tests the offset of a message, not a counter of
-///   messages this process handled, so it commits at every `n`-th offset of the partition. With
-///   `n = 0` the trigger never fires, so without an interval only [`shutdown()`](Self::shutdown)
-///   commits.
-/// - [`AutoCommitAfter::ConsumingAllMessages`] fires for the message whose offset equals the
-///   partition head seen by the poll ([`ReceivedMessage::current_offset`]), not when the buffer
-///   runs empty, so a consumer that lags behind commits nothing until it has caught up. Every
-///   [`AutoCommitAfter`] variant commits after a handler that returned `Err` as well.
-///
-/// ## Guarantees
-///
-/// - **Each message is handed over once per consumer.** Messages whose offset is not greater than
-///   the reading position of their partition are dropped before they reach the stream. Re-reading
-///   a partition, or seeing a failed message again within the same consumer, needs
-///   [`allow_replay()`], which turns that filter off. A new consumer starts with an empty filter.
-/// - **Delivering at-least-once.** If you cannot tolerate missing any messages, use [`AutoCommit::Disabled`]
-///   and store the position using [`Self::store_position()`] after handling a message. Every other
-///   setting except the plain [`AutoCommit::After`] variants can commit a message before your
-///   handler is done with it, so a crash in the handler loses it. [`AutoCommit::IntervalOrAfter`]
-///   still commits on its interval tick, and [`shutdown()`](Self::shutdown) commits the reading
-///   position under every setting but [`AutoCommit::Disabled`], a failed message included.
-///
-/// # Options and defaults
-///
-/// Everything is configured on the [`IggyConsumerBuilder`] before [`build()`] and is fixed
-/// afterwards.
-///
-/// | Option | Default | Controls |
-/// | --- | --- | --- |
-/// | [`stream()`], [`topic()`], [`partition()`] | the values passed to the entry point | what is read. [`partition()`] is for standalone consumers, a group member ignores it with a warning and reads the server's assignment |
-/// | [`batch_length()`] | 1000 | messages fetched per request |
-/// | [`poll_interval()`] | none | smallest gap between two requests |
-/// | [`polling_strategy()`] | [`PollingStrategy::next()`] | where reading each partition starts |
-/// | [`auto_commit()`] | [`AutoCommit::IntervalOrWhen`], one second, [`AutoCommitWhen::PollingMessages`] | when offsets are committed |
-/// | [`allow_replay()`] | off | whether a message can be handed over again |
-/// | [`auto_join_consumer_group()`] | on | joining the group during [`init()`](Self::init) and again whenever the membership is lost. With [`do_not_auto_join_consumer_group()`] joining is up to the caller, and a poll without a membership fails with [`IggyError::ConsumerGroupMemberNotFound`] |
-/// | [`create_consumer_group_if_not_exists()`] | on | creating the group when it is missing |
-/// | [`polling_retry_interval()`] | one second | delay before yielding a failed poll request, and between attempts while polling is paused or the member holds no partitions |
-/// | [`init_retries()`] | none, one second apart | retries when the stream or topic is missing at [`init()`](Self::init) |
-/// | [`offset_drain_timeout()`] | five seconds | how long [`shutdown()`](Self::shutdown) waits for pending commits |
-/// | [`encryptor()`] | inherited from the client | decrypting payloads and user headers, see [Encryption](#encryption) |
-///
-/// The switches have inverse setters as well, such as [`without_poll_interval()`],
-/// [`without_encryptor()`], [`do_not_auto_join_consumer_group()`] and
-/// [`do_not_create_consumer_group_if_not_exists()`].
+/// Both builders reserve a full possible batch in bytes and messages before sending
+/// each request. The reservation covers in-flight, completed and buffered replies
+/// until the batch is drained or discarded. The byte budget measures encoded poll
+/// replies; decoded message metadata is additionally bounded by the message budget.
+/// Messages retained by application code are outside these SDK buffer limits.
+/// Assignment changes invalidate buffered generations before the next delivery.
 ///
 /// # Encryption
 ///
-/// A consumer with an encryptor, inherited from the [`IggyClient`] or set with [`encryptor()`],
-/// decrypts payloads and user headers before a message is yielded. That only works if the producer
-/// encrypted them with the same key, which an [`IggyProducer`] and an `IggyConsumer` from the same
-/// client share unless one of them overrides it on its builder. Without an encryptor the consumer
-/// yields payloads as stored, encrypted or not.
-///
-/// A message that cannot be decrypted is yielded as an `Err` and the whole batch is dropped. The
-/// next request fetches the same batch and fails the same way until
-/// [`store_offset()`](Self::store_offset) moves the offset past it. Under
-/// [`AutoCommitWhen::PollingMessages`] the server would have committed the batch with the poll and
-/// skipped it for good, so [`init()`](Self::init) rejects that setting, the default included, with
-/// [`IggyError::InvalidConfiguration`] when the consumer has an encryptor.
-///
-/// # Concurrency
-///
-/// `IggyConsumer` is `Send` and `Sync` but not `Clone`. Driving the stream
-/// ([`StreamExt::next`]) and [`shutdown()`](Self::shutdown) take `&mut self`, so one task owns and
-/// drives a consumer end to end. To read offsets or commit from another task, take an
-/// [`IggyConsumerState`] via [`state()`](Self::state): it is a cheap clone of the shared
-/// bookkeeping and needs no lock.
-///
-/// Besides the stream, a consumer runs background tasks: one watching the connection lifecycle,
-/// one sending queued commits, and an interval commit task for the [`AutoCommit`] variants that
-/// carry an interval. See [`init()`](Self::init).
-///
-/// # Shutting down
-///
-/// Call [`shutdown()`](Self::shutdown) once done consuming. It drains the commit tasks, commits
-/// the reading position of every partition unless [`auto_commit()`] is [`AutoCommit::Disabled`],
-/// leaves the consumer group and stops the background tasks. Dropping an `IggyConsumer` instead
-/// skips the final commit and the group leave, so the server reassigns the member's partitions
-/// only once the connection is gone. Commits already queued are still sent and the background
-/// tasks still stop.
-///
-/// [`IggyClient`]: crate::prelude::IggyClient
-/// [`IggyClient::consumer()`]: crate::prelude::IggyClient::consumer
-/// [`IggyClient::consumer_group()`]: crate::prelude::IggyClient::consumer_group
-/// [`IggyProducer`]: crate::prelude::IggyProducer
-/// [`IggyConsumerBuilder`]: crate::prelude::IggyConsumerBuilder
-/// [`IggyConsumerMessageExt::consume_messages`]: crate::prelude::IggyConsumerMessageExt::consume_messages
-/// [`allow_replay()`]: crate::prelude::IggyConsumerBuilder::allow_replay
-/// [`auto_commit()`]: crate::prelude::IggyConsumerBuilder::auto_commit
-/// [`auto_join_consumer_group()`]: crate::prelude::IggyConsumerBuilder::auto_join_consumer_group
-/// [`batch_length()`]: crate::prelude::IggyConsumerBuilder::batch_length
-/// [`build()`]: crate::prelude::IggyConsumerBuilder::build
-/// [`create_consumer_group_if_not_exists()`]: crate::prelude::IggyConsumerBuilder::create_consumer_group_if_not_exists
-/// [`do_not_auto_join_consumer_group()`]: crate::prelude::IggyConsumerBuilder::do_not_auto_join_consumer_group
-/// [`do_not_create_consumer_group_if_not_exists()`]: crate::prelude::IggyConsumerBuilder::do_not_create_consumer_group_if_not_exists
-/// [`encryptor()`]: crate::prelude::IggyConsumerBuilder::encryptor
-/// [`init_retries()`]: crate::prelude::IggyConsumerBuilder::init_retries
-/// [`offset_drain_timeout()`]: crate::prelude::IggyConsumerBuilder::offset_drain_timeout
-/// [`partition()`]: crate::prelude::IggyConsumerBuilder::partition
-/// [`poll_interval()`]: crate::prelude::IggyConsumerBuilder::poll_interval
-/// [`polling_retry_interval()`]: crate::prelude::IggyConsumerBuilder::polling_retry_interval
-/// [`polling_strategy()`]: crate::prelude::IggyConsumerBuilder::polling_strategy
-/// [`stream()`]: crate::prelude::IggyConsumerBuilder::stream
-/// [`topic()`]: crate::prelude::IggyConsumerBuilder::topic
-/// [`without_encryptor()`]: crate::prelude::IggyConsumerBuilder::without_encryptor
-/// [`without_poll_interval()`]: crate::prelude::IggyConsumerBuilder::without_poll_interval
-///
-/// A server-side auto-commit poll can fail with `TooManyConsumerOffsets` when
-/// its consumer needs a new offset key at the partition's configured limit.
-/// The rejected poll returns no messages. Other auto-commit modes store the
-/// same key through a client request. Background and shutdown stores log a
-/// capacity failure but do not yield it through this stream. Only
-/// [`AutoCommit::Disabled`] avoids automatic key allocation. Existing keys
-/// remain usable.
+/// Encryption failures are returned without advancing the fetch position. Storing the
+/// offset of a message that never decrypts, which the log names with its partition,
+/// moves the fetch position past it. Polling
+/// auto-commit with an encryptor is rejected because the server would commit before
+/// decryption. Other auto-commit policies retain their explicit semantics, including
+/// interval commits of delivered messages and `AutoCommitAfter` commits after a
+/// handler returns either success or failure.
 pub struct IggyConsumer {
+    poll_options: DeferredPollOptions,
+    prefetch_bytes: u32,
+    prefetch_messages: u32,
+    deferred_polls: Option<deferred::DeferredPolls>,
+    fetch_generation: Arc<AtomicU64>,
+    fetch_notify: Arc<Notify>,
+    fetch_demand: Arc<AtomicBool>,
+    buffered_generation: u64,
+    buffered_assignment: Option<AssignmentVersion>,
+    /// The group state changes at which the buffered batch was still current.
+    checked_assignment: Option<u64>,
+    assignment_state: Option<(Arc<ConsumerGroupClientState>, String)>,
+    buffered_capacity: Option<deferred::FetchCapacity>,
     initialized: bool,
+    uninitialized_poll_warned: bool,
     shutdown: Arc<AtomicBool>,
     can_poll: Arc<AtomicBool>,
     client: IggyRwLock<ClientWrapper>,
@@ -753,17 +569,12 @@
     topic_id: Arc<Identifier>,
     partition_id: Option<u32>,
     polling_strategy: PollingStrategy,
-    /// The next offset to ask each partition for. Empty under [`PollingStrategy::next()`], which
-    /// leaves the continuation to the offset stored on the server.
-    next_offsets: Arc<DashMap<u32, ConsumerPosition>>,
-    poll_interval_micros: u64,
     batch_length: u32,
     auto_commit: AutoCommit,
     auto_commit_after_polling: bool,
     auto_join_consumer_group: bool,
     create_consumer_group_if_not_exists: bool,
     state: IggyConsumerState,
-    poll_future: Option<PollMessagesFuture>,
     buffered_messages: VecDeque<IggyMessage>,
     buffered_context: PartitionContext,
     buffered_current_offset: u64,
@@ -779,7 +590,6 @@
     store_offset_after_each_message: bool,
     store_offset_after_all_messages: bool,
     store_after_every_nth_message: u64,
-    last_polled_at: Arc<AtomicU64>,
     reconnection_retry_interval: NonZeroIggyDuration,
     init_retries: Option<u32>,
     init_retry_interval: NonZeroIggyDuration,
@@ -796,7 +606,9 @@
         stream_id: Identifier,
         topic_id: Identifier,
         partition_id: Option<u32>,
-        polling_interval: Option<IggyDuration>,
+        poll_options: DeferredPollOptions,
+        prefetch_bytes: u32,
+        prefetch_messages: u32,
         polling_strategy: PollingStrategy,
         batch_length: u32,
         auto_commit: AutoCommit,
@@ -830,7 +642,20 @@
             allow_replay,
         );
         Self {
+            poll_options,
+            prefetch_bytes,
+            prefetch_messages,
+            deferred_polls: None,
+            fetch_generation: Arc::new(AtomicU64::new(0)),
+            fetch_notify: Arc::new(Notify::new()),
+            fetch_demand: Arc::new(AtomicBool::new(false)),
+            buffered_generation: 0,
+            buffered_assignment: None,
+            checked_assignment: None,
+            assignment_state: None,
+            buffered_capacity: None,
             initialized: false,
+            uninitialized_poll_warned: false,
             shutdown: Arc::new(AtomicBool::new(false)),
             is_consumer_group,
             joined_consumer_group: Arc::new(AtomicBool::new(false)),
@@ -843,10 +668,7 @@
             topic_id,
             partition_id,
             polling_strategy,
-            next_offsets: Arc::new(DashMap::new()),
-            poll_interval_micros: polling_interval.map_or(0, |interval| interval.as_micros()),
             state,
-            poll_future: None,
             batch_length,
             auto_commit,
             auto_commit_after_polling: matches!(
@@ -883,7 +705,6 @@
                 }
                 _ => 0,
             },
-            last_polled_at: Arc::new(AtomicU64::new(0)),
             reconnection_retry_interval,
             init_retries,
             init_retry_interval,
@@ -896,6 +717,11 @@
         self.auto_commit
     }
 
+    /// Whether the consumer resumes after its stored offsets but never stores one itself.
+    pub(crate) fn relies_on_manual_commits(&self) -> bool {
+        self.auto_commit == AutoCommit::Disabled && self.polling_strategy.kind == PollingKind::Next
+    }
+
     /// Whether the client is up or comes back without the caller.
     pub(crate) async fn client_can_recover(&self) -> bool {
         self.client.read().await.can_recover().await
@@ -1016,11 +842,12 @@
     }
 
     /// Returns the offset this consumer last stored on the server for the given partition, or
-    /// `None` until this consumer has successfully stored an offset for that partition.
+    /// `None` until this consumer observes a successful commit for the partition.
     ///
     /// The value is this consumer's own record of what it committed, kept in memory rather than
     /// read back from the server.
-    /// Under auto-commit-on-poll (the default) this can trail the server by up to one batch.
+    /// With auto-commit-on-poll, the server can be ahead by the batch that is on its way to
+    /// this consumer.
     pub fn get_last_stored_offset(&self, partition_id: u32) -> Option<u64> {
         self.state.get_last_stored_offset(partition_id)
     }
@@ -1061,7 +888,7 @@
     ///
     /// When the consumer commits is decided by
     /// [`auto_commit()`](crate::prelude::IggyConsumerBuilder::auto_commit), see
-    /// [Tracking what has been read](IggyConsumer#tracking-what-has-been-read). `init()` spawns the
+    /// [Positions and commits](IggyConsumer#positions-and-commits). `init()` spawns the
     /// tasks behind it:
     /// - An interval task, only for the variants that carry an interval ([`AutoCommit::Interval`],
     ///   [`AutoCommit::IntervalOrWhen`], [`AutoCommit::IntervalOrAfter`]). Every tick it stores the
@@ -1073,9 +900,10 @@
     /// Both skip an offset that is not ahead of this consumer's own record of what it stored
     /// ([`get_last_stored_offset()`](Self::get_last_stored_offset)) in the same captured context.
     /// An earlier scalar store without a captured context cannot suppress these commits.
-    /// Under auto-commit-on-poll (the default) that record trails the server by one
-    /// batch, so every tick re-sends the reading position and the server, which takes an explicit
-    /// store as is, moves its offset back to it until the next poll.
+    /// Combined with auto-commit-on-poll, the interval task instead stores the delivered position
+    /// wherever it differs from that record. This moves the server offset back to the delivered
+    /// position while the rest of the fetched batch is already committed. A group member does this
+    /// only for the partitions of its current assignment.
     ///
     /// # Errors
     ///
@@ -1083,15 +911,37 @@
     ///   [`auto_commit()`](crate::prelude::IggyConsumerBuilder::auto_commit) is
     ///   [`AutoCommitWhen::PollingMessages`], checked before anything is sent. See
     ///   [Encryption](IggyConsumer#encryption).
+    /// - [`IggyError::InvalidConfiguration`] when the prefetch limits cannot hold one reply, and
+    ///   the error of [`DeferredPollOptions::validate`] when the poll options do not fit
+    ///   `batch_length`. Both are logged with the values that failed.
     /// - [`IggyError::StreamNameNotFound`], [`IggyError::StreamIdNotFound`] or [`IggyError::TopicNameNotFound`],
     ///   [`IggyError::TopicIdNotFound`] when the stream or the topic still does not exist once the retries are
     ///   exhausted.
     /// - [`IggyError::ConsumerGroupNameNotFound`] when the consumer group does not exist
     ///   and its auto creation is disabled.
+    /// - [`IggyError::FeatureUnavailable`] for a consumer group over HTTP, before the join.
     /// - Any error returned by the server while looking up the stream or the topic, or
     ///   while creating or joining the consumer group. Such an error ends initialization
     ///   immediately instead of consuming a retry.
     pub async fn init(&mut self) -> Result<(), IggyError> {
+        if let Err(error) = self.poll_options.validate(self.batch_length) {
+            error!(
+                "Consumer: {} has poll options: {:?} that do not fit batch length: {}. {error}",
+                self.consumer_name, self.poll_options, self.batch_length
+            );
+            return Err(error);
+        }
+        if self.prefetch_slots() == 0 {
+            error!(
+                "Consumer: {} cannot reserve one reply. Prefetch messages: {} must cover batch length: {}, and prefetch bytes: {} must cover poll max bytes: {}.",
+                self.consumer_name,
+                self.prefetch_messages,
+                self.batch_length,
+                self.prefetch_bytes,
+                self.poll_options.max_bytes
+            );
+            return Err(IggyError::InvalidConfiguration);
+        }
         if self.initialized {
             return Ok(());
         }
@@ -1191,7 +1041,20 @@
         if let Some(previous) = self.events_task.replace(self.subscribe_events().await) {
             previous.abort();
         }
+        // A transport without group state must fail before the join, or the
+        // member would hold partitions that it never reads.
+        let group_state = if self.is_consumer_group {
+            Some(self.client.read().await.deferred_group_state().await?)
+        } else {
+            None
+        };
         self.init_consumer_group().await?;
+        self.assignment_state = group_state.map(|state| {
+            (
+                state,
+                group_cache_key(&self.stream_id, &self.topic_id, &self.consumer.id),
+            )
+        });
 
         match self.auto_commit {
             AutoCommit::Interval(interval)
@@ -1205,6 +1068,7 @@
         self.store_offset_task = Some(self.store_pending_commits_in_background());
 
         self.initialized = true;
+        self.deferred_polls = Some(deferred::DeferredPolls::start(self));
         info!(
             "Consumer: {consumer_name} has been initialized for stream: {}, topic: {}.",
             self.stream_id, self.topic_id
@@ -1216,12 +1080,14 @@
         let state = self.state.clone();
         let shutdown = self.shutdown.clone();
         let notify = self.background_commit_notify.clone();
+        let commit_on_poll = self.auto_commit_after_polling;
+        let assignment = self.assignment_state.clone();
         tokio::spawn(async move {
             loop {
                 // Wait for the task until either the interval has passed or
                 // the task is explicitly notified, which happens when shutdown() is called.
                 tokio::select! {
-                    _ = sleep(interval.get_duration()) => {}
+                    _ = time::sleep(interval.get_duration()) => {}
                     _ = notify.notified() => {}
                 }
 
@@ -1232,6 +1098,10 @@
                     trace!("Shutdown signal received, stopping background offset storage");
                     break;
                 }
+                if commit_on_poll {
+                    state.store_delivered_offsets(assignment.as_ref()).await;
+                    continue;
+                }
                 for position in state.last_consumed_positions() {
                     state.store_in_background(position).await;
                 }
@@ -1322,6 +1192,8 @@
         let client = self.client.clone();
         let can_poll = self.can_poll.clone();
         let joined_consumer_group = self.joined_consumer_group.clone();
+        let generation = Arc::clone(&self.fetch_generation);
+        let notify = Arc::clone(&self.fetch_notify);
 
         tokio::spawn(async move {
             while let Some(event) = receiver.next().await {
@@ -1331,6 +1203,8 @@
                         warn!("Consumer has been shutdown");
                         joined_consumer_group.store(false, ORDERING);
                         can_poll.store(false, ORDERING);
+                        generation.fetch_add(1, ORDERING);
+                        notify.notify_one();
                         break;
                     }
                     DiagnosticEvent::Connected => {
@@ -1350,203 +1224,12 @@
                 // reports a later state. The state of the client decides instead.
                 let authenticated = client.read().await.is_authenticated().await;
                 can_poll.store(authenticated, ORDERING);
+                generation.fetch_add(1, ORDERING);
+                notify.notify_one();
             }
         })
     }
 
-    fn create_poll_messages_future(
-        &self,
-    ) -> impl Future<Output = Result<PolledMessages, IggyError>> + use<> {
-        let stream_id = self.stream_id.clone();
-        let topic_id = self.topic_id.clone();
-        let partition_id = self.partition_id;
-        let consumer = self.consumer.clone();
-        let polling_strategy = self.polling_strategy;
-        let next_offsets = self.next_offsets.clone();
-        let client = self.client.clone();
-        let count = self.batch_length;
-        let auto_commit_after_polling = self.auto_commit_after_polling;
-        let interval = self.poll_interval_micros;
-        let last_polled_at = self.last_polled_at.clone();
-        let can_poll = self.can_poll.clone();
-        let retry_interval = self.reconnection_retry_interval;
-        let last_consumed_offset = self.state.last_consumed_offsets.clone();
-        let allow_replay = self.allow_replay;
-        let is_consumer_group = self.is_consumer_group;
-        let auto_join_consumer_group = self.auto_join_consumer_group;
-        let create_consumer_group_if_not_exists = self.create_consumer_group_if_not_exists;
-        let joined_consumer_group = self.joined_consumer_group.clone();
-        let consumer_name = self.consumer_name.clone();
-        let uncertain_consumer_group_operation = self.uncertain_consumer_group_operation.clone();
-
-        async move {
-            if interval > 0 {
-                Self::wait_before_polling(interval, last_polled_at.load(ORDERING)).await;
-            }
-
-            while !can_poll.load(ORDERING) {
-                // Polling of a client that only the caller brings back never resumes, so the
-                // poll goes on and fails with the reason instead of parking for good.
-                if !client.read().await.can_recover().await {
-                    break;
-                }
-                trace!("Cannot poll yet, waiting {retry_interval}...");
-                sleep(retry_interval.get_duration()).await;
-            }
-
-            // A member that joins on its own is in the group before it polls. One built with
-            // `do_not_auto_join_consumer_group()` polls right away and gets a missing
-            // membership reported as a poll error.
-            if is_consumer_group
-                && auto_join_consumer_group
-                && !joined_consumer_group.load(ORDERING)
-                && let Err(error) = Self::initialize_consumer_group(
-                    client.clone(),
-                    create_consumer_group_if_not_exists,
-                    stream_id.clone(),
-                    topic_id.clone(),
-                    &consumer_name,
-                    joined_consumer_group.clone(),
-                    uncertain_consumer_group_operation,
-                )
-                .await
-            {
-                error!(
-                    "Failed to join consumer group: {consumer_name} for stream: {stream_id}, topic: {topic_id}. {error}"
-                );
-                sleep(retry_interval.get_duration()).await;
-                return Err(error);
-            }
-
-            trace!("Sending poll messages request");
-            last_polled_at.store(IggyTimestamp::now().into(), ORDERING);
-            // A refusal does not name its partition, and it always answers the last request.
-            let polled_partition = AtomicU32::new(NO_ASSIGNED_PARTITION);
-            // The map guard is dropped inside `map_or`, and the only writer is `poll_next`,
-            // which runs after this future has returned, so the lookup cannot block.
-            let strategy_for = |partition: u32| {
-                polled_partition.store(partition, ORDERING);
-                next_offsets
-                    .get(&partition)
-                    .map_or(polling_strategy, |position| {
-                        PollingStrategy::offset(position.offset).with_context(position.context)
-                    })
-            };
-            let polled_messages = client
-                .read()
-                .await
-                .poll_messages_with_strategy_for(
-                    &stream_id,
-                    &topic_id,
-                    partition_id,
-                    &consumer,
-                    &strategy_for,
-                    count,
-                    auto_commit_after_polling,
-                )
-                .await;
-
-            if let Ok(polled) = &polled_messages
-                && polled.partition_id == NO_ASSIGNED_PARTITION
-            {
-                next_offsets.clear();
-                trace!(
-                    "No partition assigned to consumer: {consumer_name}, waiting {retry_interval}..."
-                );
-                sleep(retry_interval.get_duration()).await;
-            }
-
-            if let Ok(mut polled_messages) = polled_messages {
-                if polled_messages.messages.is_empty() {
-                    return Ok(polled_messages);
-                }
-
-                let partition_id = polled_messages.partition_id;
-                let consumed = last_consumed_offset
-                    .get(&partition_id)
-                    .filter(|position| {
-                        same_incarnation_and_owner(position.context, polled_messages.context)
-                    })
-                    .map(|position| position.offset);
-                if !allow_replay && let Some(consumed) = consumed {
-                    polled_messages
-                        .messages
-                        .retain(|message| message.header.offset > consumed);
-                    polled_messages.count = polled_messages.messages.len() as u32;
-                }
-
-                trace!(
-                    "Last consumed offset: {consumed:?}, current offset: {}, in partition ID: {partition_id}, topic: {topic_id}, stream: {stream_id}, consumer: {consumer}",
-                    polled_messages.current_offset
-                );
-                return Ok(polled_messages);
-            }
-
-            let error = polled_messages.unwrap_err();
-            if matches!(
-                error,
-                IggyError::HistoryUnavailable | IggyError::ConsumerGroupPartitionNotOwned(..)
-            ) {
-                next_offsets.remove(&polled_partition.load(ORDERING));
-            }
-            error!("Failed to poll messages: {error}");
-
-            if is_consumer_group
-                && auto_join_consumer_group
-                && matches!(&error, IggyError::ConsumerGroupMemberNotFound(..))
-            {
-                info!(
-                    "Consumer group membership was revoked for consumer: {consumer_name}, stream: {stream_id}, topic: {topic_id}. Rejoining on the next poll..."
-                );
-                joined_consumer_group.store(false, ORDERING);
-                return Ok(PolledMessages::empty());
-            }
-
-            // A member joins again after these errors. The join keeps the
-            // membership of a session that survived.
-            if is_consumer_group
-                && matches!(
-                    error,
-                    IggyError::Disconnected | IggyError::Unauthenticated | IggyError::StaleClient
-                )
-            {
-                joined_consumer_group.store(false, ORDERING);
-            }
-            // Unauthenticated waits too: only a lifecycle event pauses polling, and a sign-out that
-            // this consumer missed sends none, so an error yielded at once lets a caller spin.
-            trace!("Retrying to poll messages in {retry_interval}...");
-            sleep(retry_interval.get_duration()).await;
-            Err(error)
-        }
-    }
-
-    async fn wait_before_polling(interval: u64, last_sent_at: u64) {
-        if interval == 0 {
-            return;
-        }
-
-        let now: u64 = IggyTimestamp::now().into();
-        if now < last_sent_at {
-            warn!(
-                "Returned monotonic time went backwards, now < last_sent_at: ({now} < {last_sent_at})"
-            );
-            sleep(Duration::from_micros(interval)).await;
-            return;
-        }
-
-        let elapsed = now - last_sent_at;
-        if elapsed >= interval {
-            trace!("No need to wait before polling messages. {now} - {last_sent_at} = {elapsed}");
-            return;
-        }
-
-        let remaining = interval - elapsed;
-        trace!(
-            "Waiting for {remaining} microseconds before polling messages... {interval} - {elapsed} = {remaining}"
-        );
-        sleep(Duration::from_micros(remaining)).await;
-    }
-
     async fn initialize_consumer_group(
         client: IggyRwLock<ClientWrapper>,
         create_consumer_group_if_not_exists: bool,
@@ -1640,9 +1323,38 @@
         );
         Ok(())
     }
+
+    fn prefetch_slots(&self) -> usize {
+        if self.batch_length == 0 || self.poll_options.max_bytes == 0 {
+            return 0;
+        }
+        (self.prefetch_messages / self.batch_length)
+            .min(self.prefetch_bytes / self.poll_options.max_bytes)
+            .min(crate::driver::MAX_DEFERRED_REQUESTS_PER_CONSUMER as u32) as usize
+    }
+
+    /// Locks the group state only after it changed since the buffered batch
+    /// was last found current.
+    fn buffered_assignment_is_current(&mut self) -> bool {
+        let Some((state, key)) = &self.assignment_state else {
+            return true;
+        };
+        let changes = state.assignment_changes();
+        if self.checked_assignment == Some(changes) {
+            return true;
+        }
+        let current = self.buffered_assignment.is_some_and(|assignment| {
+            state.is_current_assignment(key, assignment, self.state.partition_id())
+        });
+        if current {
+            self.checked_assignment = Some(changes);
+        }
+        current
+    }
 }
 
 /// A single message handed over by an [`IggyConsumer`].
+#[derive(Debug)]
 pub struct ReceivedMessage {
     /// Partition incarnation and owner captured by the poll. Retain it for delayed manual commits.
     pub context: PartitionContext,
@@ -1696,7 +1408,8 @@
 
 /// Yields messages one at a time, from the buffer first and from a fresh poll once it is empty.
 ///
-/// See [How messages are read](IggyConsumer#how-messages-are-read) for errors, `None` and polling.
+/// See [`IggyConsumer`] for how a poll waits, and [Positions and commits](IggyConsumer#positions-and-commits)
+/// for where reading continues after an error.
 impl Stream for IggyConsumer {
     type Item = Result<ReceivedMessage, IggyError>;
 
@@ -1704,22 +1417,55 @@
         if self.shutdown.load(ORDERING) {
             return Poll::Ready(None);
         }
-
-        let partition_id = self.state.partition_id();
-        if let Some(message) = self.buffered_messages.pop_front() {
+        if !self.initialized {
+            // A loop that continues on errors would otherwise spin without a trace.
+            if !self.uninitialized_poll_warned {
+                self.uninitialized_poll_warned = true;
+                warn!(
+                    "Consumer: {} was polled before init(). Every poll fails until init() runs.",
+                    self.consumer_name
+                );
+            }
+            return Poll::Ready(Some(Err(IggyError::InvalidConfiguration)));
+        }
+        loop {
+            // The server committed a batch of a committing fetch, so it stays.
+            if !self.auto_commit_after_polling
+                && !self.buffered_messages.is_empty()
+                && (self.buffered_generation != self.fetch_generation.load(ORDERING)
+                    || !self.buffered_assignment_is_current())
             {
+                self.buffered_messages.clear();
+                self.buffered_capacity = None;
+            }
+            let partition_id = self.state.partition_id();
+            while let Some(message) = self.buffered_messages.pop_front() {
+                let offset = message.header.offset;
+                if !self.allow_replay
+                    && self
+                        .state
+                        .last_consumed_offsets
+                        .get(&partition_id)
+                        .is_some_and(|consumed| {
+                            same_incarnation_and_owner(consumed.context, self.buffered_context)
+                                && offset <= consumed.offset
+                        })
+                {
+                    continue;
+                }
                 self.state.last_consumed_offsets.insert(
                     partition_id,
                     ConsumerPosition {
                         partition_id,
-                        offset: message.header.offset,
+                        offset,
                         context: self.buffered_context,
                     },
                 );
-
-                if (self.store_after_every_nth_message > 0
-                    && message.header.offset % self.store_after_every_nth_message == 0)
-                    || self.store_offset_after_each_message
+                let last = self.buffered_messages.is_empty();
+                if self.store_offset_after_each_message
+                    || (self.store_after_every_nth_message > 0
+                        && offset % self.store_after_every_nth_message == 0)
+                    || (self.store_offset_after_all_messages && last)
                 {
                     self.send_store_offset(ConsumerPosition {
                         partition_id,
@@ -1727,158 +1473,75 @@
                         context: self.buffered_context,
                     });
                 }
-            }
-
-            // Popping above may have left the buffer empty, so the next turn polls the server.
-            // `polling_strategy` is only where reading a partition starts; from then on each
-            // poll continues after the last message handed over from that partition.
-            if self.buffered_messages.is_empty() {
-                if self.polling_strategy.kind != PollingKind::Next {
-                    self.next_offsets.insert(
-                        partition_id,
-                        ConsumerPosition {
-                            partition_id,
-                            offset: message.header.offset + 1,
-                            context: self.buffered_context,
-                        },
-                    );
+                if last {
+                    self.buffered_capacity = None;
                 }
-
-                if self.store_offset_after_all_messages {
-                    self.send_store_offset(ConsumerPosition {
-                        partition_id,
-                        offset: message.header.offset,
-                        context: self.buffered_context,
-                    });
-                }
+                return Poll::Ready(Some(Ok(ReceivedMessage::new(
+                    message,
+                    self.buffered_current_offset,
+                    partition_id,
+                    self.buffered_context,
+                ))));
             }
-
-            // Not the position of this message but the newest offset the partition had when the
-            // batch was polled. So every message of a batch reports the same value.
-            return Poll::Ready(Some(Ok(ReceivedMessage::new(
-                message,
-                self.buffered_current_offset,
-                partition_id,
-                self.buffered_context,
-            ))));
-        }
-
-        // A used (and therefore invalid) future was dropped, thus create a fresh one.
-        if self.poll_future.is_none() {
-            let future = self.create_poll_messages_future();
-            self.poll_future = Some(Box::pin(future));
-        }
-
-        while let Some(future) = self.poll_future.as_mut() {
-            match future.poll_unpin(cx) {
-                Poll::Ready(Ok(polled_messages)) => {
-                    let PolledMessages {
-                        partition_id,
-                        current_offset,
-                        context,
-                        messages,
-                        ..
-                    } = polled_messages;
-                    let mut messages = VecDeque::from(messages);
-                    let Some(mut first) = messages.pop_front() else {
-                        self.poll_future = Some(Box::pin(self.create_poll_messages_future()));
+            self.buffered_capacity = None;
+            if self.deferred_polls.is_none() {
+                self.deferred_polls = Some(deferred::DeferredPolls::start(&self));
+            }
+            let received = self
+                .deferred_polls
+                .as_mut()
+                .expect("fetch worker initialized")
+                .poll_recv(cx);
+            match received {
+                Poll::Pending => {
+                    if self.auto_commit_after_polling && !self.fetch_demand.swap(true, ORDERING) {
+                        self.fetch_notify.notify_one();
+                    }
+                    return Poll::Pending;
+                }
+                Poll::Ready(None) => {
+                    self.deferred_polls = None;
+                    return Poll::Ready(Some(Err(IggyError::ClientShutdown)));
+                }
+                Poll::Ready(Some(batch)) => {
+                    if batch.generation != self.fetch_generation.load(ORDERING)
+                        && (batch.result.is_err() || !self.auto_commit_after_polling)
+                    {
                         continue;
-                    };
-
-                    // Only a response that carries messages names a partition; an empty one can
-                    // carry a sentinel instead of a real id.
-                    self.state
-                        .current_partition_id
-                        .store(partition_id, ORDERING);
-
-                    if let Some(ref encryptor) = self.encryptor {
-                        for message in std::iter::once(&mut first).chain(messages.iter_mut()) {
-                            let offset = message.header.offset;
-                            let payload = encryptor.decrypt(&message.payload);
-                            if let Err(error) = payload {
-                                self.poll_future = None;
-                                error!(
-                                    "Failed to decrypt the message payload at offset: {offset}, partition ID: {partition_id}",
+                    }
+                    match batch.result {
+                        Err(error) => return Poll::Ready(Some(Err(error))),
+                        Ok(polled) => {
+                            if polled.messages.is_empty() {
+                                continue;
+                            }
+                            self.state
+                                .current_partition_id
+                                .store(polled.partition_id, ORDERING);
+                            if self.auto_commit_after_polling
+                                && let Some(last) = polled.messages.last()
+                            {
+                                self.state.last_stored_offsets.insert(
+                                    polled.partition_id,
+                                    ConsumerPosition {
+                                        partition_id: polled.partition_id,
+                                        offset: last.header.offset,
+                                        context: polled.context,
+                                    },
                                 );
-                                return Poll::Ready(Some(Err(error)));
                             }
-
-                            let payload = payload.unwrap();
-                            message.payload = Bytes::from(payload);
-                            message.header.payload_length = message.payload.len() as u32;
-
-                            if let Some(ref user_headers) = message.user_headers {
-                                let decrypted_headers = encryptor.decrypt(user_headers);
-                                if let Err(error) = decrypted_headers {
-                                    self.poll_future = None;
-                                    error!(
-                                        "Failed to decrypt the message user headers at offset: {offset}, partition ID: {partition_id}",
-                                    );
-                                    return Poll::Ready(Some(Err(error)));
-                                }
-                                let decrypted_headers = decrypted_headers.unwrap();
-                                message.header.user_headers_length = decrypted_headers.len() as u32;
-                                message.user_headers = Some(Bytes::from(decrypted_headers));
-                            }
+                            self.buffered_context = polled.context;
+                            self.buffered_current_offset = polled.current_offset;
+                            self.buffered_messages = polled.messages.into();
+                            self.buffered_generation = batch.generation;
+                            self.buffered_assignment = batch.assignment;
+                            self.checked_assignment = None;
+                            self.buffered_capacity = Some(batch.capacity);
                         }
                     }
-
-                    // A poll is only sent once the buffer has run empty, so nothing is overwritten.
-                    self.buffered_messages = messages;
-                    self.buffered_context = context;
-                    self.buffered_current_offset = current_offset;
-
-                    if self.polling_strategy.kind != PollingKind::Next {
-                        self.next_offsets.insert(
-                            partition_id,
-                            ConsumerPosition {
-                                partition_id,
-                                offset: first.header.offset + 1,
-                                context,
-                            },
-                        );
-                    }
-
-                    self.state.last_consumed_offsets.insert(
-                        partition_id,
-                        ConsumerPosition {
-                            partition_id,
-                            offset: first.header.offset,
-                            context,
-                        },
-                    );
-
-                    if (self.store_after_every_nth_message > 0
-                        && first.header.offset % self.store_after_every_nth_message == 0)
-                        || self.store_offset_after_each_message
-                        || (self.store_offset_after_all_messages
-                            && self.buffered_messages.is_empty())
-                    {
-                        self.send_store_offset(ConsumerPosition {
-                            partition_id,
-                            offset: first.header.offset,
-                            context,
-                        });
-                    }
-
-                    // Drop future since it is [invalid after being ready](https://doc.rust-lang.org/std/future/trait.Future.html#panics)
-                    self.poll_future = None;
-                    return Poll::Ready(Some(Ok(ReceivedMessage::new(
-                        first,
-                        current_offset,
-                        partition_id,
-                        context,
-                    ))));
                 }
-                Poll::Ready(Err(err)) => {
-                    self.poll_future = None;
-                    return Poll::Ready(Some(Err(err)));
-                }
-                Poll::Pending => return Poll::Pending,
             }
         }
-
-        Poll::Pending
     }
 }
 
@@ -1889,10 +1552,11 @@
     /// - finish storing the offsets that are currently in-flight.
     ///   The interval task and the offset store task (see [`init()`](Self::init)) can both have
     ///   commits in flight. The consumer waits for `offset_drain_timeout` on each in turn before
-    ///   forcing it to abort.
+    ///   forcing it to abort. Under auto-commit-on-poll, a fetch in flight first gets the same
+    ///   time to hand its batch over. The store below then moves the offset back over it.
     /// - commit the reading position of every partition where it is ahead of this consumer's own
     ///   record of what it stored, unless [`auto_commit()`] is [`AutoCommit::Disabled`]. Under
-    ///   auto-commit-on-poll (the default), the poll admitted progress for the whole batch. This
+    ///   auto-commit-on-poll, the poll admitted progress for the whole batch. This
     ///   store requests the last message handed over, so the next run resumes after it when the
     ///   store succeeds. On binary transports, final stores and any blocker recovery share one
     ///   operation deadline. HTTP retains its configured request timeout.
@@ -1913,6 +1577,16 @@
             return Ok(());
         }
 
+        let grace = self
+            .auto_commit_after_polling
+            .then(|| self.offset_drain_timeout.get_duration());
+        let undelivered = match self.deferred_polls.take() {
+            Some(polls) => polls.stop(grace).await,
+            None => Vec::new(),
+        };
+        self.buffered_messages.clear();
+        self.buffered_capacity = None;
+
         info!("Shutting down consumer: {}...", self.consumer_name);
 
         // Drain the background commit tasks while still a group member, before
@@ -1949,10 +1623,32 @@
             );
         }
 
+        if self.relies_on_manual_commits()
+            && self.state.last_stored_offsets.is_empty()
+            && !self.state.last_consumed_offsets.is_empty()
+        {
+            warn!(
+                "Consumer: {} delivered messages but stored no offset, so the next run reads them again. Store offsets after processing, or enable auto-commit.",
+                self.consumer_name
+            );
+        }
+
         if self.auto_commit != AutoCommit::Disabled {
             // Replay governs reading only: this flush must never move a stored offset back.
             let allow_replay = false;
             with_deadline(Instant::now() + OPERATION_TIMEOUT, async {
+                if self.auto_commit_after_polling {
+                    let assignment = self.assignment_state.as_ref();
+                    self.state
+                        .rewind_undelivered_offsets(
+                            undelivered,
+                            assignment,
+                            self.polling_strategy.kind == PollingKind::Next,
+                        )
+                        .await;
+                    self.state.store_delivered_offsets(assignment).await;
+                    return;
+                }
                 for position in self.state.last_consumed_positions() {
                     if let Err(error) = self
                         .state
@@ -2001,8 +1697,7 @@
 }
 
 /// Stops the background tasks. Commits already queued still go out, nothing else is flushed and
-/// the consumer group is not left. Await [`IggyConsumer::shutdown`] first, see
-/// [Shutting down](IggyConsumer#shutting-down).
+/// the consumer group is not left. Await [`IggyConsumer::shutdown`] first.
 impl Drop for IggyConsumer {
     fn drop(&mut self) {
         self.shutdown.store(true, ORDERING);
@@ -2018,6 +1713,8 @@
     }
 }
 
+pub(crate) mod deferred;
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -2032,14 +1729,13 @@
     use iggy_binary_protocol::batch::{BATCH_HEADER_SIZE, BATCH_MESSAGE_HEADER_SIZE, BatchHeader};
     use iggy_binary_protocol::codes::{
         CREATE_CONSUMER_GROUP_CODE, DELETE_CONSUMER_OFFSET_CODE, GET_CONSUMER_GROUP_CODE,
-        JOIN_CONSUMER_GROUP_CODE, LOGIN_REGISTER_CODE, POLL_MESSAGES_CODE,
+        JOIN_CONSUMER_GROUP_CODE, LOGIN_REGISTER_CODE, POLL_MESSAGES_DEFERRED_CODE,
         STORE_CONSUMER_OFFSET_CODE, SYNC_CONSUMER_GROUP_CODE,
     };
     use iggy_binary_protocol::consensus::EvictionReason;
     use iggy_binary_protocol::requests::consumer_offsets::{
         DeleteConsumerOffsetRequest, StoreConsumerOffsetRequest,
     };
-    use iggy_binary_protocol::requests::messages::PollMessagesRequest;
     use iggy_binary_protocol::responses::consumer_groups::{
         ConsumerGroupDetailsResponse, ConsumerGroupResponse, SyncConsumerGroupResponse,
     };
@@ -2054,6 +1750,8 @@
     use std::iter::repeat_n;
     use std::str::FromStr;
     use std::task::Waker;
+    use std::time::Duration;
+    use tokio::time::sleep;
     use tokio::time::timeout;
     use tracing::field::Field;
     use tracing::span::{Attributes, Id, Record};
@@ -2070,7 +1768,8 @@
         }
     }
 
-    /// Records the offset of each message and stops the consumption after the first one.
+    /// Records the offset of each message. With a `shutdown` sender, it stops the consumption
+    /// after the first one.
     struct RecordMessages {
         offsets: std::sync::Mutex<Vec<u64>>,
         shutdown: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
@@ -2093,7 +1792,11 @@
     async fn given_a_blocked_poll_when_consuming_callbacks_should_return_the_retained_operation() {
         const OPERATION: u64 = 42;
         let mut consumer = builder().auto_commit(AutoCommit::Disabled).build();
-        consumer.poll_future = Some(Box::pin(async { Err(IggyError::LaneBlocked(OPERATION)) }));
+        consumer.initialized = true;
+        consumer.deferred_polls = Some(deferred::DeferredPolls::with_error(
+            &consumer,
+            IggyError::LaneBlocked(OPERATION),
+        ));
         let (_shutdown, receiver) = tokio::sync::oneshot::channel();
 
         let result = timeout(
@@ -2105,7 +1808,7 @@
         assert_eq!(result, Ok(Err(IggyError::LaneBlocked(OPERATION))));
     }
 
-    fn builder_for(consumer: Consumer) -> IggyConsumerBuilder {
+    pub(super) fn builder_for(consumer: Consumer) -> IggyConsumerBuilder {
         builder_on(TcpClient::default(), consumer)
     }
 
@@ -2118,7 +1821,6 @@
             Identifier::numeric(1).unwrap(),
             None,
             None,
-            None,
         )
     }
 
@@ -2132,6 +1834,7 @@
             .batch_length(1)
             .auto_commit(AutoCommit::Disabled)
             .build();
+        consumer.initialized = true;
         consumer.buffered_messages.extend([
             IggyMessage::from_str("a").unwrap(),
             IggyMessage::from_str("b").unwrap(),
@@ -2145,7 +1848,7 @@
 
         consumer.shutdown().await.unwrap();
 
-        assert_eq!(consumer.buffered_messages.len(), 1);
+        assert!(consumer.buffered_messages.is_empty());
         assert!(matches!(
             Pin::new(&mut consumer).poll_next(&mut context),
             Poll::Ready(None)
@@ -2165,7 +1868,7 @@
     }
 
     #[tokio::test]
-    async fn consumer_group_should_not_create_poll_future_after_shutdown() {
+    async fn consumer_group_should_not_create_fetch_worker_after_shutdown() {
         let mut consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
             .auto_commit(AutoCommit::Disabled)
             .build();
@@ -2177,7 +1880,7 @@
             Pin::new(&mut consumer).poll_next(&mut context),
             Poll::Ready(None)
         ));
-        assert!(consumer.poll_future.is_none());
+        assert!(consumer.deferred_polls.is_none());
     }
 
     #[test]
@@ -2196,7 +1899,7 @@
         assert_eq!(consumer.partition_id, Some(1));
     }
 
-    fn message_at(offset: u64) -> IggyMessage {
+    pub(super) fn message_at(offset: u64) -> IggyMessage {
         let mut message = IggyMessage::from_str("payload").unwrap();
         message.header.offset = offset;
         message
@@ -2204,6 +1907,7 @@
 
     /// Hands over `messages` as one buffered batch read from `partition_id`.
     fn hand_over_batch(consumer: &mut IggyConsumer, partition_id: u32, messages: Vec<IggyMessage>) {
+        consumer.initialized = true;
         consumer
             .state
             .current_partition_id
@@ -2218,38 +1922,176 @@
         }
     }
 
-    fn next_offset(consumer: &IggyConsumer, partition_id: u32) -> Option<u64> {
-        consumer
-            .next_offsets
-            .get(&partition_id)
-            .map(|position| position.offset)
+    #[tokio::test]
+    async fn group_member_should_drop_the_rest_of_its_batch_once_the_assignment_changes() {
+        let mut consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
+            .auto_commit(AutoCommit::Disabled)
+            .build();
+        let state = Arc::new(ConsumerGroupClientState::new());
+        let key = "1|1|1".to_owned();
+        let identifier = Identifier::numeric(1).unwrap();
+        state.register_group(
+            key.clone(),
+            identifier.clone(),
+            identifier.clone(),
+            identifier,
+        );
+        state.set_assignment(key.clone(), 1, vec![3]);
+        consumer.assignment_state = Some((Arc::clone(&state), key.clone()));
+        consumer.buffered_assignment = Some(AssignmentVersion {
+            session: state.session_generation(),
+            generation: 1,
+        });
+        consumer.initialized = true;
+        consumer.state.current_partition_id.store(3, ORDERING);
+        consumer.buffered_messages =
+            VecDeque::from(vec![message_at(10), message_at(11), message_at(12)]);
+        let mut context = Context::from_waker(Waker::noop());
+        for _ in 0..2 {
+            assert!(matches!(
+                Pin::new(&mut consumer).poll_next(&mut context),
+                Poll::Ready(Some(Ok(_)))
+            ));
+        }
+
+        state.set_assignment(key, 2, vec![3]);
+        assert!(Pin::new(&mut consumer).poll_next(&mut context).is_pending());
+        assert!(consumer.buffered_messages.is_empty());
+        assert_eq!(consumer.get_last_consumed_offset(3), Some(11));
+    }
+
+    #[tokio::test]
+    async fn given_a_committed_batch_when_the_generation_or_assignment_changes_should_hand_over_the_rest()
+     {
+        let mut consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
+            .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
+            .build();
+        let state = Arc::new(ConsumerGroupClientState::new());
+        let key = "1|1|1".to_owned();
+        let identifier = Identifier::numeric(1).unwrap();
+        state.register_group(
+            key.clone(),
+            identifier.clone(),
+            identifier.clone(),
+            identifier,
+        );
+        state.set_assignment(key.clone(), 1, vec![3]);
+        consumer.assignment_state = Some((Arc::clone(&state), key.clone()));
+        consumer.buffered_assignment = Some(AssignmentVersion {
+            session: state.session_generation(),
+            generation: 1,
+        });
+        consumer.initialized = true;
+        consumer.state.current_partition_id.store(3, ORDERING);
+        consumer.buffered_messages =
+            VecDeque::from(vec![message_at(10), message_at(11), message_at(12)]);
+        assert_eq!(poll_offset(&mut consumer), Poll::Ready(Some(Ok(10))));
+
+        consumer.fetch_generation.fetch_add(1, ORDERING);
+        assert_eq!(
+            poll_offset(&mut consumer),
+            Poll::Ready(Some(Ok(11))),
+            "the server committed the batch, so a new fetch generation keeps it"
+        );
+        state.set_assignment(key, 2, vec![3]);
+        assert_eq!(
+            poll_offset(&mut consumer),
+            Poll::Ready(Some(Ok(12))),
+            "the server committed the batch, so a new assignment keeps it"
+        );
     }
 
     #[test]
-    fn group_member_should_continue_each_partition_after_its_last_message() {
+    fn given_a_reread_up_to_the_delivered_offset_when_polled_should_hand_over_only_newer_messages()
+    {
+        const DELIVERED: u64 = 11;
+        let mut consumer = builder_for(Consumer::default())
+            .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
+            .build();
+        buffer_reread(&mut consumer, DELIVERED);
+
+        assert_eq!(
+            poll_offset(&mut consumer),
+            Poll::Ready(Some(Ok(DELIVERED + 1))),
+            "the application already took the messages up to {DELIVERED}"
+        );
+    }
+
+    #[test]
+    fn given_replay_when_a_reread_up_to_the_delivered_offset_is_polled_should_hand_it_over_again() {
+        const DELIVERED: u64 = 11;
+        let mut consumer = builder_for(Consumer::default())
+            .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
+            .allow_replay()
+            .build();
+        buffer_reread(&mut consumer, DELIVERED);
+
+        for offset in DELIVERED - 1..=DELIVERED + 1 {
+            assert_eq!(
+                poll_offset(&mut consumer),
+                Poll::Ready(Some(Ok(offset))),
+                "replay hands over every message that the batch read"
+            );
+        }
+    }
+
+    /// Buffers a batch that reads the messages around `delivered` again, in the
+    /// partition history in which the application took `delivered`.
+    fn buffer_reread(consumer: &mut IggyConsumer, delivered: u64) {
+        let position = position_at(delivered, 1);
+        consumer.initialized = true;
+        consumer
+            .state
+            .current_partition_id
+            .store(position.partition_id, ORDERING);
+        consumer
+            .state
+            .last_consumed_offsets
+            .insert(position.partition_id, position);
+        consumer.buffered_context = position.context;
+        consumer.buffered_messages = VecDeque::from(vec![
+            message_at(delivered - 1),
+            message_at(delivered),
+            message_at(delivered + 1),
+        ]);
+    }
+
+    /// The offset of the message that one poll of `consumer` hands over.
+    fn poll_offset(consumer: &mut IggyConsumer) -> Poll<Option<Result<u64, IggyError>>> {
+        Pin::new(consumer)
+            .poll_next(&mut Context::from_waker(Waker::noop()))
+            .map(|polled| {
+                polled.map(|received| received.map(|received| received.message.header.offset))
+            })
+    }
+
+    #[test]
+    fn group_member_tracks_delivered_positions_separately_per_partition() {
         let mut consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
             .polling_strategy(PollingStrategy::first())
             .auto_commit(AutoCommit::Disabled)
             .build();
 
         hand_over_batch(&mut consumer, 3, vec![message_at(10), message_at(11)]);
-        assert_eq!(next_offset(&consumer, 3), Some(12));
+        assert_eq!(consumer.get_last_consumed_offset(3), Some(11));
         assert_eq!(consumer.polling_strategy, PollingStrategy::first());
 
         hand_over_batch(&mut consumer, 4, vec![message_at(7)]);
-        assert_eq!(next_offset(&consumer, 4), Some(8));
-        assert_eq!(next_offset(&consumer, 3), Some(12));
+        assert_eq!(consumer.get_last_consumed_offset(4), Some(7));
+        assert_eq!(consumer.get_last_consumed_offset(3), Some(11));
     }
 
     #[test]
-    fn next_strategy_should_leave_the_continuation_to_the_server() {
+    fn default_consumer_does_not_commit_delivered_messages() {
         let mut consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
             .auto_commit(AutoCommit::Disabled)
             .build();
 
         hand_over_batch(&mut consumer, 3, vec![message_at(10), message_at(11)]);
 
-        assert!(consumer.next_offsets.is_empty());
+        assert_eq!(consumer.auto_commit, AutoCommit::Disabled);
+        assert!(consumer.pending_commits.is_empty());
+        assert_eq!(consumer.get_last_stored_offset(3), None);
     }
 
     struct Served(Arc<FakeCluster>);
@@ -2279,19 +2121,6 @@
                 })
                 .collect()
         }
-
-        fn polled_partitions(&self) -> Vec<u32> {
-            self.0
-                .received(POLL_MESSAGES_CODE)
-                .iter()
-                .map(|request| {
-                    PollMessagesRequest::decode_from(&request.body)
-                        .unwrap()
-                        .partition_id
-                        .unwrap()
-                })
-                .collect()
-        }
     }
 
     async fn connect_to_test_server(
@@ -2320,51 +2149,13 @@
         );
         cluster.script(
             &endpoint(0),
-            POLL_MESSAGES_CODE,
+            POLL_MESSAGES_DEFERRED_CODE,
             [Answer::Refuse(poll_refusal)],
         );
         let links = connected(&cluster, 1).await;
         (TcpClient::from_links(links), Served(cluster))
     }
 
-    #[tokio::test]
-    async fn group_member_should_drop_only_the_continuation_of_the_refused_partition() {
-        const REFUSED_PARTITION: u32 = 3;
-        const RETAINED_PARTITION: u32 = 4;
-        for refusal in [
-            IggyError::HistoryUnavailable,
-            IggyError::ConsumerGroupPartitionNotOwned(0, 0),
-        ] {
-            let (client, served) = connect_to_test_server(
-                vec![REFUSED_PARTITION, RETAINED_PARTITION],
-                refusal.clone(),
-            )
-            .await;
-            let mut consumer = builder_on(client, Consumer::group(Identifier::numeric(1).unwrap()))
-                .polling_strategy(PollingStrategy::first())
-                .do_not_auto_join_consumer_group()
-                .auto_commit(AutoCommit::Disabled)
-                .polling_retry_interval(NonZeroIggyDuration::new(POLL_RETRY_INTERVAL).unwrap())
-                .build();
-            hand_over_batch(&mut consumer, REFUSED_PARTITION, vec![message_at(10)]);
-            hand_over_batch(&mut consumer, RETAINED_PARTITION, vec![message_at(20)]);
-
-            let polled = timeout(POLL_TIMEOUT, consumer.next()).await.unwrap();
-
-            assert!(
-                matches!(&polled, Some(Err(error)) if *error == refusal),
-                "{refusal}"
-            );
-            assert_eq!(served.polled_partitions(), [REFUSED_PARTITION]);
-            assert_eq!(next_offset(&consumer, REFUSED_PARTITION), None, "{refusal}");
-            assert_eq!(
-                next_offset(&consumer, RETAINED_PARTITION),
-                Some(21),
-                "{refusal}: a refusal of another partition must keep this continuation"
-            );
-        }
-    }
-
     /// A position delivered at `offset` by a poll that saw the metadata frontier `metadata_op`.
     fn position_at(offset: u64, metadata_op: u64) -> ConsumerPosition {
         ConsumerPosition {
@@ -2396,6 +2187,7 @@
         let mut consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
             .partition(Some(1))
             .allow_replay()
+            .auto_commit(AutoCommit::When(AutoCommitWhen::ConsumingAllMessages))
             .build();
         let consumed = position_at(9, 1);
         let stored = position_at(19, 1);
@@ -2534,6 +2326,7 @@
         let (client, served) = connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
         let mut consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
             .partition(Some(1))
+            .auto_commit(AutoCommit::When(AutoCommitWhen::ConsumingAllMessages))
             .build();
         consumer.store_offset(STORED_OFFSET, Some(1)).await.unwrap();
         consumer
@@ -2827,8 +2620,9 @@
     async fn given_multiple_final_positions_when_shutdown_times_out_should_share_one_deadline() {
         let (client, served) =
             connect_to_test_server(vec![1, 2], IggyError::HistoryUnavailable).await;
-        let mut consumer =
-            builder_on(client, Consumer::new(Identifier::numeric(1).unwrap())).build();
+        let mut consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
+            .auto_commit(AutoCommit::When(AutoCommitWhen::ConsumingAllMessages))
+            .build();
         for partition_id in [1, 2] {
             consumer.state.last_consumed_offsets.insert(
                 partition_id,
@@ -2858,6 +2652,7 @@
         let (client, served) = connect_to_test_server(vec![1], IggyError::HistoryUnavailable).await;
         let mut consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
             .offset_drain_timeout(IggyDuration::new(POLL_RETRY_INTERVAL))
+            .auto_commit(AutoCommit::When(AutoCommitWhen::ConsumingAllMessages))
             .build();
         consumer
             .state
@@ -2886,14 +2681,17 @@
         assert_eq!(consumer.get_last_stored_offset(1), Some(LOWER_OFFSET));
     }
 
-    /// Records the error events emitted on the test thread. The current-thread runtime of
-    /// `#[tokio::test]` polls the tasks it spawns on that thread, so their events count too.
-    #[derive(Clone, Default)]
-    struct ErrorReports(Arc<std::sync::Mutex<Vec<String>>>);
+    /// Records the events of one level emitted on the test thread. The current-thread runtime
+    /// of `#[tokio::test]` polls the tasks it spawns on that thread, so their events count too.
+    #[derive(Clone)]
+    struct LevelReports {
+        level: Level,
+        events: Arc<std::sync::Mutex<Vec<String>>>,
+    }
 
-    impl Subscriber for ErrorReports {
+    impl Subscriber for LevelReports {
         fn enabled(&self, metadata: &Metadata<'_>) -> bool {
-            *metadata.level() == Level::ERROR
+            *metadata.level() == self.level
         }
 
         fn new_span(&self, _: &Attributes<'_>) -> Id {
@@ -2909,7 +2707,7 @@
             event.record(&mut |field: &Field, value: &dyn Debug| {
                 fields.push(format!("{field}: {value:?}"));
             });
-            self.0.lock().unwrap().push(fields.join(", "));
+            self.events.lock().unwrap().push(fields.join(", "));
         }
 
         fn enter(&self, _: &Id) {}
@@ -2917,12 +2715,26 @@
         fn exit(&self, _: &Id) {}
     }
 
-    impl ErrorReports {
+    impl LevelReports {
+        fn errors() -> Self {
+            Self {
+                level: Level::ERROR,
+                events: Arc::default(),
+            }
+        }
+
+        fn warnings() -> Self {
+            Self {
+                level: Level::WARN,
+                events: Arc::default(),
+            }
+        }
+
         /// Waits for the first report, or returns none after [`POLL_TIMEOUT`].
         async fn wait(&self) -> Vec<String> {
             let first = async {
                 loop {
-                    let reports = self.0.lock().unwrap().clone();
+                    let reports = self.events.lock().unwrap().clone();
                     if !reports.is_empty() {
                         return reports;
                     }
@@ -2935,7 +2747,7 @@
 
     #[tokio::test]
     async fn interval_commit_should_report_a_failed_store() {
-        let recorder = ErrorReports::default();
+        let recorder = LevelReports::errors();
         let _subscriber = tracing::subscriber::set_default(recorder.clone());
         let consumer = builder().partition(Some(1)).build();
         consumer
@@ -2962,7 +2774,7 @@
 
     #[tokio::test]
     async fn queued_commit_should_report_a_failed_store() {
-        let recorder = ErrorReports::default();
+        let recorder = LevelReports::errors();
         let _subscriber = tracing::subscriber::set_default(recorder.clone());
         let mut consumer = builder().partition(Some(1)).build();
         consumer.initialized = true;
@@ -2984,6 +2796,69 @@
         );
     }
 
+    #[test]
+    fn commit_on_poll_rewind_should_skip_revoked_and_already_stored_partitions() {
+        let consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap())).build();
+        for (partition_id, delivered, record) in [(1, 2, 9), (2, 5, 9), (3, 9, 9)] {
+            consumer.state.last_consumed_offsets.insert(
+                partition_id,
+                ConsumerPosition {
+                    partition_id,
+                    ..position_at(delivered, 1)
+                },
+            );
+            consumer.state.last_stored_offsets.insert(
+                partition_id,
+                ConsumerPosition {
+                    partition_id,
+                    ..position_at(record, 1)
+                },
+            );
+        }
+
+        assert_eq!(
+            consumer.state.unstored_delivered_offsets(Some(&[1, 3])),
+            [position_at(2, 1)]
+        );
+        let mut standalone = consumer.state.unstored_delivered_offsets(None);
+        standalone.sort_unstable_by_key(|position| position.partition_id);
+        assert_eq!(
+            standalone,
+            [
+                position_at(2, 1),
+                ConsumerPosition {
+                    partition_id: 2,
+                    ..position_at(5, 1)
+                }
+            ]
+        );
+    }
+
+    #[tokio::test]
+    async fn commit_on_poll_shutdown_should_rewind_over_a_queued_batch() {
+        let consumer = builder_for(Consumer::default()).build();
+        let delivered = position_at(4, 1);
+        consumer.state.last_consumed_offsets.insert(1, delivered);
+        consumer.state.last_stored_offsets.insert(1, delivered);
+        let queued = PolledMessages {
+            partition_id: 1,
+            context: delivered.context,
+            current_offset: 6,
+            count: 2,
+            messages: vec![message_at(5), message_at(6)],
+        };
+
+        consumer
+            .state
+            .rewind_undelivered_offsets(vec![queued], None, true)
+            .await;
+        assert_eq!(
+            consumer.state.unstored_delivered_offsets(None),
+            [delivered],
+            "the server committed offset 6, so the delivered offset 4 is stored again"
+        );
+    }
+
     /// Polls once as a group member on a client that is not connected. The outcome must be an
     /// error, never an endless wait for a join.
     async fn poll_once_as_group_member(
@@ -2992,6 +2867,7 @@
         let mut consumer = builder
             .polling_retry_interval(NonZeroIggyDuration::new(POLL_RETRY_INTERVAL).unwrap())
             .build();
+        consumer.initialized = true;
         timeout(POLL_TIMEOUT, consumer.next())
             .await
             .expect("a group member must poll or report an error instead of waiting for a join")
@@ -3054,6 +2930,20 @@
     }
 
     #[test]
+    fn default_prefetch_should_fit_a_batch_longer_than_the_default() {
+        assert_eq!(builder().batch_length(20_000).build().prefetch_slots(), 1);
+        assert_eq!(
+            builder()
+                .batch_length(20_000)
+                .prefetch_messages(16_000)
+                .build()
+                .prefetch_slots(),
+            0,
+            "an explicit limit stays as configured"
+        );
+    }
+
+    #[test]
     fn send_store_offset_should_keep_the_latest_offset_per_partition() {
         let mut consumer = builder().build();
         consumer.initialized = true;
@@ -3150,6 +3040,7 @@
             .auto_commit(AutoCommit::Disabled)
             .polling_retry_interval(NonZeroIggyDuration::new(POLL_RETRY_INTERVAL).unwrap())
             .build();
+        consumer.initialized = true;
         consumer.events_task = Some(consumer.subscribe_events().await);
         consumer
     }
@@ -3178,8 +3069,8 @@
         Answer::CommitBody(1, body)
     }
 
-    fn empty_poll() -> Answer {
-        poll_answer(0, 0, &[])
+    fn available_poll() -> Answer {
+        poll_of_one_message(0)
     }
 
     /// A poll reply with one empty message at `offset`. A zeroed frame is such a message, and the
@@ -3212,7 +3103,7 @@
     async fn given_an_uncertain_group_mutation_when_polling_again_should_resolve_and_join() {
         for mutation in [CREATE_CONSUMER_GROUP_CODE, JOIN_CONSUMER_GROUP_CODE] {
             let (client, cluster) = connect_to_three_replicas().await;
-            let consumer =
+            let mut consumer =
                 polling_consumer(client, Consumer::group(Identifier::numeric(1).unwrap())).await;
             let first_group = if mutation == CREATE_CONSUMER_GROUP_CODE {
                 Answer::Body(Vec::new())
@@ -3239,21 +3130,21 @@
             );
             cluster.script(
                 &endpoint(PARTITION_PRIMARY),
-                POLL_MESSAGES_CODE,
-                [empty_poll()],
+                POLL_MESSAGES_DEFERRED_CODE,
+                [available_poll()],
             );
 
-            let first = consumer.create_poll_messages_future().await;
+            let first = poll_once(&mut consumer).await;
             let op = cluster.received(mutation)[0].header.request;
             assert_eq!(first.unwrap_err(), IggyError::OperationUncertain(op));
 
-            let polled = consumer.create_poll_messages_future().await;
+            let polled = poll_once(&mut consumer).await;
             assert!(polled.is_ok(), "mutation {mutation}: {polled:?}");
 
             let requests = cluster.received(mutation);
             assert_eq!(requests[0].header.request, requests[1].header.request);
             assert_eq!(requests[0].body, requests[1].body);
-            assert_eq!(cluster.count(POLL_MESSAGES_CODE), 1);
+            assert!((1..=2).contains(&cluster.count(POLL_MESSAGES_DEFERRED_CODE)));
             assert!(consumer.joined_consumer_group.load(ORDERING));
             assert_eq!(
                 consumer
@@ -3376,37 +3267,51 @@
 
     /// Polls one poll after another, as the consumer stream does, until one
     /// succeeds.
-    async fn poll_until_success(consumer: &IggyConsumer) -> Option<PolledMessages> {
+    async fn poll_once(consumer: &mut IggyConsumer) -> Result<ReceivedMessage, IggyError> {
+        consumer.next().await.ok_or(IggyError::ClientShutdown)?
+    }
+
+    async fn poll_until_success(consumer: &mut IggyConsumer) -> Option<ReceivedMessage> {
+        let mut last_error = None;
         let polled = async {
             loop {
-                if let Ok(polled) = consumer.create_poll_messages_future().await {
-                    return polled;
+                match poll_once(consumer).await {
+                    Ok(polled) => return polled,
+                    Err(error) => last_error = Some(error),
                 }
             }
         };
-        timeout(TEST_BUDGET, polled).await.ok()
+        let result = timeout(TEST_BUDGET, polled).await.ok();
+        if result.is_none() {
+            eprintln!(
+                "last poll error: {last_error:?}; joined: {}, can_poll: {}",
+                consumer.joined_consumer_group.load(ORDERING),
+                consumer.can_poll.load(ORDERING)
+            );
+        }
+        result
     }
 
     #[tokio::test(start_paused = true)]
     async fn given_a_live_session_when_a_replica_refuses_a_poll_with_stale_client_should_poll_again_without_a_new_sign_in()
      {
         let (client, cluster) = connect_to_three_replicas().await;
-        let consumer =
+        let mut consumer =
             polling_consumer(client, Consumer::new(Identifier::numeric(1).unwrap())).await;
         cluster.script(
             &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_CODE,
-            [Answer::Detached, empty_poll()],
+            POLL_MESSAGES_DEFERRED_CODE,
+            [Answer::Detached, available_poll()],
         );
 
-        let refused = consumer.create_poll_messages_future().await;
+        let refused = poll_once(&mut consumer).await;
 
         assert!(
             matches!(refused, Err(IggyError::StaleClient)),
             "{refused:?}"
         );
-        assert!(poll_until_success(&consumer).await.is_some());
-        assert_eq!(cluster.count(POLL_MESSAGES_CODE), 2);
+        assert!(poll_until_success(&mut consumer).await.is_some());
+        assert!((2..=3).contains(&cluster.count(POLL_MESSAGES_DEFERRED_CODE)));
         assert_eq!(cluster.count(LOGIN_REGISTER_CODE), 1);
     }
 
@@ -3414,28 +3319,27 @@
     async fn given_a_lost_route_link_when_a_poll_outlasts_it_should_poll_again_once_the_link_is_back()
      {
         let (client, cluster) = connect_to_three_replicas().await;
-        let consumer =
+        let mut consumer =
             polling_consumer(client, Consumer::new(Identifier::numeric(1).unwrap())).await;
         let primary = endpoint(PARTITION_PRIMARY);
         cluster.down.lock().unwrap().insert(primary.clone());
         cluster.drop_connections(&primary);
 
-        let lost = consumer.create_poll_messages_future().await;
-
+        let lost = timeout(POLL_TIMEOUT, poll_once(&mut consumer)).await;
         assert!(
-            matches!(lost, Err(IggyError::TransientNotAccepted)),
-            "{lost:?}"
+            lost.is_err(),
+            "non-admission retries inside the consumer: {lost:?}"
         );
         cluster.down.lock().unwrap().clear();
-        cluster.script(&primary, POLL_MESSAGES_CODE, [empty_poll()]);
-        assert!(poll_until_success(&consumer).await.is_some());
+        cluster.script(&primary, POLL_MESSAGES_DEFERRED_CODE, [available_poll()]);
+        assert!(poll_until_success(&mut consumer).await.is_some());
         assert_eq!(cluster.count(LOGIN_REGISTER_CODE), 1);
     }
 
     #[tokio::test(start_paused = true)]
     async fn given_a_delayed_poll_error_when_sign_in_has_completed_should_keep_polling_enabled() {
         let (client, cluster) = connect_to_three_replicas().await;
-        let consumer =
+        let mut consumer =
             polling_consumer(client, Consumer::new(Identifier::numeric(1).unwrap())).await;
         *cluster.session.lock().unwrap() = None;
         cluster
@@ -3445,12 +3349,13 @@
             .insert("iggy".to_owned());
         cluster.script(
             &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_CODE,
-            [Answer::Evict(EvictionReason::NoSession), empty_poll()],
+            POLL_MESSAGES_DEFERRED_CODE,
+            [Answer::Evict(EvictionReason::NoSession), available_poll()],
         );
-        let mut poll = Box::pin(consumer.create_poll_messages_future());
+        let can_poll = Arc::clone(&consumer.can_poll);
+        let mut poll = Box::pin(poll_once(&mut consumer));
         timeout(TEST_BUDGET, async {
-            while cluster.count(POLL_MESSAGES_CODE) == 0 {
+            while cluster.count(POLL_MESSAGES_DEFERRED_CODE) == 0 {
                 assert!(
                     poll.as_mut()
                         .poll(&mut Context::from_waker(Waker::noop()))
@@ -3466,19 +3371,17 @@
         })
         .await;
         eventually("the consumer processes SignedOut", || {
-            !consumer.can_poll.load(ORDERING)
+            !can_poll.load(ORDERING)
         })
         .await;
         cluster.held_registers.lock().unwrap().clear();
         eventually("the consumer processes SignedIn", || {
-            consumer.can_poll.load(ORDERING)
+            can_poll.load(ORDERING)
         })
         .await;
 
-        assert!(matches!(poll.await, Err(IggyError::TransientNotCommitted)));
-
+        assert!(timeout(POLL_TIMEOUT, poll).await.unwrap().is_ok());
         assert!(consumer.can_poll.load(ORDERING));
-        assert!(poll_until_success(&consumer).await.is_some());
     }
 
     #[tokio::test(start_paused = true)]
@@ -3488,10 +3391,13 @@
         let (client, cluster) = connect_to_three_replicas().await;
         let mut consumer =
             polling_consumer(client, Consumer::new(Identifier::numeric(1).unwrap())).await;
-        consumer.poll_future = Some(Box::pin(async { Err(IggyError::Disconnected) }));
+        consumer.deferred_polls = Some(deferred::DeferredPolls::with_error(
+            &consumer,
+            IggyError::Disconnected,
+        ));
         cluster.script(
             &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_CODE,
+            POLL_MESSAGES_DEFERRED_CODE,
             [poll_of_one_message(OFFSET)],
         );
         let (shutdown, receiver) = tokio::sync::oneshot::channel();
@@ -3507,37 +3413,239 @@
     }
 
     #[tokio::test(start_paused = true)]
-    async fn given_a_signed_out_client_when_a_poll_fails_with_unauthenticated_should_wait_before_yielding_it()
+    async fn given_a_lost_commit_reply_when_shutdown_comes_before_the_retry_should_store_the_delivered_offset_again()
+     {
+        const DELIVERED: u64 = 4;
+        let (client, cluster) = connect_to_three_replicas().await;
+        let mut consumer = committing_consumer(client).await;
+        record_delivered(&consumer, DELIVERED);
+        cluster.script(
+            &endpoint(PARTITION_PRIMARY),
+            POLL_MESSAGES_DEFERRED_CODE,
+            [
+                Answer::Refuse(IggyError::HistoryUnavailable),
+                Answer::Refuse(IggyError::TransientNotCommitted),
+            ],
+        );
+
+        let refused = poll_once(&mut consumer).await;
+        assert!(
+            matches!(refused, Err(IggyError::HistoryUnavailable)),
+            "{refused:?}"
+        );
+        assert_eq!(
+            consumer.get_last_stored_offset(POLLED_PARTITION),
+            Some(DELIVERED),
+            "a refused poll commits nothing"
+        );
+        let lost = poll_once(&mut consumer).await;
+        assert!(
+            matches!(lost, Err(IggyError::TransientNotCommitted)),
+            "{lost:?}"
+        );
+        consumer.shutdown().await.unwrap();
+
+        assert_eq!(
+            Served(cluster).stored_offsets(),
+            [DELIVERED],
+            "the server can have committed the batch of the lost reply"
+        );
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_committing_fetch_in_flight_when_shutdown_starts_should_rewind_over_its_batch()
+    {
+        const FIRST: u64 = 5;
+        const REPLY_DELAY: Duration = Duration::from_millis(100);
+        let (client, cluster) = connect_to_three_replicas().await;
+        let mut consumer = committing_consumer(client).await;
+        cluster.hold(POLL_MESSAGES_DEFERRED_CODE);
+        cluster.script(
+            &endpoint(PARTITION_PRIMARY),
+            POLL_MESSAGES_DEFERRED_CODE,
+            [poll_of_one_message(FIRST)],
+        );
+        start_fetch(&mut consumer, &cluster).await;
+        let started = Instant::now();
+
+        let (shut_down, ()) = tokio::join!(consumer.shutdown(), async {
+            sleep(REPLY_DELAY).await;
+            cluster.release(POLL_MESSAGES_DEFERRED_CODE);
+        });
+
+        shut_down.unwrap();
+        assert!(
+            started.elapsed() < consumer.offset_drain_timeout.get_duration(),
+            "the stop ends with the fetch, not with the drain timeout"
+        );
+        assert_eq!(
+            Served(cluster).stored_offsets(),
+            [FIRST - 1],
+            "the server committed a batch that the application never got"
+        );
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_committing_fetch_that_outlasts_the_drain_timeout_when_shutdown_aborts_it_should_store_the_delivered_offset_again()
+     {
+        const DELIVERED: u64 = 4;
+        let (client, cluster) = connect_to_three_replicas().await;
+        let mut consumer = committing_consumer(client).await;
+        assert!(
+            consumer.offset_drain_timeout.get_duration()
+                < consumer.poll_options.request_timeout.get_duration(),
+            "the drain timeout must end before the poll does"
+        );
+        record_delivered(&consumer, DELIVERED);
+        cluster.script(
+            &endpoint(PARTITION_PRIMARY),
+            POLL_MESSAGES_DEFERRED_CODE,
+            [Answer::Silence],
+        );
+        start_fetch(&mut consumer, &cluster).await;
+
+        consumer.shutdown().await.unwrap();
+
+        assert_eq!(
+            Served(cluster).stored_offsets(),
+            [DELIVERED],
+            "the server can commit the batch of the aborted poll"
+        );
+    }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+    async fn given_a_shutdown_task_on_the_worker_thread_when_it_aborts_a_committing_fetch_should_store_after_the_fetch_drops()
+     {
+        const DELIVERED: u64 = 4;
+        const DRAIN_TIMEOUT: Duration = Duration::from_millis(50);
+        let (client, cluster) = connect_to_three_replicas().await;
+        let mut consumer = committing_consumer(client).await;
+        consumer.offset_drain_timeout = IggyDuration::from(DRAIN_TIMEOUT);
+        record_delivered(&consumer, DELIVERED);
+        cluster.script(
+            &endpoint(PARTITION_PRIMARY),
+            POLL_MESSAGES_DEFERRED_CODE,
+            [Answer::Silence],
+        );
+        start_fetch(&mut consumer, &cluster).await;
+
+        // The only worker thread runs the woken shutdown task before the
+        // aborted fetch task, which drops its future later.
+        let shut_down = tokio::spawn(async move { consumer.shutdown().await }).await;
+
+        assert!(matches!(shut_down, Ok(Ok(()))), "{shut_down:?}");
+        assert_eq!(
+            Served(cluster).stored_offsets(),
+            [DELIVERED],
+            "the server can commit the batch of the aborted poll"
+        );
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_committing_fetch_in_flight_when_the_generation_changes_should_hand_over_its_batch()
+     {
+        const FIRST: u64 = 5;
+        let (client, cluster) = connect_to_three_replicas().await;
+        let mut consumer = committing_consumer(client).await;
+        cluster.hold(POLL_MESSAGES_DEFERRED_CODE);
+        cluster.script(
+            &endpoint(PARTITION_PRIMARY),
+            POLL_MESSAGES_DEFERRED_CODE,
+            [poll_of_one_message(FIRST)],
+        );
+        start_fetch(&mut consumer, &cluster).await;
+
+        // The worker reads the generation only at the top of its loop, so it
+        // hands this batch over with the generation that the fetch started in.
+        consumer.fetch_generation.fetch_add(1, ORDERING);
+        cluster.release(POLL_MESSAGES_DEFERRED_CODE);
+        let polled = timeout(TEST_BUDGET, poll_once(&mut consumer)).await;
+
+        assert!(
+            matches!(&polled, Ok(Ok(received)) if received.message.header.offset == FIRST),
+            "the server committed this batch, so a new fetch generation must not drop it: {polled:?}"
+        );
+    }
+
+    /// Records `offset` of `POLLED_PARTITION` as the last delivered and stored message.
+    fn record_delivered(consumer: &IggyConsumer, offset: u64) {
+        let delivered = position_at(offset, 1);
+        consumer
+            .state
+            .last_consumed_offsets
+            .insert(POLLED_PARTITION, delivered);
+        consumer
+            .state
+            .last_stored_offsets
+            .insert(POLLED_PARTITION, delivered);
+    }
+
+    /// A consumer of `POLLED_PARTITION` whose every poll commits its batch.
+    async fn committing_consumer(client: TcpClient) -> IggyConsumer {
+        let mut consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
+            .partition(Some(POLLED_PARTITION))
+            .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
+            .polling_retry_interval(NonZeroIggyDuration::new(POLL_RETRY_INTERVAL).unwrap())
+            .build();
+        consumer.initialized = true;
+        consumer.events_task = Some(consumer.subscribe_events().await);
+        consumer
+    }
+
+    /// Polls `consumer` until its fetch reaches `cluster`, then leaves that fetch in flight.
+    async fn start_fetch(consumer: &mut IggyConsumer, cluster: &FakeCluster) {
+        let mut poll = Box::pin(poll_once(consumer));
+        timeout(TEST_BUDGET, async {
+            while cluster.count(POLL_MESSAGES_DEFERRED_CODE) == 0 {
+                assert!(
+                    poll.as_mut()
+                        .poll(&mut Context::from_waker(Waker::noop()))
+                        .is_pending()
+                );
+                tokio::task::yield_now().await;
+            }
+        })
+        .await
+        .unwrap();
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_signed_out_client_when_polls_fail_with_unauthenticated_should_back_off_between_attempts()
      {
         let (client, cluster) = connect_to_three_replicas().await;
         client.logout_user().await.unwrap();
         // No lifecycle task, as for a sign-out that `init` did not see yet.
-        let consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
+        let mut consumer = builder_on(client, Consumer::new(Identifier::numeric(1).unwrap()))
             .partition(Some(POLLED_PARTITION))
             .auto_commit(AutoCommit::Disabled)
             .polling_retry_interval(NonZeroIggyDuration::new(POLL_RETRY_INTERVAL).unwrap())
             .build();
+        consumer.initialized = true;
+        assert!(matches!(
+            poll_once(&mut consumer).await,
+            Err(IggyError::Unauthenticated)
+        ));
         let started = Instant::now();
 
-        let polled = consumer.create_poll_messages_future().await;
+        let polled = poll_once(&mut consumer).await;
 
         assert!(
             matches!(polled, Err(IggyError::Unauthenticated)),
             "{polled:?}"
         );
         assert!(started.elapsed() >= POLL_RETRY_INTERVAL);
-        assert_eq!(cluster.count(POLL_MESSAGES_CODE), 0);
+        assert_eq!(cluster.count(POLL_MESSAGES_DEFERRED_CODE), 0);
     }
 
     #[tokio::test(start_paused = true)]
     async fn given_a_signed_in_client_when_a_late_disconnected_event_arrives_should_keep_polling() {
         let (client, cluster) = connect_to_three_replicas().await;
-        let consumer =
+        let mut consumer =
             polling_consumer(client, Consumer::new(Identifier::numeric(1).unwrap())).await;
         cluster.script(
             &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_CODE,
-            [empty_poll()],
+            POLL_MESSAGES_DEFERRED_CODE,
+            [available_poll()],
         );
 
         publish(
@@ -3546,16 +3654,16 @@
         )
         .await;
         sleep(POLL_RETRY_INTERVAL).await;
-        let polled = timeout(POLL_TIMEOUT, consumer.create_poll_messages_future()).await;
+        let polled = timeout(POLL_TIMEOUT, poll_once(&mut consumer)).await;
 
         assert!(matches!(polled, Ok(Ok(_))), "{polled:?}");
-        assert_eq!(cluster.count(POLL_MESSAGES_CODE), 1);
+        assert!((1..=2).contains(&cluster.count(POLL_MESSAGES_DEFERRED_CODE)));
     }
 
     #[tokio::test(start_paused = true)]
     async fn given_an_evicted_poll_when_the_client_signs_in_again_should_wait_for_signed_in() {
         let (client, cluster) = connect_to_three_replicas().await;
-        let consumer =
+        let mut consumer =
             polling_consumer(client, Consumer::new(Identifier::numeric(1).unwrap())).await;
         *cluster.session.lock().unwrap() = None;
         cluster
@@ -3565,32 +3673,31 @@
             .insert("iggy".to_owned());
         cluster.script(
             &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_CODE,
-            [Answer::Evict(EvictionReason::NoSession), empty_poll()],
+            POLL_MESSAGES_DEFERRED_CODE,
+            [Answer::Evict(EvictionReason::NoSession), available_poll()],
         );
 
-        let evicted = consumer.create_poll_messages_future().await;
-
+        let evicted = timeout(POLL_TIMEOUT, poll_once(&mut consumer)).await;
         assert!(
-            matches!(evicted, Err(IggyError::TransientNotCommitted)),
-            "{evicted:?}"
+            evicted.is_err(),
+            "polling waits for the new session: {evicted:?}"
         );
         eventually("the client signs in again", || {
             cluster.count(LOGIN_REGISTER_CODE) == 2
         })
         .await;
-        let parked = timeout(POLL_TIMEOUT, consumer.create_poll_messages_future()).await;
+        let parked = timeout(POLL_TIMEOUT, poll_once(&mut consumer)).await;
         assert!(parked.is_err(), "{parked:?}");
-        assert_eq!(cluster.count(POLL_MESSAGES_CODE), 1);
+        assert_eq!(cluster.count(POLL_MESSAGES_DEFERRED_CODE), 1);
         cluster.held_registers.lock().unwrap().clear();
-        assert!(poll_until_success(&consumer).await.is_some());
+        assert!(poll_until_success(&mut consumer).await.is_some());
     }
 
     #[tokio::test(start_paused = true)]
     async fn given_a_group_member_when_a_poll_finds_its_session_ended_should_sign_in_and_join_again()
      {
         let (client, cluster) = connect_to_three_replicas().await;
-        let consumer =
+        let mut consumer =
             polling_consumer(client, Consumer::group(Identifier::numeric(1).unwrap())).await;
         let group = ConsumerGroupDetailsResponse {
             group: ConsumerGroupResponse {
@@ -3608,28 +3715,28 @@
         cluster.script(
             &endpoint(0),
             GET_CONSUMER_GROUP_CODE,
-            repeat_n(Answer::Body(group.to_bytes().to_vec()), 2),
+            repeat_n(Answer::Body(group.to_bytes().to_vec()), 4),
         );
         cluster.script(
             &endpoint(0),
             SYNC_CONSUMER_GROUP_CODE,
-            repeat_n(Answer::Body(assignment.to_bytes().to_vec()), 2),
+            repeat_n(Answer::Body(assignment.to_bytes().to_vec()), 4),
         );
         cluster.script(
             &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_CODE,
-            [Answer::Refuse(IggyError::StaleClient), empty_poll()],
+            POLL_MESSAGES_DEFERRED_CODE,
+            [Answer::Refuse(IggyError::StaleClient), available_poll()],
         );
 
-        let refused = consumer.create_poll_messages_future().await;
+        let refused = poll_once(&mut consumer).await;
 
         assert!(
             matches!(refused, Err(IggyError::StaleClient)),
             "{refused:?}"
         );
-        assert!(poll_until_success(&consumer).await.is_some());
+        assert!(poll_until_success(&mut consumer).await.is_some());
         assert_eq!(cluster.count(LOGIN_REGISTER_CODE), 2);
-        assert_eq!(cluster.count(JOIN_CONSUMER_GROUP_CODE), 2);
+        assert!((2..=3).contains(&cluster.count(JOIN_CONSUMER_GROUP_CODE)));
     }
 
     #[tokio::test(start_paused = true)]
@@ -3652,7 +3759,7 @@
         cluster.down.lock().unwrap().clear();
         cluster.script(
             &endpoint(PARTITION_PRIMARY),
-            POLL_MESSAGES_CODE,
+            POLL_MESSAGES_DEFERRED_CODE,
             [poll_of_one_message(OFFSET)],
         );
         let (shutdown, receiver) = tokio::sync::oneshot::channel();
@@ -3723,4 +3830,109 @@
 
         assert_eq!(result, Ok(Err(IggyError::ClientShutdown)));
     }
+
+    #[tokio::test]
+    async fn given_an_uninitialized_consumer_when_consuming_callbacks_should_end_at_once() {
+        let mut consumer = builder_for(Consumer::default()).build();
+        let (_shutdown, receiver) = tokio::sync::oneshot::channel();
+
+        let result = timeout(
+            POLL_TIMEOUT,
+            consumer.consume_messages(&RejectMessages, receiver),
+        )
+        .await;
+
+        assert_eq!(result, Ok(Err(IggyError::InvalidConfiguration)));
+    }
+
+    #[tokio::test]
+    async fn given_disabled_auto_commit_and_next_when_consuming_callbacks_should_warn_once() {
+        let warnings = consume_three_messages(AutoCommit::Disabled, PollingStrategy::next()).await;
+
+        assert_eq!(
+            warnings.len(),
+            1,
+            "a run warns once, not per message: {warnings:?}"
+        );
+        assert!(warnings[0].contains("never stores offsets"), "{warnings:?}");
+    }
+
+    #[tokio::test]
+    async fn given_auto_commit_or_a_fixed_start_when_consuming_callbacks_should_not_warn() {
+        for (auto_commit, polling_strategy) in [
+            (
+                AutoCommit::When(AutoCommitWhen::PollingMessages),
+                PollingStrategy::next(),
+            ),
+            (AutoCommit::Disabled, PollingStrategy::first()),
+        ] {
+            let warnings = consume_three_messages(auto_commit, polling_strategy).await;
+
+            assert!(
+                warnings.is_empty(),
+                "{auto_commit:?} with {polling_strategy:?}: {warnings:?}"
+            );
+        }
+    }
+
+    /// Runs `consume_messages` over three buffered messages, ends it with a blocked poll, and
+    /// returns the warnings that the run logged.
+    async fn consume_three_messages(
+        auto_commit: AutoCommit,
+        polling_strategy: PollingStrategy,
+    ) -> Vec<String> {
+        const OPERATION: u64 = 42;
+        let mut consumer = builder()
+            .partition(Some(1))
+            .auto_commit(auto_commit)
+            .polling_strategy(polling_strategy)
+            .build();
+        consumer.initialized = true;
+        consumer.buffered_messages =
+            VecDeque::from(vec![message_at(1), message_at(2), message_at(3)]);
+        consumer.deferred_polls = Some(deferred::DeferredPolls::with_error(
+            &consumer,
+            IggyError::LaneBlocked(OPERATION),
+        ));
+        let handler = RecordMessages {
+            offsets: std::sync::Mutex::default(),
+            shutdown: std::sync::Mutex::new(None),
+        };
+        let (_shutdown, receiver) = tokio::sync::oneshot::channel();
+        let recorder = LevelReports::warnings();
+        let _subscriber = tracing::subscriber::set_default(recorder.clone());
+
+        let result = timeout(POLL_TIMEOUT, consumer.consume_messages(&handler, receiver)).await;
+
+        assert_eq!(result, Ok(Err(IggyError::LaneBlocked(OPERATION))));
+        assert_eq!(*handler.offsets.lock().unwrap(), [1, 2, 3]);
+        recorder.events.lock().unwrap().clone()
+    }
+
+    #[tokio::test]
+    async fn given_an_uninitialized_consumer_when_polled_should_fail_each_poll_and_warn_once() {
+        let mut consumer = builder_for(Consumer::default()).build();
+        let recorder = LevelReports::warnings();
+        let _subscriber = tracing::subscriber::set_default(recorder.clone());
+
+        let first = poll_once(&mut consumer).await;
+        let second = poll_once(&mut consumer).await;
+
+        for polled in [first, second] {
+            assert!(
+                matches!(polled, Err(IggyError::InvalidConfiguration)),
+                "every poll before init fails: {polled:?}"
+            );
+        }
+        let warnings = recorder.events.lock().unwrap().clone();
+        assert_eq!(
+            warnings.len(),
+            1,
+            "only the first poll before init warns: {warnings:?}"
+        );
+        assert!(
+            warnings[0].contains("was polled before init()"),
+            "{warnings:?}"
+        );
+    }
 }
diff --git a/core/sdk/src/clients/consumer/deferred.rs b/core/sdk/src/clients/consumer/deferred.rs
new file mode 100644
index 0000000..d231fe9
--- /dev/null
+++ b/core/sdk/src/clients/consumer/deferred.rs
@@ -0,0 +1,1954 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::ops::Bound::{Excluded, Unbounded};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, AtomicU64};
+use std::task::{Context, Poll};
+use std::time::Duration;
+
+use bytes::Bytes;
+use dashmap::DashMap;
+use iggy_common::locking::IggyRwLockFn;
+use iggy_common::{
+    AssignmentVersion, ConsumerPosition, EncryptorKind, IggyError, IggyMessage, MessageClient,
+    PolledMessages, PollingKind, PollingStrategy,
+};
+use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore, mpsc};
+use tokio::task::{AbortHandle, Id, JoinHandle, JoinSet};
+use tokio::time::{Instant, MissedTickBehavior};
+use tracing::error;
+
+use super::{IggyConsumer, ORDERING, same_incarnation_and_owner};
+use crate::driver::{
+    DEFERRED_CONSUMER_CAPACITY, MAX_DEFERRED_REQUESTS_PER_CONSUMER, with_deadline,
+};
+
+const ASSIGNMENT_REFRESH_INTERVAL: Duration = Duration::from_secs(1);
+pub(crate) const DEFAULT_PREFETCH_BYTES: u32 = 16 * 1024 * 1024;
+pub(crate) const DEFAULT_PREFETCH_MESSAGES: u32 = 16_000;
+
+/// A reservation covers the whole possible reply, including work still in flight.
+/// Keep it until the batch is drained: message slices can share its backing allocation.
+pub(super) struct FetchCapacity {
+    permit: Option<OwnedSemaphorePermit>,
+    wake: Arc<Notify>,
+}
+
+impl Drop for FetchCapacity {
+    fn drop(&mut self) {
+        drop(self.permit.take());
+        self.wake.notify_one();
+    }
+}
+
+pub(super) struct PrefetchedBatch {
+    pub generation: u64,
+    pub assignment: Option<AssignmentVersion>,
+    pub result: Result<PolledMessages, IggyError>,
+    pub capacity: FetchCapacity,
+}
+
+pub(super) struct DeferredPolls {
+    receiver: mpsc::Receiver<PrefetchedBatch>,
+    wake: Arc<Notify>,
+    task: JoinHandle<()>,
+}
+
+impl DeferredPolls {
+    pub(super) fn start(consumer: &IggyConsumer) -> Self {
+        let slots = consumer.prefetch_slots();
+        let (sender, receiver) = mpsc::channel(slots);
+        let worker = Worker::new(consumer, sender);
+        Self {
+            receiver,
+            wake: Arc::clone(&worker.wake),
+            task: tokio::spawn(worker.run()),
+        }
+    }
+
+    #[cfg(test)]
+    pub(super) fn with_error(consumer: &IggyConsumer, error: IggyError) -> Self {
+        let (sender, receiver) = mpsc::channel(consumer.prefetch_slots());
+        let worker = Worker::new(consumer, sender.clone());
+        let capacity = worker.reserve().unwrap();
+        let generation = worker.epoch;
+        sender
+            .try_send(PrefetchedBatch {
+                generation,
+                assignment: None,
+                result: Err(error),
+                capacity,
+            })
+            .unwrap_or_else(|_| panic!("the test queue is empty"));
+        Self {
+            receiver,
+            wake: Arc::clone(&worker.wake),
+            task: tokio::spawn(async move {
+                worker.wake.notified().await;
+                worker.run().await;
+            }),
+        }
+    }
+
+    pub(super) fn poll_recv(&mut self, cx: &mut Context<'_>) -> Poll<Option<PrefetchedBatch>> {
+        self.receiver.poll_recv(cx)
+    }
+
+    /// Stops fetching and returns the polled batches that the application did not take.
+    ///
+    /// The server can commit the batch of a committing fetch in flight. With a `grace`, the
+    /// woken worker sees the shutdown flag of the consumer and lets its fetches hand their
+    /// batches over first. After the grace, a fetch still in flight is aborted, and its
+    /// pending commit forgets the stored offset.
+    pub(super) async fn stop(mut self, grace: Option<Duration>) -> Vec<PolledMessages> {
+        let ended = match grace {
+            Some(grace) => {
+                self.wake.notify_one();
+                tokio::time::timeout(grace, &mut self.task).await.is_ok()
+            }
+            None => false,
+        };
+        if !ended {
+            self.task.abort();
+        }
+        // The channel closes once the worker and every pending commit are gone.
+        // The drain then holds every batch, and each aborted fetch already
+        // forgot its stored offset.
+        let mut undelivered = Vec::new();
+        while let Some(batch) = self.receiver.recv().await {
+            if let Ok(polled) = batch.result {
+                undelivered.push(polled);
+            }
+        }
+        undelivered
+    }
+}
+
+impl Drop for DeferredPolls {
+    fn drop(&mut self) {
+        self.task.abort();
+    }
+}
+
+struct CompletedPoll {
+    /// The fetch state that started the fetch.
+    epoch: u64,
+    partition: u32,
+    /// The position that the fetch read from.
+    strategy: PollingStrategy,
+    result: Result<PolledMessages, IggyError>,
+    capacity: FetchCapacity,
+    /// Set for a fetch that commits its batch.
+    commit: Option<PendingCommit>,
+}
+
+/// The server can commit the batch of a committing fetch whose reply never
+/// reaches the application. Unless the fetch settles, dropping this forgets
+/// the stored offset of its partition. A shutdown then stores the last
+/// delivered message again, which moves the server offset back to it.
+struct PendingCommit {
+    partition: u32,
+    consumed: Arc<DashMap<u32, ConsumerPosition>>,
+    stored: Arc<DashMap<u32, ConsumerPosition>>,
+    /// An aborted worker leaves the drop of a fetch in flight to the task of that
+    /// fetch. Until then, this keeps the batch channel open for `stop` to wait on.
+    _batches: mpsc::Sender<PrefetchedBatch>,
+    settled: bool,
+}
+
+impl PendingCommit {
+    fn settle(mut self) {
+        self.settled = true;
+    }
+}
+
+impl Drop for PendingCommit {
+    fn drop(&mut self) {
+        if self.settled {
+            return;
+        }
+        // A record that differs from the delivered message makes a shutdown
+        // store that message anyway. Such a record can also be an offset that
+        // the application stored to skip a message.
+        let consumed = self
+            .consumed
+            .get(&self.partition)
+            .map(|entry| *entry.value());
+        self.stored
+            .remove_if(&self.partition, |_, stored| consumed == Some(*stored));
+    }
+}
+
+struct CompletedWait {
+    partitions: Vec<u32>,
+    result: Result<Vec<u32>, IggyError>,
+}
+
+/// The assignment that a refresh read.
+struct Refreshed {
+    /// The fetch state that started the refresh.
+    epoch: u64,
+    assignment: AssignmentVersion,
+    partitions: Vec<u32>,
+    /// Set when readiness waits cover the partitions.
+    groups: Option<Vec<Vec<u32>>>,
+}
+
+/// Readiness state of the fetches that start only once a partition holds a
+/// message: every fetch of `AutoCommitWhen::PollingMessages`, where each poll
+/// commits its batch on the server, and the fetches of a group member with
+/// more partitions than fetch slots.
+struct OnDemand {
+    /// Set while the application waits for a message and its buffer is empty.
+    demand: Arc<AtomicBool>,
+    /// One readiness wait per group that has idle partitions.
+    waits: JoinSet<CompletedWait>,
+    /// The assigned partitions, grouped by primary endpoint and split to fit
+    /// one wait request each.
+    groups: Vec<Vec<u32>>,
+    /// Partitions that hold a message for their next poll.
+    ready: BTreeSet<u32>,
+    /// The wait that covers each waiting partition.
+    waiting: BTreeMap<u32, AbortHandle>,
+    /// The offset after the newest message that a `Timestamp` poll skipped.
+    floors: BTreeMap<u32, u64>,
+}
+
+impl OnDemand {
+    fn clear(&mut self) {
+        self.waits = JoinSet::new();
+        self.groups.clear();
+        self.ready.clear();
+        self.waiting.clear();
+        self.floors.clear();
+    }
+
+    /// A wait can end before the restart that replaces it cancels it. Only
+    /// the wait that covers the partitions now reports them.
+    fn covers(&self, wait: Id, partitions: &[u32]) -> bool {
+        partitions.iter().all(|partition| {
+            self.waiting
+                .get(partition)
+                .is_some_and(|running| running.id() == wait)
+        })
+    }
+}
+
+struct Worker {
+    client: iggy_common::locking::IggyRwLock<crate::client_wrappers::client_wrapper::ClientWrapper>,
+    stream: Arc<iggy_common::Identifier>,
+    topic: Arc<iggy_common::Identifier>,
+    consumer: Arc<iggy_common::Consumer>,
+    name: String,
+    standalone_partition: u32,
+    is_group: bool,
+    auto_join: bool,
+    create_group: bool,
+    joined: Arc<std::sync::atomic::AtomicBool>,
+    uncertain_group_operation: Arc<tokio::sync::Mutex<Option<u64>>>,
+    can_poll: Arc<std::sync::atomic::AtomicBool>,
+    shutdown: Arc<AtomicBool>,
+    generation: Arc<AtomicU64>,
+    wake: Arc<Notify>,
+    strategy: PollingStrategy,
+    options: iggy_common::DeferredPollOptions,
+    count: u32,
+    auto_commit: bool,
+    encryptor: Option<Arc<iggy_common::EncryptorKind>>,
+    retry_interval: Duration,
+    sender: mpsc::Sender<PrefetchedBatch>,
+    capacity: Arc<Semaphore>,
+    request_capacity: Arc<Semaphore>,
+    /// The permits of `capacity`.
+    slots: usize,
+    tasks: JoinSet<CompletedPoll>,
+    /// At most one refresh, apart from the fetches, so a slow one never holds
+    /// their replies back.
+    refreshing: JoinSet<Result<Refreshed, IggyError>>,
+    active: BTreeSet<u32>,
+    /// Partitions whose next record exceeds `max_bytes`.
+    oversized: BTreeSet<u32>,
+    positions: BTreeMap<u32, PollingStrategy>,
+    /// The last message that the application took from each partition.
+    consumed: Arc<DashMap<u32, ConsumerPosition>>,
+    /// The last offset that the application stored for each partition.
+    stored: Arc<DashMap<u32, ConsumerPosition>>,
+    retry_at: BTreeMap<u32, Instant>,
+    partitions: Vec<u32>,
+    assignment: Option<AssignmentVersion>,
+    pending_error: Option<IggyError>,
+    epoch: u64,
+    cursor: Option<u32>,
+    on_demand: OnDemand,
+}
+
+impl Worker {
+    fn new(consumer: &IggyConsumer, sender: mpsc::Sender<PrefetchedBatch>) -> Self {
+        let slots = consumer.prefetch_slots();
+        Self {
+            client: consumer.client.clone(),
+            stream: Arc::clone(&consumer.stream_id),
+            topic: Arc::clone(&consumer.topic_id),
+            consumer: Arc::clone(&consumer.consumer),
+            name: consumer.consumer_name.clone(),
+            standalone_partition: consumer.partition_id.unwrap_or_default(),
+            is_group: consumer.is_consumer_group,
+            auto_join: consumer.auto_join_consumer_group,
+            create_group: consumer.create_consumer_group_if_not_exists,
+            joined: Arc::clone(&consumer.joined_consumer_group),
+            uncertain_group_operation: Arc::clone(&consumer.uncertain_consumer_group_operation),
+            can_poll: Arc::clone(&consumer.can_poll),
+            shutdown: Arc::clone(&consumer.shutdown),
+            generation: Arc::clone(&consumer.fetch_generation),
+            wake: Arc::clone(&consumer.fetch_notify),
+            strategy: consumer.polling_strategy,
+            options: consumer.poll_options,
+            count: consumer.batch_length,
+            auto_commit: consumer.auto_commit_after_polling,
+            encryptor: consumer.encryptor.clone(),
+            retry_interval: consumer.reconnection_retry_interval.get_duration(),
+            sender,
+            capacity: Arc::new(Semaphore::new(slots)),
+            request_capacity: Arc::new(Semaphore::new(MAX_DEFERRED_REQUESTS_PER_CONSUMER)),
+            slots,
+            tasks: JoinSet::new(),
+            refreshing: JoinSet::new(),
+            active: BTreeSet::new(),
+            oversized: BTreeSet::new(),
+            positions: BTreeMap::new(),
+            consumed: Arc::clone(&consumer.state.last_consumed_offsets),
+            stored: Arc::clone(&consumer.state.last_stored_offsets),
+            retry_at: BTreeMap::new(),
+            partitions: Vec::new(),
+            assignment: None,
+            pending_error: None,
+            epoch: consumer.fetch_generation.load(ORDERING),
+            cursor: None,
+            on_demand: OnDemand {
+                demand: Arc::clone(&consumer.fetch_demand),
+                waits: JoinSet::new(),
+                groups: Vec::new(),
+                ready: BTreeSet::new(),
+                waiting: BTreeMap::new(),
+                floors: BTreeMap::new(),
+            },
+        }
+    }
+
+    async fn run(mut self) {
+        let mut refresh = tokio::time::interval(ASSIGNMENT_REFRESH_INTERVAL);
+        refresh.set_missed_tick_behavior(MissedTickBehavior::Delay);
+        loop {
+            if self.sender.is_closed() {
+                return;
+            }
+            if self.shutdown.load(ORDERING) {
+                self.finish_fetches().await;
+                return;
+            }
+            if self.epoch != self.generation.load(ORDERING) {
+                self.rewind();
+                self.epoch = self.generation.load(ORDERING);
+                refresh.reset_immediately();
+            }
+            let enabled =
+                self.can_poll.load(ORDERING) || !self.client.read().await.can_recover().await;
+            self.flush_error();
+            if enabled {
+                self.schedule();
+            }
+            let retry_at = self
+                .retry_at
+                .values()
+                .min()
+                .copied()
+                .unwrap_or_else(|| Instant::now() + ASSIGNMENT_REFRESH_INTERVAL);
+            tokio::select! {
+                _ = self.sender.closed() => return,
+                _ = self.wake.notified() => {},
+                _ = tokio::time::sleep_until(retry_at), if !self.retry_at.is_empty() => {
+                    self.retry_at.retain(|_, deadline| *deadline > Instant::now());
+                },
+                _ = refresh.tick() => {
+                    if enabled && self.refreshing.is_empty() {
+                        let deadline = Instant::now() + self.options.request_timeout.get_duration();
+                        self.refreshing.spawn(with_deadline(deadline, self.read_assignment()));
+                    }
+                },
+                Some(refreshed) = self.refreshing.join_next(), if !self.refreshing.is_empty() => {
+                    match refreshed {
+                        Ok(Ok(refreshed)) => {
+                            if self.apply(refreshed) {
+                                refresh.reset();
+                            } else {
+                                refresh.reset_immediately();
+                            }
+                        }
+                        Ok(Err(error)) => {
+                            self.report(error);
+                            refresh.reset_after(self.retry_interval);
+                        }
+                        Err(_) => {
+                            self.invalidate();
+                            self.report(IggyError::ClientShutdown);
+                            return;
+                        }
+                    }
+                },
+                Some(completed) = self.tasks.join_next(), if !self.tasks.is_empty() => {
+                    match completed {
+                        Ok(completed) => {
+                            self.complete(completed);
+                            if self.partitions.is_empty() {
+                                refresh.reset_after(self.retry_interval);
+                            }
+                        },
+                        Err(_) => {
+                            self.invalidate();
+                            self.report(IggyError::ClientShutdown);
+                            return;
+                        }
+                    }
+                }
+                Some(joined) = self.on_demand.waits.join_next_with_id(), if !self.on_demand.waits.is_empty() => {
+                    match joined {
+                        Ok((wait, completed)) => {
+                            if self.on_demand.covers(wait, &completed.partitions) {
+                                self.complete_wait(completed);
+                            }
+                        }
+                        // A restart cancelled the wait.
+                        Err(error) if error.is_cancelled() => {}
+                        Err(_) => {
+                            self.invalidate();
+                            self.report(IggyError::ClientShutdown);
+                            return;
+                        }
+                    }
+                    if self.partitions.is_empty() {
+                        refresh.reset_after(self.retry_interval);
+                    }
+                }
+            }
+        }
+    }
+
+    /// The server can commit the batch of a committing fetch in flight, so the
+    /// worker ends once its fetches hand their batches over. Nothing new starts.
+    async fn finish_fetches(&mut self) {
+        while let Some(Ok(completed)) = self.tasks.join_next().await {
+            self.complete(completed);
+        }
+    }
+
+    /// A new fetch generation discards every batch that the application did
+    /// not take, so each partition continues after its last delivered message.
+    /// The server already committed the batch of a committing fetch, so that
+    /// fetch finishes, delivers it and moves its position. Nothing is fetched
+    /// until the next refresh confirms the assignment.
+    fn rewind(&mut self) {
+        if !self.auto_commit {
+            self.tasks = JoinSet::new();
+            self.active.clear();
+            let consumed = &self.consumed;
+            // A partition without a delivered message in the incarnation of
+            // its position restarts from the initial strategy.
+            self.positions.retain(|partition, position| {
+                let Some(consumed) = consumed.get(partition).map(|entry| *entry.value()) else {
+                    return false;
+                };
+                let same_incarnation = position
+                    .context
+                    .is_some_and(|context| same_incarnation_and_owner(context, consumed.context));
+                match consumed.offset.checked_add(1) {
+                    Some(next) if same_incarnation => {
+                        *position = PollingStrategy::offset(next).with_context(consumed.context);
+                        true
+                    }
+                    _ => false,
+                }
+            });
+        }
+        self.on_demand.clear();
+        self.oversized.clear();
+        self.retry_at.clear();
+        self.partitions.clear();
+        self.pending_error = None;
+    }
+
+    fn invalidate(&mut self) {
+        self.epoch = self.generation.fetch_add(1, ORDERING) + 1;
+        self.rewind();
+    }
+
+    fn read_assignment(
+        &self,
+    ) -> impl Future<Output = Result<Refreshed, IggyError>> + Send + 'static {
+        let client = self.client.clone();
+        let stream = Arc::clone(&self.stream);
+        let topic = Arc::clone(&self.topic);
+        let consumer = Arc::clone(&self.consumer);
+        let name = self.name.clone();
+        let joined = Arc::clone(&self.joined);
+        let uncertain_group_operation = Arc::clone(&self.uncertain_group_operation);
+        let join = self.is_group && self.auto_join;
+        let create_group = self.create_group;
+        let is_group = self.is_group;
+        let standalone_partition = self.standalone_partition;
+        let (auto_commit, slots) = (self.auto_commit, self.slots);
+        let epoch = self.epoch;
+        async move {
+            if join && !joined.load(ORDERING) {
+                IggyConsumer::initialize_consumer_group(
+                    client.clone(),
+                    create_group,
+                    Arc::clone(&stream),
+                    Arc::clone(&topic),
+                    &name,
+                    joined,
+                    uncertain_group_operation,
+                )
+                .await?;
+            }
+            let (assignment, mut partitions) = if is_group {
+                client
+                    .read()
+                    .await
+                    .deferred_poll_partitions(&stream, &topic, &consumer.id)
+                    .await?
+            } else {
+                (AssignmentVersion::default(), vec![standalone_partition])
+            };
+            partitions.sort_unstable();
+            let groups = if waits_for_readiness(is_group, auto_commit, partitions.len(), slots)
+                && !partitions.is_empty()
+            {
+                Some(
+                    client
+                        .read()
+                        .await
+                        .readiness_groups(&stream, &topic, &partitions)
+                        .await?,
+                )
+            } else {
+                None
+            };
+            Ok(Refreshed {
+                epoch,
+                assignment,
+                partitions,
+                groups,
+            })
+        }
+    }
+
+    /// Returns false for a refresh that started before the current fetch
+    /// state, which only a new refresh replaces.
+    fn apply(&mut self, refreshed: Refreshed) -> bool {
+        if refreshed.epoch != self.epoch || self.epoch != self.generation.load(ORDERING) {
+            return false;
+        }
+        if self.assignment != Some(refreshed.assignment) {
+            self.invalidate();
+            // The server keeps the owner of a partition that stays with its
+            // member, so that partition continues from its position. Every
+            // partition has a new owner in a new session. A partition that
+            // moved away and back between two refreshes has one too: the
+            // server refuses a poll from its old position, and the worker
+            // then drops that position.
+            if self
+                .assignment
+                .is_some_and(|assignment| assignment.session == refreshed.assignment.session)
+            {
+                self.positions
+                    .retain(|partition, _| refreshed.partitions.binary_search(partition).is_ok());
+            } else {
+                self.positions.clear();
+            }
+            self.assignment = Some(refreshed.assignment);
+        }
+        self.partitions = refreshed.partitions;
+        if let Some(groups) = refreshed.groups
+            && groups != self.on_demand.groups
+        {
+            // A restart cancels the wait of its own group only, so no wait
+            // may cover partitions of two groups.
+            self.on_demand.waits = JoinSet::new();
+            self.on_demand.waiting.clear();
+            self.on_demand.groups = groups;
+        }
+        true
+    }
+
+    fn reserve(&self) -> Option<FetchCapacity> {
+        Arc::clone(&self.capacity)
+            .try_acquire_owned()
+            .ok()
+            .map(|permit| FetchCapacity {
+                permit: Some(permit),
+                wake: Arc::clone(&self.wake),
+            })
+    }
+
+    fn report(&mut self, error: IggyError) {
+        if matches!(error, IggyError::ConsumerGroupMemberNotFound(..)) {
+            self.joined.store(false, ORDERING);
+            if self.auto_join {
+                return;
+            }
+        }
+        if matches!(
+            error,
+            IggyError::Disconnected | IggyError::Unauthenticated | IggyError::StaleClient
+        ) {
+            self.joined.store(false, ORDERING);
+        }
+        self.pending_error = Some(error);
+        self.flush_error();
+    }
+
+    fn flush_error(&mut self) {
+        let Some(error) = self.pending_error.take() else {
+            return;
+        };
+        let Some(capacity) = self.reserve() else {
+            self.pending_error = Some(error);
+            return;
+        };
+        self.deliver(PrefetchedBatch {
+            generation: self.epoch,
+            assignment: self.assignment,
+            result: Err(error),
+            capacity,
+        });
+    }
+
+    /// The application takes one item per demand and asks again for the next.
+    fn deliver(&self, batch: PrefetchedBatch) {
+        self.on_demand.demand.store(false, ORDERING);
+        let _ = self.sender.try_send(batch);
+    }
+
+    fn schedule(&mut self) {
+        if self.partitions.is_empty() {
+            return;
+        }
+        if self.auto_commit {
+            self.schedule_on_demand();
+            return;
+        }
+        if self.waits_for_readiness() {
+            self.schedule_ready();
+            return;
+        }
+        let start = self.cursor.map_or(0, |cursor| {
+            self.partitions.partition_point(|id| *id <= cursor)
+        });
+        for index in 0..self.partitions.len() {
+            if self.tasks.len() >= MAX_DEFERRED_REQUESTS_PER_CONSUMER {
+                break;
+            }
+            let partition = self.partitions[(start + index) % self.partitions.len()];
+            if self.active.contains(&partition) || self.retry_at.contains_key(&partition) {
+                continue;
+            }
+            let Some(capacity) = self.reserve() else {
+                break;
+            };
+            self.spawn_fetch(partition, capacity);
+        }
+    }
+
+    fn waits_for_readiness(&self) -> bool {
+        waits_for_readiness(
+            self.is_group,
+            self.auto_commit,
+            self.partitions.len(),
+            self.slots,
+        )
+    }
+
+    fn schedule_ready(&mut self) {
+        while let Some(partition) = self.next_ready() {
+            let Some(capacity) = self.reserve() else {
+                break;
+            };
+            self.spawn_fetch(partition, capacity);
+        }
+        self.start_waits();
+    }
+
+    /// Every poll of this mode commits its batch. The worker fetches one batch
+    /// per demand, so it never holds a committed batch that the application
+    /// did not ask for. A wait commits nothing, so the waits run without
+    /// demand.
+    fn schedule_on_demand(&mut self) {
+        if self.on_demand.demand.load(ORDERING) && self.tasks.is_empty() {
+            let partition = if self.is_group {
+                self.next_ready()
+            } else {
+                self.partitions.first().copied()
+            };
+            if let Some(partition) =
+                partition.filter(|partition| !self.retry_at.contains_key(partition))
+                && let Some(capacity) = self.reserve()
+            {
+                self.spawn_fetch(partition, capacity);
+            }
+        }
+        self.start_waits();
+    }
+
+    fn next_ready(&self) -> Option<u32> {
+        let after = self.cursor.map_or(Unbounded, Excluded);
+        self.on_demand
+            .ready
+            .range((after, Unbounded))
+            .chain(&self.on_demand.ready)
+            .copied()
+            .find(|partition| {
+                !self.retry_at.contains_key(partition) && !self.active.contains(partition)
+            })
+    }
+
+    /// One readiness wait per group covers the idle partitions of the group:
+    /// those that are not ready, not fetched and not retrying. A wait takes
+    /// no partitions after it starts, so a partition that becomes idle
+    /// restarts the wait of its group. A second wait would hold one more of
+    /// the deferred requests that the fetches of the consumer share.
+    fn start_waits(&mut self) {
+        let idle = |partition: &&u32| {
+            !self.on_demand.ready.contains(*partition)
+                && !self.retry_at.contains_key(*partition)
+                && !self.active.contains(*partition)
+        };
+        for group in &self.on_demand.groups {
+            if group
+                .iter()
+                .filter(idle)
+                .all(|partition| self.on_demand.waiting.contains_key(partition))
+            {
+                continue;
+            }
+            for partition in group {
+                if let Some(wait) = self.on_demand.waiting.remove(partition) {
+                    wait.abort();
+                }
+            }
+            let targets: Vec<_> = group
+                .iter()
+                .filter(idle)
+                .map(|partition| (*partition, self.wait_target(*partition)))
+                .collect();
+            let partitions: Vec<_> = targets.iter().map(|(partition, _)| *partition).collect();
+            let client = self.client.clone();
+            let stream = Arc::clone(&self.stream);
+            let topic = Arc::clone(&self.topic);
+            let consumer = Arc::clone(&self.consumer);
+            let options = self.options;
+            let wait = self.on_demand.waits.spawn(DEFERRED_CONSUMER_CAPACITY.scope(
+                Arc::clone(&self.request_capacity),
+                async move {
+                    let result =
+                        tokio::time::timeout(options.request_timeout.get_duration(), async {
+                            let client = client.read().await;
+                            client
+                                .wait_for_messages(&stream, &topic, &targets, &consumer, options)
+                                .await
+                        })
+                        .await
+                        .unwrap_or(Err(IggyError::TransientNotAccepted))
+                        // A wait changes nothing on the server, so a lost reply only retries.
+                        .map_err(|error| match error {
+                            IggyError::TransientNotCommitted => IggyError::TransientNotAccepted,
+                            error => error,
+                        });
+                    CompletedWait {
+                        partitions: targets
+                            .into_iter()
+                            .map(|(partition, _)| partition)
+                            .collect(),
+                        result,
+                    }
+                },
+            ));
+            self.on_demand.waiting.extend(
+                partitions
+                    .into_iter()
+                    .map(|partition| (partition, wait.clone())),
+            );
+        }
+    }
+
+    /// A stored offset at or after the position moves it past that offset, so
+    /// the application can skip a message that never reads, such as one that
+    /// does not decrypt.
+    fn position(&self, partition: u32) -> PollingStrategy {
+        let position = self
+            .positions
+            .get(&partition)
+            .copied()
+            .unwrap_or(self.strategy);
+        let stored = self.stored.get(&partition).map(|entry| *entry.value());
+        match (position.kind, position.context, stored) {
+            (PollingKind::Offset, Some(context), Some(stored))
+                if same_incarnation_and_owner(context, stored.context)
+                    && stored.offset >= position.value =>
+            {
+                PollingStrategy::offset(stored.offset.saturating_add(1)).with_context(context)
+            }
+            _ => position,
+        }
+    }
+
+    /// A readiness wait takes an offset or `Next`. Before the first batch, a
+    /// `First`, `Last` or `Timestamp` start waits for any message after the
+    /// ones that a timestamp skipped.
+    fn wait_target(&self, partition: u32) -> PollingStrategy {
+        let strategy = self.position(partition);
+        match strategy.kind {
+            PollingKind::Next | PollingKind::Offset => strategy,
+            _ => PollingStrategy::offset(
+                self.on_demand
+                    .floors
+                    .get(&partition)
+                    .copied()
+                    .unwrap_or_default(),
+            ),
+        }
+    }
+
+    fn spawn_fetch(&mut self, partition: u32, capacity: FetchCapacity) {
+        let client = self.client.clone();
+        let stream = Arc::clone(&self.stream);
+        let topic = Arc::clone(&self.topic);
+        let consumer = Arc::clone(&self.consumer);
+        let strategy = self.position(partition);
+        // The record alone, without a byte limit, as a plain poll reads it.
+        let (count, options) = if self.oversized.contains(&partition) {
+            let options = iggy_common::DeferredPollOptions {
+                min_count: 1,
+                max_bytes: u32::MAX,
+                ..self.options
+            };
+            (1, options)
+        } else {
+            (self.count, self.options)
+        };
+        let auto_commit = self.auto_commit;
+        let commit = auto_commit.then(|| PendingCommit {
+            partition,
+            consumed: Arc::clone(&self.consumed),
+            stored: Arc::clone(&self.stored),
+            _batches: self.sender.clone(),
+            settled: false,
+        });
+        let encryptor = self.encryptor.clone();
+        let epoch = self.epoch;
+        self.tasks.spawn(DEFERRED_CONSUMER_CAPACITY.scope(
+            Arc::clone(&self.request_capacity),
+            async move {
+            let started = Instant::now();
+            let result = tokio::time::timeout(options.request_timeout.get_duration(), async {
+                let client = client.read().await;
+                let mut result = client
+                    .poll_messages_deferred(
+                        &stream,
+                        &topic,
+                        Some(partition),
+                        &consumer,
+                        &strategy,
+                        count,
+                        auto_commit,
+                        options.remaining(started.elapsed())?,
+                    )
+                    .await?;
+                if result.count > count || result.messages.len() > count as usize {
+                    return Err(IggyError::InvalidMessagesCount);
+                }
+                if let Some(encryptor) = encryptor {
+                    for message in &mut result.messages {
+                        let offset = message.header.offset;
+                        decrypt(&encryptor, message).inspect_err(|error| {
+                            error!(
+                                "Failed to decrypt the message at offset: {offset}, partition ID: {partition}. Storing its offset skips it. {error}"
+                            );
+                        })?;
+                    }
+                }
+                Ok(result)
+            })
+            .await
+            // A fetch that commits nothing leaves nothing behind its timeout.
+            .unwrap_or(Err(if auto_commit {
+                IggyError::TransientNotCommitted
+            } else {
+                IggyError::TransientNotAccepted
+            }));
+            CompletedPoll {
+                epoch,
+                partition,
+                strategy,
+                result,
+                capacity,
+                commit,
+            }
+            },
+        ));
+        self.active.insert(partition);
+        self.cursor = Some(partition);
+    }
+
+    fn complete(&mut self, mut completed: CompletedPoll) {
+        self.active.remove(&completed.partition);
+        // A reply that never arrived can commit a batch that never reaches the
+        // application. So can a reply that the worker drops for holding more
+        // messages than it asked for.
+        if !matches!(
+            completed.result,
+            Err(IggyError::TransientNotCommitted | IggyError::InvalidMessagesCount)
+        ) && let Some(commit) = completed.commit.take()
+        {
+            commit.settle();
+        }
+        // A record above `max_bytes` would stall its partition, so the next
+        // fetch reads it alone. Only a failure of that fetch is reported.
+        if !self.oversized.remove(&completed.partition)
+            && matches!(completed.result, Err(IggyError::InvalidSizeBytes))
+            && completed.epoch == self.epoch
+            && self.epoch == self.generation.load(ORDERING)
+        {
+            self.oversized.insert(completed.partition);
+            return;
+        }
+        if self.auto_commit {
+            self.complete_on_demand(completed);
+            return;
+        }
+        if self.epoch != self.generation.load(ORDERING) {
+            return;
+        }
+        self.positions
+            .insert(completed.partition, completed.strategy);
+        if let Ok(result) = &completed.result {
+            if result.partition_id == iggy_common::RESYNC_REQUIRED_PARTITION_SENTINEL {
+                self.invalidate();
+                return;
+            }
+            self.track_readiness(completed.partition, completed.strategy, result);
+            if let Some(last) = result.messages.last() {
+                match last.header.offset.checked_add(1) {
+                    Some(next) => {
+                        self.positions.insert(
+                            completed.partition,
+                            PollingStrategy::offset(next).with_context(result.context),
+                        );
+                    }
+                    None => {
+                        completed.result = Err(IggyError::InvalidOffset(last.header.offset));
+                    }
+                }
+            } else {
+                return;
+            }
+        }
+        if let Err(error) = completed.result {
+            if matches!(
+                error,
+                IggyError::HistoryUnavailable | IggyError::ConsumerGroupPartitionNotOwned(..)
+            ) {
+                self.positions.remove(&completed.partition);
+            }
+            if matches!(
+                error,
+                IggyError::ConsumerGroupMemberNotFound(..)
+                    | IggyError::Disconnected
+                    | IggyError::Unauthenticated
+                    | IggyError::StaleClient
+            ) {
+                if self.is_group {
+                    self.invalidate();
+                }
+                self.retry_at
+                    .insert(completed.partition, Instant::now() + self.retry_interval);
+                self.report(error);
+                return;
+            }
+            self.retry_at
+                .insert(completed.partition, Instant::now() + self.retry_interval);
+            completed.result = Err(error);
+        }
+        self.deliver(PrefetchedBatch {
+            generation: self.epoch,
+            assignment: self.assignment,
+            result: completed.result,
+            capacity: completed.capacity,
+        });
+    }
+
+    /// The server committed a batch that holds messages, so the batch reaches
+    /// the application even after the fetch state was cleared.
+    fn complete_on_demand(&mut self, completed: CompletedPoll) {
+        let current = completed.epoch == self.epoch && self.epoch == self.generation.load(ORDERING);
+        let partition = completed.partition;
+        let polled = match completed.result {
+            Ok(polled) => polled,
+            Err(error) => {
+                if current {
+                    if matches!(
+                        error,
+                        IggyError::HistoryUnavailable
+                            | IggyError::ConsumerGroupPartitionNotOwned(..)
+                    ) {
+                        self.positions.remove(&partition);
+                    }
+                    self.fail_on_demand(&[partition], error);
+                }
+                return;
+            }
+        };
+        let Some(last) = polled.messages.last().map(|message| message.header.offset) else {
+            if current {
+                if polled.partition_id == iggy_common::RESYNC_REQUIRED_PARTITION_SENTINEL {
+                    self.invalidate();
+                } else {
+                    self.track_readiness(partition, completed.strategy, &polled);
+                }
+            }
+            return;
+        };
+        let mut overflow = None;
+        // A new assignment replaces the position that the fetch started from.
+        // `Next` continues from an explicit offset too, because the interval
+        // commit can move the server offset back to the delivered message.
+        if completed.strategy == self.position(partition) {
+            match last.checked_add(1) {
+                Some(next) => {
+                    self.positions.insert(
+                        partition,
+                        PollingStrategy::offset(next).with_context(polled.context),
+                    );
+                }
+                None => overflow = Some(IggyError::InvalidOffset(last)),
+            }
+        }
+        if current {
+            self.track_readiness(partition, completed.strategy, &polled);
+        }
+        self.deliver(PrefetchedBatch {
+            generation: self.epoch,
+            assignment: self.assignment,
+            result: Ok(polled),
+            capacity: completed.capacity,
+        });
+        if let Some(error) = overflow {
+            self.fail_on_demand(&[partition], error);
+        }
+    }
+
+    /// The reply carries the commit offset, so a partition that holds more
+    /// messages stays ready without another wait.
+    fn track_readiness(
+        &mut self,
+        partition: u32,
+        strategy: PollingStrategy,
+        polled: &PolledMessages,
+    ) {
+        match polled.messages.last() {
+            Some(last) => {
+                self.on_demand.floors.remove(&partition);
+                if last.header.offset >= polled.current_offset {
+                    self.on_demand.ready.remove(&partition);
+                }
+            }
+            None => {
+                self.on_demand.ready.remove(&partition);
+                if strategy.kind == PollingKind::Timestamp {
+                    self.on_demand
+                        .floors
+                        .insert(partition, polled.current_offset.saturating_add(1));
+                }
+            }
+        }
+    }
+
+    fn complete_wait(&mut self, completed: CompletedWait) {
+        for partition in &completed.partitions {
+            self.on_demand.waiting.remove(partition);
+        }
+        match completed.result {
+            Ok(ready) => self.on_demand.ready.extend(
+                ready
+                    .into_iter()
+                    .filter(|partition| completed.partitions.contains(partition)),
+            ),
+            // A wait does not name the partition whose history is gone, so
+            // each partition fetches alone and its own poll tells.
+            Err(IggyError::HistoryUnavailable) => {
+                self.on_demand.ready.extend(&completed.partitions);
+            }
+            Err(error) => self.fail_on_demand(&completed.partitions, error),
+        }
+    }
+
+    /// Membership and connection errors clear the fetch state. Other errors
+    /// make the partitions retry later, and the application sees them.
+    fn fail_on_demand(&mut self, partitions: &[u32], error: IggyError) {
+        match error {
+            IggyError::ConsumerGroupPartitionNotOwned(..) => self.invalidate(),
+            IggyError::ConsumerGroupMemberNotFound(..)
+            | IggyError::Disconnected
+            | IggyError::Unauthenticated
+            | IggyError::StaleClient => {
+                self.invalidate();
+                self.report(error);
+            }
+            error => {
+                let retry_at = Instant::now() + self.retry_interval;
+                for partition in partitions {
+                    self.retry_at.insert(*partition, retry_at);
+                }
+                self.report(error);
+            }
+        }
+    }
+}
+
+fn decrypt(encryptor: &EncryptorKind, message: &mut IggyMessage) -> Result<(), IggyError> {
+    message.payload = Bytes::from(encryptor.decrypt(&message.payload)?);
+    message.header.payload_length = message.payload.len() as u32;
+    if let Some(headers) = &message.user_headers {
+        let headers = encryptor.decrypt(headers)?;
+        message.header.user_headers_length = headers.len() as u32;
+        message.user_headers = Some(Bytes::from(headers));
+    }
+    Ok(())
+}
+
+/// A partition without a known message waits in a readiness wait when every
+/// poll commits, or when idle partitions could hold every fetch slot for
+/// `max_wait`.
+fn waits_for_readiness(is_group: bool, auto_commit: bool, partitions: usize, slots: usize) -> bool {
+    is_group && (auto_commit || partitions > slots)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::client_wrappers::client_wrapper::ClientWrapper;
+    use crate::clients::consumer::tests::{builder_for, message_at};
+    use crate::clients::consumer::{AutoCommit, AutoCommitWhen};
+    use crate::clients::consumer_builder::IggyConsumerBuilder;
+    use crate::tcp::tcp_client::TcpClient;
+    use iggy_common::locking::{IggyRwLock, IggyRwLockFn};
+    use iggy_common::{Consumer, DeferredPollOptions, Identifier, IggyTimestamp, PartitionContext};
+
+    #[tokio::test(start_paused = true)]
+    async fn byte_and_message_reservations_cover_inflight_and_queued_replies() {
+        const BATCH_COUNT: u32 = 10;
+        const REPLY_BYTES: u32 = 1024;
+        for (bytes, messages) in [
+            (REPLY_BYTES * 2, BATCH_COUNT * 10),
+            (REPLY_BYTES * 10, BATCH_COUNT * 2),
+        ] {
+            let consumer = IggyConsumerBuilder::new(
+                IggyRwLock::new(ClientWrapper::Tcp(TcpClient::default())),
+                "reader".into(),
+                Consumer::default(),
+                Identifier::numeric(1).unwrap(),
+                Identifier::numeric(1).unwrap(),
+                None,
+                None,
+            )
+            .batch_length(BATCH_COUNT)
+            .poll_options(DeferredPollOptions {
+                max_bytes: REPLY_BYTES,
+                ..Default::default()
+            })
+            .prefetch_bytes(bytes)
+            .prefetch_messages(messages)
+            .build();
+            let client = consumer.client.clone();
+            let _blocked_connection = client.write().await;
+            let (sender, mut receiver) = mpsc::channel(consumer.prefetch_slots());
+            let mut worker = Worker::new(&consumer, sender);
+            worker.partitions = vec![0, 1, 2, 3];
+            worker.schedule();
+            assert_eq!(worker.tasks.len(), 2, "reserve before sending");
+            assert_eq!(worker.capacity.available_permits(), 0);
+            while let Some(completed) = worker.tasks.join_next().await {
+                let mut completed = completed.unwrap();
+                assert!(
+                    matches!(completed.result, Err(IggyError::TransientNotAccepted)),
+                    "a timed-out fetch that commits nothing was not accepted"
+                );
+                completed.result = Err(IggyError::CannotReadMessage);
+                worker.complete(completed);
+            }
+            assert_eq!(
+                receiver.len(),
+                2,
+                "completed failures retain their bounded queue reservations"
+            );
+            worker.schedule();
+            assert!(
+                worker.tasks.is_empty(),
+                "a stalled application stops new fetches"
+            );
+            drop(receiver.recv().await.unwrap());
+            worker.retry_at.clear();
+            worker.schedule();
+            assert_eq!(
+                worker.tasks.len(),
+                1,
+                "draining releases exactly one full-batch reservation"
+            );
+            while let Some(completed) = worker.tasks.join_next().await {
+                let mut completed = completed.unwrap();
+                completed.result = Err(IggyError::CannotReadMessage);
+                worker.complete(completed);
+            }
+            worker.invalidate();
+            worker.report(IggyError::Disconnected);
+            assert!(
+                worker.pending_error.is_some(),
+                "retain control errors under backpressure"
+            );
+            drop(receiver.recv().await.unwrap());
+            worker.flush_error();
+            assert_ne!(receiver.recv().await.unwrap().generation, worker.epoch);
+            let error = receiver.recv().await.unwrap();
+            assert_eq!(error.generation, worker.epoch);
+            assert!(matches!(error.result, Err(IggyError::Disconnected)));
+        }
+    }
+
+    #[tokio::test]
+    async fn refused_polls_reach_the_application_and_retry_later() {
+        let consumer = IggyConsumerBuilder::new(
+            IggyRwLock::new(ClientWrapper::Tcp(TcpClient::default())),
+            "reader".into(),
+            Consumer::default(),
+            Identifier::numeric(1).unwrap(),
+            Identifier::numeric(1).unwrap(),
+            None,
+            None,
+        )
+        .build();
+        let (sender, mut receiver) = mpsc::channel(consumer.prefetch_slots());
+        let mut worker = Worker::new(&consumer, sender);
+        for error in [
+            IggyError::TransientNotAccepted,
+            IggyError::DeferredPollCapacityExceeded,
+        ] {
+            let capacity = worker.reserve().unwrap();
+            worker.complete(CompletedPoll {
+                epoch: worker.epoch,
+                partition: 0,
+                strategy: worker.strategy,
+                result: Err(error.clone()),
+                capacity,
+                commit: None,
+            });
+            assert!(worker.retry_at.contains_key(&0), "the partition retries");
+            assert!(
+                matches!(receiver.try_recv().unwrap().result, Err(ref fetched) if *fetched == error),
+                "a refused fetch reaches the application"
+            );
+            worker.report(error.clone());
+            assert!(
+                matches!(receiver.try_recv().unwrap().result, Err(ref reported) if *reported == error),
+                "a refused wait or refresh reaches the application"
+            );
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn committing_fetch_waits_for_demand_and_survives_invalidation() {
+        let (mut worker, mut receiver) =
+            on_demand_worker(Consumer::default(), PollingStrategy::next());
+        let client = worker.client.clone();
+        let _blocked_connection = client.write().await;
+        worker.partitions = vec![0];
+        worker.schedule();
+        assert!(
+            worker.tasks.is_empty(),
+            "no fetch before the application asks"
+        );
+        worker.on_demand.demand.store(true, ORDERING);
+        worker.schedule();
+        worker.schedule();
+        assert_eq!(worker.tasks.len(), 1, "one committing fetch at a time");
+        let started = worker.epoch;
+        worker.invalidate();
+        let completed = worker.tasks.join_next().await.unwrap().unwrap();
+        assert_eq!(completed.epoch, started, "the fetch survives invalidation");
+        assert!(matches!(
+            completed.result,
+            Err(IggyError::TransientNotCommitted)
+        ));
+        worker.complete(completed);
+        assert!(receiver.is_empty(), "an invalidated fetch drops its error");
+        assert!(worker.retry_at.is_empty());
+
+        worker.complete(fetched(
+            &worker,
+            started,
+            PollingStrategy::next(),
+            0,
+            [3, 4],
+            9,
+        ));
+        let batch = receiver.try_recv().unwrap();
+        assert_eq!(
+            batch.result.unwrap().messages.len(),
+            2,
+            "the server committed the batch, so the application gets it"
+        );
+        assert!(
+            !worker.on_demand.demand.load(ORDERING),
+            "the batch answers the demand"
+        );
+
+        let position = worker.position(0);
+        assert_eq!(
+            position,
+            PollingStrategy::offset(5).with_context(Default::default()),
+            "an interval commit that moves the server offset back is not read again"
+        );
+        worker.complete(fetched(&worker, worker.epoch, position, 0, [5], 9));
+        assert!(receiver.try_recv().is_ok());
+        assert_eq!(
+            worker.position(0),
+            PollingStrategy::offset(6).with_context(Default::default())
+        );
+    }
+
+    #[tokio::test]
+    async fn readiness_follows_the_commit_offset_and_the_timestamp_floor() {
+        let start = PollingStrategy::timestamp(IggyTimestamp::from(1));
+        let (mut worker, _receiver) =
+            on_demand_worker(Consumer::group(Identifier::numeric(1).unwrap()), start);
+        worker.partitions = vec![0, 1];
+        worker.on_demand.ready.extend([0, 1]);
+        let epoch = worker.epoch;
+
+        worker.complete(fetched(&worker, epoch, start, 0, [5, 6], 9));
+        assert_eq!(
+            worker.positions[&0],
+            PollingStrategy::offset(7).with_context(Default::default())
+        );
+        assert!(
+            worker.on_demand.ready.contains(&0),
+            "offsets 7 to 9 are committed and not read yet"
+        );
+        worker.complete(fetched(&worker, epoch, worker.position(0), 0, [7, 8, 9], 9));
+        assert!(!worker.on_demand.ready.contains(&0));
+        assert_eq!(
+            worker.wait_target(0),
+            PollingStrategy::offset(10).with_context(Default::default())
+        );
+
+        worker.complete(fetched(&worker, epoch, start, 1, [], 4));
+        assert!(!worker.on_demand.ready.contains(&1));
+        assert_eq!(
+            worker.wait_target(1),
+            PollingStrategy::offset(5),
+            "wait for a message after the ones that the timestamp skipped"
+        );
+        worker.complete(fetched(&worker, epoch, start, 1, [5], 5));
+        assert!(worker.on_demand.floors.is_empty());
+        assert_eq!(
+            worker.wait_target(1),
+            PollingStrategy::offset(6).with_context(Default::default())
+        );
+
+        let mut fenced = fetched(&worker, epoch, PollingStrategy::offset(6), 1, [], 5);
+        fenced.result.as_mut().unwrap().partition_id =
+            iggy_common::RESYNC_REQUIRED_PARTITION_SENTINEL;
+        worker.complete(fenced);
+        assert_ne!(worker.epoch, epoch, "a fenced assignment refreshes");
+        assert!(
+            worker.partitions.is_empty(),
+            "nothing is fetched before the refresh confirms the assignment"
+        );
+    }
+
+    #[tokio::test]
+    async fn oversized_record_is_fetched_alone_before_its_error_is_reported() {
+        for auto_commit in [
+            AutoCommit::Disabled,
+            AutoCommit::When(AutoCommitWhen::PollingMessages),
+        ] {
+            let consumer = builder_for(Consumer::default())
+                .auto_commit(auto_commit)
+                .build();
+            let (sender, mut receiver) = mpsc::channel(consumer.prefetch_slots());
+            let mut worker = Worker::new(&consumer, sender);
+            for reported in [false, true] {
+                worker.complete(CompletedPoll {
+                    epoch: worker.epoch,
+                    partition: 0,
+                    strategy: worker.strategy,
+                    result: Err(IggyError::InvalidSizeBytes),
+                    capacity: worker.reserve().unwrap(),
+                    commit: None,
+                });
+                assert_eq!(worker.oversized.contains(&0), !reported);
+                assert_eq!(worker.retry_at.contains_key(&0), reported);
+                assert_eq!(
+                    receiver.try_recv().is_ok(),
+                    reported,
+                    "only the fetch without a byte limit reports the error"
+                );
+            }
+        }
+    }
+
+    #[tokio::test]
+    async fn new_generation_resumes_after_the_last_delivered_message() {
+        let consumer = builder_for(Consumer::default())
+            .polling_strategy(PollingStrategy::first())
+            .auto_commit(AutoCommit::Disabled)
+            .build();
+        let (sender, mut receiver) = mpsc::channel(consumer.prefetch_slots());
+        let mut worker = Worker::new(&consumer, sender);
+        assert!(worker.apply(worker.read_assignment().await.unwrap()));
+        let partition = worker.partitions[0];
+        for (delivered, resumed) in [
+            (None, PollingStrategy::first()),
+            (
+                Some(0),
+                PollingStrategy::offset(1).with_context(Default::default()),
+            ),
+        ] {
+            worker.complete(fetched(
+                &worker,
+                worker.epoch,
+                PollingStrategy::first(),
+                partition,
+                [0, 1, 2],
+                9,
+            ));
+            assert!(receiver.try_recv().is_ok());
+            if let Some(offset) = delivered {
+                consumer.state.last_consumed_offsets.insert(
+                    partition,
+                    ConsumerPosition {
+                        partition_id: partition,
+                        offset,
+                        context: Default::default(),
+                    },
+                );
+            }
+            let stale = worker.read_assignment().await.unwrap();
+            worker.invalidate();
+            assert!(
+                !worker.apply(stale),
+                "a refresh from before the new generation is read again"
+            );
+            assert!(worker.partitions.is_empty());
+            assert!(worker.apply(worker.read_assignment().await.unwrap()));
+            assert_eq!(worker.partitions, [partition]);
+            assert_eq!(
+                worker.position(partition),
+                resumed,
+                "the batch that the application did not take is fetched again"
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn rebalance_keeps_the_position_of_a_partition_that_stays_with_the_member() {
+        let consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
+            .polling_strategy(PollingStrategy::first())
+            .auto_commit(AutoCommit::Disabled)
+            .build();
+        let (sender, _receiver) = mpsc::channel(consumer.prefetch_slots());
+        let mut worker = Worker::new(&consumer, sender);
+        assert!(worker.apply(assigned(&worker, 1, 1, [0, 1, 2])));
+        for partition in [0, 2] {
+            worker.complete(fetched(
+                &worker,
+                worker.epoch,
+                PollingStrategy::first(),
+                partition,
+                [0, 1, 2],
+                9,
+            ));
+            consumer.state.last_consumed_offsets.insert(
+                partition,
+                ConsumerPosition {
+                    partition_id: partition,
+                    offset: 0,
+                    context: Default::default(),
+                },
+            );
+        }
+
+        assert!(worker.apply(assigned(&worker, 1, 2, [0, 1])));
+        assert_eq!(
+            worker.position(0),
+            PollingStrategy::offset(1).with_context(Default::default()),
+            "the partition continues after its last delivered message"
+        );
+        assert!(
+            !worker.positions.contains_key(&2),
+            "a partition that left starts over when it comes back"
+        );
+
+        assert!(worker.apply(assigned(&worker, 2, 2, [0, 1])));
+        assert_eq!(
+            worker.position(0),
+            PollingStrategy::first(),
+            "a new session owns every partition anew"
+        );
+    }
+
+    #[tokio::test]
+    async fn committing_member_retries_a_lost_reply_until_the_partition_changes_owner() {
+        let (mut worker, _receiver) = on_demand_worker(
+            Consumer::group(Identifier::numeric(1).unwrap()),
+            PollingStrategy::next(),
+        );
+        assert!(worker.apply(assigned(&worker, 1, 1, [0, 1])));
+        worker.complete(fetched(
+            &worker,
+            worker.epoch,
+            PollingStrategy::next(),
+            0,
+            [3, 4],
+            9,
+        ));
+        let retried = worker.position(0);
+        worker.complete(CompletedPoll {
+            epoch: worker.epoch,
+            partition: 0,
+            strategy: retried,
+            result: Err(IggyError::TransientNotCommitted),
+            capacity: worker.reserve().unwrap(),
+            commit: None,
+        });
+        assert!(worker.retry_at.contains_key(&0));
+
+        assert!(worker.apply(assigned(&worker, 1, 2, [0])));
+        assert_eq!(
+            worker.position(0),
+            retried,
+            "`Next` would start after the batch that the server may have committed"
+        );
+
+        worker.complete(CompletedPoll {
+            epoch: worker.epoch,
+            partition: 0,
+            strategy: retried,
+            result: Err(IggyError::ConsumerGroupPartitionNotOwned(1, 0)),
+            capacity: worker.reserve().unwrap(),
+            commit: None,
+        });
+        assert!(worker.apply(assigned(&worker, 1, 2, [0])));
+        assert_eq!(
+            worker.position(0),
+            PollingStrategy::next(),
+            "a partition that moved away and back resolves its position again"
+        );
+    }
+
+    #[tokio::test]
+    async fn stored_offset_moves_the_fetch_position_past_a_message_that_never_reads() {
+        let consumer = builder_for(Consumer::default())
+            .auto_commit(AutoCommit::Disabled)
+            .build();
+        let (sender, _receiver) = mpsc::channel(consumer.prefetch_slots());
+        let mut worker = Worker::new(&consumer, sender);
+        let context = PartitionContext {
+            incarnation: 7,
+            ..Default::default()
+        };
+        let stuck = PollingStrategy::offset(5).with_context(context);
+        worker.positions.insert(0, stuck);
+        let store = |offset, context| {
+            consumer.state.last_stored_offsets.insert(
+                0,
+                ConsumerPosition {
+                    partition_id: 0,
+                    offset,
+                    context,
+                },
+            );
+        };
+
+        store(4, context);
+        assert_eq!(worker.position(0), stuck, "a commit behind the position");
+        store(
+            9,
+            PartitionContext {
+                incarnation: 8,
+                ..Default::default()
+            },
+        );
+        assert_eq!(worker.position(0), stuck, "a commit in another incarnation");
+        store(5, context);
+        assert_eq!(
+            worker.position(0),
+            PollingStrategy::offset(6).with_context(context)
+        );
+    }
+
+    #[tokio::test]
+    async fn readiness_waits_cover_each_endpoint_once_and_retry_after_errors() {
+        let (mut worker, mut receiver) = on_demand_worker(
+            Consumer::group(Identifier::numeric(1).unwrap()),
+            PollingStrategy::next(),
+        );
+        let client = worker.client.clone();
+        let _blocked_connection = client.write().await;
+        worker.partitions = vec![0, 1, 2];
+        worker.on_demand.groups = vec![vec![0, 2], vec![1]];
+        worker.on_demand.demand.store(true, ORDERING);
+        worker.schedule();
+        worker.schedule();
+        assert_eq!(
+            worker.on_demand.waits.len(),
+            2,
+            "one wait per primary endpoint"
+        );
+        assert!(worker.tasks.is_empty(), "no partition is known to be ready");
+
+        worker.complete_wait(CompletedWait {
+            partitions: vec![0, 2],
+            result: Ok(vec![2, 5]),
+        });
+        assert_eq!(
+            worker.on_demand.ready,
+            BTreeSet::from([2]),
+            "a reply marks only the partitions that the wait named"
+        );
+        worker.complete_wait(CompletedWait {
+            partitions: vec![1],
+            result: Err(IggyError::TransientNotAccepted),
+        });
+        assert!(worker.retry_at.contains_key(&1), "the partition retries");
+        assert!(worker.pending_error.is_none());
+        assert!(
+            matches!(
+                receiver.try_recv().unwrap().result,
+                Err(IggyError::TransientNotAccepted)
+            ),
+            "the application sees a refused wait"
+        );
+        // The application asks again after it took the error.
+        worker.on_demand.demand.store(true, ORDERING);
+
+        worker.schedule();
+        assert!(
+            worker.active.contains(&2),
+            "a ready partition is fetched without another wait"
+        );
+
+        worker.complete_wait(CompletedWait {
+            partitions: vec![0],
+            result: Err(IggyError::InvalidCommand),
+        });
+        assert!(matches!(
+            receiver.try_recv().unwrap().result,
+            Err(IggyError::InvalidCommand)
+        ));
+
+        let epoch = worker.epoch;
+        worker.complete_wait(CompletedWait {
+            partitions: vec![0, 2],
+            result: Err(IggyError::ConsumerGroupPartitionNotOwned(1, 0)),
+        });
+        assert_ne!(
+            worker.epoch, epoch,
+            "a moved partition refreshes the assignment"
+        );
+        assert!(worker.on_demand.groups.is_empty());
+        assert!(worker.on_demand.waits.is_empty());
+        assert!(receiver.is_empty());
+    }
+
+    #[tokio::test]
+    async fn committing_member_watches_idle_partitions_while_it_fetches_a_backlog() {
+        let (mut worker, _receiver) = on_demand_worker(
+            Consumer::group(Identifier::numeric(1).unwrap()),
+            PollingStrategy::next(),
+        );
+        let client = worker.client.clone();
+        let _blocked_connection = client.write().await;
+        worker.partitions = vec![0, 1];
+        worker.on_demand.groups = vec![vec![0, 1]];
+        worker.on_demand.ready.insert(0);
+        worker.on_demand.demand.store(true, ORDERING);
+        worker.schedule();
+        assert!(worker.active.contains(&0));
+        let watching = worker.on_demand.waiting.get(&1).map(AbortHandle::id);
+        assert!(
+            watching.is_some(),
+            "a backlog on one partition does not hide new messages on another"
+        );
+
+        let epoch = worker.epoch;
+        worker.complete(fetched(
+            &worker,
+            epoch,
+            PollingStrategy::next(),
+            0,
+            [5, 6],
+            9,
+        ));
+        worker.schedule();
+        assert_eq!(
+            worker.on_demand.waiting.get(&1).map(AbortHandle::id),
+            watching,
+            "the wait runs on while no partition becomes idle"
+        );
+
+        worker.complete(fetched(&worker, epoch, worker.position(0), 0, [7, 8, 9], 9));
+        worker.schedule();
+        let restarted = worker.on_demand.waiting.get(&0).map(AbortHandle::id);
+        assert!(
+            restarted.is_some_and(|wait| worker.on_demand.covers(wait, &[0, 1])),
+            "a drained partition restarts the wait of its group before the application asks"
+        );
+        let replaced = worker.on_demand.waits.join_next_with_id().await.unwrap();
+        assert!(
+            replaced.is_err_and(|error| error.is_cancelled() && Some(error.id()) == watching),
+            "the restart cancels the wait that it replaces"
+        );
+        assert_eq!(worker.on_demand.waits.len(), 1, "one wait per group");
+        assert!(
+            watching.is_some_and(|wait| !worker.on_demand.covers(wait, &[1])),
+            "a reply of the replaced wait is stale"
+        );
+    }
+
+    #[tokio::test]
+    async fn changed_readiness_groups_restart_every_wait() {
+        let (mut worker, _receiver) = on_demand_worker(
+            Consumer::group(Identifier::numeric(1).unwrap()),
+            PollingStrategy::next(),
+        );
+        let client = worker.client.clone();
+        let _blocked_connection = client.write().await;
+        let (epoch, assignment) = (worker.epoch, AssignmentVersion::default());
+        worker.assignment = Some(assignment);
+        let refreshed = |groups| Refreshed {
+            epoch,
+            assignment,
+            partitions: vec![0, 1],
+            groups: Some(groups),
+        };
+        assert!(worker.apply(refreshed(vec![vec![0, 1]])));
+        worker.schedule();
+        let watching = worker.on_demand.waiting.get(&0).map(AbortHandle::id);
+        assert!(worker.apply(refreshed(vec![vec![0, 1]])));
+        worker.schedule();
+        assert!(
+            watching.is_some_and(|wait| worker.on_demand.covers(wait, &[0, 1])),
+            "a refresh that keeps the groups keeps the waits"
+        );
+
+        assert!(worker.apply(refreshed(vec![vec![0], vec![1]])));
+        worker.schedule();
+        let waits: BTreeSet<_> = worker
+            .on_demand
+            .waiting
+            .values()
+            .map(AbortHandle::id)
+            .collect();
+        assert_eq!(
+            (worker.on_demand.waiting.len(), waits.len()),
+            (2, 2),
+            "each wait covers one group, so a restart of one group leaves the other watched"
+        );
+    }
+
+    #[tokio::test]
+    async fn more_partitions_than_fetch_slots_fetch_only_partitions_that_hold_messages() {
+        let consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
+            .auto_commit(AutoCommit::Disabled)
+            .build();
+        let (sender, _receiver) = mpsc::channel(consumer.prefetch_slots());
+        let mut worker = Worker::new(&consumer, sender);
+        let client = worker.client.clone();
+        let _blocked_connection = client.write().await;
+        let partitions: Vec<u32> = (0..=u32::try_from(worker.slots).unwrap()).collect();
+        worker.partitions = partitions.clone();
+        worker.on_demand.groups = vec![partitions.clone()];
+        worker.schedule();
+        assert!(worker.tasks.is_empty(), "an idle partition holds no slot");
+        assert_eq!(worker.on_demand.waits.len(), 1);
+
+        worker.complete_wait(CompletedWait {
+            partitions: partitions.clone(),
+            result: Ok(vec![3]),
+        });
+        worker.schedule();
+        assert_eq!(worker.active, BTreeSet::from([3]));
+        assert_eq!(
+            worker.on_demand.waiting.len(),
+            partitions.len() - 1,
+            "the next wait covers every other partition"
+        );
+
+        let epoch = worker.epoch;
+        worker.complete(fetched(
+            &worker,
+            epoch,
+            PollingStrategy::next(),
+            3,
+            [5, 6],
+            9,
+        ));
+        worker.schedule();
+        assert!(
+            worker.active.contains(&3),
+            "a partition that holds more messages is fetched again without a wait"
+        );
+        worker.complete(fetched(&worker, epoch, worker.position(3), 3, [7, 8, 9], 9));
+        assert!(!worker.on_demand.ready.contains(&3));
+        worker.schedule();
+        let restarted = worker.on_demand.waiting.get(&3).map(AbortHandle::id);
+        assert!(
+            restarted.is_some_and(|wait| worker.on_demand.covers(wait, &partitions)),
+            "a drained partition restarts the wait of its group at once"
+        );
+    }
+
+    #[tokio::test]
+    async fn group_member_should_drop_only_the_continuation_of_the_refused_partition() {
+        for error in [
+            IggyError::HistoryUnavailable,
+            IggyError::ConsumerGroupPartitionNotOwned(1, 3),
+        ] {
+            let consumer = builder_for(Consumer::group(Identifier::numeric(1).unwrap()))
+                .auto_commit(AutoCommit::Disabled)
+                .build();
+            let (sender, mut receiver) = mpsc::channel(consumer.prefetch_slots());
+            let mut worker = Worker::new(&consumer, sender);
+            worker.positions.insert(3, PollingStrategy::offset(11));
+            worker.positions.insert(4, PollingStrategy::offset(21));
+            worker.complete(CompletedPoll {
+                epoch: worker.epoch,
+                partition: 3,
+                strategy: PollingStrategy::offset(11),
+                result: Err(error.clone()),
+                capacity: worker.reserve().unwrap(),
+                commit: None,
+            });
+            assert_eq!(receiver.recv().await.unwrap().result.unwrap_err(), error);
+            assert!(!worker.positions.contains_key(&3));
+            assert_eq!(worker.positions.get(&4), Some(&PollingStrategy::offset(21)));
+        }
+    }
+
+    #[tokio::test]
+    async fn committing_worker_should_drop_only_the_continuation_of_a_partition_without_history() {
+        let (mut worker, mut receiver) = on_demand_worker(
+            Consumer::new(Identifier::numeric(1).unwrap()),
+            PollingStrategy::first(),
+        );
+        worker.positions.insert(3, PollingStrategy::offset(11));
+        worker.positions.insert(4, PollingStrategy::offset(21));
+        worker.complete(CompletedPoll {
+            epoch: worker.epoch,
+            partition: 3,
+            strategy: PollingStrategy::offset(11),
+            result: Err(IggyError::HistoryUnavailable),
+            capacity: worker.reserve().unwrap(),
+            commit: None,
+        });
+        assert!(matches!(
+            receiver.recv().await.unwrap().result,
+            Err(IggyError::HistoryUnavailable)
+        ));
+        assert!(!worker.positions.contains_key(&3));
+        assert!(worker.retry_at.contains_key(&3));
+
+        worker.complete_wait(CompletedWait {
+            partitions: vec![4, 5],
+            result: Err(IggyError::HistoryUnavailable),
+        });
+        assert_eq!(
+            worker.on_demand.ready,
+            BTreeSet::from([4, 5]),
+            "each partition of the wait fetches alone"
+        );
+        assert!(receiver.is_empty(), "only a fetch reports the lost history");
+        assert_eq!(worker.positions.get(&4), Some(&PollingStrategy::offset(21)));
+    }
+
+    fn on_demand_worker(
+        consumer: Consumer,
+        strategy: PollingStrategy,
+    ) -> (Worker, mpsc::Receiver<PrefetchedBatch>) {
+        let consumer = builder_for(consumer)
+            .polling_strategy(strategy)
+            .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
+            .build();
+        let (sender, receiver) = mpsc::channel(consumer.prefetch_slots());
+        (Worker::new(&consumer, sender), receiver)
+    }
+
+    /// A refresh in the current fetch state that read `partitions`.
+    fn assigned(
+        worker: &Worker,
+        session: u64,
+        generation: u64,
+        partitions: impl Into<Vec<u32>>,
+    ) -> Refreshed {
+        Refreshed {
+            epoch: worker.epoch,
+            assignment: AssignmentVersion {
+                session,
+                generation,
+            },
+            partitions: partitions.into(),
+            groups: None,
+        }
+    }
+
+    /// A fetch of `partition` that the worker started in `epoch`.
+    fn fetched(
+        worker: &Worker,
+        epoch: u64,
+        strategy: PollingStrategy,
+        partition: u32,
+        offsets: impl IntoIterator<Item = u64>,
+        current_offset: u64,
+    ) -> CompletedPoll {
+        let messages: Vec<_> = offsets.into_iter().map(message_at).collect();
+        CompletedPoll {
+            epoch,
+            partition,
+            strategy,
+            result: Ok(PolledMessages {
+                partition_id: partition,
+                context: Default::default(),
+                current_offset,
+                count: messages.len() as u32,
+                messages,
+            }),
+            capacity: worker.reserve().unwrap(),
+            commit: None,
+        }
+    }
+}
diff --git a/core/sdk/src/clients/consumer_builder.rs b/core/sdk/src/clients/consumer_builder.rs
index 91cb9ac..3086327 100644
--- a/core/sdk/src/clients/consumer_builder.rs
+++ b/core/sdk/src/clients/consumer_builder.rs
@@ -16,10 +16,11 @@
 // under the License.
 
 use crate::client_wrappers::client_wrapper::ClientWrapper;
-use crate::prelude::{AutoCommit, AutoCommitWhen, IggyConsumer};
+use crate::prelude::{AutoCommit, IggyConsumer};
 use iggy_common::locking::IggyRwLock;
 use iggy_common::{
-    Consumer, EncryptorKind, Identifier, IggyDuration, NonZeroIggyDuration, PollingStrategy,
+    Consumer, DeferredPollOptions, EncryptorKind, Identifier, IggyDuration, NonZeroIggyDuration,
+    PollingStrategy,
 };
 use std::sync::Arc;
 
@@ -32,8 +33,10 @@
     topic: Identifier,
     partition: Option<u32>,
     polling_strategy: PollingStrategy,
-    polling_interval: Option<IggyDuration>,
     batch_length: u32,
+    poll_options: DeferredPollOptions,
+    prefetch_bytes: u32,
+    prefetch_messages: Option<u32>,
     auto_commit: AutoCommit,
     auto_join_consumer_group: bool,
     create_consumer_group_if_not_exists: bool,
@@ -55,7 +58,6 @@
         topic_id: Identifier,
         partition_id: Option<u32>,
         encryptor: Option<Arc<EncryptorKind>>,
-        polling_interval: Option<IggyDuration>,
     ) -> Self {
         Self {
             client,
@@ -66,14 +68,13 @@
             partition: partition_id,
             polling_strategy: PollingStrategy::next(),
             batch_length: 1000,
-            auto_commit: AutoCommit::IntervalOrWhen(
-                NonZeroIggyDuration::ONE_SECOND,
-                AutoCommitWhen::PollingMessages,
-            ),
+            poll_options: DeferredPollOptions::default(),
+            prefetch_bytes: super::consumer::deferred::DEFAULT_PREFETCH_BYTES,
+            prefetch_messages: None,
+            auto_commit: AutoCommit::Disabled,
             auto_join_consumer_group: true,
             create_consumer_group_if_not_exists: true,
             encryptor,
-            polling_interval,
             polling_retry_interval: NonZeroIggyDuration::ONE_SECOND,
             init_retries: None,
             init_retry_interval: NonZeroIggyDuration::ONE_SECOND,
@@ -115,7 +116,38 @@
         }
     }
 
+    /// Sets readiness, response-byte and overall request limits. Defaults to one
+    /// message, a 1 MiB reply, a one-second wait and an eleven-second request timeout.
+    /// A message larger than the reply limit is fetched alone, without that limit.
+    pub fn poll_options(self, poll_options: DeferredPollOptions) -> Self {
+        Self {
+            poll_options,
+            ..self
+        }
+    }
+
+    /// Caps encoded bytes reserved for in-flight and buffered replies. Default: 16 MiB.
+    /// Must fit at least one complete reply configured by `poll_options`.
+    pub fn prefetch_bytes(self, prefetch_bytes: u32) -> Self {
+        Self {
+            prefetch_bytes,
+            ..self
+        }
+    }
+
+    /// Caps messages reserved for in-flight and buffered replies. Default: 16,000, or
+    /// `batch_length` when that is larger. Must fit at least `batch_length` messages.
+    pub fn prefetch_messages(self, prefetch_messages: u32) -> Self {
+        Self {
+            prefetch_messages: Some(prefetch_messages),
+            ..self
+        }
+    }
+
     /// Sets the auto-commit configuration for storing the offset on the server.
+    /// Defaults to [`AutoCommit::Disabled`]: the consumer stores no offset, so a
+    /// restart with `Next` reads every message after the last offset stored with
+    /// `store_offset` or `store_position` again.
     pub fn auto_commit(self, auto_commit: AutoCommit) -> Self {
         Self {
             auto_commit,
@@ -158,22 +190,6 @@
         }
     }
 
-    /// Sets the polling interval for messages.
-    pub fn poll_interval(self, interval: IggyDuration) -> Self {
-        Self {
-            polling_interval: Some(interval),
-            ..self
-        }
-    }
-
-    /// Clears the polling interval for messages.
-    pub fn without_poll_interval(self) -> Self {
-        Self {
-            polling_interval: None,
-            ..self
-        }
-    }
-
     /// Sets the encryptor for decrypting the messages' payloads.
     pub fn encryptor(self, encryptor: Arc<EncryptorKind>) -> Self {
         Self {
@@ -219,8 +235,9 @@
         }
     }
 
-    /// Sets how long `shutdown()` waits for the background auto-commit tasks to
-    /// drain before leaving the consumer group. 5 seconds by default.
+    /// Sets how long `shutdown()` waits for each background auto-commit task to
+    /// drain before leaving the consumer group. Under auto-commit-on-poll, a fetch
+    /// in flight gets the same time to hand its batch over. 5 seconds by default.
     pub fn offset_drain_timeout(self, timeout: IggyDuration) -> Self {
         Self {
             offset_drain_timeout: timeout,
@@ -239,7 +256,11 @@
             self.stream,
             self.topic,
             self.partition,
-            self.polling_interval,
+            self.poll_options,
+            self.prefetch_bytes,
+            self.prefetch_messages.unwrap_or(
+                super::consumer::deferred::DEFAULT_PREFETCH_MESSAGES.max(self.batch_length),
+            ),
             self.polling_strategy,
             self.batch_length,
             self.auto_commit,
diff --git a/core/sdk/src/consumer_ext/consumer_message_ext.rs b/core/sdk/src/consumer_ext/consumer_message_ext.rs
index 63961b9..39dbea8 100644
--- a/core/sdk/src/consumer_ext/consumer_message_ext.rs
+++ b/core/sdk/src/consumer_ext/consumer_message_ext.rs
@@ -21,7 +21,7 @@
 use async_trait::async_trait;
 use futures_util::StreamExt;
 use tokio::sync::oneshot;
-use tracing::{error, info, trace};
+use tracing::{error, info, trace, warn};
 
 #[async_trait]
 impl<'a> IggyConsumerMessageExt<'a> for IggyConsumer {
@@ -59,6 +59,15 @@
     where
         P: MessageConsumer + Sync,
     {
+        // Unlike a caller of `next()`, a message consumer gets no handle to store offsets with.
+        if self.relies_on_manual_commits() {
+            warn!(
+                "Consumer: {name} on topic: {topic} and stream: {stream} never stores offsets, because it runs with AutoCommit::Disabled. With PollingStrategy::next(), each restart reads the same messages again. Set auto_commit to store them.",
+                name = self.name(),
+                topic = self.topic(),
+                stream = self.stream()
+            );
+        }
         let auto_commit = self.auto_commit();
         let store_offset_after_each_message = matches!(
             auto_commit,
@@ -123,6 +132,7 @@
                                 err,
                                 IggyError::InvalidServerAddress
                                     | IggyError::InvalidClientAddress
+                                    | IggyError::InvalidConfiguration
                                     | IggyError::LaneBlocked(_)
                                     | IggyError::ClientShutdown
                             ) || !self.client_can_recover().await;
diff --git a/core/sdk/src/driver.rs b/core/sdk/src/driver.rs
index 3a61faa..45b3802 100644
--- a/core/sdk/src/driver.rs
+++ b/core/sdk/src/driver.rs
@@ -24,10 +24,13 @@
 use crate::session_owner::{LaneKey, LaneTicket, RetainedOperation, SessionEpoch, SessionOwner};
 use crate::vsr::decode_reply_for;
 use bytes::Bytes;
+use futures::stream::{self, FuturesUnordered, StreamExt};
 use iggy_binary_protocol::codes::{
-    BIND_SESSION_CODE, CHANGE_PASSWORD_CODE, DELETE_CONSUMER_OFFSET_CODE, GET_CONSUMER_OFFSET_CODE,
-    GET_PARTITION_ROUTE_CODE, LOGIN_REGISTER_CODE, LOGIN_REGISTER_WITH_PAT_CODE,
-    POLL_MESSAGES_CODE, SEND_MESSAGES_CODE, STORE_CONSUMER_OFFSET_CODE, UPDATE_RESOURCE_CODE,
+    BIND_SESSION_CODE, CANCEL_DEFERRED_REQUEST_CODE, CHANGE_PASSWORD_CODE,
+    DELETE_CONSUMER_OFFSET_CODE, GET_CONSUMER_OFFSET_CODE, GET_PARTITION_ROUTE_CODE,
+    LOGIN_REGISTER_CODE, LOGIN_REGISTER_WITH_PAT_CODE, POLL_MESSAGES_CODE,
+    POLL_MESSAGES_DEFERRED_CODE, SEND_MESSAGES_CODE, STORE_CONSUMER_OFFSET_CODE,
+    UPDATE_RESOURCE_CODE, WAIT_FOR_MESSAGES_CODE,
 };
 use iggy_binary_protocol::consensus::{Operation, operation_for_code};
 use iggy_binary_protocol::primitives::partition_history::PartitionContext;
@@ -35,32 +38,84 @@
     DeleteConsumerOffsetRequest, GetConsumerOffsetRequest, StoreConsumerOffsetRequest,
 };
 use iggy_binary_protocol::requests::messages::{
-    GetPartitionRouteRequest, PollMessagesRequest, SendMessagesHeader,
+    DeferredPollMessagesRequest, GetPartitionRouteRequest, MIN_DEFERRED_POLL_REPLY_MARGIN_US,
+    PollMessagesRequest, SendMessagesHeader, WaitForMessagesRequest,
 };
 use iggy_binary_protocol::requests::system::{ResourceTarget, UpdateResourceRequest};
 use iggy_binary_protocol::requests::users::ChangePasswordRequest;
-use iggy_binary_protocol::responses::messages::PartitionRouteResponse;
+use iggy_binary_protocol::responses::messages::{PartitionRouteResponse, WaitForMessagesResponse};
 use iggy_binary_protocol::responses::users::LoginRegisterResponse;
 use iggy_binary_protocol::{
-    WireDecode, WireEncode, WireOptions, WirePartitioning, WirePollingStrategy,
+    MAX_PARTITIONS_PER_REQUEST, WireDecode, WireEncode, WireOptions, WirePartitioning,
+    WirePollingStrategy,
 };
 use iggy_common::{
-    BinaryOperationLineage, ClientState, Credentials, DiagnosticEvent, HeaderKind, Identifier,
-    IggyError, OptionKey,
+    BinaryOperationLineage, ClientState, Credentials, DeferredPollOptions, DiagnosticEvent,
+    HeaderKind, Identifier, IggyError, OptionKey,
 };
 use route_cache::RouteCache;
 use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
+use tokio::sync::{OwnedSemaphorePermit, Semaphore, SemaphorePermit};
 use tokio::time::{Instant, timeout_at};
 use tracing::debug;
 
 mod route_cache;
 
+pub(crate) const MAX_DEFERRED_REQUESTS: usize = 64;
+pub(crate) const MAX_DEFERRED_REQUESTS_PER_CONSUMER: usize = 16;
+/// Route lookups in flight while [`OperationDriver::readiness_groups`] groups
+/// the partitions of a wait. After a move, each stale partition needs one, so
+/// a primary that held 333 of 1000 partitions on 3 nodes costs 21 round trips
+/// instead of 333. Sixteen is a small share of the requests that one link
+/// holds, so the polls and sends of the client keep their room.
+const MAX_ROUTE_LOOKUPS_IN_FLIGHT: usize = 16;
+
+tokio::task_local! {
+    pub(crate) static DEFERRED_CONSUMER_CAPACITY: Arc<Semaphore>;
+}
+
 pub(crate) use route_cache::PartitionKey;
 
 pub(crate) use iggy_common::{
     binary_operation_deadline as operation_deadline, with_binary_deadline as with_deadline,
 };
 
+/// Readiness fanout and fetches share the consumer's wire admission limit.
+pub(crate) struct DeferredLease<'a> {
+    _client: SemaphorePermit<'a>,
+    _consumer: Option<OwnedSemaphorePermit>,
+}
+
+impl<'a> DeferredLease<'a> {
+    pub(crate) async fn acquire(
+        client: &'a Semaphore,
+        deadline: Instant,
+    ) -> Result<Self, IggyError> {
+        timeout_at(deadline, async {
+            // Queued work from one consumer must not reserve every client slot.
+            let consumer = match DEFERRED_CONSUMER_CAPACITY.try_with(Arc::clone) {
+                Ok(capacity) => Some(
+                    capacity
+                        .acquire_owned()
+                        .await
+                        .map_err(|_| IggyError::ClientShutdown)?,
+                ),
+                Err(_) => None,
+            };
+            let client = client
+                .acquire()
+                .await
+                .map_err(|_| IggyError::ClientShutdown)?;
+            Ok(Self {
+                _client: client,
+                _consumer: consumer,
+            })
+        })
+        .await
+        .map_err(|_| IggyError::TransientNotAccepted)?
+    }
+}
+
 /// Where a binary client sends one request.
 #[derive(Debug, Clone, Copy)]
 pub(crate) enum RequestTarget<'a> {
@@ -86,6 +141,7 @@
     session: Arc<SessionOwner>,
     links: ReplicaLinks,
     routes: Mutex<RouteCache>,
+    deferred_requests: tokio::sync::Semaphore,
 }
 
 impl OperationDriver {
@@ -95,6 +151,7 @@
             session: Arc::clone(links.session()),
             links,
             routes: Mutex::default(),
+            deferred_requests: tokio::sync::Semaphore::new(MAX_DEFERRED_REQUESTS),
         }
     }
 
@@ -155,6 +212,15 @@
         request: &PollMessagesRequest,
         context: Option<PartitionContext>,
     ) -> Result<Bytes, IggyError> {
+        self.poll_with_options(request, context, None).await
+    }
+
+    pub(crate) async fn poll_with_options(
+        &self,
+        request: &PollMessagesRequest,
+        context: Option<PartitionContext>,
+        options: Option<DeferredPollOptions>,
+    ) -> Result<Bytes, IggyError> {
         let stream_id = Identifier::try_from(&request.stream_id)?;
         let topic_id = Identifier::try_from(&request.topic_id)?;
         let partition_id = match request.partition_id {
@@ -166,25 +232,226 @@
             }
             None => return Err(IggyError::InvalidIdentifier),
         };
-        let call = Call::new(
-            POLL_MESSAGES_CODE,
-            request.to_bytes(),
-            RequestTarget::Partition(PartitionTarget {
-                stream_id: &stream_id,
-                topic_id: &topic_id,
-                partition_id,
-                context,
-            }),
-            operation_deadline(),
-        );
-        self.drive(Call {
-            progress_poll: request.auto_commit
-                || request.strategy.kind == WirePollingStrategy::next().kind,
-            ..call
+        let started = Instant::now();
+        let deadline = match options {
+            Some(options) => {
+                options.validate(request.count)?;
+                started
+                    .checked_add(options.request_timeout.get_duration())
+                    .ok_or(IggyError::InvalidCommand)?
+            }
+            None => operation_deadline(),
+        };
+        with_deadline(deadline, async {
+            let (code, payload, _lease) = match options {
+                Some(options) => {
+                    let lease = self.deferred_lease().await?;
+                    // A link fails on a reply larger than it reads, so the
+                    // server must not fill more.
+                    let max_bytes =
+                        self.links
+                            .reply_body_limit()
+                            .map_or(options.max_bytes, |limit| {
+                                options
+                                    .max_bytes
+                                    .min(u32::try_from(limit).unwrap_or(u32::MAX))
+                            });
+                    let wire = DeferredPollMessagesRequest {
+                        poll: request.clone(),
+                        wait_us: options.max_wait.as_micros(),
+                        min_count: options.min_count,
+                        max_bytes,
+                        request_timeout_us: options.request_timeout.as_micros(),
+                    };
+                    (POLL_MESSAGES_DEFERRED_CODE, wire.to_bytes(), Some(lease))
+                }
+                None => (POLL_MESSAGES_CODE, request.to_bytes(), None),
+            };
+            let call = Call::new(
+                code,
+                payload,
+                RequestTarget::Partition(PartitionTarget {
+                    stream_id: &stream_id,
+                    topic_id: &topic_id,
+                    partition_id,
+                    context,
+                }),
+                deadline,
+            );
+            self.drive(Call {
+                started,
+                progress_poll: request.auto_commit
+                    || request.strategy.kind == WirePollingStrategy::next().kind,
+                deadline: operation_deadline(),
+                ..call
+            })
+            .await
         })
         .await
     }
 
+    /// The partitions grouped by primary endpoint and split to fit one wait
+    /// request each. Each group keeps the order of `partitions`, and the first
+    /// failed lookup in that order is the error.
+    pub(crate) async fn readiness_groups(
+        &self,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        partitions: &[u32],
+    ) -> Result<Vec<Vec<u32>>, IggyError> {
+        let lineage = self.session.operation_lineage().lineage();
+        let deadline = operation_deadline();
+        let mut routes = stream::iter(partitions.iter().copied())
+            .map(|partition_id| {
+                let key = PartitionKey {
+                    stream_id: stream_id.clone(),
+                    topic_id: topic_id.clone(),
+                    partition_id,
+                };
+                async move {
+                    self.route(&key, lineage, deadline)
+                        .await
+                        .map(|route| (route.primary_replica, partition_id))
+                }
+            })
+            .buffered(MAX_ROUTE_LOOKUPS_IN_FLIGHT);
+        let mut endpoints = std::collections::BTreeMap::<u8, Vec<u32>>::new();
+        while let Some(route) = routes.next().await {
+            let (primary_replica, partition_id) = route?;
+            endpoints
+                .entry(primary_replica)
+                .or_default()
+                .push(partition_id);
+        }
+        let mut groups = Vec::with_capacity(endpoints.len());
+        for partitions in endpoints.into_values() {
+            groups.extend(
+                partitions
+                    .chunks(MAX_PARTITIONS_PER_REQUEST as usize)
+                    .map(<[u32]>::to_vec),
+            );
+        }
+        Ok(groups)
+    }
+
+    pub(crate) async fn wait_for_messages(
+        &self,
+        request: &WaitForMessagesRequest,
+    ) -> Result<Bytes, IggyError> {
+        let started = Instant::now();
+        let deadline = started
+            .checked_add(std::time::Duration::from_micros(request.request_timeout_us))
+            .ok_or(IggyError::InvalidCommand)?;
+        let stream_id = Identifier::try_from(&request.stream_id)?;
+        let topic_id = Identifier::try_from(&request.topic_id)?;
+        let partitions: Vec<u32> = request
+            .partitions
+            .iter()
+            .map(|target| target.partition_id)
+            .collect();
+        let wait = move |group: Vec<u32>| {
+            let mut request = request.clone();
+            request
+                .partitions
+                .retain(|target| group.contains(&target.partition_id));
+            async move { (self.wait_on_primary(request, started).await, group) }
+        };
+        with_deadline(deadline, async {
+            let mut waits: FuturesUnordered<_> = self
+                .wait_groups(&stream_id, &topic_id, &partitions)
+                .await?
+                .into_iter()
+                .map(wait)
+                .collect();
+            let mut moves = 0;
+            while let Some((reply, group)) = waits.next().await {
+                let body = match reply {
+                    Err(IggyError::NotPrimary) => {
+                        self.routes().may_have_moved(&stream_id, &topic_id, &group);
+                        if !pause_before_retry(moves, operation_deadline(), rand::random()).await {
+                            return Err(IggyError::TransientNotAccepted);
+                        }
+                        moves = moves.saturating_add(1);
+                        let groups = self.wait_groups(&stream_id, &topic_id, &group).await?;
+                        waits.extend(groups.into_iter().map(wait));
+                        continue;
+                    }
+                    reply => reply?,
+                };
+                let response = WaitForMessagesResponse::decode_from(&body)
+                    .map_err(|_| IggyError::InvalidCommand)?;
+                if !response.partitions.is_empty() {
+                    return Ok(body);
+                }
+            }
+            Ok(WaitForMessagesResponse {
+                partitions: Vec::new(),
+            }
+            .to_bytes())
+        })
+        .await
+    }
+
+    /// [`Self::readiness_groups`], retried until a lookup answers or the
+    /// deadline passes.
+    async fn wait_groups(
+        &self,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        partitions: &[u32],
+    ) -> Result<Vec<Vec<u32>>, IggyError> {
+        let mut retries = 0;
+        loop {
+            match self.readiness_groups(stream_id, topic_id, partitions).await {
+                Err(error) if retries_lookup(&error) => {
+                    if !pause_before_retry(retries, operation_deadline(), rand::random()).await {
+                        return Err(IggyError::TransientNotAccepted);
+                    }
+                    retries = retries.saturating_add(1);
+                }
+                groups => return groups,
+            }
+        }
+    }
+
+    /// `started` is when the wait began, before routing. The caller's deadline
+    /// ends the wait.
+    async fn wait_on_primary(
+        &self,
+        request: WaitForMessagesRequest,
+        started: Instant,
+    ) -> Result<Bytes, IggyError> {
+        let partition_id = request
+            .partitions
+            .first()
+            .ok_or(IggyError::InvalidCommand)?
+            .partition_id;
+        let stream_id = Identifier::try_from(&request.stream_id)?;
+        let topic_id = Identifier::try_from(&request.topic_id)?;
+        let _lease = self.deferred_lease().await?;
+        self.drive(Call {
+            started,
+            ..Call::new(
+                WAIT_FOR_MESSAGES_CODE,
+                request.to_bytes(),
+                RequestTarget::Partition(PartitionTarget {
+                    stream_id: &stream_id,
+                    topic_id: &topic_id,
+                    partition_id,
+                    context: None,
+                }),
+                operation_deadline(),
+            )
+        })
+        .await
+    }
+
+    /// Waits for one of the [`MAX_DEFERRED_REQUESTS`] slots. The wait spends
+    /// the request budget, so a frame encodes its budget after the lease.
+    async fn deferred_lease(&self) -> Result<DeferredLease<'_>, IggyError> {
+        DeferredLease::acquire(&self.deferred_requests, operation_deadline()).await
+    }
+
     pub(crate) async fn send_raw(
         &self,
         code: u32,
@@ -210,6 +477,21 @@
                     .map_err(|_| IggyError::InvalidCommand)?;
                 return self.poll(&request, context).await;
             }
+            POLL_MESSAGES_DEFERRED_CODE => {
+                let request = DeferredPollMessagesRequest::decode_from(&payload)
+                    .map_err(|_| IggyError::InvalidCommand)?;
+                return self
+                    .poll_with_options(&request.poll, context, Some((&request).into()))
+                    .await;
+            }
+            WAIT_FOR_MESSAGES_CODE => {
+                let request = WaitForMessagesRequest::decode_from(&payload)
+                    .map_err(|_| IggyError::InvalidCommand)?;
+                return self.wait_for_messages(&request).await;
+            }
+            // A cancel names a pending request by its id, and the server answers no
+            // cancel, so a new raw request could only wait for its deadline.
+            CANCEL_DEFERRED_REQUEST_CODE => return Err(IggyError::InvalidCommand),
             GET_CONSUMER_OFFSET_CODE => {
                 let request = GetConsumerOffsetRequest::decode_from(&payload)
                     .map_err(|_| IggyError::InvalidCommand)?;
@@ -256,8 +538,10 @@
 
     /// Sends a non-replicated request to replica `replica_id` and returns its
     /// reply. Writes and partition reads go through the routing of their own
-    /// kind: a poll or a consumer-offset read needs the partition's primary and
-    /// its incarnation, which a request to a chosen replica does not carry.
+    /// kind: a poll, a readiness wait or a consumer-offset read needs the
+    /// partition's primary and its incarnation, which a request to a chosen
+    /// replica does not carry. The server answers no cancel, so a raw one
+    /// could only wait for its deadline.
     pub(crate) async fn send_to_replica(
         &self,
         code: u32,
@@ -265,8 +549,14 @@
         replica_id: u8,
     ) -> Result<Bytes, IggyError> {
         if operation_for_code(code) != Operation::NonReplicated
-            || code == POLL_MESSAGES_CODE
-            || code == GET_CONSUMER_OFFSET_CODE
+            || matches!(
+                code,
+                POLL_MESSAGES_CODE
+                    | POLL_MESSAGES_DEFERRED_CODE
+                    | WAIT_FOR_MESSAGES_CODE
+                    | CANCEL_DEFERRED_REQUEST_CODE
+                    | GET_CONSUMER_OFFSET_CODE
+            )
         {
             return Err(IggyError::InvalidCommand);
         }
@@ -587,18 +877,32 @@
         match call.ticket.take() {
             Some(ticket) => Ok(ticket),
             None => {
-                let key = if call.progress_poll {
-                    Some(LaneKey::Partition {
-                        incarnation: context.incarnation,
-                        partition_id: call.partition_id(),
-                    })
+                let key = LaneKey::of(call.code, context, call.partition_id());
+                if call.progress_poll
+                    || matches!(
+                        call.code,
+                        GET_CONSUMER_OFFSET_CODE
+                            | STORE_CONSUMER_OFFSET_CODE
+                            | DELETE_CONSUMER_OFFSET_CODE
+                    )
+                {
+                    self.session
+                        .enter_progress_lane(
+                            LaneKey::Progress {
+                                incarnation: context.incarnation,
+                                partition_id: call.partition_id(),
+                            },
+                            key,
+                            call.deadline,
+                        )
+                        .await
+                        .map_err(Next::Fail)
                 } else {
-                    LaneKey::of(call.code, context, call.partition_id())
-                };
-                self.session
-                    .enter_lane(key, call.deadline)
-                    .await
-                    .map_err(Next::Fail)
+                    self.session
+                        .enter_lane(key, call.deadline)
+                        .await
+                        .map_err(Next::Fail)
+                }
             }
         }
     }
@@ -691,6 +995,14 @@
             }
             // The caller chose the replica, so its refusal is the answer.
             IggyError::NotPrimary if call.replica.is_some() => Err(Next::Fail(error)),
+            IggyError::DeferredPollCapacityExceeded => {
+                call.refusal = error;
+                call.settle_sent();
+                Err(Next::Pause)
+            }
+            // A wait names several partitions and its refusal does not name
+            // the one that moved, so the caller looks up every route again.
+            IggyError::NotPrimary if call.code == WAIT_FOR_MESSAGES_CODE => Err(Next::Fail(error)),
             IggyError::NotPrimary => {
                 // Neither was admitted, so the deadline reports both as 58.
                 call.refusal = IggyError::TransientNotAccepted;
@@ -816,7 +1128,10 @@
     /// The first one is the replica that a refusal named, else the metadata
     /// primary. An answer older than the cached route goes to the next one.
     /// A lookup that no replica answers usably ends with 58, which the caller
-    /// retries until its deadline.
+    /// retries until its deadline. A client with no ready replica, no session
+    /// and no credentials cannot get a route, because the server refuses it
+    /// every read and it never signs in by itself, so its lookup ends with
+    /// `Unauthenticated` at once.
     async fn lookup(
         &self,
         key: &PartitionKey,
@@ -829,6 +1144,12 @@
         let metadata_primary = self.links.metadata_primary();
         let first = hinted.or(metadata_primary);
         let mut replicas = self.links.ready_replicas();
+        if replicas.is_empty()
+            && !self.session.epoch().bound
+            && self.session.sign_in_credentials().is_none()
+        {
+            return Err(IggyError::Unauthenticated);
+        }
         let start = first.map_or(0, |first| replicas.partition_point(|id| *id < first));
         replicas.rotate_left(start);
         for replica in replicas {
@@ -944,6 +1265,7 @@
     replica: Option<u8>,
     /// A call to a replica the roster did not name read the roster once.
     roster_read: bool,
+    started: Instant,
     deadline: Instant,
     ticket: Option<LaneTicket>,
     frame: Option<Frame>,
@@ -1037,6 +1359,7 @@
             partition,
             replica,
             roster_read: false,
+            started: Instant::now(),
             deadline,
             ticket: None,
             frame: None,
@@ -1085,8 +1408,10 @@
         context: PartitionContext,
         lineage: u64,
     ) -> Result<(Frame, &mut LaneTicket), Next> {
+        let payload = self.deferred_payload();
         let ticket = self.ticket.insert(ticket);
-        let frame = match &self.frame {
+        let payload = payload.map_err(Next::Fail)?;
+        let mut frame = match &self.frame {
             Some(frame) => {
                 if !ticket.revalidate(lineage).map_err(Next::Fail)? {
                     return Err(Next::Again);
@@ -1105,9 +1430,57 @@
                 frame
             }
         };
+        if let Some(payload) = payload {
+            // Non-replicated reads have no replay receipt; their budgets can shrink
+            // without changing the request identity used to match replies.
+            frame.body = payload;
+        }
         Ok((frame, ticket))
     }
 
+    fn deferred_payload(&self) -> Result<Option<Bytes>, IggyError> {
+        let remaining = |wait_us: u64, request_timeout_us: u64| {
+            let now = Instant::now();
+            let mut options = DeferredPollOptions {
+                max_wait: wait_us.into(),
+                request_timeout: request_timeout_us.into(),
+                ..DeferredPollOptions::default()
+            }
+            .remaining(now.saturating_duration_since(self.started))?;
+            options.request_timeout = options
+                .request_timeout
+                .get_duration()
+                .min(self.deadline.saturating_duration_since(now))
+                .into();
+            let request_timeout_us = options.request_timeout.as_micros();
+            if request_timeout_us == 0 {
+                return Err(IggyError::TransientNotAccepted);
+            }
+            let wait_us = options
+                .max_wait
+                .as_micros()
+                .min(request_timeout_us.saturating_sub(MIN_DEFERRED_POLL_REPLY_MARGIN_US));
+            Ok((wait_us, request_timeout_us))
+        };
+        match self.code {
+            POLL_MESSAGES_DEFERRED_CODE => {
+                let mut request = DeferredPollMessagesRequest::decode_from(&self.payload)
+                    .map_err(|_| IggyError::InvalidCommand)?;
+                (request.wait_us, request.request_timeout_us) =
+                    remaining(request.wait_us, request.request_timeout_us)?;
+                Ok(Some(request.to_bytes()))
+            }
+            WAIT_FOR_MESSAGES_CODE => {
+                let mut request = WaitForMessagesRequest::decode_from(&self.payload)
+                    .map_err(|_| IggyError::InvalidCommand)?;
+                (request.wait_us, request.request_timeout_us) =
+                    remaining(request.wait_us, request.request_timeout_us)?;
+                Ok(Some(request.to_bytes()))
+            }
+            _ => Ok(None),
+        }
+    }
+
     /// Whether a send that got `HistoryUnavailable` starts over under the
     /// next route. Only a send without a captured context starts over, and
     /// only once.
@@ -1241,7 +1614,9 @@
         STORE_CONSUMER_OFFSET_CODE,
     };
     use iggy_binary_protocol::consensus::EvictionReason;
-    use iggy_binary_protocol::requests::messages::{RawMessage, SendMessagesEncoder};
+    use iggy_binary_protocol::requests::messages::{
+        RawMessage, SendMessagesEncoder, WaitForMessagesTarget,
+    };
     use iggy_binary_protocol::{WireConsumer, WireIdentifier};
     use iggy_common::{
         AutoLogin, Client, Credentials, DiagnosticEvent, IggyMessage, MessageClient, Partitioning,
@@ -1257,6 +1632,8 @@
     const REPLICAS: u8 = 3;
     const PRIMARY: u8 = 1;
     const PARTITION_ID: u32 = 1;
+    /// A partition that keeps its primary when [`PARTITION_ID`] moves.
+    const STAYED: u32 = 0;
     const PAYLOAD: &[u8] = b"payload";
     const COMMIT: u64 = 20;
     /// Long enough for a frame to reach a replica, far below the deadline.
@@ -1340,6 +1717,19 @@
             }
         }
 
+        /// The body of a raw deferred poll that moves its consumer.
+        fn deferred_poll(&self) -> Bytes {
+            let options = DeferredPollOptions::default();
+            DeferredPollMessagesRequest {
+                poll: self.poll(true, WirePollingStrategy::next()),
+                wait_us: options.max_wait.as_micros(),
+                min_count: options.min_count,
+                max_bytes: options.max_bytes,
+                request_timeout_us: options.request_timeout.as_micros(),
+            }
+            .to_bytes()
+        }
+
         async fn send_messages(&self) -> Result<Bytes, IggyError> {
             self.call(SEND_MESSAGES_CODE, self.partition(None)).await
         }
@@ -1433,6 +1823,18 @@
     }
 
     #[tokio::test(start_paused = true)]
+    async fn given_raw_cancel_when_sent_should_refuse_it_before_the_wire() {
+        let f = Fixture::new().await;
+        assert_eq!(
+            f.driver
+                .send_raw(CANCEL_DEFERRED_REQUEST_CODE, Bytes::new(), None)
+                .await,
+            Err(IggyError::InvalidCommand)
+        );
+        assert!(f.sends().is_empty());
+    }
+
+    #[tokio::test(start_paused = true)]
     async fn given_raw_send_with_length_prefixed_metadata_when_sent_should_route_the_unchanged_frame()
      {
         let f = Fixture::new().await;
@@ -2054,6 +2456,9 @@
             SEND_MESSAGES_CODE,
             STORE_CONSUMER_OFFSET_CODE,
             POLL_MESSAGES_CODE,
+            POLL_MESSAGES_DEFERRED_CODE,
+            WAIT_FOR_MESSAGES_CODE,
+            CANCEL_DEFERRED_REQUEST_CODE,
             GET_CONSUMER_OFFSET_CODE,
         ] {
             assert_eq!(
@@ -2328,41 +2733,48 @@
 
     #[tokio::test(start_paused = true)]
     async fn given_uncertain_poll_progress_when_a_reply_is_lost_should_end_without_replay() {
-        for (auto_commit, strategy, progress) in [
-            (true, WirePollingStrategy::first(), true),
-            (false, WirePollingStrategy::next(), true),
-            (false, WirePollingStrategy::first(), false),
-        ] {
-            for (failure, uncertain) in [
-                (Answer::Refuse(IggyError::TransientNotCommitted), true),
-                (Answer::HangUp, true),
-                (Answer::Refuse(IggyError::TransientNotAccepted), false),
+        for code in [POLL_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE] {
+            for (auto_commit, strategy, progress) in [
+                (true, WirePollingStrategy::first(), true),
+                (false, WirePollingStrategy::next(), true),
+                (false, WirePollingStrategy::first(), false),
             ] {
-                let f = Fixture::new().await;
-                f.cluster.script(
-                    &endpoint(PRIMARY),
-                    POLL_MESSAGES_CODE,
-                    [failure, Answer::Body(Vec::new())],
-                );
-                let result = f
-                    .driver
-                    .poll(&f.poll(auto_commit, strategy.clone()), None)
-                    .await;
-                let polls = f.cluster.received(POLL_MESSAGES_CODE);
-                if progress && uncertain {
-                    assert_eq!(result, Err(IggyError::TransientNotCommitted));
-                    assert_eq!(polls.len(), 1, "uncertain progress must not be replayed");
-                    let operation_id = polls[0].header.request;
-                    assert_eq!(
-                        f.driver.abandon(operation_id),
-                        Err(IggyError::OperationNotRetained(operation_id))
+                for (failure, uncertain) in [
+                    (Answer::Refuse(IggyError::TransientNotCommitted), true),
+                    (Answer::HangUp, true),
+                    (Answer::Refuse(IggyError::TransientNotAccepted), false),
+                ] {
+                    let f = Fixture::new().await;
+                    f.cluster.script(
+                        &endpoint(PRIMARY),
+                        code,
+                        [failure, Answer::Body(Vec::new())],
                     );
-                    f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None))
-                        .await
-                        .unwrap();
-                } else {
-                    assert_eq!(result, Ok(Bytes::new()));
-                    assert_eq!(polls.len(), 2, "pure reads and unaccepted polls may retry");
+                    let result = f
+                        .driver
+                        .poll_with_options(
+                            &f.poll(auto_commit, strategy.clone()),
+                            None,
+                            (code == POLL_MESSAGES_DEFERRED_CODE)
+                                .then(DeferredPollOptions::default),
+                        )
+                        .await;
+                    let polls = f.cluster.received(code);
+                    if progress && uncertain {
+                        assert_eq!(result, Err(IggyError::TransientNotCommitted));
+                        assert_eq!(polls.len(), 1, "uncertain progress must not be replayed");
+                        let operation_id = polls[0].header.request;
+                        assert_eq!(
+                            f.driver.abandon(operation_id),
+                            Err(IggyError::OperationNotRetained(operation_id))
+                        );
+                        f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None))
+                            .await
+                            .unwrap();
+                    } else {
+                        assert_eq!(result, Ok(Bytes::new()));
+                        assert_eq!(polls.len(), 2, "pure reads and unaccepted polls may retry");
+                    }
                 }
             }
         }
@@ -2370,58 +2782,121 @@
 
     #[tokio::test(start_paused = true)]
     async fn given_a_progress_poll_when_cancelled_should_release_the_barrier_without_retention() {
-        for (auto_commit, strategy) in [
-            (true, WirePollingStrategy::first()),
-            (false, WirePollingStrategy::next()),
+        for code in [
+            GET_CONSUMER_OFFSET_CODE,
+            STORE_CONSUMER_OFFSET_CODE,
+            DELETE_CONSUMER_OFFSET_CODE,
         ] {
-            let f = Fixture::new().await;
-            f.cluster
-                .script(&endpoint(PRIMARY), POLL_MESSAGES_CODE, [Answer::Silence]);
-            let request = f.poll(auto_commit, strategy);
-            let poll = f.driver.poll(&request, None);
-            let store = f.call(STORE_CONSUMER_OFFSET_CODE, f.partition(None));
-            tokio::pin!(store);
-            let expired = timeout(CALLER_TIMEOUT, async {
-                tokio::pin!(poll);
-                tokio::select! {
-                    _ = &mut poll => panic!("the unanswered poll returned"),
-                    () = eventually("the progress poll arrived", || f.cluster.count(POLL_MESSAGES_CODE) == 1) => {}
-                }
-                tokio::select! {
-                    _ = &mut poll => panic!("the unanswered poll returned"),
-                    _ = &mut store => panic!("the store bypassed the progress barrier"),
-                }
-            }).await;
-            assert!(expired.is_err());
-            assert_eq!(f.cluster.count(STORE_CONSUMER_OFFSET_CODE), 0);
-            let poll_id = f.cluster.received(POLL_MESSAGES_CODE)[0].header.request;
-            assert_eq!(
-                f.driver.abandon(poll_id),
-                Err(IggyError::OperationNotRetained(poll_id))
-            );
-
-            store.await.unwrap();
-            assert_eq!(f.cluster.count(STORE_CONSUMER_OFFSET_CODE), 1);
+            for (auto_commit, strategy) in [
+                (true, WirePollingStrategy::first()),
+                (false, WirePollingStrategy::next()),
+            ] {
+                let f = Fixture::new().await;
+                f.cluster.script(
+                    &endpoint(PRIMARY),
+                    POLL_MESSAGES_DEFERRED_CODE,
+                    [Answer::Silence],
+                );
+                let request = f.poll(auto_commit, strategy);
+                let poll = f.driver.poll_with_options(
+                    &request,
+                    None,
+                    Some(DeferredPollOptions::default()),
+                );
+                let offset = f.call(code, f.partition(None));
+                tokio::pin!(offset);
+                let expired = timeout(CALLER_TIMEOUT, async {
+                    tokio::pin!(poll);
+                    tokio::select! {
+                        _ = &mut poll => panic!("the unanswered poll returned"),
+                        () = eventually("the progress poll arrived", || f.cluster.count(POLL_MESSAGES_DEFERRED_CODE) == 1) => {}
+                    }
+                    assert!(futures::poll!(&mut offset).is_pending());
+                    tokio::select! {
+                        _ = &mut poll => panic!("the unanswered poll returned"),
+                        _ = &mut offset => panic!("the offset request bypassed the progress barrier"),
+                        sent = f.send_messages() => { sent.unwrap(); }
+                    }
+                    tokio::select! {
+                        _ = &mut poll => panic!("the unanswered poll returned"),
+                        _ = &mut offset => panic!("the offset request bypassed the progress barrier"),
+                    }
+                }).await;
+                assert!(expired.is_err());
+                assert_eq!(
+                    f.cluster.count(SEND_MESSAGES_CODE),
+                    1,
+                    "an offset request waiting behind a poll must leave sends unblocked"
+                );
+                assert_eq!(f.cluster.count(code), 0);
+                let poll_id = f.cluster.received(POLL_MESSAGES_DEFERRED_CODE)[0]
+                    .header
+                    .request;
+                assert_eq!(
+                    f.driver.abandon(poll_id),
+                    Err(IggyError::OperationNotRetained(poll_id))
+                );
+                offset.await.unwrap();
+                assert_eq!(f.cluster.count(code), 1);
+            }
         }
     }
 
     #[tokio::test(start_paused = true)]
-    async fn given_a_retained_mutation_when_progress_poll_enters_should_return_its_blocking_id() {
-        let f = Fixture::new().await;
-        let op = f.uncertain_send().await;
+    async fn given_a_retained_offset_mutation_when_progress_enters_should_return_its_blocking_id() {
+        for code in [STORE_CONSUMER_OFFSET_CODE, DELETE_CONSUMER_OFFSET_CODE] {
+            let f = Fixture::new().await;
+            f.cluster.script(
+                &endpoint(PRIMARY),
+                code,
+                [
+                    Answer::Refuse(IggyError::TransientNotCommitted),
+                    Answer::Refuse(IggyError::RequestTooOld),
+                ],
+            );
+            let result = f.call(code, f.partition(None)).await;
+            let op = f.cluster.received(code)[0].header.request;
+            assert_eq!(result, Err(IggyError::OperationUncertain(op)));
+            assert_eq!(
+                f.call(GET_CONSUMER_OFFSET_CODE, f.partition(None)).await,
+                Err(IggyError::LaneBlocked(op))
+            );
+            for (auto_commit, strategy) in [
+                (true, WirePollingStrategy::first()),
+                (false, WirePollingStrategy::next()),
+            ] {
+                let polled = f.driver.poll(&f.poll(auto_commit, strategy), None).await;
+                assert_eq!(polled, Err(IggyError::LaneBlocked(op)));
+            }
+            assert_eq!(f.cluster.count(POLL_MESSAGES_CODE), 0);
+            f.driver
+                .poll(&f.poll(false, WirePollingStrategy::first()), None)
+                .await
+                .unwrap();
+            f.driver.abandon(op).unwrap();
+            f.driver
+                .poll(&f.poll(false, WirePollingStrategy::next()), None)
+                .await
+                .unwrap();
+            assert_eq!(f.cluster.count(POLL_MESSAGES_CODE), 2);
+        }
+    }
 
-        let polled = f
+    #[tokio::test(start_paused = true)]
+    async fn given_a_retained_send_when_progress_poll_enters_should_keep_progress_independent() {
+        let fixture = Fixture::new().await;
+        let op = fixture.uncertain_send().await;
+        fixture
             .driver
-            .poll(&f.poll(false, WirePollingStrategy::next()), None)
-            .await;
-
-        assert_eq!(polled, Err(IggyError::LaneBlocked(op)));
-        assert_eq!(f.cluster.count(POLL_MESSAGES_CODE), 0);
-        f.driver
-            .poll(&f.poll(false, WirePollingStrategy::first()), None)
+            .poll(&fixture.poll(false, WirePollingStrategy::next()), None)
             .await
-            .expect("a poll that moves no consumer takes no lane");
-        assert_eq!(f.cluster.count(POLL_MESSAGES_CODE), 1);
+            .unwrap();
+        assert_eq!(fixture.cluster.count(POLL_MESSAGES_CODE), 1);
+        assert_eq!(
+            fixture.send_messages().await,
+            Err(IggyError::LaneBlocked(op))
+        );
+        fixture.driver.abandon(op).unwrap();
     }
 
     #[tokio::test(start_paused = true)]
@@ -2865,6 +3340,361 @@
     }
 
     #[tokio::test(start_paused = true)]
+    async fn given_more_partitions_on_one_endpoint_than_a_wait_holds_when_grouping_should_split_them()
+     {
+        let f = Fixture::new().await;
+        let partitions: Vec<u32> = (0..=MAX_PARTITIONS_PER_REQUEST).collect();
+
+        let groups = f
+            .driver
+            .readiness_groups(&f.stream_id, &f.topic_id, &partitions)
+            .await
+            .unwrap();
+
+        let (first, rest) = partitions.split_at(MAX_PARTITIONS_PER_REQUEST as usize);
+        assert_eq!(groups, [first, rest]);
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_partitions_without_routes_when_grouping_should_look_up_a_bounded_number_at_once()
+    {
+        const PARTITIONS: u32 = 3 * MAX_ROUTE_LOOKUPS_IN_FLIGHT as u32;
+        let f = Fixture::new().await;
+        // Descending, so a group in the order that lookups finish differs.
+        let partitions: Vec<u32> = (0..PARTITIONS).rev().collect();
+        f.cluster.hold(GET_PARTITION_ROUTE_CODE);
+
+        let (groups, ()) = tokio::join!(
+            f.driver
+                .readiness_groups(&f.stream_id, &f.topic_id, &partitions),
+            async {
+                eventually("the lookups fill the bound", || {
+                    f.cluster.count(GET_PARTITION_ROUTE_CODE) >= MAX_ROUTE_LOOKUPS_IN_FLIGHT
+                })
+                .await;
+                sleep(CALLER_TIMEOUT).await;
+                assert_eq!(
+                    f.cluster.count(GET_PARTITION_ROUTE_CODE),
+                    MAX_ROUTE_LOOKUPS_IN_FLIGHT,
+                    "a full bound starts no lookup"
+                );
+                for _ in 0..MAX_ROUTE_LOOKUPS_IN_FLIGHT {
+                    f.cluster.release_latest(GET_PARTITION_ROUTE_CODE);
+                }
+                f.cluster.release(GET_PARTITION_ROUTE_CODE);
+            }
+        );
+
+        assert_eq!(
+            groups.unwrap(),
+            [partitions.as_slice()],
+            "a group keeps the order of the partitions"
+        );
+        assert_eq!(
+            f.cluster.count(GET_PARTITION_ROUTE_CODE),
+            partitions.len(),
+            "one lookup per partition"
+        );
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_89_on_a_wait_when_one_of_its_partitions_moved_should_regroup_and_keep_the_other_route()
+     {
+        let f = Fixture::new().await;
+        let options = DeferredPollOptions::default();
+        let wait = WaitForMessagesRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            wait_us: options.max_wait.as_micros(),
+            request_timeout_us: options.request_timeout.as_micros(),
+            partitions: [STAYED, PARTITION_ID]
+                .map(|partition_id| WaitForMessagesTarget {
+                    partition_id,
+                    strategy: WirePollingStrategy::next(),
+                })
+                .to_vec(),
+        };
+        let ready = |partitions: Vec<u32>| {
+            Answer::Body(WaitForMessagesResponse { partitions }.to_bytes().to_vec())
+        };
+        f.cluster.script(
+            &endpoint(PRIMARY),
+            WAIT_FOR_MESSAGES_CODE,
+            [Answer::Refuse(IggyError::NotPrimary), ready(Vec::new())],
+        );
+        // The refusing primary answers the lookups in partition order.
+        f.cluster.script(
+            &endpoint(PRIMARY),
+            GET_PARTITION_ROUTE_CODE,
+            [route_body(ROUTE), route_body(MOVED)],
+        );
+        f.cluster.script(
+            &endpoint(MOVED.primary_replica),
+            WAIT_FOR_MESSAGES_CODE,
+            [ready(vec![PARTITION_ID])],
+        );
+
+        let reply = f.driver.wait_for_messages(&wait).await.unwrap();
+
+        let ready = WaitForMessagesResponse::decode_from(&reply).unwrap();
+        assert_eq!(ready.partitions, [PARTITION_ID]);
+        let waits = f.cluster.received(WAIT_FOR_MESSAGES_CODE);
+        let targets = |received: &Received| -> Vec<u32> {
+            WaitForMessagesRequest::decode_from(&received.body)
+                .unwrap()
+                .partitions
+                .iter()
+                .map(|target| target.partition_id)
+                .collect()
+        };
+        assert_eq!(waits[0].endpoint, endpoint(PRIMARY));
+        assert_eq!(targets(&waits[0]), [STAYED, PARTITION_ID]);
+        let moved: Vec<_> = waits
+            .iter()
+            .filter(|received| received.endpoint == endpoint(MOVED.primary_replica))
+            .map(targets)
+            .collect();
+        assert_eq!(moved, [[PARTITION_ID]]);
+        let stayed = PartitionKey {
+            stream_id: f.stream_id.clone(),
+            topic_id: f.topic_id.clone(),
+            partition_id: STAYED,
+        };
+        let route = f.driver.routes().usable(
+            &stayed,
+            f.driver.session.fence(),
+            f.driver.session.metadata_watermark(),
+        );
+        assert_eq!(route, Some(ROUTE));
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_consumer_limit_when_readiness_regroups_should_bound_fetches_and_release_leases()
+     {
+        let fixture = Fixture::new().await;
+        let capacity = Arc::new(Semaphore::new(1));
+        let options = DeferredPollOptions::default();
+        let wait = WaitForMessagesRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            wait_us: options.max_wait.as_micros(),
+            request_timeout_us: options.request_timeout.as_micros(),
+            partitions: [STAYED, PARTITION_ID]
+                .map(|partition_id| WaitForMessagesTarget {
+                    partition_id,
+                    strategy: WirePollingStrategy::next(),
+                })
+                .to_vec(),
+        };
+        fixture.cluster.script(
+            &endpoint(PRIMARY),
+            WAIT_FOR_MESSAGES_CODE,
+            [Answer::Refuse(IggyError::NotPrimary), Answer::Silence],
+        );
+        fixture.cluster.script(
+            &endpoint(PRIMARY),
+            GET_PARTITION_ROUTE_CODE,
+            [route_body(ROUTE), route_body(MOVED)],
+        );
+        fixture.cluster.script(
+            &endpoint(MOVED.primary_replica),
+            WAIT_FOR_MESSAGES_CODE,
+            [Answer::Silence],
+        );
+        let mut waiting = Box::pin(DEFERRED_CONSUMER_CAPACITY.scope(
+            Arc::clone(&capacity),
+            fixture.driver.wait_for_messages(&wait),
+        ));
+        tokio::select! {
+            reply = &mut waiting => panic!("a regrouped wait ended: {reply:?}"),
+            () = eventually("one regrouped wait reaches its primary", || {
+                fixture.cluster.count(WAIT_FOR_MESSAGES_CODE) >= 2
+            }) => {}
+        }
+        let poll = fixture.poll(false, WirePollingStrategy::first());
+        let short_options = DeferredPollOptions {
+            max_wait: Duration::ZERO.into(),
+            request_timeout: CALLER_TIMEOUT.into(),
+            ..options
+        };
+        let mut fetching = Box::pin(
+            DEFERRED_CONSUMER_CAPACITY.scope(
+                Arc::clone(&capacity),
+                fixture
+                    .driver
+                    .poll_with_options(&poll, None, Some(short_options)),
+            ),
+        );
+        let mut other_poll = poll.clone();
+        other_poll.consumer = WireConsumer::consumer(WireIdentifier::numeric(2));
+        tokio::select! {
+            biased;
+            reply = &mut waiting => panic!("a parked wait ended: {reply:?}"),
+            reply = &mut fetching => panic!("a fetch passed its consumer's wait: {reply:?}"),
+            reply = DEFERRED_CONSUMER_CAPACITY.scope(
+                Arc::new(Semaphore::new(1)),
+                fixture.driver.poll_with_options(&other_poll, None, Some(options)),
+            ) => { reply.unwrap(); }
+        }
+        assert_eq!(fixture.cluster.count(POLL_MESSAGES_DEFERRED_CODE), 1);
+        assert_eq!(
+            fixture.driver.deferred_requests.available_permits(),
+            MAX_DEFERRED_REQUESTS - 1,
+            "queued work must not reserve another consumer's client slots"
+        );
+        tokio::select! {
+            reply = &mut waiting => panic!("a parked wait ended before the fetch deadline: {reply:?}"),
+            reply = &mut fetching => assert_eq!(reply, Err(IggyError::TransientNotAccepted)),
+        }
+        assert_eq!(
+            fixture.cluster.count(WAIT_FOR_MESSAGES_CODE),
+            2,
+            "regrouping must not admit both primaries at once"
+        );
+        assert_eq!(fixture.cluster.count(POLL_MESSAGES_DEFERRED_CODE), 1);
+        assert_eq!(capacity.available_permits(), 0);
+        drop(fetching);
+        drop(waiting);
+        assert_eq!(capacity.available_permits(), 1);
+        assert_eq!(
+            fixture.driver.deferred_requests.available_permits(),
+            MAX_DEFERRED_REQUESTS
+        );
+        DEFERRED_CONSUMER_CAPACITY
+            .scope(
+                Arc::clone(&capacity),
+                fixture.driver.poll_with_options(&poll, None, Some(options)),
+            )
+            .await
+            .unwrap();
+        assert_eq!(fixture.cluster.count(POLL_MESSAGES_DEFERRED_CODE), 2);
+        assert_eq!(capacity.available_permits(), 1);
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_full_client_when_a_consumer_poll_expires_should_release_its_consumer_lease() {
+        let mut fixture = Fixture::new().await;
+        fixture.driver.deferred_requests = Semaphore::new(1);
+        fixture.cluster.script(
+            &endpoint(PRIMARY),
+            POLL_MESSAGES_DEFERRED_CODE,
+            [Answer::Silence],
+        );
+        let poll = fixture.poll(false, WirePollingStrategy::first());
+        let options = DeferredPollOptions::default();
+        let mut parked = Box::pin(fixture.driver.poll_with_options(&poll, None, Some(options)));
+        tokio::select! {
+            reply = &mut parked => panic!("the client lease was released early: {reply:?}"),
+            () = eventually("the first poll occupies the client lease", || {
+                fixture.cluster.count(POLL_MESSAGES_DEFERRED_CODE) == 1
+            }) => {}
+        }
+        let capacity = Arc::new(Semaphore::new(1));
+        let short_options = DeferredPollOptions {
+            max_wait: Duration::ZERO.into(),
+            request_timeout: CALLER_TIMEOUT.into(),
+            ..options
+        };
+        tokio::select! {
+            reply = &mut parked => panic!("the client lease was released early: {reply:?}"),
+            reply = DEFERRED_CONSUMER_CAPACITY.scope(
+                Arc::clone(&capacity),
+                fixture.driver.poll_with_options(&poll, None, Some(short_options)),
+            ) => assert_eq!(reply, Err(IggyError::TransientNotAccepted)),
+        }
+        assert_eq!(fixture.cluster.count(POLL_MESSAGES_DEFERRED_CODE), 1);
+        assert_eq!(capacity.available_permits(), 1);
+        assert_eq!(fixture.driver.deferred_requests.available_permits(), 0);
+        drop(parked);
+        assert_eq!(fixture.driver.deferred_requests.available_permits(), 1);
+        DEFERRED_CONSUMER_CAPACITY
+            .scope(
+                Arc::clone(&capacity),
+                fixture.driver.poll_with_options(&poll, None, Some(options)),
+            )
+            .await
+            .unwrap();
+        assert_eq!(fixture.cluster.count(POLL_MESSAGES_DEFERRED_CODE), 2);
+        assert_eq!(capacity.available_permits(), 1);
+        assert_eq!(fixture.driver.deferred_requests.available_permits(), 1);
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_no_session_when_a_raw_deferred_call_needs_a_route_should_end_with_unauthenticated_at_once()
+     {
+        let mut f = Fixture::new().await;
+        f.driver = OperationDriver::new(ReplicaLinks::new(
+            f.cluster.dialer(),
+            endpoint(0),
+            policy(),
+            Arc::new(SessionOwner::new(AutoLogin::Disabled)),
+        ));
+        f.driver.links.connect().await.unwrap();
+        assert!(f.driver.links.ready_replicas().is_empty());
+        let options = DeferredPollOptions::default();
+        let wait = WaitForMessagesRequest {
+            consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+            stream_id: WireIdentifier::numeric(1),
+            topic_id: WireIdentifier::numeric(2),
+            wait_us: options.max_wait.as_micros(),
+            request_timeout_us: options.request_timeout.as_micros(),
+            partitions: vec![WaitForMessagesTarget {
+                partition_id: PARTITION_ID,
+                strategy: WirePollingStrategy::next(),
+            }],
+        };
+
+        for (code, payload) in [
+            (POLL_MESSAGES_DEFERRED_CODE, f.deferred_poll()),
+            (WAIT_FOR_MESSAGES_CODE, wait.to_bytes()),
+        ] {
+            let result = timeout(RECOVERY_TIMEOUT, f.driver.send_raw(code, payload, None))
+                .await
+                .expect("the call must not wait for its deadline");
+            assert_eq!(result, Err(IggyError::Unauthenticated), "{code}");
+        }
+        assert_eq!(f.cluster.count(GET_PARTITION_ROUTE_CODE), 0);
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_bound_session_without_credentials_when_its_replicas_bind_again_should_wait_for_a_route()
+     {
+        let mut f = Fixture::new().await;
+        let session = Arc::new(SessionOwner::new(AutoLogin::Disabled));
+        session.bind(FIRST_SESSION).unwrap();
+        f.driver = OperationDriver::new(ReplicaLinks::new(
+            f.cluster.dialer(),
+            endpoint(0),
+            policy(),
+            session,
+        ));
+        f.driver.links.connect().await.unwrap();
+        eventually("every slot serves the session", || {
+            f.driver.links.ready_replicas().len() == usize::from(REPLICAS)
+        })
+        .await;
+        f.cluster.hold(BIND_SESSION_CODE);
+        for id in 0..REPLICAS {
+            f.cluster.drop_connections(&endpoint(id));
+        }
+        eventually("no replica serves the session", || {
+            f.driver.links.ready_replicas().is_empty()
+        })
+        .await;
+        let mut poll = Box::pin(f.driver.send_raw(
+            POLL_MESSAGES_DEFERRED_CODE,
+            f.deferred_poll(),
+            None,
+        ));
+
+        assert!(timeout(CALLER_TIMEOUT, &mut poll).await.is_err());
+        f.cluster.release(BIND_SESSION_CODE);
+        poll.await.unwrap();
+    }
+
+    #[tokio::test(start_paused = true)]
     async fn given_a_stopped_client_when_sending_should_fail_without_a_frame() {
         let f = Fixture::new().await;
 
@@ -3698,27 +4528,287 @@
     }
 
     #[tokio::test(start_paused = true)]
+    async fn given_a_lease_wait_and_shorter_caller_deadline_when_polling_should_keep_a_valid_remaining_budget()
+     {
+        const LEASE_DELAY: Duration = Duration::from_millis(250);
+        for budget in [Duration::from_secs(2), Duration::from_millis(500)] {
+            let fixture = Fixture::new().await;
+            let lease = fixture
+                .driver
+                .deferred_requests
+                .acquire_many(MAX_DEFERRED_REQUESTS as u32)
+                .await
+                .unwrap();
+            let options = DeferredPollOptions {
+                max_wait: Duration::from_secs(5).into(),
+                request_timeout: Duration::from_secs(8).into(),
+                ..DeferredPollOptions::default()
+            };
+            let poll = fixture.poll(true, WirePollingStrategy::next());
+            let deadline = Instant::now() + budget;
+            let (result, ()) = tokio::join!(
+                with_deadline(
+                    deadline,
+                    fixture.driver.poll_with_options(&poll, None, Some(options))
+                ),
+                async {
+                    sleep(LEASE_DELAY).await;
+                    drop(lease);
+                }
+            );
+            result.unwrap();
+            let polls = fixture.cluster.received(POLL_MESSAGES_DEFERRED_CODE);
+            assert_eq!(polls.len(), 1);
+            let request = DeferredPollMessagesRequest::decode_from(&polls[0].body).unwrap();
+            assert!(request.request_timeout_us <= (budget - LEASE_DELAY).as_micros() as u64);
+            assert!(request.request_timeout_us > 0);
+            if request.request_timeout_us <= MIN_DEFERRED_POLL_REPLY_MARGIN_US {
+                assert_eq!(
+                    request.wait_us, 0,
+                    "a short remaining budget permits only an immediate poll"
+                );
+            } else {
+                assert!(request.wait_us > 0);
+                assert!(
+                    request.wait_us + MIN_DEFERRED_POLL_REPLY_MARGIN_US
+                        <= request.request_timeout_us
+                );
+            }
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_deferred_routing_and_refusal_delays_when_retrying_should_shrink_each_wire_budget()
+     {
+        const WAIT: Duration = Duration::from_secs(5);
+        const REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
+        const ROUTE_DELAY: Duration = Duration::from_millis(500);
+        const REFUSAL_DELAY: Duration = Duration::from_secs(1);
+        for (code, refusal) in [
+            (POLL_MESSAGES_DEFERRED_CODE, IggyError::TransientNotAccepted),
+            (POLL_MESSAGES_DEFERRED_CODE, IggyError::NotPrimary),
+            (
+                POLL_MESSAGES_DEFERRED_CODE,
+                IggyError::DeferredPollCapacityExceeded,
+            ),
+            (WAIT_FOR_MESSAGES_CODE, IggyError::TransientNotAccepted),
+            (
+                WAIT_FOR_MESSAGES_CODE,
+                IggyError::DeferredPollCapacityExceeded,
+            ),
+        ] {
+            let fixture = Fixture::new().await;
+            let options = DeferredPollOptions {
+                max_wait: WAIT.into(),
+                request_timeout: REQUEST_TIMEOUT.into(),
+                ..DeferredPollOptions::default()
+            };
+            let poll = fixture.poll(true, WirePollingStrategy::next());
+            let wait = WaitForMessagesRequest {
+                consumer: poll.consumer.clone(),
+                stream_id: poll.stream_id.clone(),
+                topic_id: poll.topic_id.clone(),
+                wait_us: options.max_wait.as_micros(),
+                request_timeout_us: options.request_timeout.as_micros(),
+                partitions: vec![WaitForMessagesTarget {
+                    partition_id: PARTITION_ID,
+                    strategy: poll.strategy.clone(),
+                }],
+            };
+            fixture.cluster.hold(GET_PARTITION_ROUTE_CODE);
+            fixture.cluster.hold(code);
+            if refusal == IggyError::NotPrimary {
+                fixture.cluster.script(
+                    &endpoint(PRIMARY),
+                    GET_PARTITION_ROUTE_CODE,
+                    [route_body(PartitionRouteResponse {
+                        view: ROUTE.view + 1,
+                        ..ROUTE
+                    })],
+                );
+            }
+            fixture.cluster.script(
+                &endpoint(PRIMARY),
+                code,
+                [
+                    Answer::Refuse(refusal),
+                    Answer::Body(
+                        WaitForMessagesResponse {
+                            partitions: Vec::new(),
+                        }
+                        .to_bytes()
+                        .to_vec(),
+                    ),
+                ],
+            );
+            let (result, ()) = tokio::join!(
+                async {
+                    if code == POLL_MESSAGES_DEFERRED_CODE {
+                        fixture
+                            .driver
+                            .poll_with_options(&poll, None, Some(options))
+                            .await
+                    } else {
+                        fixture.driver.wait_for_messages(&wait).await
+                    }
+                },
+                async {
+                    eventually("initial deferred route is held", || {
+                        fixture.cluster.count(GET_PARTITION_ROUTE_CODE) == 1
+                    })
+                    .await;
+                    sleep(ROUTE_DELAY).await;
+                    fixture.cluster.release(GET_PARTITION_ROUTE_CODE);
+                    eventually("initial deferred refusal is held", || {
+                        fixture.cluster.count(code) == 1
+                    })
+                    .await;
+                    sleep(REFUSAL_DELAY).await;
+                    fixture.cluster.release(code);
+                }
+            );
+            let attempts = fixture.cluster.received(code);
+            assert!(
+                result.is_ok(),
+                "code={code}, result={result:?}, attempts={}",
+                attempts.len()
+            );
+            assert_eq!(attempts.len(), 2);
+            assert_eq!(
+                bytemuck::bytes_of(&attempts[0].header),
+                bytemuck::bytes_of(&attempts[1].header),
+                "only the deferred budget changes on a refused attempt"
+            );
+            let budgets: Vec<_> = attempts
+                .iter()
+                .map(|attempt| {
+                    if code == POLL_MESSAGES_DEFERRED_CODE {
+                        let request =
+                            DeferredPollMessagesRequest::decode_from(&attempt.body).unwrap();
+                        assert_eq!(request.poll, poll);
+                        assert_eq!(request.min_count, options.min_count);
+                        assert_eq!(request.max_bytes, options.max_bytes);
+                        (request.wait_us, request.request_timeout_us)
+                    } else {
+                        let request = WaitForMessagesRequest::decode_from(&attempt.body).unwrap();
+                        assert_eq!(request.partitions, wait.partitions);
+                        (request.wait_us, request.request_timeout_us)
+                    }
+                })
+                .collect();
+            assert!(budgets[0].0 <= (WAIT - ROUTE_DELAY).as_micros() as u64);
+            assert!(budgets[0].1 <= (REQUEST_TIMEOUT - ROUTE_DELAY).as_micros() as u64);
+            assert!(budgets[1].0 < budgets[0].0);
+            assert!(
+                budgets[1].1 <= (REQUEST_TIMEOUT - ROUTE_DELAY - REFUSAL_DELAY).as_micros() as u64
+            );
+            assert!(budgets.iter().all(|(wait_us, timeout_us)| {
+                *wait_us > 0 && *timeout_us >= *wait_us + MIN_DEFERRED_POLL_REPLY_MARGIN_US
+            }));
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_capacity_refusals_when_polling_should_back_off_without_refreshing_the_route() {
+        for auto_commit in [false, true] {
+            let fixture = Fixture::new().await;
+            fixture.cluster.script(
+                &endpoint(PRIMARY),
+                POLL_MESSAGES_DEFERRED_CODE,
+                [
+                    Answer::Refuse(IggyError::DeferredPollCapacityExceeded),
+                    Answer::Refuse(IggyError::DeferredPollCapacityExceeded),
+                    Answer::Body(Vec::new()),
+                ],
+            );
+            let request = fixture.poll(auto_commit, WirePollingStrategy::first());
+            let started = Instant::now();
+
+            fixture
+                .driver
+                .poll_with_options(&request, None, Some(DeferredPollOptions::default()))
+                .await
+                .unwrap();
+
+            assert!(
+                started.elapsed() > Duration::ZERO,
+                "capacity refusals back off"
+            );
+            assert_eq!(fixture.cluster.count(GET_PARTITION_ROUTE_CODE), 1);
+            let polls = fixture.cluster.received(POLL_MESSAGES_DEFERRED_CODE);
+            assert_eq!(polls.len(), 3);
+            assert!(polls.iter().all(|poll| {
+                poll.endpoint == endpoint(PRIMARY) && poll.header.request == polls[0].header.request
+            }));
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_many_quiet_polls_when_a_client_is_shared_should_admit_another_consumer() {
+        const QUIET_POLLS: usize = 16;
+        let fixture = Fixture::new().await;
+        fixture.cluster.script(
+            &endpoint(PRIMARY),
+            POLL_MESSAGES_DEFERRED_CODE,
+            repeat_n(Answer::Silence, QUIET_POLLS + 1),
+        );
+        let request = fixture.poll(false, WirePollingStrategy::first());
+        let mut polls: FuturesUnordered<_> = (0..=QUIET_POLLS)
+            .map(|_| {
+                fixture.driver.poll_with_options(
+                    &request,
+                    None,
+                    Some(DeferredPollOptions::default()),
+                )
+            })
+            .collect();
+
+        tokio::select! {
+            reply = polls.next() => panic!("a poll ended before every consumer was admitted: {reply:?}"),
+            () = eventually("all consumers reach the primary", || {
+                fixture.cluster.count(POLL_MESSAGES_DEFERRED_CODE) == QUIET_POLLS + 1
+            }) => {}
+        }
+        tokio::select! {
+            sent = fixture.send_messages() => { sent.unwrap(); }
+            reply = polls.next() => panic!("a parked poll blocked the send: {reply:?}"),
+        }
+        drop(polls);
+        assert_eq!(
+            fixture.driver.deferred_requests.available_permits(),
+            MAX_DEFERRED_REQUESTS,
+            "abandoned consumers release their leases"
+        );
+    }
+
+    #[tokio::test(start_paused = true)]
     async fn given_a_slow_poll_when_a_send_shares_the_link_should_not_wait_for_the_poll() {
-        let f = Fixture::new().await;
-        f.cluster
-            .script(&endpoint(PRIMARY), POLL_MESSAGES_CODE, [Answer::Silence]);
-        let request = f.poll(false, WirePollingStrategy::first());
-        let poll = f.driver.poll(&request, None);
-        tokio::pin!(poll);
-        let arrived = tokio::select! {
-            _ = &mut poll => false,
-            () = eventually("the poll waits at the primary", || {
-                f.cluster.count(POLL_MESSAGES_CODE) == 1
-            }) => true,
-        };
-        assert!(arrived);
-
-        let sent = tokio::select! {
-            sent = f.send_messages() => sent,
-            polled = &mut poll => panic!("the poll ended first: {polled:?}"),
-        };
-
-        sent.unwrap();
-        assert_eq!(endpoints(&f.sends()), [endpoint(PRIMARY)]);
+        for (auto_commit, strategy) in [
+            (false, WirePollingStrategy::first()),
+            (true, WirePollingStrategy::first()),
+            (false, WirePollingStrategy::next()),
+        ] {
+            let f = Fixture::new().await;
+            f.cluster.script(
+                &endpoint(PRIMARY),
+                POLL_MESSAGES_DEFERRED_CODE,
+                [Answer::Silence],
+            );
+            let request = f.poll(auto_commit, strategy);
+            let poll =
+                f.driver
+                    .poll_with_options(&request, None, Some(DeferredPollOptions::default()));
+            tokio::pin!(poll);
+            tokio::select! {
+                result = &mut poll => panic!("the poll ended before its reply: {result:?}"),
+                () = eventually("the poll waits at the primary", || f.cluster.count(POLL_MESSAGES_DEFERRED_CODE) == 1) => {}
+            }
+            let sent = tokio::select! {
+                sent = f.send_messages() => sent,
+                polled = &mut poll => panic!("the poll ended first: {polled:?}"),
+            };
+            sent.unwrap();
+            assert_eq!(endpoints(&f.sends()), [endpoint(PRIMARY)]);
+        }
     }
 }
diff --git a/core/sdk/src/driver/route_cache.rs b/core/sdk/src/driver/route_cache.rs
index 9c36918..34c2bbe 100644
--- a/core/sdk/src/driver/route_cache.rs
+++ b/core/sdk/src/driver/route_cache.rs
@@ -234,6 +234,30 @@
         });
     }
 
+    /// Marks the routes of `partitions` stale after a primary answered that
+    /// it is not the primary of one of them. The answer does not name that
+    /// partition, so every floor stays, and each next lookup asks the primary
+    /// of its route first.
+    pub(crate) fn may_have_moved(
+        &mut self,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        partitions: &[u32],
+    ) {
+        for &partition_id in partitions {
+            let Some(route) = self
+                .topic(stream_id, topic_id)
+                .and_then(|routes| routes.get(&partition_id))
+                .map(|entry| entry.route)
+            else {
+                continue;
+            };
+            self.mark_stale(&route, partition_id, |entry| {
+                entry.ask_first = Some(route.primary_replica);
+            });
+        }
+    }
+
     /// Every spelling of one partition history shares its staleness.
     fn mark_stale(
         &mut self,
diff --git a/core/sdk/src/http/http_client.rs b/core/sdk/src/http/http_client.rs
index cdf8378..46ff9f9 100644
--- a/core/sdk/src/http/http_client.rs
+++ b/core/sdk/src/http/http_client.rs
@@ -16,6 +16,7 @@
 // under the License.
 
 use crate::diagnostic_events::diagnostic_event_channel;
+use crate::driver::{DeferredLease, MAX_DEFERRED_REQUESTS};
 use crate::http::http_transport::HttpTransport;
 use crate::prelude::{Client, HttpClientConfig, IggyError, NonZeroIggyDuration};
 use crate::vsr::LIFECYCLE_RETRY_MAX_INTERVAL;
@@ -64,6 +65,7 @@
     pub(crate) heartbeat_interval: NonZeroIggyDuration,
     client: reqwest::Client,
     retry_policy: ExponentialBackoff,
+    deferred_leases: tokio::sync::Semaphore,
     access_token: IggyRwLock<String>,
     /// The contexts that fence sends to an explicit partition, read from the topic details.
     pub(super) send_contexts: ConsumerGroupClientState,
@@ -223,6 +225,94 @@
 }
 
 impl HttpClient {
+    pub(super) async fn get_deferred_poll(
+        &self,
+        path: &str,
+        poll: &iggy_common::PollMessages,
+        options: iggy_common::DeferredPollOptions,
+    ) -> Result<iggy_common::PolledMessages, IggyError> {
+        options.validate(poll.count)?;
+        let started = tokio::time::Instant::now();
+        let deadline = started + options.request_timeout.get_duration();
+        let _permit = DeferredLease::acquire(&self.deferred_leases, deadline).await?;
+        let url = self.get_url(path)?;
+        self.fail_if_not_authenticated(path).await?;
+        let mut retry_interval = LIFECYCLE_RETRY_INTERVAL;
+        loop {
+            let request = {
+                let token = self.access_token.read().await;
+                let remaining = options.remaining(started.elapsed())?;
+                // Deferred auto-commits cannot replay an ambiguous transport failure.
+                self.client
+                    .get(url.clone())
+                    .bearer_auth(token.as_str())
+                    .query(poll)
+                    .query(&[
+                        ("wait_us", remaining.max_wait.as_micros()),
+                        ("min_count", u64::from(remaining.min_count)),
+                        ("max_bytes", u64::from(remaining.max_bytes)),
+                        ("request_timeout_us", remaining.request_timeout.as_micros()),
+                    ])
+                    .timeout(remaining.request_timeout.get_duration())
+            };
+            let sent = request
+                .send()
+                .await
+                .map_err(reqwest_middleware::Error::Reqwest);
+            // A poll that may have reached the server has an unknown commit.
+            let uncertain = may_have_committed(&sent);
+            let mut response = sent.map_err(|_| {
+                if uncertain {
+                    IggyError::TransientNotCommitted
+                } else {
+                    IggyError::InvalidHttpRequest
+                }
+            })?;
+            let status = response.status();
+            // Bound JSON expansion and error bodies under the same request budget.
+            const MAX_JSON_EXPANSION: usize = 16;
+            let limit = (options.max_bytes as usize).saturating_mul(MAX_JSON_EXPANSION);
+            if response
+                .content_length()
+                .is_some_and(|length| length > limit as u64)
+            {
+                return Err(IggyError::InvalidSizeBytes);
+            }
+            let mut body = Vec::new();
+            while let Some(chunk) = tokio::time::timeout_at(deadline, response.chunk())
+                .await
+                .map_err(|_| IggyError::TransientNotCommitted)?
+                .map_err(|_| IggyError::TransientNotCommitted)?
+            {
+                if chunk.len() > limit.saturating_sub(body.len()) {
+                    return Err(IggyError::InvalidSizeBytes);
+                }
+                body.extend_from_slice(&chunk);
+            }
+            if status.is_success() {
+                let messages: iggy_common::PolledMessages =
+                    serde_json::from_slice(&body).map_err(|_| IggyError::InvalidJsonResponse)?;
+                if messages.count > poll.count || messages.messages.len() != messages.count as usize
+                {
+                    return Err(IggyError::InvalidMessagesCount);
+                }
+                return Ok(messages);
+            }
+            let error = Self::response_error(status, String::from_utf8_lossy(&body).into_owned());
+            if !matches!(
+                error,
+                IggyError::TransientNotAccepted | IggyError::DeferredPollCapacityExceeded
+            ) {
+                return Err(error);
+            }
+            if tokio::time::Instant::now() + retry_interval >= deadline {
+                return Err(error);
+            }
+            tokio::time::sleep(retry_interval).await;
+            retry_interval = (retry_interval * 2).min(LIFECYCLE_RETRY_MAX_INTERVAL);
+        }
+    }
+
     /// Create a new HTTP client for interacting with the Iggy API using the provided API URL.
     pub fn new(api_url: &str) -> Result<Self, IggyError> {
         Self::create(Arc::new(HttpClientConfig {
@@ -243,6 +333,7 @@
             api_url,
             client: reqwest::Client::new(),
             retry_policy,
+            deferred_leases: tokio::sync::Semaphore::new(MAX_DEFERRED_REQUESTS),
             heartbeat_interval: config.heartbeat_interval,
             access_token: IggyRwLock::new(access_token),
             send_contexts: ConsumerGroupClientState::new(),
@@ -366,13 +457,7 @@
             true => Ok(response),
             false => {
                 let reason = response.text().await.unwrap_or("error".to_string());
-                match status {
-                    StatusCode::UNAUTHORIZED => Err(IggyError::Unauthenticated),
-                    StatusCode::FORBIDDEN => Err(IggyError::Unauthorized),
-                    StatusCode::NOT_FOUND => Err(IggyError::ResourceNotFound(reason)),
-                    _ => Err(typed_refusal(&reason)
-                        .unwrap_or_else(|| IggyError::HttpResponseError(status.as_u16(), reason))),
-                }
+                Err(Self::response_error(status, reason))
             }
         }
     }
@@ -394,6 +479,16 @@
     async fn disconnect(&self) -> Result<(), IggyError> {
         Ok(())
     }
+
+    fn response_error(status: StatusCode, reason: String) -> IggyError {
+        match status {
+            StatusCode::UNAUTHORIZED => IggyError::Unauthenticated,
+            StatusCode::FORBIDDEN => IggyError::Unauthorized,
+            StatusCode::NOT_FOUND => IggyError::ResourceNotFound(reason),
+            _ => typed_refusal(&reason)
+                .unwrap_or_else(|| IggyError::HttpResponseError(status.as_u16(), reason)),
+        }
+    }
 }
 
 #[derive(Debug, Serialize)]
@@ -435,7 +530,7 @@
 
 /// Decodes the refusals the client acts on: a producer never repeats `RequestTooOld`, a send
 /// refreshes its context once on `HistoryUnavailable`, `LifecycleBusy` is retried, and
-/// `TransientNotAccepted` proves that the last attempt was admitted nowhere.
+/// non-admission and capacity refusals permit a retry without risking duplicate progress.
 fn typed_refusal(body: &str) -> Option<IggyError> {
     #[derive(Deserialize)]
     struct ErrorId {
@@ -447,6 +542,7 @@
         IggyError::HistoryUnavailable,
         IggyError::LifecycleBusy,
         IggyError::TransientNotAccepted,
+        IggyError::DeferredPollCapacityExceeded,
     ]
     .into_iter()
     .find(|error| error.as_code() == id)
@@ -599,6 +695,7 @@
             IggyError::HistoryUnavailable,
             IggyError::LifecycleBusy,
             IggyError::TransientNotAccepted,
+            IggyError::DeferredPollCapacityExceeded,
         ] {
             let body = format!(r#"{{"id":{},"reason":"unavailable"}}"#, error.as_code());
             assert_eq!(typed_refusal(&body), Some(error));
@@ -608,6 +705,193 @@
         }
     }
 
+    use iggy_common::{Consumer, PollMessages, PollingStrategy};
+    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+    use tokio::net::TcpListener;
+    use tokio::task::JoinHandle;
+
+    #[tokio::test]
+    async fn deferred_poll_retries_only_nonadmission_with_remaining_wait() {
+        let (client, requests) = deferred_response_server(
+            [
+                IggyError::TransientNotAccepted.as_code(),
+                IggyError::DeferredPollCapacityExceeded.as_code(),
+                IggyError::TransientNotCommitted.as_code(),
+            ]
+            .into_iter()
+            .map(|id| (503, format!(r#"{{"id":{id}}}"#)))
+            .collect(),
+        )
+        .await;
+        let result = client
+            .get_deferred_poll(
+                "messages/deferred",
+                &deferred_request(),
+                iggy_common::DeferredPollOptions::default(),
+            )
+            .await;
+        assert!(
+            matches!(result, Err(IggyError::HttpResponseError(503, body))
+            if serde_json::from_str::<serde_json::Value>(&body).unwrap()["id"].as_u64().unwrap() as u32
+                == IggyError::TransientNotCommitted.as_code())
+        );
+        let waits = requests.await.unwrap();
+        assert_eq!(waits.len(), 3);
+        assert!(waits[0] <= 1_000_000);
+        assert!(
+            waits
+                .windows(2)
+                .all(|pair| pair[1] > 0 && pair[1] < pair[0]),
+            "waits: {waits:?}"
+        );
+    }
+
+    #[tokio::test]
+    async fn deferred_poll_exhausted_retry_budget_does_not_send_zero_wait() {
+        let (client, requests) = deferred_response_server(vec![(
+            503,
+            format!(r#"{{"id":{}}}"#, IggyError::TransientNotAccepted.as_code()),
+        )])
+        .await;
+        let result = client
+            .get_deferred_poll(
+                "messages/deferred",
+                &deferred_request(),
+                iggy_common::DeferredPollOptions {
+                    max_wait: 0.into(),
+                    request_timeout: 50_000.into(),
+                    ..Default::default()
+                },
+            )
+            .await;
+        assert!(matches!(result, Err(IggyError::TransientNotAccepted)));
+        assert_eq!(
+            tokio::time::timeout(std::time::Duration::from_secs(5), requests)
+                .await
+                .unwrap()
+                .unwrap()
+                .len(),
+            1
+        );
+    }
+
+    #[tokio::test]
+    async fn deferred_poll_lost_after_sending_reports_an_unknown_commit() {
+        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let client = HttpClient::create(Arc::new(HttpClientConfig {
+            api_url: format!("http://{}", listener.local_addr().unwrap()),
+            jwt: Some("test-token".to_owned()),
+            ..HttpClientConfig::default()
+        }))
+        .unwrap();
+        let server = tokio::spawn(async move {
+            let (stream, _) = listener.accept().await.unwrap();
+            let mut stream = BufReader::new(stream);
+            let mut line = String::new();
+            while stream.read_line(&mut line).await.unwrap() > 0 && line != "\r\n" {
+                line.clear();
+            }
+        });
+        let result = client
+            .get_deferred_poll(
+                "messages/deferred",
+                &deferred_request(),
+                iggy_common::DeferredPollOptions::default(),
+            )
+            .await;
+        assert!(
+            matches!(result, Err(IggyError::TransientNotCommitted)),
+            "{result:?}"
+        );
+        server.await.unwrap();
+    }
+
+    #[tokio::test]
+    async fn deferred_poll_bounds_success_and_error_bodies() {
+        const MAX_BYTES: u32 = iggy_common::MIN_DEFERRED_POLL_MAX_BYTES as u32;
+        for status in [200, 503] {
+            let (client, requests) =
+                deferred_response_server(vec![(status, " ".repeat(16 * MAX_BYTES as usize + 1))])
+                    .await;
+            let result = client
+                .get_deferred_poll(
+                    "messages/deferred",
+                    &deferred_request(),
+                    iggy_common::DeferredPollOptions {
+                        max_bytes: MAX_BYTES,
+                        ..Default::default()
+                    },
+                )
+                .await;
+            assert!(
+                matches!(result, Err(IggyError::InvalidSizeBytes)),
+                "status {status}: {result:?}"
+            );
+            assert_eq!(requests.await.unwrap().len(), 1);
+        }
+    }
+
+    fn deferred_request() -> PollMessages {
+        PollMessages {
+            consumer: Consumer::default(),
+            stream_id: Identifier::default(),
+            topic_id: Identifier::default(),
+            partition_id: Some(0),
+            strategy: PollingStrategy::default(),
+            count: 1,
+            auto_commit: true,
+        }
+    }
+
+    async fn deferred_response_server(
+        responses: Vec<(u16, String)>,
+    ) -> (HttpClient, JoinHandle<Vec<u64>>) {
+        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let client = HttpClient::create(Arc::new(HttpClientConfig {
+            api_url: format!("http://{}", listener.local_addr().unwrap()),
+            jwt: Some("test-token".to_owned()),
+            ..HttpClientConfig::default()
+        }))
+        .unwrap();
+        let requests = tokio::spawn(async move {
+            let mut waits = Vec::new();
+            for (status, body) in responses {
+                let (stream, _) = listener.accept().await.unwrap();
+                let mut stream = BufReader::new(stream);
+                let mut line = String::new();
+                stream.read_line(&mut line).await.unwrap();
+                let path = line.split_whitespace().nth(1).unwrap();
+                let url = Url::parse(&format!("http://localhost{path}")).unwrap();
+                let wait = url
+                    .query_pairs()
+                    .find(|(name, _)| name == "wait_us")
+                    .unwrap()
+                    .1
+                    .parse::<u64>()
+                    .unwrap();
+                waits.push(wait);
+                loop {
+                    line.clear();
+                    assert_ne!(stream.read_line(&mut line).await.unwrap(), 0);
+                    if line == "\r\n" {
+                        break;
+                    }
+                }
+                let response = format!(
+                    "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
+                    body.len()
+                );
+                stream
+                    .get_mut()
+                    .write_all(response.as_bytes())
+                    .await
+                    .unwrap();
+            }
+            waits
+        });
+        (client, requests)
+    }
+
     #[test]
     fn should_fail_with_empty_connection_string() {
         let value = "";
diff --git a/core/sdk/src/http/messages.rs b/core/sdk/src/http/messages.rs
index 4d929ae..ae61020 100644
--- a/core/sdk/src/http/messages.rs
+++ b/core/sdk/src/http/messages.rs
@@ -30,6 +30,55 @@
 
 #[async_trait]
 impl MessageClient for HttpClient {
+    async fn poll_messages_with_strategy_for_and_options(
+        &self,
+        stream_id: &Identifier,
+        topic_id: &Identifier,
+        partition_id: Option<u32>,
+        consumer: &Consumer,
+        strategy_for: &(dyn Fn(u32) -> PollingStrategy + Send + Sync),
+        count: u32,
+        auto_commit: bool,
+        options: Option<iggy_common::DeferredPollOptions>,
+    ) -> Result<PolledMessages, IggyError> {
+        let Some(options) = options else {
+            return self
+                .poll_messages_with_strategy_for(
+                    stream_id,
+                    topic_id,
+                    partition_id,
+                    consumer,
+                    strategy_for,
+                    count,
+                    auto_commit,
+                )
+                .await;
+        };
+        if consumer.kind == ConsumerKind::ExternalGroup
+            || (consumer.kind == ConsumerKind::ConsumerGroup && partition_id.is_none())
+        {
+            return Err(IggyError::FeatureUnavailable);
+        }
+        let path = format!(
+            "{}/deferred",
+            get_path(&stream_id.as_cow_str(), &topic_id.as_cow_str())
+        );
+        self.get_deferred_poll(
+            &path,
+            &PollMessages {
+                stream_id: stream_id.clone(),
+                topic_id: topic_id.clone(),
+                partition_id,
+                consumer: consumer.clone(),
+                strategy: strategy_for(partition_id.unwrap_or(0)),
+                count,
+                auto_commit,
+            },
+            options,
+        )
+        .await
+    }
+
     async fn poll_messages(
         &self,
         stream_id: &Identifier,
@@ -230,9 +279,10 @@
     use crate::http::http_client::HttpClient;
     use crate::http::scripted_server::{Recorded, Reply, ScriptedServer};
     use crate::prelude::{
-        Identifier, IggyError, IggyMessage, MessageClient, PartitionClient, PartitionContext,
-        Partitioning, SendMessagesConfirmationResponse, StreamClient, StreamCreateOptions,
-        TopicClient, TopicCreateOptions,
+        Consumer, DeferredPollOptions, Identifier, IggyError, IggyMessage, MessageClient,
+        PartitionClient, PartitionContext, Partitioning, PollingStrategy,
+        SendMessagesConfirmationResponse, StreamClient, StreamCreateOptions, TopicClient,
+        TopicCreateOptions,
     };
     use bytes::Bytes;
 
@@ -282,6 +332,29 @@
     }
 
     #[tokio::test]
+    async fn given_an_external_group_when_polling_deferred_should_refuse_it_before_sending() {
+        let server = ScriptedServer::start(vec![]).await;
+        let client = server.client();
+        let (stream_id, topic_id) = ids();
+
+        let polled = client
+            .poll_messages_deferred(
+                &stream_id,
+                &topic_id,
+                Some(PARTITION_ID),
+                &Consumer::external_group(Identifier::numeric(1).unwrap()),
+                &PollingStrategy::next(),
+                1,
+                false,
+                DeferredPollOptions::default(),
+            )
+            .await;
+
+        assert_eq!(polled.map(|_| ()), Err(IggyError::FeatureUnavailable));
+        assert!(server.requests().is_empty());
+    }
+
+    #[tokio::test]
     async fn given_explicit_partition_when_sending_should_carry_its_cached_topic_context() {
         let server = ScriptedServer::start(vec![
             Reply::topic(&[(OTHER_PARTITION_ID, RECREATED), (PARTITION_ID, CURRENT)]),
diff --git a/core/sdk/src/links/dial.rs b/core/sdk/src/links/dial.rs
index 25c19ea..43d7389 100644
--- a/core/sdk/src/links/dial.rs
+++ b/core/sdk/src/links/dial.rs
@@ -67,6 +67,18 @@
         }
     }
 
+    /// The largest reply body that a link reads, where the transport caps it.
+    pub(crate) fn reply_body_limit(&self) -> Option<usize> {
+        match self {
+            Self::Quic { config, .. } => Some(
+                usize::try_from(config.response_buffer_size)
+                    .unwrap_or(usize::MAX)
+                    .saturating_sub(iggy_binary_protocol::HEADER_SIZE),
+            ),
+            _ => None,
+        }
+    }
+
     /// Opens a link to `endpoint` within `DIAL_TIMEOUT`.
     ///
     /// `CannotEstablishConnection` means that this endpoint failed, and
@@ -471,6 +483,22 @@
         assert_eq!(result.err(), Some(IggyError::CannotEstablishConnection));
     }
 
+    #[tokio::test]
+    async fn given_a_quic_response_buffer_when_limiting_replies_should_leave_room_for_the_header() {
+        let quic = quic_dialer(QuicClientConfig {
+            response_buffer_size: 1 << 20,
+            ..Default::default()
+        });
+        assert_eq!(
+            quic.reply_body_limit(),
+            Some((1 << 20) - iggy_binary_protocol::HEADER_SIZE)
+        );
+        assert_eq!(
+            Dialer::Tcp(Arc::new(TcpClientConfig::default())).reply_body_limit(),
+            None
+        );
+    }
+
     #[test]
     fn given_dns_ipv4_and_ipv6_endpoints_when_naming_the_tls_server_should_drop_the_port() {
         assert_eq!(tls_server_name("iggy-1:8090"), "iggy-1");
diff --git a/core/sdk/src/links/mod.rs b/core/sdk/src/links/mod.rs
index 003631f..1639f14 100644
--- a/core/sdk/src/links/mod.rs
+++ b/core/sdk/src/links/mod.rs
@@ -21,6 +21,9 @@
 use crate::vsr::{decode_reply, encode_request_header};
 use bytes::Bytes;
 use iggy_binary_protocol::consensus::{HEADER_SIZE, RequestHeader};
+use iggy_binary_protocol::{
+    WireDecode, codes::POLL_MESSAGES_DEFERRED_CODE, requests::messages::DeferredPollMessagesRequest,
+};
 #[cfg(test)]
 use iggy_common::DiagnosticEvent;
 use iggy_common::IggyError;
@@ -187,6 +190,18 @@
     pub(crate) body: Bytes,
 }
 
+impl Frame {
+    pub(crate) fn response_body_limit(&self) -> Option<usize> {
+        let code = u32::from_le_bytes(self.header.reserved[..size_of::<u32>()].try_into().ok()?);
+        if code != POLL_MESSAGES_DEFERRED_CODE {
+            return None;
+        }
+        DeferredPollMessagesRequest::decode_from(&self.body)
+            .ok()
+            .map(|request| request.max_bytes as usize)
+    }
+}
+
 #[derive(Debug)]
 pub(crate) struct Reply {
     pub(crate) header: [u8; HEADER_SIZE],
diff --git a/core/sdk/src/links/mux.rs b/core/sdk/src/links/mux.rs
index f85f17e..1733282 100644
--- a/core/sdk/src/links/mux.rs
+++ b/core/sdk/src/links/mux.rs
@@ -58,12 +58,17 @@
 
 type Waiter = oneshot::Sender<Result<Reply, IggyError>>;
 
+struct QueuedFrame {
+    frame: Frame,
+    uses_write_permit: bool,
+}
+
 /// A connection that carries many requests at once. Dropping the link stops
 /// its tasks, which closes the connection.
 #[derive(Debug)]
 pub(crate) struct MuxLink {
     shared: Arc<MuxShared>,
-    queue: mpsc::Sender<Frame>,
+    queue: mpsc::Sender<QueuedFrame>,
     tasks: LinkTasks,
 }
 
@@ -73,6 +78,7 @@
     no_session_eviction: AtomicBool,
     pending: StdMutex<Pending>,
     permits: Semaphore,
+    write_permits: Semaphore,
     /// Before bind, request ids repeat, so one such exchange runs at a time.
     unbound: Mutex<()>,
     /// Turns true once, when the link closes.
@@ -83,7 +89,7 @@
 struct Pending {
     /// Cleared when the link closes. A closed link never opens again.
     open: bool,
-    waiters: HashMap<u64, Waiter>,
+    waiters: HashMap<u64, (Waiter, Option<usize>)>,
 }
 
 #[derive(Debug)]
@@ -106,6 +112,7 @@
     /// Set for a repeated id that reached the queue: its late reply would
     /// answer the next request with the same id.
     close_if_abandoned: bool,
+    cancellation: Option<(mpsc::OwnedPermit<QueuedFrame>, Frame)>,
 }
 
 impl MuxLink {
@@ -165,15 +172,47 @@
             .map_err(|_| IggyError::NotConnected)?
             .map_err(|_| IggyError::NotConnected)?;
         let (waiter, reply) = oneshot::channel();
-        let mut entry = self.shared.wait_for(request, waiter)?;
+        let cancellation = if let Some(header) = frame.header.deferred_cancellation() {
+            let permit = timeout_at(deadline, self.queue.clone().reserve_owned())
+                .await
+                .map_err(|_| IggyError::NotConnected)?
+                .map_err(|_| IggyError::NotConnected)?;
+            Some((
+                permit,
+                Frame {
+                    header,
+                    body: Bytes::new(),
+                },
+            ))
+        } else {
+            None
+        };
+        let write_permit = timeout_at(deadline, self.shared.write_permits.acquire())
+            .await
+            .map_err(|_| IggyError::NotConnected)?
+            .map_err(|_| IggyError::NotConnected)?;
+        let mut entry = self
+            .shared
+            .wait_for(request, waiter, frame.response_body_limit())?;
         if !matches!(
-            timeout_at(deadline, self.queue.send(frame)).await,
+            timeout_at(
+                deadline,
+                self.queue.send(QueuedFrame {
+                    frame,
+                    uses_write_permit: true,
+                })
+            )
+            .await,
             Ok(Ok(()))
         ) {
             return Err(IggyError::NotConnected);
         }
+        // A successful enqueue transfers the permit to the writer. A failed
+        // or cancelled send still returns it through the borrowed guard.
+        write_permit.forget();
         *sent = true;
         entry.close_if_abandoned = unbound;
+        entry.cancellation = cancellation;
         match timeout_at(deadline, reply).await {
             Ok(Ok(result)) => result,
             Ok(Err(_)) => Err(IggyError::Disconnected),
@@ -216,7 +255,7 @@
 
     fn start<Writer, Reader>(
         peer: SocketAddr,
-        writer: impl FnOnce(mpsc::Receiver<Frame>, Arc<MuxShared>) -> Writer,
+        writer: impl FnOnce(mpsc::Receiver<QueuedFrame>, Arc<MuxShared>) -> Writer,
         reader: impl FnOnce(Arc<MuxShared>) -> Reader,
     ) -> Self
     where
@@ -231,10 +270,13 @@
                 waiters: HashMap::new(),
             }),
             permits: Semaphore::new(MAX_PENDING_PER_LINK),
+            write_permits: Semaphore::new(LINK_WRITE_QUEUE),
             unbound: Mutex::new(()),
             closed: watch::Sender::new(false),
         });
-        let (queue, frames) = mpsc::channel(LINK_WRITE_QUEUE);
+        // At most MAX_PENDING_PER_LINK slots are held for cancellation. The
+        // remaining slots let the FIFO drain, while write_permits bounds payloads.
+        let (queue, frames) = mpsc::channel(LINK_WRITE_QUEUE + MAX_PENDING_PER_LINK);
         let tasks = LinkTasks {
             writer: tokio::spawn(writer(frames, Arc::clone(&shared))),
             reader: tokio::spawn(reader(Arc::clone(&shared))),
@@ -248,7 +290,12 @@
 }
 
 impl MuxShared {
-    fn wait_for(&self, request: u64, waiter: Waiter) -> Result<PendingEntry<'_>, IggyError> {
+    fn wait_for(
+        &self,
+        request: u64,
+        waiter: Waiter,
+        limit: Option<usize>,
+    ) -> Result<PendingEntry<'_>, IggyError> {
         let mut pending = self.lock_pending();
         if !pending.open {
             return Err(IggyError::NotConnected);
@@ -263,11 +310,12 @@
                 Err(IggyError::NotConnected)
             }
             Entry::Vacant(entry) => {
-                entry.insert(waiter);
+                entry.insert((waiter, limit));
                 Ok(PendingEntry {
                     shared: self,
                     request,
                     close_if_abandoned: false,
+                    cancellation: None,
                 })
             }
         }
@@ -281,7 +329,7 @@
             return self.deliver_buffered(buffer);
         }
         while let Some(&header) = message.first_chunk::<HEADER_SIZE>() {
-            let size = self.frame_size(&header)?;
+            let (size, _) = self.frame_size(&header)?;
             if message.len() < size {
                 break;
             }
@@ -295,7 +343,7 @@
     /// Cuts every whole frame off the front of `buffer` and dispatches it.
     fn deliver_buffered(&self, buffer: &mut BytesMut) -> Result<(), IggyError> {
         while let Some(&header) = buffer.first_chunk::<HEADER_SIZE>() {
-            let size = self.frame_size(&header)?;
+            let (size, _) = self.frame_size(&header)?;
             if buffer.len() < size {
                 break;
             }
@@ -305,14 +353,25 @@
         Ok(())
     }
 
-    fn frame_size(&self, header: &[u8; HEADER_SIZE]) -> Result<usize, IggyError> {
-        response_size(header).map_err(|_| {
+    /// The size of the frame, and whether something reads its body. Nothing
+    /// reads a reply that no request waits for.
+    fn frame_size(&self, header: &[u8; HEADER_SIZE]) -> Result<(usize, bool), IggyError> {
+        let size = response_size(header).map_err(|_| {
             error!(
                 "Received a frame shorter than its header from {}.",
                 self.peer
             );
             IggyError::Disconnected
-        })
+        })?;
+        if peek_command(header) != Command::Reply {
+            return Ok((size, true));
+        }
+        match self.lock_pending().waiters.get(&reply_request_id(header)) {
+            Some((_, Some(limit))) if size - HEADER_SIZE > *limit => {
+                Err(IggyError::InvalidSizeBytes)
+            }
+            waiter => Ok((size, waiter.is_some())),
+        }
     }
 
     /// Gives a reply to the request that waits for it. An error closes the link,
@@ -323,7 +382,7 @@
                 let request = reply_request_id(&reply.header);
                 let waiter = self.lock_pending().waiters.remove(&request);
                 match waiter {
-                    Some(waiter) => {
+                    Some((waiter, _)) => {
                         let _ = waiter.send(Ok(reply));
                     }
                     None => trace!(
@@ -359,20 +418,26 @@
             std::mem::take(&mut pending.waiters)
         };
         self.permits.close();
+        self.write_permits.close();
         self.closed.send_replace(true);
-        for waiter in waiters.into_values() {
+        for (waiter, _) in waiters.into_values() {
             let _ = waiter.send(Err(reason.clone()));
         }
     }
 
     /// The next frame to write, or `None` once the link closed or every
     /// sender went away.
-    async fn next_frame(&self, frames: &mut mpsc::Receiver<Frame>) -> Option<Frame> {
+    async fn next_frame(&self, frames: &mut mpsc::Receiver<QueuedFrame>) -> Option<Frame> {
         let mut closed = self.closed.subscribe();
         tokio::select! {
             biased;
             _ = closed.wait_for(|closed| *closed) => None,
-            frame = frames.recv() => frame,
+            frame = frames.recv() => frame.map(|queued| {
+                if queued.uses_write_permit {
+                    self.write_permits.add_permits(1);
+                }
+                queued.frame
+            }),
         }
     }
 
@@ -393,6 +458,14 @@
             .waiters
             .remove(&self.request)
             .is_some();
+        if abandoned && let Some((permit, frame)) = self.cancellation.take() {
+            // Reserved before sending the poll, so cancellation cannot lose a
+            // race with queue backpressure and always follows the original frame.
+            permit.send(QueuedFrame {
+                frame,
+                uses_write_permit: false,
+            });
+        }
         if abandoned && self.close_if_abandoned {
             debug!(
                 "Closing the link to {}: request {} left before its reply.",
@@ -405,7 +478,7 @@
 
 async fn write_tcp<W: AsyncWrite + Unpin>(
     mut writer: W,
-    mut frames: mpsc::Receiver<Frame>,
+    mut frames: mpsc::Receiver<QueuedFrame>,
     shared: Arc<MuxShared>,
 ) {
     while let Some(frame) = shared.next_frame(&mut frames).await {
@@ -432,7 +505,7 @@
 
 async fn read_tcp<R: AsyncRead + Unpin>(mut reader: R, shared: Arc<MuxShared>) {
     let reason = loop {
-        match read_tcp_reply(&mut reader).await {
+        match read_tcp_reply(&mut reader, &shared).await {
             Ok(reply) => {
                 if let Err(reason) = shared.dispatch(reply) {
                     break reason;
@@ -449,12 +522,30 @@
     shared.close(&reason);
 }
 
-async fn read_tcp_reply<R: AsyncRead + Unpin>(reader: &mut R) -> io::Result<Reply> {
+async fn read_tcp_reply<R: AsyncRead + Unpin>(
+    reader: &mut R,
+    shared: &MuxShared,
+) -> io::Result<Reply> {
     let mut header = [0; HEADER_SIZE];
     reader.read_exact(&mut header).await?;
-    let size = response_size(&header)
-        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "frame shorter than its header"))?;
+    let (size, read) = shared
+        .frame_size(&header)
+        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?;
     let body_len = size - HEADER_SIZE;
+    if !read {
+        let copied = tokio::io::copy(
+            &mut (&mut *reader).take(body_len as u64),
+            &mut tokio::io::sink(),
+        )
+        .await?;
+        if copied != body_len as u64 {
+            return Err(io::ErrorKind::UnexpectedEof.into());
+        }
+        return Ok(Reply {
+            header,
+            body: Bytes::new(),
+        });
+    }
     let mut body = BytesMut::with_capacity(body_len);
     // The limit stops a read at the end of the frame, even when the buffer
     // got more capacity than it asked for.
@@ -472,7 +563,7 @@
 
 async fn write_websocket<S: AsyncRead + AsyncWrite + Unpin>(
     mut sink: SplitSink<WebSocketStream<S>, Message>,
-    mut frames: mpsc::Receiver<Frame>,
+    mut frames: mpsc::Receiver<QueuedFrame>,
     shared: Arc<MuxShared>,
 ) {
     while let Some(frame) = shared.next_frame(&mut frames).await {
@@ -540,6 +631,7 @@
         CLIENT, DUPLEX_CAPACITY, LOCALHOST, read_request_header, reply, request, request_with_body,
     };
     use iggy_binary_protocol::consensus::{EvictionHeader, EvictionReason, RequestHeader};
+    use std::collections::BTreeSet;
     use std::io::IoSlice;
     use std::pin::Pin;
     use std::task::{Context, Poll};
@@ -728,6 +820,224 @@
     }
 
     #[tokio::test]
+    async fn given_a_cancelled_deferred_poll_should_send_cancellation_and_keep_the_connection() {
+        let (websocket, server) = websocket_link().await;
+        for (link, mut peer) in [tcp_link(), (websocket, Peer::WebSocket(Box::new(server)))] {
+            let deadline = Instant::now() + TEST_BUDGET;
+            let mut frame = request(1, BOUND_SESSION);
+            frame.header.reserved =
+                iggy_binary_protocol::codes::POLL_MESSAGES_DEFERRED_CODE.to_le_bytes();
+            {
+                let cancelled = send(&link, frame, deadline);
+                tokio::pin!(cancelled);
+                tokio::select! {
+                    result = &mut cancelled => panic!("no reply was sent: {result:?}"),
+                    (header, _) = peer.read_request() => assert_eq!(header.request, 1),
+                }
+            }
+            let (cancel, body) = timeout(TEST_BUDGET, peer.read_request()).await.unwrap();
+            assert_eq!(
+                cancel.reserved,
+                iggy_binary_protocol::codes::CANCEL_DEFERRED_REQUEST_CODE.to_le_bytes()
+            );
+            assert_eq!(cancel.request, 1);
+            assert_eq!(cancel.session, BOUND_SESSION);
+            assert!(body.is_empty());
+
+            let answer = async {
+                let (header, _) = peer.read_request().await;
+                peer.write(reply(header.request, b"next")).await;
+            };
+            let (next, ()) = tokio::join!(send(&link, request(2, BOUND_SESSION), deadline), answer);
+            assert_eq!(next.unwrap().body[..], *b"next");
+        }
+    }
+
+    #[tokio::test]
+    async fn given_more_parked_polls_than_write_slots_when_sending_should_keep_serving_requests() {
+        const PARKED_POLLS: usize = LINK_WRITE_QUEUE * 2;
+        let (websocket, server) = websocket_link().await;
+        for (link, mut peer) in [tcp_link(), (websocket, Peer::WebSocket(Box::new(server)))] {
+            let deadline = Instant::now() + TEST_BUDGET;
+            let parked = futures::future::join_all((1..=PARKED_POLLS as u64).map(|id| {
+                let mut frame = request(id, BOUND_SESSION);
+                frame.header.reserved =
+                    iggy_binary_protocol::codes::POLL_MESSAGES_DEFERRED_CODE.to_le_bytes();
+                send(&link, frame, deadline)
+            }));
+            tokio::pin!(parked);
+            tokio::select! {
+                results = &mut parked => panic!("polls received no reply: {results:?}"),
+                result = timeout(TEST_BUDGET, async {
+                    for _ in 0..PARKED_POLLS {
+                        peer.read_request().await;
+                    }
+                    let answer = async {
+                        let (header, _) = peer.read_request().await;
+                        peer.write(reply(header.request, b"ordinary")).await;
+                    };
+                    let (result, ()) = tokio::join!(
+                        send(&link, request(PARKED_POLLS as u64 + 1, BOUND_SESSION), deadline),
+                        answer,
+                    );
+                    assert_eq!(result.unwrap().body[..], *b"ordinary");
+                }) => result.expect("parked polls must leave write capacity"),
+            }
+        }
+    }
+
+    #[tokio::test]
+    async fn given_aborted_polls_and_full_queue_when_writer_resumes_should_preserve_order_and_drain()
+     {
+        for websocket in [false, true] {
+            let (client, server) = duplex(DUPLEX_CAPACITY);
+            let (resume, paused) = oneshot::channel();
+            let (link, mut peer) = if websocket {
+                let (client, server) = tokio::join!(
+                    client_async("ws://localhost/", client),
+                    accept_async(server)
+                );
+                let (sink, stream) = client.unwrap().0.split();
+                (
+                    MuxLink::start(
+                        LOCALHOST,
+                        |frames, shared| async move {
+                            paused.await.unwrap();
+                            write_websocket(sink, frames, shared).await;
+                        },
+                        |shared| read_websocket(stream, shared),
+                    ),
+                    Peer::WebSocket(Box::new(server.unwrap())),
+                )
+            } else {
+                let (reader, writer) = tokio::io::split(client);
+                (
+                    MuxLink::start(
+                        LOCALHOST,
+                        |frames, shared| async move {
+                            paused.await.unwrap();
+                            write_tcp(BufWriter::new(writer), frames, shared).await;
+                        },
+                        |shared| read_tcp(BufReader::new(reader), shared),
+                    ),
+                    Peer::Tcp(server),
+                )
+            };
+            let deadline = Instant::now() + TEST_BUDGET;
+            let poll = |id| {
+                let mut frame = request(id, BOUND_SESSION);
+                frame.header.reserved =
+                    iggy_binary_protocol::codes::POLL_MESSAGES_DEFERRED_CODE.to_le_bytes();
+                send(&link, frame, deadline)
+            };
+            let mut abandoned = Box::pin(futures::future::join_all(
+                (1..=LINK_WRITE_QUEUE as u64).map(poll),
+            ));
+            timeout(TEST_BUDGET, async {
+                while link.queue.capacity() != MAX_PENDING_PER_LINK - LINK_WRITE_QUEUE {
+                    assert!(futures::poll!(&mut abandoned).is_pending());
+                    tokio::task::yield_now().await;
+                }
+            })
+            .await
+            .expect("every original poll must enter the FIFO before cancellation");
+            assert_eq!(link.shared.write_permits.available_permits(), 0);
+            let mut not_enqueued = Box::pin(poll((LINK_WRITE_QUEUE + 1) as u64));
+            assert!(futures::poll!(&mut not_enqueued).is_pending());
+            drop(not_enqueued);
+            assert_eq!(link.shared.write_permits.available_permits(), 0);
+            assert_eq!(
+                link.queue.capacity(),
+                MAX_PENDING_PER_LINK - LINK_WRITE_QUEUE
+            );
+            drop(abandoned);
+
+            let mut waiting = Box::pin(futures::future::join_all(
+                ((LINK_WRITE_QUEUE + 1) as u64..=(LINK_WRITE_QUEUE + MAX_PENDING_PER_LINK) as u64)
+                    .map(poll),
+            ));
+            timeout(TEST_BUDGET, async {
+                while link.queue.capacity() != 0 {
+                    assert!(futures::poll!(&mut waiting).is_pending());
+                    tokio::task::yield_now().await;
+                }
+            })
+            .await
+            .expect("cancellations and reservations must fill the FIFO");
+            assert_eq!(
+                link.queue.capacity(),
+                0,
+                "cancels and reservations fill the FIFO"
+            );
+            assert_eq!(link.shared.write_permits.available_permits(), 0);
+            resume.send(()).unwrap();
+
+            let mut pending_cancels = BTreeSet::new();
+            tokio::select! {
+                results = &mut waiting => panic!("polls received no reply: {results:?}"),
+                result = timeout(TEST_BUDGET, async {
+                    let mut abandoned = BTreeSet::new();
+                    for _ in 0..LINK_WRITE_QUEUE {
+                        let (header, _) = peer.read_request().await;
+                        assert!(abandoned.insert(header.request));
+                        assert_eq!(header.reserved, iggy_binary_protocol::codes::POLL_MESSAGES_DEFERRED_CODE.to_le_bytes());
+                    }
+                    for _ in 0..LINK_WRITE_QUEUE {
+                        let (header, body) = peer.read_request().await;
+                        assert!(abandoned.remove(&header.request), "cancel must match an earlier poll exactly once");
+                        assert_eq!(header.reserved, iggy_binary_protocol::codes::CANCEL_DEFERRED_REQUEST_CODE.to_le_bytes());
+                        assert!(body.is_empty());
+                    }
+                    for _ in 0..MAX_PENDING_PER_LINK {
+                        let (header, _) = peer.read_request().await;
+                        assert_eq!(header.reserved, iggy_binary_protocol::codes::POLL_MESSAGES_DEFERRED_CODE.to_le_bytes());
+                        assert!(pending_cancels.insert(header.request));
+                    }
+                }) => result.expect("queued frames must drain despite held cancel reservations"),
+            }
+            drop(waiting);
+
+            timeout(TEST_BUDGET, async {
+                for _ in 0..MAX_PENDING_PER_LINK {
+                    let (header, body) = peer.read_request().await;
+                    assert_eq!(
+                        header.reserved,
+                        iggy_binary_protocol::codes::CANCEL_DEFERRED_REQUEST_CODE.to_le_bytes()
+                    );
+                    assert!(
+                        pending_cancels.remove(&header.request),
+                        "cancel must match an earlier poll exactly once"
+                    );
+                    assert!(body.is_empty());
+                }
+                let answer = async {
+                    let (header, _) = peer.read_request().await;
+                    peer.write(reply(header.request, b"after cancellation"))
+                        .await;
+                };
+                let (result, ()) = tokio::join!(
+                    send(
+                        &link,
+                        request(
+                            (LINK_WRITE_QUEUE + MAX_PENDING_PER_LINK + 1) as u64,
+                            BOUND_SESSION
+                        ),
+                        deadline
+                    ),
+                    answer,
+                );
+                assert_eq!(result.unwrap().body[..], *b"after cancellation");
+            })
+            .await
+            .expect("all cancellations and subsequent traffic must reach the peer");
+            assert_eq!(
+                link.shared.write_permits.available_permits(),
+                LINK_WRITE_QUEUE
+            );
+        }
+    }
+
+    #[tokio::test]
     async fn given_a_lost_link_when_requests_are_pending_should_fail_all_of_them_at_once() {
         let (link, mut peer) = tcp_link();
         let deadline = Instant::now() + TEST_BUDGET;
@@ -832,28 +1142,41 @@
 
         let duplicate = send(&link, request(1, BOUND_SESSION), deadline).await;
         assert_eq!(duplicate.unwrap_err(), IggyError::NotConnected);
+        assert_eq!(
+            link.shared.write_permits.available_permits(),
+            LINK_WRITE_QUEUE
+        );
         peer.write(reply(1, b"first")).await;
         assert_eq!(first.await.unwrap().body[..], *b"first");
     }
 
     #[tokio::test(start_paused = true)]
     async fn given_a_sender_waiting_for_a_permit_when_the_link_closes_should_fail_at_once() {
-        let (link, peer) = tcp_link();
-        let far = Instant::now() + Duration::from_secs(3600);
-        let all_permits = u32::try_from(MAX_PENDING_PER_LINK).unwrap();
-        let _held = link.shared.permits.acquire_many(all_permits).await.unwrap();
-        let started = Instant::now();
-        let waiting = send(&link, request(1, BOUND_SESSION), far);
-        tokio::pin!(waiting);
-        tokio::select! {
-            result = &mut waiting => panic!("no permit was free: {result:?}"),
-            () = tokio::task::yield_now() => {}
+        for write_permits in [false, true] {
+            let (link, peer) = tcp_link();
+            let far = Instant::now() + Duration::from_secs(3600);
+            let (semaphore, count) = if write_permits {
+                (&link.shared.write_permits, LINK_WRITE_QUEUE)
+            } else {
+                (&link.shared.permits, MAX_PENDING_PER_LINK)
+            };
+            let _held = semaphore
+                .acquire_many(u32::try_from(count).unwrap())
+                .await
+                .unwrap();
+            let started = Instant::now();
+            let waiting = send(&link, request(1, BOUND_SESSION), far);
+            tokio::pin!(waiting);
+            tokio::select! {
+                result = &mut waiting => panic!("no permit was free: {result:?}"),
+                () = tokio::task::yield_now() => {}
+            }
+
+            drop(peer);
+
+            assert_eq!(waiting.await.unwrap_err(), IggyError::NotConnected);
+            assert_eq!(started.elapsed(), Duration::ZERO);
         }
-
-        drop(peer);
-
-        assert_eq!(waiting.await.unwrap_err(), IggyError::NotConnected);
-        assert_eq!(started.elapsed(), Duration::ZERO);
     }
 
     #[tokio::test]
@@ -890,8 +1213,8 @@
         let message = Bytes::from(message);
         let (first_waiter, first) = oneshot::channel();
         let (second_waiter, second) = oneshot::channel();
-        let _first_entry = link.shared.wait_for(1, first_waiter).unwrap();
-        let _second_entry = link.shared.wait_for(2, second_waiter).unwrap();
+        let _first_entry = link.shared.wait_for(1, first_waiter, None).unwrap();
+        let _second_entry = link.shared.wait_for(2, second_waiter, None).unwrap();
         let mut buffer = BytesMut::new();
 
         link.shared
@@ -910,6 +1233,39 @@
     }
 
     #[tokio::test]
+    async fn given_reply_limits_when_requests_share_a_link_should_bound_only_the_matching_reply() {
+        const BODY_LIMIT: usize = 40;
+        let (link, _peer) = tcp_link();
+        let (limited, _limited_reply) = oneshot::channel();
+        let (ordinary, _ordinary_reply) = oneshot::channel();
+        let _limited_entry = link.shared.wait_for(1, limited, Some(BODY_LIMIT)).unwrap();
+        let _ordinary_entry = link.shared.wait_for(2, ordinary, None).unwrap();
+        let body = vec![0; BODY_LIMIT + 1];
+        let mut frames = reply(3, &body);
+        frames.extend_from_slice(&reply(2, &body));
+        let mut reader = frames.as_slice();
+
+        let orphan = read_tcp_reply(&mut reader, &link.shared).await.unwrap();
+        assert!(
+            orphan.body.is_empty(),
+            "a cancelled reply is drained without retaining its body"
+        );
+        let ordinary = read_tcp_reply(&mut reader, &link.shared).await.unwrap();
+        assert_eq!(ordinary.body.as_ref(), body);
+
+        let oversized = reply(1, &body);
+        let mut header_only = &oversized[..HEADER_SIZE];
+        let error = read_tcp_reply(&mut header_only, &link.shared)
+            .await
+            .unwrap_err();
+        assert_eq!(
+            error.kind(),
+            io::ErrorKind::InvalidData,
+            "reject before reading the body"
+        );
+    }
+
+    #[tokio::test]
     async fn given_a_websocket_link_when_shut_down_should_send_a_normal_close() {
         let (link, mut server) = websocket_link().await;
 
diff --git a/core/sdk/src/links/quic.rs b/core/sdk/src/links/quic.rs
index 72208a7..c1b06f0 100644
--- a/core/sdk/src/links/quic.rs
+++ b/core/sdk/src/links/quic.rs
@@ -112,7 +112,15 @@
                 return Err(IggyError::Disconnected);
             }
         }
-        let reply = match timeout_at(deadline, recv.read_to_end(self.response_buffer_size)).await {
+        let response_limit =
+            frame
+                .response_body_limit()
+                .map_or(self.response_buffer_size, |limit| {
+                    limit
+                        .saturating_add(iggy_binary_protocol::HEADER_SIZE)
+                        .min(self.response_buffer_size)
+                });
+        let reply = match timeout_at(deadline, recv.read_to_end(response_limit)).await {
             Ok(Ok(reply)) => reply,
             Ok(Err(error)) => {
                 error!(
diff --git a/core/sdk/src/links/replicas.rs b/core/sdk/src/links/replicas.rs
index aebe460..d6623a1 100644
--- a/core/sdk/src/links/replicas.rs
+++ b/core/sdk/src/links/replicas.rs
@@ -430,6 +430,10 @@
         &self.shared.session
     }
 
+    pub(crate) fn reply_body_limit(&self) -> Option<usize> {
+        self.shared.dialer.reply_body_limit()
+    }
+
     /// Records that `link` carries the session of `fence`. A sign-in calls it
     /// before it binds the session, so the slots keep this link when they
     /// close the links of the old session.
diff --git a/core/sdk/src/prelude.rs b/core/sdk/src/prelude.rs
index f55176d..e0b945e 100644
--- a/core/sdk/src/prelude.rs
+++ b/core/sdk/src/prelude.rs
@@ -53,24 +53,24 @@
     Aes256GcmEncryptor, Args, ArgsOptional, AutoLogin, CacheMetrics, CacheMetricsKey, ClientError,
     ClientInfoDetails, ClusterMetadata, ClusterNode, ClusterNodeRole, ClusterNodeStatus,
     CompressionAlgorithm, Consumer, ConsumerGroup, ConsumerGroupDetails, ConsumerGroupMember,
-    ConsumerKind, ConsumerPosition, Credentials, Durability, EncryptorKind, GlobalPermissions,
-    HeaderField, HeaderKey, HeaderKind, HeaderValue, HttpClientConfig, HttpClientConfigBuilder,
-    HttpMethod, IdKind, Identifier, IdentityInfo, IggyByteSize, IggyDuration, IggyError,
-    IggyExpiry, IggyIndexView, IggyMessage, IggyMessageHeader, IggyMessageHeaderView,
-    IggyMessageView, IggyMessageViewIterator, IggyTimestamp, MaxTopicSize, NonZeroDurationError,
-    NonZeroIggyDuration, OptionKey, OptionSpec, OptionValue, OptionsScope, Partition,
-    PartitionContext, PartitionResizePolicy, Partitioner, Partitioning, PendingOperationsConfig,
-    Permissions, PersonalAccessTokenExpiry, PollMessages, PolledMessages, PollingKind,
-    PollingStrategy, QuicClientConfig, QuicClientConfigBuilder, QuicClientReconnectionConfig,
-    ResourceOptions, SendMessages, SendMessagesConfirmationResponse, SendMessagesResponse,
-    Sizeable, SnapshotCompression, Stats, Stream, StreamCreateOptions, StreamDetails,
-    StreamEncryptionKey, StreamEncryptionSource, StreamPermissions, StreamUpdateOptions,
-    SystemSnapshotType, TcpClientConfig, TcpClientConfigBuilder, TcpClientReconnectionConfig,
-    Topic, TopicCreateOptions, TopicDetails, TopicPermissions, TopicResolvedOptions,
-    TopicUpdateOptions, TransportEndpoints, TransportProtocol, UserCreateOptions, UserId, UserInfo,
-    UserInfoDetails, UserStatus, UserUpdateOptions, Validatable, WebSocketClientConfig,
-    WebSocketClientConfigBuilder, WebSocketClientReconnectionConfig, WebSocketConfig, defaults,
-    locking, topic_option_keys,
+    ConsumerKind, ConsumerPosition, Credentials, DeferredPollOptions, Durability, EncryptorKind,
+    GlobalPermissions, HeaderField, HeaderKey, HeaderKind, HeaderValue, HttpClientConfig,
+    HttpClientConfigBuilder, HttpMethod, IdKind, Identifier, IdentityInfo, IggyByteSize,
+    IggyDuration, IggyError, IggyExpiry, IggyIndexView, IggyMessage, IggyMessageHeader,
+    IggyMessageHeaderView, IggyMessageView, IggyMessageViewIterator, IggyTimestamp, MaxTopicSize,
+    NonZeroDurationError, NonZeroIggyDuration, OptionKey, OptionSpec, OptionValue, OptionsScope,
+    Partition, PartitionContext, PartitionResizePolicy, Partitioner, Partitioning,
+    PendingOperationsConfig, Permissions, PersonalAccessTokenExpiry, PollMessages, PolledMessages,
+    PollingKind, PollingStrategy, QuicClientConfig, QuicClientConfigBuilder,
+    QuicClientReconnectionConfig, ResourceOptions, SendMessages, SendMessagesConfirmationResponse,
+    SendMessagesResponse, Sizeable, SnapshotCompression, Stats, Stream, StreamCreateOptions,
+    StreamDetails, StreamEncryptionKey, StreamEncryptionSource, StreamPermissions,
+    StreamUpdateOptions, SystemSnapshotType, TcpClientConfig, TcpClientConfigBuilder,
+    TcpClientReconnectionConfig, Topic, TopicCreateOptions, TopicDetails, TopicPermissions,
+    TopicResolvedOptions, TopicUpdateOptions, TransportEndpoints, TransportProtocol,
+    UserCreateOptions, UserId, UserInfo, UserInfoDetails, UserStatus, UserUpdateOptions,
+    Validatable, WebSocketClientConfig, WebSocketClientConfigBuilder,
+    WebSocketClientReconnectionConfig, WebSocketConfig, defaults, locking, topic_option_keys,
 };
 pub use iggy_common::{
     Client, ClusterClient, ConsumerGroupClient, ConsumerOffsetClient, MessageClient,
@@ -78,11 +78,11 @@
     TopicClient, UserClient,
 };
 pub use iggy_common::{
-    IGGY_MESSAGE_CHECKSUM_OFFSET_RANGE, IGGY_MESSAGE_HEADER_SIZE,
+    DEFAULT_POLL_MAX_BYTES, IGGY_MESSAGE_CHECKSUM_OFFSET_RANGE, IGGY_MESSAGE_HEADER_SIZE,
     IGGY_MESSAGE_HEADERS_LENGTH_OFFSET_RANGE, IGGY_MESSAGE_ID_OFFSET_RANGE,
     IGGY_MESSAGE_OFFSET_OFFSET_RANGE, IGGY_MESSAGE_ORIGIN_TIMESTAMP_OFFSET_RANGE,
     IGGY_MESSAGE_PAYLOAD_LENGTH_OFFSET_RANGE, IGGY_MESSAGE_TIMESTAMP_OFFSET_RANGE, INDEX_SIZE,
-    MAX_PAYLOAD_SIZE, MAX_USER_HEADERS_SIZE, NO_ASSIGNED_PARTITION,
+    MAX_DEFERRED_POLL_WAIT_US, MAX_PAYLOAD_SIZE, MAX_USER_HEADERS_SIZE, NO_ASSIGNED_PARTITION,
     RESYNC_REQUIRED_PARTITION_SENTINEL, SEC_IN_MICRO,
     defaults::{DEFAULT_ROOT_PASSWORD, DEFAULT_ROOT_USER_ID, DEFAULT_ROOT_USERNAME},
 };
diff --git a/core/sdk/src/session_owner/lanes.rs b/core/sdk/src/session_owner/lanes.rs
index 0e8058d..6185a0b 100644
--- a/core/sdk/src/session_owner/lanes.rs
+++ b/core/sdk/src/session_owner/lanes.rs
@@ -22,8 +22,10 @@
 //! the newest operation of its lane, and its replay can never age out.
 //! A replay that proves the outcome records it, so a later resolve of that
 //! operation returns the same outcome without a replay.
-//! Auto-commit and Next polls share the partition gate while active. Their
-//! progress is not a replayable mutation, so uncertainty releases that gate.
+//! Offset reads, stores, deletes, auto-commit and Next polls share a separate
+//! progress gate per partition. Stores and deletes take it before the mutation
+//! gate, so waiting for a parked poll does not block sends. Retained offset
+//! mutations also block progress until resolved or abandoned.
 
 use super::SessionOwner;
 use super::pending::Reservation;
@@ -32,6 +34,7 @@
 use crate::vsr::encode_request_header;
 use bytes::Bytes;
 use iggy_binary_protocol::HEADER_SIZE;
+use iggy_binary_protocol::codes::{DELETE_CONSUMER_OFFSET_CODE, STORE_CONSUMER_OFFSET_CODE};
 use iggy_binary_protocol::consensus::{Operation, operation_for_code};
 use iggy_binary_protocol::primitives::partition_history::PartitionContext;
 use iggy_common::IggyError;
@@ -50,13 +53,17 @@
         incarnation: u64,
         partition_id: u32,
     },
+    Progress {
+        incarnation: u64,
+        partition_id: u32,
+    },
 }
 
 impl LaneKey {
     /// The lane of `code`. A write to a partition takes the lane of the
     /// incarnation that its context carries. Reads, Register and Logout take
-    /// no lane by code alone. The driver selects the partition lane for
-    /// auto-commit and Next polls from their decoded arguments.
+    /// no mutation lane by code alone. The driver selects the progress lane
+    /// for offset operations and polls from their decoded arguments.
     pub(crate) fn of(code: u32, context: &PartitionContext, partition_id: u32) -> Option<Self> {
         match operation_for_code(code) {
             Operation::NonReplicated | Operation::Register | Operation::Logout => None,
@@ -67,6 +74,23 @@
             _ => Some(Self::Metadata),
         }
     }
+
+    fn blocking_operation(&self, lanes: &Lanes) -> Option<u64> {
+        let retained = match *self {
+            Self::Progress {
+                incarnation,
+                partition_id,
+            } => lanes
+                .get(&Self::Partition {
+                    incarnation,
+                    partition_id,
+                })
+                .and_then(|lane| lane.retained.as_ref())
+                .filter(|retained| retained.changes_progress()),
+            _ => lanes.get(self).and_then(|lane| lane.retained.as_ref()),
+        };
+        retained.map(RetainedOperation::op)
+    }
 }
 
 pub(super) type Lanes = HashMap<LaneKey, Lane>;
@@ -128,11 +152,18 @@
     pub(crate) fn op(&self) -> u64 {
         self.frame.header.request
     }
+
+    fn changes_progress(&self) -> bool {
+        matches!(
+            self.code,
+            STORE_CONSUMER_OFFSET_CODE | DELETE_CONSUMER_OFFSET_CODE
+        )
+    }
 }
 
 /// The right of one call to send in its lane. Dropping the ticket frees the
 /// lane, except that a mutation which left the client without a definitive
-/// outcome stays retained. A progress poll holds the same gate only while
+/// outcome stays retained. A progress poll holds its progress gate only while
 /// active; replaying its frame would not resolve its earlier progress.
 #[derive(Debug)]
 pub(crate) struct LaneTicket {
@@ -140,6 +171,8 @@
     /// `None` for a call that takes no lane.
     key: Option<LaneKey>,
     guard: Option<OwnedMutexGuard<()>>,
+    /// Offset mutations keep this gate even if abandoned during their replay.
+    progress: Option<Box<LaneTicket>>,
     operation: Option<RetainedOperation>,
     reservation: Option<Arc<Reservation>>,
     fence: u64,
@@ -165,15 +198,42 @@
         key: Option<LaneKey>,
         deadline: Instant,
     ) -> Result<LaneTicket, IggyError> {
+        self.enter_lane_for(key, deadline, None).await
+    }
+
+    pub(crate) async fn enter_progress_lane(
+        self: &Arc<Self>,
+        progress_key: LaneKey,
+        mutation_key: Option<LaneKey>,
+        deadline: Instant,
+    ) -> Result<LaneTicket, IggyError> {
+        let progress = self.enter_lane(Some(progress_key), deadline).await?;
+        let Some(key) = mutation_key else {
+            return Ok(progress);
+        };
+        let mut ticket = self.enter_lane(Some(key), deadline).await?;
+        ticket.progress = Some(Box::new(progress));
+        Ok(ticket)
+    }
+
+    async fn enter_lane_for(
+        self: &Arc<Self>,
+        key: Option<LaneKey>,
+        deadline: Instant,
+        resuming: Option<u64>,
+    ) -> Result<LaneTicket, IggyError> {
         let Some(key) = key else {
             return Ok(LaneTicket::new(Arc::clone(self), None, false));
         };
         let (mut ticket, gate) = {
             let mut lanes = self.lanes();
-            let lane = lanes.entry(key).or_default();
-            if let Some(retained) = &lane.retained {
-                return Err(IggyError::LaneBlocked(retained.op()));
+            if let Some(op) = key
+                .blocking_operation(&lanes)
+                .filter(|op| Some(*op) != resuming)
+            {
+                return Err(IggyError::LaneBlocked(op));
             }
+            let lane = lanes.entry(key).or_default();
             lane.tickets += 1;
             (
                 LaneTicket::new(Arc::clone(self), Some(key), false),
@@ -185,10 +245,9 @@
             .map_err(|_| IggyError::TransientNotAccepted)?;
         ticket.guard = Some(guard);
         // The call that held the gate can have left its operation retained.
-        let blocked = self
-            .lanes()
-            .get(&key)
-            .and_then(|lane| lane.retained.as_ref().map(RetainedOperation::op));
+        let blocked = key
+            .blocking_operation(&self.lanes())
+            .filter(|op| Some(*op) != resuming);
         if let Some(op) = blocked {
             return Err(IggyError::LaneBlocked(op));
         }
@@ -203,17 +262,45 @@
         op: u64,
         deadline: Instant,
     ) -> Result<(LaneTicket, RetainedOperation), IggyError> {
-        let (mut ticket, gate) = {
+        let (mut ticket, gate, progress_key) = {
             let mut lanes = self.lanes();
             let Some((key, lane)) = lanes.iter_mut().find(|(_, lane)| lane.holds(op)) else {
                 return Err(IggyError::OperationNotRetained(op));
             };
+            let progress_key = match *key {
+                LaneKey::Partition {
+                    incarnation,
+                    partition_id,
+                } if lane
+                    .retained
+                    .as_ref()
+                    .is_some_and(RetainedOperation::changes_progress) =>
+                {
+                    Some(LaneKey::Progress {
+                        incarnation,
+                        partition_id,
+                    })
+                }
+                _ => None,
+            };
             lane.tickets += 1;
             (
                 LaneTicket::new(Arc::clone(self), Some(*key), true),
                 Arc::clone(&lane.gate),
+                progress_key,
             )
         };
+        if let Some(key) = progress_key {
+            let progress = self
+                .enter_lane_for(Some(key), deadline, Some(op))
+                .await
+                .map_err(|error| match error {
+                    IggyError::TransientNotAccepted => IggyError::OperationUncertain(op),
+                    IggyError::LaneBlocked(_) => IggyError::OperationNotRetained(op),
+                    error => error,
+                })?;
+            ticket.progress = Some(Box::new(progress));
+        }
         let guard = timeout_at(deadline, gate.lock_owned())
             .await
             .map_err(|_| IggyError::OperationUncertain(op))?;
@@ -281,6 +368,7 @@
             owner,
             key,
             guard: None,
+            progress: None,
             operation: None,
             reservation: None,
             fence: 0,
@@ -530,6 +618,13 @@
         LaneKey::of(SEND_MESSAGES_CODE, &context, PARTITION_ID)
     }
 
+    fn progress_lane() -> LaneKey {
+        LaneKey::Progress {
+            incarnation: INCARNATION,
+            partition_id: PARTITION_ID,
+        }
+    }
+
     /// Enters the metadata lane, freezes a write and drops the ticket after
     /// its send point, so the lane retains the write.
     async fn retain_write(owner: &Arc<SessionOwner>, key: Option<LaneKey>) -> u64 {
@@ -963,14 +1058,14 @@
     }
 
     #[tokio::test(start_paused = true)]
-    async fn given_a_progress_poll_when_waiting_for_reply_should_block_its_partition_only() {
+    async fn given_a_progress_poll_when_waiting_for_reply_should_allow_partition_mutations() {
         let owner = bound_owner();
         let context = PartitionContext {
             incarnation: INCARNATION,
             ..PartitionContext::default()
         };
         let mut poll = owner
-            .enter_lane(partition_lane(), deadline())
+            .enter_lane(Some(progress_lane()), deadline())
             .await
             .unwrap();
         poll.freeze(POLL_MESSAGES_CODE, Bytes::new(), context, None)
@@ -979,7 +1074,7 @@
         let next_id = owner.session().unwrap().current_request_id();
 
         let blocked = owner
-            .enter_lane(partition_lane(), Instant::now() + SHORT_DEADLINE)
+            .enter_lane(Some(progress_lane()), Instant::now() + SHORT_DEADLINE)
             .await;
         assert_eq!(blocked.unwrap_err(), IggyError::TransientNotAccepted);
         assert_eq!(owner.session().unwrap().current_request_id(), next_id);
@@ -998,12 +1093,17 @@
             )
             .await
             .unwrap();
+        let same_partition = owner
+            .enter_lane(partition_lane(), deadline())
+            .await
+            .unwrap();
         pure_read.complete();
         other_partition.complete();
+        same_partition.complete();
         poll.complete();
 
         owner
-            .enter_lane(partition_lane(), deadline())
+            .enter_lane(Some(progress_lane()), deadline())
             .await
             .unwrap()
             .complete();
@@ -1016,7 +1116,7 @@
         for cancelled in [true, false] {
             let owner = bound_owner();
             let mut poll = owner
-                .enter_lane(partition_lane(), deadline())
+                .enter_lane(Some(progress_lane()), deadline())
                 .await
                 .unwrap();
             let frame = poll
@@ -1048,7 +1148,7 @@
                 Err(IggyError::OperationNotRetained(missing)) if missing == op
             ));
             owner
-                .enter_lane(partition_lane(), deadline())
+                .enter_lane(Some(progress_lane()), deadline())
                 .await
                 .unwrap()
                 .complete();
@@ -1057,45 +1157,117 @@
     }
 
     #[tokio::test(start_paused = true)]
-    async fn given_a_retained_partition_mutation_when_progress_poll_enters_should_remain_blocked() {
+    async fn given_a_retained_offset_mutation_when_progress_poll_enters_should_remain_blocked() {
+        for code in [STORE_CONSUMER_OFFSET_CODE, DELETE_CONSUMER_OFFSET_CODE] {
+            for reset in [false, true] {
+                let owner = bound_owner();
+                let mut mutation = owner
+                    .enter_progress_lane(progress_lane(), partition_lane(), deadline())
+                    .await
+                    .unwrap();
+                mutation.reserve(code, 0, deadline()).await.unwrap();
+                let frame = mutation
+                    .freeze(
+                        code,
+                        Bytes::new(),
+                        PartitionContext {
+                            incarnation: INCARNATION,
+                            ..PartitionContext::default()
+                        },
+                        None,
+                    )
+                    .unwrap();
+                *mutation.sent_flag() = true;
+                drop(mutation);
+                let next_id = owner.session().unwrap().current_request_id();
+
+                let progress_poll = owner.enter_lane(Some(progress_lane()), deadline()).await;
+
+                assert!(
+                    matches!(progress_poll, Err(IggyError::LaneBlocked(op)) if op == frame.header.request)
+                );
+                assert_eq!(owner.session().unwrap().current_request_id(), next_id);
+                if reset {
+                    owner.reset().unwrap();
+                    owner.bind(NEXT_SESSION).unwrap();
+                    owner
+                        .enter_progress_lane(progress_lane(), partition_lane(), deadline())
+                        .await
+                        .unwrap()
+                        .complete();
+                    assert_eq!(lane_count(&owner), 0);
+                    continue;
+                }
+                let (replay, retained) = owner
+                    .resume(frame.header.request, deadline())
+                    .await
+                    .unwrap();
+                assert_eq!(retained.code, code);
+                owner.abandon(frame.header.request).unwrap();
+                assert!(
+                    timeout(
+                        SHORT_DEADLINE,
+                        owner.enter_lane(Some(progress_lane()), deadline())
+                    )
+                    .await
+                    .is_err(),
+                    "an abandoned replay still owns progress until it finishes"
+                );
+                replay.complete();
+                owner
+                    .enter_lane(Some(progress_lane()), deadline())
+                    .await
+                    .unwrap()
+                    .complete();
+                assert_eq!(lane_count(&owner), 0);
+            }
+        }
+    }
+
+    #[tokio::test(start_paused = true)]
+    async fn given_a_store_waiting_for_mutation_when_session_resets_should_keep_its_progress_gate()
+    {
         let owner = bound_owner();
-        let mut mutation = owner
+        let lineage = owner.epoch().lineage;
+        let holder = owner
             .enter_lane(partition_lane(), deadline())
             .await
             .unwrap();
-        mutation
-            .reserve(SEND_MESSAGES_CODE, 0, deadline())
-            .await
-            .unwrap();
-        let frame = mutation
-            .freeze(
-                SEND_MESSAGES_CODE,
-                Bytes::new(),
-                PartitionContext {
-                    incarnation: INCARNATION,
-                    ..PartitionContext::default()
-                },
-                None,
-            )
-            .unwrap();
-        *mutation.sent_flag() = true;
-        drop(mutation);
-        let next_id = owner.session().unwrap().current_request_id();
+        let mut waiting =
+            Box::pin(owner.enter_progress_lane(progress_lane(), partition_lane(), deadline()));
+        assert!(futures::poll!(&mut waiting).is_pending());
 
-        let progress_poll = owner.enter_lane(partition_lane(), deadline()).await;
-
+        owner.reset().unwrap();
+        owner.bind(NEXT_SESSION).unwrap();
         assert!(
-            matches!(progress_poll, Err(IggyError::LaneBlocked(op)) if op == frame.header.request)
+            timeout(
+                SHORT_DEADLINE,
+                owner.enter_lane(Some(progress_lane()), deadline())
+            )
+            .await
+            .is_err()
         );
-        assert_eq!(owner.session().unwrap().current_request_id(), next_id);
-        let (replay, retained) = owner
-            .resume(frame.header.request, deadline())
+        holder.complete();
+        let mut store = waiting.await.unwrap();
+        store
+            .reserve(STORE_CONSUMER_OFFSET_CODE, 0, deadline())
             .await
             .unwrap();
-        assert_eq!(retained.code, SEND_MESSAGES_CODE);
-        replay.complete();
+        assert_eq!(
+            store
+                .freeze_in(
+                    lineage,
+                    STORE_CONSUMER_OFFSET_CODE,
+                    Bytes::new(),
+                    PartitionContext::default(),
+                    None
+                )
+                .unwrap_err(),
+            IggyError::Unauthenticated
+        );
+        drop(store);
         owner
-            .enter_lane(partition_lane(), deadline())
+            .enter_progress_lane(progress_lane(), partition_lane(), deadline())
             .await
             .unwrap()
             .complete();
diff --git a/core/sdk/src/stream_builder/build/build_iggy_consumer.rs b/core/sdk/src/stream_builder/build/build_iggy_consumer.rs
index 60c61a2..a4bdf54 100644
--- a/core/sdk/src/stream_builder/build/build_iggy_consumer.rs
+++ b/core/sdk/src/stream_builder/build/build_iggy_consumer.rs
@@ -49,7 +49,7 @@
     let consumer_kind = config.consumer_kind();
     let consumer_name = config.consumer_name();
     let batch_length = config.batch_length();
-    let polling_interval = config.polling_interval();
+    let poll_options = config.poll_options();
     let polling_strategy = config.polling_strategy();
     let partition = config.partition_id();
     let polling_retry_interval = config.polling_retry_interval();
@@ -66,10 +66,15 @@
     .create_consumer_group_if_not_exists()
     .auto_join_consumer_group()
     .polling_strategy(polling_strategy)
-    .poll_interval(polling_interval)
+    .poll_options(poll_options)
+    .prefetch_bytes(config.prefetch_bytes())
     .batch_length(batch_length)
     .polling_retry_interval(polling_retry_interval);
 
+    if let Some(prefetch_messages) = config.prefetch_messages() {
+        builder = builder.prefetch_messages(prefetch_messages);
+    }
+
     if let Some(encryptor) = config.encryptor() {
         trace!("Set encryptor");
         builder = builder.encryptor(encryptor);
diff --git a/core/sdk/src/stream_builder/config/config_iggy_consumer.rs b/core/sdk/src/stream_builder/config/config_iggy_consumer.rs
index b6da466..293466f 100644
--- a/core/sdk/src/stream_builder/config/config_iggy_consumer.rs
+++ b/core/sdk/src/stream_builder/config/config_iggy_consumer.rs
@@ -15,9 +15,10 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::clients::consumer::{AutoCommit, AutoCommitWhen};
+use crate::clients::consumer::AutoCommit;
+use crate::clients::consumer::deferred::DEFAULT_PREFETCH_BYTES;
 use crate::prelude::{
-    ConsumerKind, EncryptorKind, Identifier, IggyDuration, IggyError, NonZeroIggyDuration,
+    ConsumerKind, DeferredPollOptions, EncryptorKind, Identifier, IggyError, NonZeroIggyDuration,
     PollingStrategy, Validatable,
 };
 use bon::Builder;
@@ -35,10 +36,17 @@
     /// Name of the topic. Must be unique.
     topic_name: String,
     /// The auto-commit configuration for storing the message offset on the server. See  `AutoCommit` for details.
+    #[builder(default = AutoCommit::Disabled)]
     auto_commit: AutoCommit,
     /// The max number of messages to poll in a batch. The greater the batch length, the higher the throughput for bulk data.
     /// Note, there is a tradeoff between batch size and latency, so you want to benchmark your setup.
     batch_length: u32,
+    /// Maximum encoded bytes reserved for buffered and in-flight batches.
+    #[builder(default = DEFAULT_PREFETCH_BYTES)]
+    prefetch_bytes: u32,
+    /// Maximum messages reserved for buffered and in-flight batches. Defaults to 16,000, or
+    /// `batch_length` when that is larger.
+    prefetch_messages: Option<u32>,
     /// Create the stream if it doesn't exist.
     create_stream_if_not_exists: bool,
     /// Create the topic if it doesn't exist.
@@ -53,9 +61,11 @@
     /// Partition ID for an ordinary consumer. Defaults to 0 and is ignored by consumer groups.
     #[builder(default = DEFAULT_PARTITION_ID)]
     partition_id: u32,
-    /// The polling interval for messages.
-    polling_interval: IggyDuration,
+    /// Readiness, response size and request timeout limits.
+    #[builder(default)]
+    poll_options: DeferredPollOptions,
     /// `PollingStrategy` specifies from where to start polling messages. See `PollingStrategy` for details.
+    #[builder(default = PollingStrategy::next())]
     polling_strategy: PollingStrategy,
     /// Sets the polling retry interval in case of server disconnection.
     polling_retry_interval: NonZeroIggyDuration,
@@ -73,14 +83,16 @@
         Self {
             stream_name: "test_stream".to_string(),
             topic_name: "test_topic".to_string(),
-            auto_commit: AutoCommit::When(AutoCommitWhen::PollingMessages),
+            auto_commit: AutoCommit::Disabled,
             batch_length: 100,
+            prefetch_bytes: DEFAULT_PREFETCH_BYTES,
+            prefetch_messages: None,
             create_stream_if_not_exists: false,
             create_topic_if_not_exists: false,
             consumer_name: "test_consumer".to_string(),
             consumer_kind: ConsumerKind::ConsumerGroup,
-            polling_interval: IggyDuration::from_str("5ms").unwrap(),
-            polling_strategy: PollingStrategy::last(),
+            poll_options: DeferredPollOptions::default(),
+            polling_strategy: PollingStrategy::next(),
             partitions_count: 1,
             partition_id: DEFAULT_PARTITION_ID,
             encryptor: None,
@@ -104,7 +116,7 @@
     /// * `create_topic_if_not_exists` - Whether to create the topic if it does not exists.
     /// * `consumer_name` - The consumer name.
     /// * `consumer_kind` - The consumer kind.
-    /// * `polling_interval` - The interval between polling for new messages.
+    /// * `poll_options` - Readiness, response size and request timeout limits.
     /// * `polling_strategy` - The polling strategy.
     /// * `partitions_count` - Topic creation count.
     /// * `encryptor` - The encryptor.
@@ -128,7 +140,7 @@
         create_topic_if_not_exists: bool,
         consumer_name: String,
         consumer_kind: ConsumerKind,
-        polling_interval: IggyDuration,
+        poll_options: DeferredPollOptions,
         polling_strategy: PollingStrategy,
         partitions_count: u32,
         encryptor: Option<Arc<EncryptorKind>>,
@@ -141,11 +153,13 @@
             topic_name,
             auto_commit,
             batch_length,
+            prefetch_bytes: DEFAULT_PREFETCH_BYTES,
+            prefetch_messages: None,
             create_stream_if_not_exists,
             create_topic_if_not_exists,
             consumer_name,
             consumer_kind,
-            polling_interval,
+            poll_options,
             polling_strategy,
             partitions_count,
             partition_id: DEFAULT_PARTITION_ID,
@@ -163,7 +177,7 @@
     /// * `stream` - The stream name.
     /// * `topic` - The topic name.
     /// * `batch_length` - The max number of messages to poll in a batch.
-    /// * `polling_interval` - The interval between polling for new messages.
+    /// * `poll_options` - Readiness, response size and request timeout limits.
     ///
     /// Returns:
     /// A new `IggyConsumerConfig`.
@@ -172,7 +186,7 @@
         stream: &str,
         topic: &str,
         batch_length: u32,
-        polling_interval: IggyDuration,
+        poll_options: DeferredPollOptions,
     ) -> Result<Self, IggyError> {
         // Validate stream and topic names. Returns IggyError if names are not allowed.
         Identifier::named(stream)?;
@@ -181,14 +195,16 @@
         Ok(Self {
             stream_name: stream.to_string(),
             topic_name: topic.to_string(),
-            auto_commit: AutoCommit::When(AutoCommitWhen::PollingMessages),
+            auto_commit: AutoCommit::Disabled,
             batch_length,
+            prefetch_bytes: DEFAULT_PREFETCH_BYTES,
+            prefetch_messages: None,
             create_stream_if_not_exists: false,
             create_topic_if_not_exists: false,
             consumer_name: format!("consumer-{stream}-{topic}"),
             consumer_kind: ConsumerKind::ConsumerGroup,
-            polling_interval,
-            polling_strategy: PollingStrategy::last(),
+            poll_options,
+            polling_strategy: PollingStrategy::next(),
             partitions_count: 1,
             partition_id: DEFAULT_PARTITION_ID,
             encryptor: None,
@@ -206,6 +222,12 @@
         self
     }
 
+    /// Sets the auto-commit configuration, which `default` and `from_stream_topic` leave disabled.
+    pub fn with_auto_commit(mut self, auto_commit: AutoCommit) -> Self {
+        self.auto_commit = auto_commit;
+        self
+    }
+
     pub fn stream_name(&self) -> &str {
         &self.stream_name
     }
@@ -218,6 +240,14 @@
         self.auto_commit
     }
 
+    pub fn prefetch_bytes(&self) -> u32 {
+        self.prefetch_bytes
+    }
+
+    pub fn prefetch_messages(&self) -> Option<u32> {
+        self.prefetch_messages
+    }
+
     pub fn batch_length(&self) -> u32 {
         self.batch_length
     }
@@ -237,8 +267,8 @@
         self.consumer_kind
     }
 
-    pub fn polling_interval(&self) -> IggyDuration {
-        self.polling_interval
+    pub fn poll_options(&self) -> DeferredPollOptions {
+        self.poll_options
     }
 
     pub fn polling_strategy(&self) -> PollingStrategy {
@@ -315,14 +345,11 @@
         let config = IggyConsumerConfig::builder()
             .stream_name("test_stream".to_string())
             .topic_name("test_topic".to_string())
-            .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
             .batch_length(100)
             .create_stream_if_not_exists(true)
             .create_topic_if_not_exists(true)
             .consumer_name("test_consumer".to_string())
             .consumer_kind(ConsumerKind::ConsumerGroup)
-            .polling_interval(IggyDuration::from_str("5ms").unwrap())
-            .polling_strategy(PollingStrategy::last())
             .polling_retry_interval(NonZeroIggyDuration::ONE_SECOND)
             .partitions_count(1)
             .init_retries(3)
@@ -331,20 +358,14 @@
 
         assert_eq!(config.stream_name(), "test_stream");
         assert_eq!(config.topic_name(), "test_topic");
-        assert_eq!(
-            config.auto_commit(),
-            AutoCommit::When(AutoCommitWhen::PollingMessages)
-        );
+        assert_eq!(config.auto_commit(), AutoCommit::Disabled);
         assert_eq!(config.batch_length(), 100);
         assert!(config.create_stream_if_not_exists());
         assert!(config.create_topic_if_not_exists());
         assert_eq!(config.consumer_name(), "test_consumer");
         assert_eq!(config.consumer_kind(), ConsumerKind::ConsumerGroup);
-        assert_eq!(
-            config.polling_interval(),
-            IggyDuration::from_str("5ms").unwrap()
-        );
-        assert_eq!(config.polling_strategy(), PollingStrategy::last());
+        assert_eq!(config.poll_options(), DeferredPollOptions::default());
+        assert_eq!(config.polling_strategy(), PollingStrategy::next());
         assert_eq!(config.partitions_count(), 1);
 
         assert_eq!(
@@ -364,20 +385,14 @@
         let config = IggyConsumerConfig::default();
         assert_eq!(config.stream_name(), "test_stream");
         assert_eq!(config.topic_name(), "test_topic");
-        assert_eq!(
-            config.auto_commit(),
-            AutoCommit::When(AutoCommitWhen::PollingMessages)
-        );
+        assert_eq!(config.auto_commit(), AutoCommit::Disabled);
         assert_eq!(config.batch_length(), 100);
         assert!(!config.create_stream_if_not_exists());
         assert!(!config.create_topic_if_not_exists());
         assert_eq!(config.consumer_name(), "test_consumer");
         assert_eq!(config.consumer_kind(), ConsumerKind::ConsumerGroup);
-        assert_eq!(
-            config.polling_interval(),
-            IggyDuration::from_str("5ms").unwrap()
-        );
-        assert_eq!(config.polling_strategy(), PollingStrategy::last());
+        assert_eq!(config.poll_options(), DeferredPollOptions::default());
+        assert_eq!(config.polling_strategy(), PollingStrategy::next());
         assert_eq!(config.partitions_count(), 1);
 
         assert_eq!(
@@ -396,13 +411,13 @@
         let config = IggyConsumerConfig::new(
             "test_stream".to_string(),
             "test_topic".to_string(),
-            AutoCommit::When(AutoCommitWhen::PollingMessages),
+            AutoCommit::Disabled,
             100,
             false,
             false,
             "test_consumer".to_string(),
             ConsumerKind::ConsumerGroup,
-            IggyDuration::from_str("5ms").unwrap(),
+            DeferredPollOptions::default(),
             PollingStrategy::last(),
             1,
             None,
@@ -412,19 +427,13 @@
         );
         assert_eq!(config.stream_name(), "test_stream");
         assert_eq!(config.topic_name(), "test_topic");
-        assert_eq!(
-            config.auto_commit(),
-            AutoCommit::When(AutoCommitWhen::PollingMessages)
-        );
+        assert_eq!(config.auto_commit(), AutoCommit::Disabled);
         assert_eq!(config.batch_length(), 100);
         assert!(!config.create_stream_if_not_exists());
         assert!(!config.create_topic_if_not_exists());
         assert_eq!(config.consumer_name(), "test_consumer");
         assert_eq!(config.consumer_kind(), ConsumerKind::ConsumerGroup);
-        assert_eq!(
-            config.polling_interval(),
-            IggyDuration::from_str("5ms").unwrap()
-        );
+        assert_eq!(config.poll_options(), DeferredPollOptions::default());
         assert_eq!(config.polling_strategy(), PollingStrategy::last());
         assert_eq!(config.partitions_count(), 1);
 
@@ -445,7 +454,7 @@
             "test_stream",
             "test_topic",
             100,
-            IggyDuration::from_str("5ms").unwrap(),
+            DeferredPollOptions::default(),
         );
         assert!(res.is_ok());
         let config = res.unwrap();
@@ -457,11 +466,8 @@
         assert!(!config.create_topic_if_not_exists());
         assert_eq!(config.consumer_name(), "consumer-test_stream-test_topic");
         assert_eq!(config.consumer_kind(), ConsumerKind::ConsumerGroup);
-        assert_eq!(
-            config.polling_interval(),
-            IggyDuration::from_str("5ms").unwrap()
-        );
-        assert_eq!(config.polling_strategy(), PollingStrategy::last());
+        assert_eq!(config.poll_options(), DeferredPollOptions::default());
+        assert_eq!(config.polling_strategy(), PollingStrategy::next());
         assert_eq!(config.partitions_count(), 1);
     }
 }
diff --git a/core/sdk/src/stream_builder/config/config_iggy_stream.rs b/core/sdk/src/stream_builder/config/config_iggy_stream.rs
index b3277e7..e1a5107 100644
--- a/core/sdk/src/stream_builder/config/config_iggy_stream.rs
+++ b/core/sdk/src/stream_builder/config/config_iggy_stream.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::prelude::{IggyDuration, IggyError};
+use crate::prelude::{DeferredPollOptions, IggyDuration, IggyError};
 use crate::stream_builder::{IggyConsumerConfig, IggyProducerConfig};
 use bon::Builder;
 
@@ -44,7 +44,7 @@
     }
 
     /// Creates a new `IggyStreamConfig` from the given stream and topic names, along with the max
-    /// batch size, the send interval and the polling interval.
+    /// batch size, send interval and poll limits.
     ///
     /// # Args
     ///
@@ -52,7 +52,7 @@
     /// * `topic` - The topic name.
     /// * `batch_length` - The max number of messages to send in a batch.
     /// * `linger_time` - The interval between messages sent.
-    /// * `polling_interval` - The interval between polling for new messages.
+    /// * `poll_options` - Readiness, response size and request timeout limits.
     ///
     /// Returns:
     /// A new `IggyStreamConfig`.
@@ -62,10 +62,10 @@
         topic: &str,
         batch_length: u32,
         linger_time: IggyDuration,
-        polling_interval: IggyDuration,
+        poll_options: DeferredPollOptions,
     ) -> Result<Self, IggyError> {
         let consumer_config =
-            IggyConsumerConfig::from_stream_topic(stream, topic, batch_length, polling_interval)?;
+            IggyConsumerConfig::from_stream_topic(stream, topic, batch_length, poll_options)?;
 
         let producer_config =
             IggyProducerConfig::from_stream_topic(stream, topic, batch_length, linger_time)?;
@@ -96,7 +96,7 @@
             "test_stream",
             "test_topic",
             100,
-            IggyDuration::from_str("5ms").unwrap(),
+            DeferredPollOptions::default(),
         )
         .unwrap();
 
@@ -113,8 +113,8 @@
         assert_eq!(config.consumer_config().batch_length(), 100);
         assert_eq!(config.producer_config().batch_length(), 100);
         assert_eq!(
-            config.consumer_config().polling_interval(),
-            IggyDuration::from_str("5ms").unwrap()
+            config.consumer_config().poll_options(),
+            DeferredPollOptions::default()
         );
         assert_eq!(
             config.producer_config().linger_time(),
@@ -130,8 +130,8 @@
         assert_eq!(config.consumer_config().batch_length(), 100);
         assert_eq!(config.producer_config().batch_length(), 100);
         assert_eq!(
-            config.consumer_config().polling_interval(),
-            IggyDuration::from_str("5ms").unwrap()
+            config.consumer_config().poll_options(),
+            DeferredPollOptions::default()
         );
         assert_eq!(
             config.producer_config().linger_time(),
@@ -146,7 +146,7 @@
             "test_topic",
             100,
             IggyDuration::from_str("5ms").unwrap(),
-            IggyDuration::from_str("5ms").unwrap(),
+            DeferredPollOptions::default(),
         );
 
         assert!(res.is_ok());
@@ -157,8 +157,8 @@
         assert_eq!(config.consumer_config().batch_length(), 100);
         assert_eq!(config.producer_config().batch_length(), 100);
         assert_eq!(
-            config.consumer_config().polling_interval(),
-            IggyDuration::from_str("5ms").unwrap()
+            config.consumer_config().poll_options(),
+            DeferredPollOptions::default()
         );
         assert_eq!(
             config.producer_config().linger_time(),
diff --git a/core/sdk/src/vsr.rs b/core/sdk/src/vsr.rs
index 3797eca..451a27d 100644
--- a/core/sdk/src/vsr.rs
+++ b/core/sdk/src/vsr.rs
@@ -196,6 +196,7 @@
                     outcome.result,
                     Err(IggyError::TransientNotCommitted
                         | IggyError::TransientNotAccepted
+                        | IggyError::DeferredPollCapacityExceeded
                         | IggyError::NotPrimary)
                 );
             }
@@ -512,6 +513,7 @@
         for error in [
             IggyError::TransientNotCommitted,
             IggyError::TransientNotAccepted,
+            IggyError::DeferredPollCapacityExceeded,
             IggyError::NotPrimary,
         ] {
             let body = rejection_body(error.as_code());
diff --git a/core/server/Cargo.toml b/core/server/Cargo.toml
index cdddfde..c90ed37 100644
--- a/core/server/Cargo.toml
+++ b/core/server/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "server"
-version = "0.9.1-edge.3"
+version = "0.9.1-edge.4"
 edition = "2024"
 license = "Apache-2.0"
 publish = false
diff --git a/core/server/config.toml b/core/server/config.toml
index fc63036..ef02a55 100644
--- a/core/server/config.toml
+++ b/core/server/config.toml
@@ -794,6 +794,25 @@
 # separate capacity. This limits operation count, not retained message bytes.
 poll_completion_capacity = 1024
 
+# Longest wait of a deferred poll or readiness wait (at most 10 minutes). A
+# longer requested wait is shortened to it.
+deferred_poll_max_wait = "30 s"
+# Deferred polls and readiness waits parked on one shard, and the part of
+# them that one session may hold. An HTTP request without a binary session
+# counts against its user. A request above either limit is refused with a
+# retryable error.
+deferred_poll_max_pending = 1024
+deferred_poll_max_pending_per_session = 64
+# A deferred disk read reserves three times the max_bytes of its poll, and at
+# least three times 1 MiB, because one disk read can take a 1 MiB chunk
+# whatever its max_bytes. It reserves no more than the first value. The
+# deferred reads of one shard reserve no more than the second value together
+# (at most 1 GiB). A read that finds no room waits for one, behind the reads
+# that found no room before it. A reply carries no more than the first value,
+# unless its first message alone is wider.
+deferred_poll_max_read_bytes = "16 MiB"
+deferred_poll_max_inflight_bytes = "64 MiB"
+
 # Active partition file jobs plus results awaiting owner acceptance per shard (1..=256).
 # Exhausting either limit queues partition file work until capacity is released.
 # Running jobs use [cluster] superblock_wedged_fatal_timeout, counted in pump ticks.
diff --git a/core/server/src/boot/recovery.rs b/core/server/src/boot/recovery.rs
index f6cd676..6f63298 100644
--- a/core/server/src/boot/recovery.rs
+++ b/core/server/src/boot/recovery.rs
@@ -46,6 +46,7 @@
 use partitions::{IggyPartitions, PartitionsConfig, StreamKeyIdentity};
 use server_common::sharding::{IggyNamespace, PartitionLocation, ShardId};
 use shard::builder::IggyShardBuilder;
+use shard::config::DeferredPollConfig;
 use shard::metrics::ShardMetrics;
 use shard::shards_table::{PapayaShardsTable, ShardsTable, calculate_shard_assignment};
 use shard::{
@@ -365,6 +366,7 @@
     .map_err(ServerError::ShardConstruction)?;
 
     let shard = Rc::new(built.shard);
+    shard.set_deferred_poll_config(deferred_poll_config(config));
     // Repair pacing is shared by both planes' repair loops, so it is a
     // per-shard tunable set once here rather than per consensus group.
     shard.set_repair_retry_ticks(repair_retry_ticks(config));
@@ -602,6 +604,18 @@
     }
 }
 
+/// The `[sharding]` deferred poll limits in the units the shard compares.
+fn deferred_poll_config(config: &ServerConfig) -> DeferredPollConfig {
+    let sharding = &config.sharding;
+    DeferredPollConfig {
+        max_wait_us: sharding.deferred_poll_max_wait.as_micros(),
+        max_pending: sharding.deferred_poll_max_pending,
+        max_pending_per_session: sharding.deferred_poll_max_pending_per_session,
+        max_read_bytes: sharding.deferred_poll_max_read_bytes.as_bytes_usize(),
+        max_inflight_bytes: sharding.deferred_poll_max_inflight_bytes.as_bytes_usize(),
+    }
+}
+
 /// `[cluster] superblock_wedged_fatal_timeout` as a consecutive-failure count,
 /// which is the only shape the shard's `superblock_wedged` can compare. Zero
 /// stays zero (fail-stop disabled).
@@ -914,6 +928,15 @@
     use metadata::stm::StateMachine as _;
 
     const CONFIG_KEY: &str = "/rvT1xP4V8u1EAhk4xDdqzqM2UOPXyy9XYkl4uRShgE=";
+
+    #[test]
+    fn given_default_server_config_when_converting_deferred_poll_limits_should_match_shard_defaults()
+     {
+        assert_eq!(
+            deferred_poll_config(&ServerConfig::default()),
+            DeferredPollConfig::default()
+        );
+    }
     const OTHER_CONFIG_KEY: &str = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=";
 
     #[test]
diff --git a/core/server/src/dispatch/authz.rs b/core/server/src/dispatch/authz.rs
index f05e165..36b1b33 100644
--- a/core/server/src/dispatch/authz.rs
+++ b/core/server/src/dispatch/authz.rs
@@ -462,11 +462,12 @@
     use crate::dispatch::test_support::{FIRST_BOOT, SpyBus, TestShard, test_shard};
     use iggy_binary_protocol::COMMAND_TABLE;
     use iggy_binary_protocol::codes::{
-        BIND_SESSION_CODE, CREATE_STREAM_CODE, GET_CLIENT_CODE, GET_CLIENTS_CODE,
-        GET_CONSUMER_OFFSET_CODE, GET_ME_CODE, GET_METADATA_CHANGES_CODE, GET_PARTITION_ROUTE_CODE,
-        GET_SNAPSHOT_FILE_CODE, LOGIN_REGISTER_CODE, LOGIN_REGISTER_WITH_PAT_CODE, LOGIN_USER_CODE,
-        LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE, LOGOUT_USER_CODE, PING_CODE, POLL_MESSAGES_CODE,
-        SYNC_CONSUMER_GROUP_CODE,
+        BIND_SESSION_CODE, CANCEL_DEFERRED_REQUEST_CODE, CREATE_STREAM_CODE, GET_CLIENT_CODE,
+        GET_CLIENTS_CODE, GET_CONSUMER_OFFSET_CODE, GET_ME_CODE, GET_METADATA_CHANGES_CODE,
+        GET_PARTITION_ROUTE_CODE, GET_SNAPSHOT_FILE_CODE, LOGIN_REGISTER_CODE,
+        LOGIN_REGISTER_WITH_PAT_CODE, LOGIN_USER_CODE, LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE,
+        LOGOUT_USER_CODE, PING_CODE, POLL_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE,
+        SYNC_CONSUMER_GROUP_CODE, WAIT_FOR_MESSAGES_CODE,
     };
     use iggy_common::defaults::DEFAULT_ROOT_USER_ID;
 
@@ -536,6 +537,17 @@
             (POLL_MESSAGES_CODE, invalid_command, invalid_command),
             (BIND_SESSION_CODE, invalid_command, invalid_command),
             (GET_PARTITION_ROUTE_CODE, invalid_command, invalid_command),
+            (
+                POLL_MESSAGES_DEFERRED_CODE,
+                invalid_command,
+                invalid_command,
+            ),
+            (WAIT_FOR_MESSAGES_CODE, invalid_command, invalid_command),
+            (
+                CANCEL_DEFERRED_REQUEST_CODE,
+                invalid_command,
+                invalid_command,
+            ),
             (GET_CONSUMER_OFFSET_CODE, invalid_command, invalid_command),
             (GET_STREAM_CODE, allow, allow),
             (GET_STREAMS_CODE, unauthenticated, allow),
diff --git a/core/server/src/dispatch/mod.rs b/core/server/src/dispatch/mod.rs
index 2d5dfc8..b84f3a8 100644
--- a/core/server/src/dispatch/mod.rs
+++ b/core/server/src/dispatch/mod.rs
@@ -66,7 +66,8 @@
 use futures::future::{Either, select};
 use iggy_binary_protocol::PrepareHeader;
 use iggy_binary_protocol::codes::{
-    BIND_SESSION_CODE, LOGIN_USER_CODE, LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE, PING_CODE,
+    BIND_SESSION_CODE, CANCEL_DEFERRED_REQUEST_CODE, LOGIN_USER_CODE,
+    LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE, PING_CODE,
 };
 use iggy_binary_protocol::requests::system::SessionIdentity;
 use iggy_binary_protocol::requests::users::login_register::BindSecret;
@@ -205,6 +206,45 @@
     // Captured on arrival: every reply to this frame, however late, goes to the
     // connection that sent it.
     let origin = ClientOrigin::capture(bus, client_id);
+    if let Ok(cancel) = message.try_as_typed::<RequestHeader>()
+        && cancel.header().operation == Operation::NonReplicated
+        && cancel.header().reserved == CANCEL_DEFERRED_REQUEST_CODE.to_le_bytes()
+        && cancel.body().is_empty()
+        && sessions.borrow().cancel_deferred_request(
+            client_id,
+            cancel.header().client,
+            cancel.header().session,
+            cancel.header().request,
+        )
+    {
+        // Cancellation bypasses admission limits. A poll still queued behind
+        // another operation must be removed before it can be admitted.
+        let mut queues = queues.borrow_mut();
+        if let Some(queue) = queues.get_mut(&client_id) {
+            for index in (0..queue.requests.len()).rev() {
+                let matches = queue.requests[index]
+                    .message
+                    .try_as_typed::<RequestHeader>()
+                    .is_ok_and(|request| {
+                        let header = request.header();
+                        header.deferred_cancellation().is_some()
+                            && header.client == cancel.header().client
+                            && header.session == cancel.header().session
+                            && header.request == cancel.header().request
+                    });
+                if matches {
+                    queue.remove(index);
+                }
+            }
+            if queue.requests.is_empty() {
+                queues.remove(&client_id);
+            }
+        }
+        if let Some(drain) = active.borrow().get(&client_id) {
+            drain.wake();
+        }
+        return;
+    }
     {
         let request = QueuedClientRequest::new(origin.queued(), message);
         let bytes_max = server_config
@@ -494,6 +534,16 @@
             if drain.metadata_write.get() {
                 return false;
             }
+            // A deferred request waits for room in the queue, where a cancel
+            // still finds it, not at the cap where nothing does.
+            if drain.in_flight.get() >= drain.capacity
+                && queued
+                    .message
+                    .try_as_typed::<RequestHeader>()
+                    .is_ok_and(|request| request.header().deferred_cancellation().is_some())
+            {
+                return false;
+            }
             queued
                 .progress_key(metadata_frontier, |message| progress_key(&shard, message))
                 .is_none_or(|key| {
@@ -860,7 +910,10 @@
                 metadata_watermark,
             };
             // `ClientDrain` says which frames stay on the drain.
-            if matches!(non_replicated_code(header), PING_CODE | BIND_SESSION_CODE) {
+            if matches!(
+                non_replicated_code(header),
+                PING_CODE | BIND_SESSION_CODE | CANCEL_DEFERRED_REQUEST_CODE
+            ) {
                 handle_non_replicated_request(
                     shard,
                     sessions,
@@ -1225,14 +1278,17 @@
     use iggy_binary_protocol::codes::{
         GET_CLIENT_CODE, GET_CLIENTS_CODE, GET_CLUSTER_METADATA_CODE, GET_CONSUMER_OFFSET_CODE,
         GET_ME_CODE, GET_METADATA_CHANGES_CODE, GET_PARTITION_ROUTE_CODE, GET_STATS_CODE,
-        POLL_MESSAGES_CODE,
+        POLL_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE,
     };
     use iggy_binary_protocol::primitives::metadata_changes::WireMetadataChangeStatus;
     use iggy_binary_protocol::primitives::polling_strategy::WirePollingStrategy;
     use iggy_binary_protocol::requests::consumer_offsets::{
         GetConsumerOffsetRequest, StoreConsumerOffsetRequest,
     };
-    use iggy_binary_protocol::requests::messages::{GetPartitionRouteRequest, PollMessagesRequest};
+    use iggy_binary_protocol::requests::messages::{
+        DeferredPollMessagesRequest, GetPartitionRouteRequest, MIN_DEFERRED_POLL_MAX_BYTES,
+        MIN_DEFERRED_POLL_REPLY_MARGIN_US, PollMessagesRequest,
+    };
     use iggy_binary_protocol::requests::personal_access_tokens::CreatePersonalAccessTokenRequest as WireCreatePersonalAccessTokenRequest;
     use iggy_binary_protocol::requests::streams::{
         CreateStreamRequest, UpdateStreamResolvedRequest,
@@ -2894,6 +2950,12 @@
                 RequestClass::UnauthenticatedRead,
             ),
             (
+                "a deferred-request cancel is not pre-auth",
+                nr(CANCEL_DEFERRED_REQUEST_CODE),
+                false,
+                RequestClass::UnauthenticatedRead,
+            ),
+            (
                 "poll-messages is a non-replicated code, not a partition op",
                 nr(POLL_MESSAGES_CODE),
                 true,
@@ -3466,6 +3528,245 @@
     }
 
     #[compio::test]
+    async fn given_a_deferred_poll_when_resolving_progress_should_take_the_lane_of_its_plain_poll()
+    {
+        let (_bus, shard, _host) = root_host(ServerConfig::default());
+        create_one_partition(&shard);
+        let key = |code: u32, body: &[u8]| {
+            let mut message =
+                wire_request(Operation::NonReplicated, ROOT_CLIENT, ROOT_SESSION, 1, body);
+            bytemuck::checked::from_bytes_mut::<RequestHeader>(
+                &mut message.as_mut_slice()[..size_of::<RequestHeader>()],
+            )
+            .reserved[..4]
+                .copy_from_slice(&code.to_le_bytes());
+            progress_key(&shard, &message.into_generic())
+        };
+        for (strategy, auto_commit, takes_lane) in [
+            (WirePollingStrategy::next(), false, true),
+            (WirePollingStrategy::offset(0), true, true),
+            (WirePollingStrategy::offset(0), false, false),
+        ] {
+            let poll = PollMessagesRequest {
+                consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+                stream_id: WireIdentifier::numeric(0),
+                topic_id: WireIdentifier::numeric(0),
+                partition_id: Some(0),
+                strategy,
+                count: 1,
+                auto_commit,
+            };
+            let plain = key(POLL_MESSAGES_CODE, &poll.to_bytes());
+            assert_eq!(plain.is_some(), takes_lane);
+            let deferred = DeferredPollMessagesRequest {
+                poll,
+                wait_us: 1,
+                min_count: 1,
+                max_bytes: u32::try_from(MIN_DEFERRED_POLL_MAX_BYTES).unwrap(),
+                request_timeout_us: 1 + MIN_DEFERRED_POLL_REPLY_MARGIN_US,
+            };
+            assert_eq!(
+                key(POLL_MESSAGES_DEFERRED_CODE, &deferred.to_bytes()),
+                plain
+            );
+        }
+    }
+
+    #[compio::test]
+    async fn given_a_full_cap_when_a_deferred_poll_queues_should_keep_it_where_a_cancel_finds_it() {
+        let (bus, shard, host) = root_host(ServerConfig::default());
+        create_one_partition(&shard);
+        bind_root_session(&shard, host.sessions()).await;
+        let mut config = ServerConfig::default();
+        config.message_bus.client_queue_capacity = 1;
+        let config = Arc::new(config);
+        let drain = Rc::new(ClientDrain::new(&config));
+        let held = drain.admit(Admission::Cap).await;
+        let message = root_deferred_poll(1, false);
+        let mut queue = ClientRequestQueue::default();
+        queue.push_back(QueuedClientRequest::new(
+            ClientOrigin::capture(&bus, ROOT_TRANSPORT),
+            message.into_generic(),
+        ));
+        let queues = Rc::new(RefCell::new(AHashMap::from_iter([(ROOT_TRANSPORT, queue)])));
+        let queued = || {
+            queues
+                .borrow()
+                .get(&ROOT_TRANSPORT)
+                .map_or(0, |queue| queue.requests.len())
+        };
+        let running = drain_client_requests(
+            Rc::clone(&shard),
+            Rc::clone(host.sessions()),
+            config,
+            Rc::clone(&queues),
+            &drain,
+            ROOT_TRANSPORT,
+        );
+        futures::pin_mut!(running);
+        assert!(futures::poll!(running.as_mut()).is_pending());
+        assert_eq!(queued(), 1, "at the cap the deferred poll stays queued");
+
+        drop(held);
+        let _ = futures::poll!(running.as_mut());
+        assert_eq!(queued(), 0, "room under the cap admits it");
+    }
+
+    #[compio::test]
+    async fn given_a_queued_deferred_poll_when_its_cancel_arrives_should_drop_it_before_admission()
+    {
+        assert_cancel_of_queued_deferred_poll(true).await;
+    }
+
+    #[compio::test]
+    async fn given_a_queued_deferred_poll_when_a_cancel_names_another_request_should_still_admit_it()
+     {
+        assert_cancel_of_queued_deferred_poll(false).await;
+    }
+
+    /// Queues an auto-commit deferred poll behind a drain whose only place
+    /// under the cap is taken, then sends a cancellation the way the transport
+    /// does. A poll admitted after its own cancellation would park and commit
+    /// with nothing left to abort it, so only that cancellation may keep it
+    /// from its owner.
+    async fn assert_cancel_of_queued_deferred_poll(cancel_names_the_poll: bool) {
+        const POLL_REQUEST: u64 = 3;
+        let cancelled = if cancel_names_the_poll {
+            POLL_REQUEST
+        } else {
+            POLL_REQUEST + 1
+        };
+        let bus = SpyBus::default();
+        let mut shard = test_shard(&bus, 0, 1, FIRST_BOOT);
+        let (sender, owner_inbox, _owner_replies) = shard_channel(0, 1, 1);
+        shard.attach_senders(vec![sender]);
+        let shard = Rc::new(shard);
+        let namespace = create_one_partition(&shard);
+        shard
+            .shards_table()
+            .insert(namespace, PartitionLocation::new(ShardId::new(0), 0));
+        commit_root_session(&shard);
+        let shard_handle = Rc::new(RefCell::new(Some(Rc::downgrade(&shard))));
+        let sessions = Rc::new(RefCell::new(SessionManager::new()));
+        bind_root_session(&shard, &sessions).await;
+        let mut server_config = ServerConfig::default();
+        server_config.message_bus.client_queue_capacity = 1;
+        let server_config = Arc::new(server_config);
+        let queues: ClientRequestQueues = Rc::default();
+        let active: ActiveClientRequests = Rc::default();
+        let enqueue = |message| {
+            enqueue_client_request(
+                &bus,
+                &shard_handle,
+                &sessions,
+                &server_config,
+                &queues,
+                &active,
+                ROOT_TRANSPORT,
+                message,
+            );
+        };
+        let poll_queued = || {
+            queues.borrow().get(&ROOT_TRANSPORT).is_some_and(|queue| {
+                queue.requests.iter().any(|queued| {
+                    queued
+                        .message
+                        .try_as_typed::<RequestHeader>()
+                        .is_ok_and(|request| request.header().request == POLL_REQUEST)
+                })
+            })
+        };
+
+        // The first read takes the only place under the cap and the second
+        // holds the drain, so the poll behind them stays queued.
+        enqueue(held_get_me(1, UNAPPLIED_OP));
+        enqueue(held_get_me(2, UNAPPLIED_OP));
+        settle().await;
+        let mut poll = root_deferred_poll(POLL_REQUEST, true);
+        bytemuck::checked::from_bytes_mut::<RequestHeader>(
+            &mut poll.as_mut_slice()[..size_of::<RequestHeader>()],
+        )
+        .partition_incarnation = shard
+            .plane
+            .metadata()
+            .mux_stm
+            .streams()
+            .created_revision_for_namespace(namespace)
+            .unwrap();
+        enqueue(poll.into_generic());
+        assert!(poll_queued(), "the full cap keeps the poll queued");
+
+        enqueue(root_read(CANCEL_DEFERRED_REQUEST_CODE, cancelled).into_generic());
+        assert_eq!(
+            poll_queued(),
+            !cancel_names_the_poll,
+            "a cancellation removes only the queued poll that it names"
+        );
+
+        shard
+            .plane
+            .metadata()
+            .applied_frontier()
+            .advance(UNAPPLIED_OP);
+        await_replies(&bus, 2).await;
+        settle().await;
+        let reached_owner = std::iter::from_fn(|| owner_inbox.try_recv().ok()).any(|frame| {
+            matches!(
+                frame,
+                ShardFrame::Lifecycle(LifecycleFrame::PartitionRead {
+                    read: PartitionRead::DeferredPoll(_),
+                    ..
+                })
+            )
+        });
+        assert_eq!(
+            reached_owner, !cancel_names_the_poll,
+            "only a poll that was not cancelled reaches its owner"
+        );
+        let mut replies: Vec<u64> = bus
+            .client_replies
+            .borrow()
+            .iter()
+            .map(|(_, frame)| reply_request(frame))
+            .collect();
+        replies.sort_unstable();
+        assert_eq!(replies, [1, 2], "a cancellation gets no reply of its own");
+    }
+
+    /// A deferred poll of partition 0 by the root session, sent as `request`.
+    fn root_deferred_poll(request: u64, auto_commit: bool) -> Message<RequestHeader> {
+        let poll = DeferredPollMessagesRequest {
+            poll: PollMessagesRequest {
+                consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+                stream_id: WireIdentifier::numeric(0),
+                topic_id: WireIdentifier::numeric(0),
+                partition_id: Some(0),
+                strategy: WirePollingStrategy::offset(0),
+                count: 1,
+                auto_commit,
+            },
+            wait_us: 1,
+            min_count: 1,
+            max_bytes: u32::try_from(MIN_DEFERRED_POLL_MAX_BYTES).unwrap(),
+            request_timeout_us: 1 + MIN_DEFERRED_POLL_REPLY_MARGIN_US,
+        }
+        .to_bytes();
+        let mut message = wire_request(
+            Operation::NonReplicated,
+            ROOT_CLIENT,
+            ROOT_SESSION,
+            request,
+            &poll,
+        );
+        bytemuck::checked::from_bytes_mut::<RequestHeader>(
+            &mut message.as_mut_slice()[..size_of::<RequestHeader>()],
+        )
+        .reserved[..4]
+            .copy_from_slice(&POLL_MESSAGES_DEFERRED_CODE.to_le_bytes());
+        message
+    }
+
+    #[compio::test]
     async fn given_a_held_store_when_next_and_offset_reads_queue_should_wait_for_its_guard() {
         const CONSUMER_ID: u32 = 1;
         const NEXT_REQUEST: u64 = 2;
diff --git a/core/server/src/dispatch/partition.rs b/core/server/src/dispatch/partition.rs
index 0c067be..de2d5e6 100644
--- a/core/server/src/dispatch/partition.rs
+++ b/core/server/src/dispatch/partition.rs
@@ -44,21 +44,27 @@
 use consensus::MetadataHandle;
 use consensus::client_table::SessionAttachment;
 use iggy_binary_protocol::PrepareHeader;
-use iggy_binary_protocol::codes::{GET_CONSUMER_OFFSET_CODE, POLL_MESSAGES_CODE};
+use iggy_binary_protocol::codes::{
+    GET_CONSUMER_OFFSET_CODE, POLL_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE,
+};
 use iggy_binary_protocol::primitives::consumer::WireConsumer;
 use iggy_binary_protocol::primitives::partition_history::PartitionContext;
 use iggy_binary_protocol::primitives::polling_strategy::WirePollingStrategy;
 use iggy_binary_protocol::requests::consumer_offsets::{
     DeleteConsumerOffsetRequest, GetConsumerOffsetRequest, StoreConsumerOffsetRequest,
 };
-use iggy_binary_protocol::requests::messages::PollMessagesRequest;
+use iggy_binary_protocol::requests::messages::{
+    DeferredPollMessagesRequest, PollMessagesRequest, WaitForMessagesRequest,
+};
 use iggy_binary_protocol::requests::segments::DeleteSegmentsRequest;
 use iggy_binary_protocol::responses::messages::poll_messages::PollMessagesResponseHeader;
 use iggy_binary_protocol::{
     GenericHeader, KIND_CONSUMER, KIND_CONSUMER_GROUP, KIND_EXTERNAL_GROUP, Operation,
     RequestHeader, RoutedRequestHeader, WireDecode, WireEncode, WireIdentifier,
 };
-use iggy_common::{ConsumerKind, IggyError, PollingStrategy, RESYNC_REQUIRED_PARTITION_SENTINEL};
+use iggy_common::{
+    ConsumerKind, IggyError, PollingKind, PollingStrategy, RESYNC_REQUIRED_PARTITION_SENTINEL,
+};
 use journal::superblock::SuperblockStore;
 use journal::{Journal, JournalHandle};
 use message_bus::BusMessage;
@@ -72,7 +78,10 @@
 use server_common::Message;
 use server_common::sharding::IggyNamespace;
 use shard::shards_table::ShardsTable;
-use shard::{ConsumerAttachment, PartitionRead, PartitionReadReply};
+use shard::{
+    ConsumerAttachment, DeferredPollContext, DeferredPollRequest, PartitionRead,
+    PartitionReadReply, PollQuotaIdentity, ReadinessTarget,
+};
 use std::rc::Rc;
 use tracing::{debug, warn};
 
@@ -434,8 +443,11 @@
         {
             GetConsumerOffsetRequest::decode_from(request.body()).ok()?
         }
+        // A deferred poll starts with the same fields and holds the lane until
+        // it answers, so a later store cannot pass the commit of its wait.
         Operation::NonReplicated
-            if request.header().reserved[..4] == POLL_MESSAGES_CODE.to_le_bytes() =>
+            if request.header().reserved[..4] == POLL_MESSAGES_CODE.to_le_bytes()
+                || request.header().reserved[..4] == POLL_MESSAGES_DEFERRED_CODE.to_le_bytes() =>
         {
             let (strategy, auto_commit) =
                 PollMessagesRequest::peek_progress(request.body()).ok()?;
@@ -670,6 +682,204 @@
     send_non_replicated_bytes(shard, request, origin, body, channel, "poll_messages").await;
 }
 
+pub(super) fn resolve_deferred_poll<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    wire: &DeferredPollMessagesRequest,
+    header: &RoutedRequestHeader,
+    (consumer_client_id, session, user_id): (u128, SessionAttachment, u32),
+) -> Result<(IggyNamespace, u32, DeferredPollRequest), IggyError>
+where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    if let Some(status) = authorize_partition_read(
+        shard,
+        &wire.poll.stream_id,
+        &wire.poll.topic_id,
+        Some(user_id),
+        |permissioner, user_id, stream_id, topic_id| {
+            permissioner.poll_messages(user_id, stream_id, topic_id)
+        },
+    ) {
+        return Err(IggyError::from_code(status));
+    }
+    let (namespace, partition_id, consumer, args) =
+        resolve_poll_request(shard, &wire.poll, consumer_client_id)?;
+    let metadata = consumer_poll_metadata(shard, namespace, consumer, consumer_client_id)?;
+    let local = metadata.context(current_metadata_commit(shard));
+    fence_poll_context(
+        PartitionContext {
+            incarnation: header.partition_incarnation,
+            owner_generation: if header.owner_generation == 0 {
+                local.owner_generation
+            } else {
+                header.owner_generation
+            },
+            metadata_op: header.minimum_metadata_op,
+        },
+        local,
+        consumer_client_id,
+        partition_id,
+    )?;
+    let request = DeferredPollRequest::new(
+        consumer,
+        args,
+        metadata,
+        DeferredPollContext {
+            session: Some(session),
+            user_id,
+            user_created_at: None,
+            quota: PollQuotaIdentity::Session(consumer_client_id),
+            primary: true,
+        },
+        wire.into(),
+    );
+    Ok((namespace, partition_id, request))
+}
+
+/// Resolve and authorize every partition of a readiness wait with the fence
+/// and metadata identity of a poll. The owner checks them again at each
+/// evaluation. Returns the caller that every target shares, and the targets.
+pub(super) fn resolve_readiness_wait<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    wire: WaitForMessagesRequest,
+    (consumer_client_id, session, user_id): (u128, SessionAttachment, u32),
+) -> Result<(DeferredPollContext, Vec<ReadinessTarget>), IggyError>
+where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    if let Some(status) = authorize_partition_read(
+        shard,
+        &wire.stream_id,
+        &wire.topic_id,
+        Some(user_id),
+        |permissioner, user_id, stream_id, topic_id| {
+            permissioner.poll_messages(user_id, stream_id, topic_id)
+        },
+    ) {
+        return Err(IggyError::from_code(status));
+    }
+    let mut poll = PollMessagesRequest {
+        consumer: wire.consumer,
+        stream_id: wire.stream_id,
+        topic_id: wire.topic_id,
+        partition_id: None,
+        strategy: WirePollingStrategy::next(),
+        count: 1,
+        auto_commit: false,
+    };
+    let targets = wire
+        .partitions
+        .into_iter()
+        .map(|target| {
+            poll.partition_id = Some(target.partition_id);
+            poll.strategy = target.strategy;
+            let (namespace, partition_id, consumer, args) =
+                resolve_poll_request(shard, &poll, consumer_client_id)?;
+            if !matches!(args.kind, PollingKind::Offset | PollingKind::Next) {
+                return Err(IggyError::InvalidCommand);
+            }
+            Ok(ReadinessTarget {
+                namespace,
+                partition_id,
+                consumer,
+                strategy: PollingStrategy {
+                    kind: args.kind,
+                    value: args.value,
+                    context: None,
+                },
+                metadata: consumer_poll_metadata(shard, namespace, consumer, consumer_client_id)?,
+            })
+        })
+        .collect::<Result<_, _>>()?;
+    let caller = DeferredPollContext {
+        session: Some(session),
+        user_id,
+        user_created_at: None,
+        quota: PollQuotaIdentity::Session(consumer_client_id),
+        primary: true,
+    };
+    Ok((caller, targets))
+}
+
+fn consumer_poll_metadata<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    namespace: IggyNamespace,
+    consumer: PollingConsumer,
+    consumer_client_id: u128,
+) -> Result<PollMetadata, IggyError>
+where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    let group_id = match consumer {
+        PollingConsumer::ConsumerGroup(group, _) => Some(group as u64),
+        PollingConsumer::Consumer(..) => None,
+    };
+    shard
+        .plane
+        .metadata()
+        .mux_stm
+        .streams()
+        .poll_metadata(namespace, group_id, consumer_client_id)
+        .ok_or(IggyError::TransientNotAccepted)
+}
+
+#[allow(clippy::future_not_send)]
+pub(super) async fn read_deferred_poll<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    request: &Message<RoutedRequestHeader>,
+    (namespace, partition_id, poll): (IggyNamespace, u32, DeferredPollRequest),
+    attachment: &SessionAttachment,
+) -> Result<BusMessage, IggyError>
+where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    let reply = shard
+        .partition_read(namespace, PartitionRead::DeferredPoll(Box::new(poll)))
+        .await;
+    authorize_served_partition_read(
+        shard,
+        Some(attachment),
+        namespace,
+        request.header().partition_incarnation,
+        Permissioner::poll_messages,
+    )?;
+    match reply {
+        Some(PartitionReadReply::Poll {
+            context,
+            fragments,
+            current_offset,
+            encryptor,
+        }) => build_polled_messages_reply(
+            request.header(),
+            current_metadata_commit(shard),
+            partition_id,
+            current_offset,
+            context,
+            fragments,
+            encryptor.as_deref(),
+        ),
+        Some(PartitionReadReply::Rejected(error)) => Err(error),
+        Some(PartitionReadReply::NotFound) => Err(IggyError::TransientNotAccepted),
+        _ => Err(IggyError::ShardCommunicationError),
+    }
+}
+
 /// Run the resolved poll on the owning shard and re-encode the stored
 /// batches into the wire `PolledMessages` reply. Owner rejections preserve
 /// their error; a missing reply reports a communication error without claiming
@@ -692,28 +902,18 @@
     SB: SuperblockStore + 'static,
 {
     let transport_client_id = origin.id;
-    let group_id = match consumer {
-        PollingConsumer::ConsumerGroup(group, _) => Some(group as u64),
-        PollingConsumer::Consumer(..) => None,
-    };
-    let metadata = shard
-        .plane
-        .metadata()
-        .mux_stm
-        .streams()
-        .poll_metadata(namespace, group_id, consumer_client_id)
-        .ok_or(ReadPolledMessagesError::Rejected(
-            IggyError::TransientNotAccepted,
-        ))?;
+    let metadata = consumer_poll_metadata(shard, namespace, consumer, consumer_client_id)
+        .map_err(ReadPolledMessagesError::Rejected)?;
     let header = request.header();
     let local = metadata.context(current_metadata_commit(shard));
     // A group poll with owner 0 captured no owner. The primary then serves
     // only the owner it installed for the caller, and the reply names it.
-    let owner_generation = if group_id.is_some() && header.owner_generation == 0 {
-        local.owner_generation
-    } else {
-        header.owner_generation
-    };
+    let owner_generation =
+        if matches!(consumer, PollingConsumer::ConsumerGroup(..)) && header.owner_generation == 0 {
+            local.owner_generation
+        } else {
+            header.owner_generation
+        };
     fence_poll_context(
         PartitionContext {
             incarnation: header.partition_incarnation,
@@ -2107,7 +2307,7 @@
             (WirePollingStrategy::next(), true),
         ] {
             for auto_commit in [false, true] {
-                let body = PollMessagesRequest {
+                let poll = PollMessagesRequest {
                     consumer: WireConsumer::consumer(WireIdentifier::numeric(CONSUMER)),
                     stream_id: WireIdentifier::numeric(0),
                     topic_id: WireIdentifier::numeric(0),
@@ -2115,23 +2315,34 @@
                     strategy: strategy.clone(),
                     count: 1,
                     auto_commit,
+                };
+                let deferred = DeferredPollMessagesRequest {
+                    poll: poll.clone(),
+                    wait_us: 0,
+                    min_count: 1,
+                    max_bytes: 1024 * 1024,
+                    request_timeout_us: 1_000_000,
+                };
+                for (code, body) in [
+                    (POLL_MESSAGES_CODE, poll.to_bytes()),
+                    (POLL_MESSAGES_DEFERRED_CODE, deferred.to_bytes()),
+                ] {
+                    let mut request = request_message(Operation::NonReplicated, 1, 1, 1, &body);
+                    bytemuck::checked::from_bytes_mut::<RequestHeader>(
+                        &mut request.as_mut_slice()[..size_of::<RequestHeader>()],
+                    )
+                    .reserved[..4]
+                        .copy_from_slice(&code.to_le_bytes());
+                    assert_eq!(
+                        progress_key(&shard, &request.into_generic()),
+                        if reads_stored || auto_commit {
+                            expected
+                        } else {
+                            None
+                        },
+                        "code {code}, strategy {strategy:?}, auto_commit {auto_commit}",
+                    );
                 }
-                .to_bytes();
-                let mut request = request_message(Operation::NonReplicated, 1, 1, 1, &body);
-                bytemuck::checked::from_bytes_mut::<RequestHeader>(
-                    &mut request.as_mut_slice()[..size_of::<RequestHeader>()],
-                )
-                .reserved[..4]
-                    .copy_from_slice(&POLL_MESSAGES_CODE.to_le_bytes());
-                assert_eq!(
-                    progress_key(&shard, &request.into_generic()),
-                    if reads_stored || auto_commit {
-                        expected
-                    } else {
-                        None
-                    },
-                    "strategy {strategy:?}, auto_commit {auto_commit}",
-                );
             }
         }
         let requests = [
diff --git a/core/server/src/dispatch/reads.rs b/core/server/src/dispatch/reads.rs
index 301f42b..4f9173c 100644
--- a/core/server/src/dispatch/reads.rs
+++ b/core/server/src/dispatch/reads.rs
@@ -36,9 +36,12 @@
     send_non_replicated_deny,
 };
 use crate::dispatch::metadata_changes::metadata_changes_reply;
-use crate::dispatch::partition::{handle_get_consumer_offset, handle_poll_messages};
+use crate::dispatch::partition::{
+    handle_get_consumer_offset, handle_poll_messages, read_deferred_poll, resolve_deferred_poll,
+    resolve_readiness_wait,
+};
 use crate::namespace::resolve_partition_namespace;
-use crate::reply_frame::{build_empty_reply, current_metadata_commit};
+use crate::reply_frame::{build_empty_reply, build_reply_from_bytes, current_metadata_commit};
 use crate::responses::{
     build_get_me_response, build_get_personal_access_tokens_response,
     build_non_replicated_response, connected_client_to_response,
@@ -51,17 +54,20 @@
 use configs::server::ServerConfig;
 use consensus::MetadataHandle;
 use consensus::client_table::SessionAttachment;
-use futures::future::{Either, select};
+use futures::future::{Abortable, Either, select};
 use iggy_binary_protocol::PrepareHeader;
 use iggy_binary_protocol::codes::{
-    BIND_SESSION_CODE, DESCRIBE_OPTIONS_CODE, GET_CLIENT_CODE, GET_CLIENTS_CODE,
-    GET_CLUSTER_METADATA_CODE, GET_CONSUMER_OFFSET_CODE, GET_ME_CODE, GET_METADATA_CHANGES_CODE,
-    GET_PARTITION_ROUTE_CODE, GET_PERSONAL_ACCESS_TOKENS_CODE, GET_SNAPSHOT_FILE_CODE,
-    GET_STATS_CODE, PING_CODE, POLL_MESSAGES_CODE, SYNC_CONSUMER_GROUP_CODE,
+    BIND_SESSION_CODE, CANCEL_DEFERRED_REQUEST_CODE, DESCRIBE_OPTIONS_CODE, GET_CLIENT_CODE,
+    GET_CLIENTS_CODE, GET_CLUSTER_METADATA_CODE, GET_CONSUMER_OFFSET_CODE, GET_ME_CODE,
+    GET_METADATA_CHANGES_CODE, GET_PARTITION_ROUTE_CODE, GET_PERSONAL_ACCESS_TOKENS_CODE,
+    GET_SNAPSHOT_FILE_CODE, GET_STATS_CODE, PING_CODE, POLL_MESSAGES_CODE,
+    POLL_MESSAGES_DEFERRED_CODE, SYNC_CONSUMER_GROUP_CODE, WAIT_FOR_MESSAGES_CODE,
 };
 use iggy_binary_protocol::dispatch::lookup_command;
 use iggy_binary_protocol::requests::consumer_groups::SyncConsumerGroupRequest;
-use iggy_binary_protocol::requests::messages::GetPartitionRouteRequest;
+use iggy_binary_protocol::requests::messages::{
+    DeferredPollMessagesRequest, GetPartitionRouteRequest, WaitForMessagesRequest,
+};
 use iggy_binary_protocol::requests::system::BindSessionRequest;
 use iggy_binary_protocol::requests::system::get_client::GetClientRequest;
 use iggy_binary_protocol::requests::system::get_snapshot::GetSnapshotRequest;
@@ -69,20 +75,21 @@
 use iggy_binary_protocol::responses::clients::get_client::ClientDetailsResponse;
 use iggy_binary_protocol::responses::clients::get_clients::GetClientsResponse;
 use iggy_binary_protocol::responses::consumer_groups::SyncConsumerGroupResponse;
-use iggy_binary_protocol::responses::messages::PartitionRouteResponse;
+use iggy_binary_protocol::responses::messages::{PartitionRouteResponse, WaitForMessagesResponse};
 use iggy_binary_protocol::responses::system::get_snapshot::GetSnapshotResponse;
 use iggy_binary_protocol::responses::users::LoginRegisterResponse;
 use iggy_binary_protocol::{HEADER_SIZE, RoutedRequestHeader, WireDecode, WireEncode};
 use iggy_common::{IggyError, SnapshotCompression, SystemSnapshotType};
 use journal::superblock::SuperblockStore;
 use journal::{Journal, JournalHandle};
+use message_bus::BusMessage;
 use message_bus::framing::MAX_MESSAGE_SIZE;
 use metadata::AppliedFrontier;
 use metadata::impls::metadata::StreamsFrontend;
 use metadata::permissioner::Permissioner;
 use server_common::Message;
 use shard::{PartitionRead, PartitionReadReply};
-use std::cell::RefCell;
+use std::cell::{Cell, RefCell};
 use std::future::Future;
 use std::net::IpAddr;
 use std::pin::pin;
@@ -191,9 +198,11 @@
 /// - `GetClusterMetadata` answers from the configured roster plus the
 ///   consensus view, and sits on the SDK's leader-discovery path, where the
 ///   wait would be real.
-/// - `PollMessages` and `GetConsumerOffset` are partition-plane reads: their
-///   answer comes from a partition group's own log, not the metadata STM, and
-///   holding them would put metadata lag on the data path.
+/// - `PollMessages`, `PollMessagesDeferred`, `WaitForMessages` and
+///   `GetConsumerOffset` are partition-plane reads: their answer comes from a
+///   partition group's own log, not the metadata STM, and holding them would
+///   put metadata lag on the data path.
+/// - `CancelDeferredRequest` reads nothing and gets no reply.
 /// - `GetSnapshotFile` shells out to system tools off-thread; there is no
 ///   metadata answer to hold.
 /// - A code this build does not know: its only outcome is `InvalidCommand`,
@@ -209,6 +218,9 @@
             | DESCRIBE_OPTIONS_CODE
             | GET_CLUSTER_METADATA_CODE
             | POLL_MESSAGES_CODE
+            | POLL_MESSAGES_DEFERRED_CODE
+            | WAIT_FOR_MESSAGES_CODE
+            | CANCEL_DEFERRED_REQUEST_CODE
             | GET_CONSUMER_OFFSET_CODE
             | GET_SNAPSHOT_FILE_CODE
     ) && lookup_command(code).is_some()
@@ -391,6 +403,7 @@
     let watermark = watermark.max(request.header().minimum_metadata_op);
     let code = u32::from_le_bytes(request.header().reserved[CODE_RANGE].try_into().unwrap());
     match code {
+        CANCEL_DEFERRED_REQUEST_CODE => {}
         PING_CODE => {
             // No `record_heartbeat` here: the funnel records one for EVERY
             // frame before classification, so a ping is already covered.
@@ -574,13 +587,27 @@
                 }
             }
         }
-        POLL_MESSAGES_CODE => {
+        POLL_MESSAGES_CODE | POLL_MESSAGES_DEFERRED_CODE | WAIT_FOR_MESSAGES_CODE => {
             let consumer_client = bound
                 .zip(attachment)
                 .map(|((client_id, _), attachment)| (client_id, attachment))
                 .ok_or(IggyError::Unauthenticated);
             match consumer_client {
                 Ok((consumer_client_id, attachment)) => {
+                    if code != POLL_MESSAGES_CODE {
+                        handle_deferred_request(
+                            shard,
+                            sessions,
+                            origin,
+                            &request,
+                            code,
+                            user_id,
+                            consumer_client_id,
+                            attachment,
+                        )
+                        .await;
+                        return;
+                    }
                     handle_poll_messages(
                         shard,
                         origin,
@@ -629,6 +656,172 @@
     }
 }
 
+#[allow(clippy::future_not_send, clippy::too_many_arguments)]
+async fn handle_deferred_request<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    sessions: &Rc<RefCell<SessionManager>>,
+    origin: ClientOrigin,
+    request: &Message<RoutedRequestHeader>,
+    code: u32,
+    user_id: Option<u32>,
+    consumer_client_id: u128,
+    attachment: &SessionAttachment,
+) where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    let decoded = if code == POLL_MESSAGES_DEFERRED_CODE {
+        DeferredPollMessagesRequest::decode_from(request_body(request)).map(Either::Left)
+    } else {
+        WaitForMessagesRequest::decode_from(request_body(request)).map(Either::Right)
+    };
+    let Ok(wire) = decoded else {
+        send_non_replicated_deny(shard, request, origin, IggyError::InvalidCommand.as_code()).await;
+        return;
+    };
+    let timeout_us = match &wire {
+        Either::Left(poll) => poll.request_timeout_us,
+        Either::Right(wait) => wait.request_timeout_us,
+    };
+    serve_deferred_request(
+        shard,
+        sessions,
+        (origin, request),
+        Duration::from_micros(timeout_us),
+        async {
+            let user_id = user_id.ok_or(IggyError::Unauthenticated)?;
+            if !attachment.is_valid() {
+                return Err(IggyError::StaleClient);
+            }
+            if !shard
+                .plane
+                .metadata()
+                .applied_frontier()
+                .partition_requests_allowed(shard.bus.monotonic_now())
+                || request.header().minimum_metadata_op > current_metadata_commit(shard)
+            {
+                return Err(IggyError::TransientNotAccepted);
+            }
+            match wire {
+                Either::Left(poll) => {
+                    let resolved = resolve_deferred_poll(
+                        shard,
+                        &poll,
+                        request.header(),
+                        (consumer_client_id, attachment.clone(), user_id),
+                    )?;
+                    read_deferred_poll(shard, request, resolved, attachment).await
+                }
+                Either::Right(wait) => {
+                    let duration = Duration::from_micros(shard.deferred_wait_us(wait.wait_us));
+                    let (caller, targets) = resolve_readiness_wait(
+                        shard,
+                        wait,
+                        (consumer_client_id, attachment.clone(), user_id),
+                    )?;
+                    let contexts: Vec<_> = targets
+                        .iter()
+                        .map(|target| {
+                            (
+                                target.namespace,
+                                target.metadata.context(current_metadata_commit(shard)),
+                            )
+                        })
+                        .collect();
+                    let partitions = shard.wait_for_messages(caller, targets, duration).await?;
+                    for (namespace, context) in contexts {
+                        authorize_served_partition_read(
+                            shard,
+                            Some(attachment),
+                            namespace,
+                            context.incarnation,
+                            Permissioner::poll_messages,
+                        )?;
+                    }
+                    let header = request.header();
+                    Ok(build_reply_from_bytes(
+                        header,
+                        header.client,
+                        header.session,
+                        current_metadata_commit(shard),
+                        &WaitForMessagesResponse { partitions }.to_bytes(),
+                    )
+                    .into_generic()
+                    .into_frozen()
+                    .into())
+                }
+            }
+        },
+    )
+    .await;
+}
+
+#[allow(clippy::future_not_send)]
+async fn serve_deferred_request<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
+    sessions: &Rc<RefCell<SessionManager>>,
+    (origin, request): (ClientOrigin, &Message<RoutedRequestHeader>),
+    watchdog: Duration,
+    reply: impl Future<Output = Result<BusMessage, IggyError>>,
+) where
+    B: ShellBus,
+    MJ: JournalHandle + 'static,
+    MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
+    S: 'static,
+    SB: SuperblockStore + 'static,
+{
+    let (guard, cancellation) = match SessionManager::begin_deferred_request(
+        sessions,
+        origin.id,
+        request.header().request,
+        watchdog,
+        shard.bus.monotonic_now(),
+    ) {
+        Ok(active) => active,
+        Err(error) => {
+            send_non_replicated_deny(shard, request, origin, error.as_code()).await;
+            return;
+        }
+    };
+    let response_queued = Cell::new(false);
+    let outcome = {
+        let operation = Abortable::new(
+            async {
+                let mut reply = reply.await?;
+                let written = reply.track_write();
+                // A watchdog expiry while the send waits for room drops the reply
+                // unsent, so only a returned send counts as queued.
+                send_host_frame(shard, origin, reply, FrameChannel::Reply, "deferred_reply").await;
+                response_queued.set(true);
+                written
+                    .await
+                    .map_err(|_| IggyError::ShardCommunicationError)
+            },
+            cancellation,
+        );
+        let operation = pin!(operation);
+        let timeout = pin!(shard.bus.sleep(watchdog));
+        match select(operation, timeout).await {
+            Either::Left((Ok(result), _)) => Some(result),
+            Either::Left((Err(_), _)) => None,
+            Either::Right(_) => Some(Err(IggyError::ShardCommunicationError)),
+        }
+    };
+    if matches!(outcome, Some(Ok(()))) {
+        guard.written(shard.bus.monotonic_now());
+    }
+    drop(guard);
+    // A queued reply can still reach the peer after the write watchdog expires.
+    if let Some(Err(error)) = outcome
+        && !response_queued.get()
+    {
+        send_non_replicated_deny(shard, request, origin, error.as_code()).await;
+    }
+}
+
 #[allow(clippy::future_not_send)]
 async fn bind_session<B, MJ, S, SB>(
     shard: &Rc<ShellShard<B, MJ, S, SB>>,
@@ -1039,9 +1232,10 @@
     };
     use configs::server::ServerConfig;
     use iggy_binary_protocol::codes::{
-        DESCRIBE_OPTIONS_CODE, GET_CLUSTER_METADATA_CODE, GET_CONSUMER_OFFSET_CODE, GET_ME_CODE,
-        GET_SNAPSHOT_FILE_CODE, GET_STREAM_CODE, PING_CODE, POLL_MESSAGES_CODE,
-        SYNC_CONSUMER_GROUP_CODE,
+        CANCEL_DEFERRED_REQUEST_CODE, DESCRIBE_OPTIONS_CODE, GET_CLUSTER_METADATA_CODE,
+        GET_CONSUMER_OFFSET_CODE, GET_ME_CODE, GET_SNAPSHOT_FILE_CODE, GET_STREAM_CODE, PING_CODE,
+        POLL_MESSAGES_CODE, POLL_MESSAGES_DEFERRED_CODE, SYNC_CONSUMER_GROUP_CODE,
+        WAIT_FOR_MESSAGES_CODE,
     };
     use iggy_common::IggyDuration;
     use metadata::AppliedFrontier;
@@ -1161,6 +1355,9 @@
             DESCRIBE_OPTIONS_CODE,
             GET_CLUSTER_METADATA_CODE,
             POLL_MESSAGES_CODE,
+            POLL_MESSAGES_DEFERRED_CODE,
+            WAIT_FOR_MESSAGES_CODE,
+            CANCEL_DEFERRED_REQUEST_CODE,
             GET_CONSUMER_OFFSET_CODE,
             GET_SNAPSHOT_FILE_CODE,
         ] {
diff --git a/core/server/src/http.rs b/core/server/src/http.rs
index 370d29b..bee3fab 100644
--- a/core/server/src/http.rs
+++ b/core/server/src/http.rs
@@ -74,9 +74,9 @@
     delete_stream, delete_topic, delete_user, describe_options, get_cg, get_cgs, get_client,
     get_clients, get_cluster_metadata, get_consumer_offset, get_metadata_changes, get_pats,
     get_snapshot, get_stats, get_stream, get_streams, get_topic, get_topics, get_user, get_users,
-    login_user, login_with_personal_access_token, logout_user, ping, poll_messages, refresh_token,
-    send_messages, store_consumer_offset, update_permissions, update_stream, update_topic,
-    update_user,
+    login_user, login_with_personal_access_token, logout_user, ping, poll_messages,
+    poll_messages_deferred, refresh_token, send_messages, store_consumer_offset,
+    update_permissions, update_stream, update_topic, update_user,
 };
 use crate::http::jwt::JwtManager;
 use crate::http::session::RegistrationBarrier;
@@ -482,6 +482,10 @@
             post(send_messages).get(poll_messages),
         )
         .route(
+            "/streams/{stream_id}/topics/{topic_id}/messages/deferred",
+            get(poll_messages_deferred),
+        )
+        .route(
             "/streams/{stream_id}/topics/{topic_id}/consumer-offsets",
             put(store_consumer_offset).get(get_consumer_offset),
         )
diff --git a/core/server/src/http/admission.rs b/core/server/src/http/admission.rs
index e3e52fd..e1dadb7 100644
--- a/core/server/src/http/admission.rs
+++ b/core/server/src/http/admission.rs
@@ -92,7 +92,7 @@
 /// node sends, so held targets never leave this node without a slot to relay
 /// its own callers' requests, and two nodes relaying to each other never
 /// starve each other.
-const MAX_IN_FLIGHT_RELAY_TARGETS: u32 = 128;
+pub(in crate::http) const MAX_IN_FLIGHT_RELAY_TARGETS: u32 = 128;
 
 /// Per-credential share of [`MAX_IN_FLIGHT_RELAY_TARGETS`], equal to the
 /// per-session write cap, so one caller relayed through every peer cannot
diff --git a/core/server/src/http/error.rs b/core/server/src/http/error.rs
index dbf719e..6441f10 100644
--- a/core/server/src/http/error.rs
+++ b/core/server/src/http/error.rs
@@ -85,9 +85,9 @@
                     // the op twice: 504, the unknown outcome, never a
                     // retryable 503.
                     IggyError::TransientNotCommitted => StatusCode::GATEWAY_TIMEOUT,
-                    IggyError::TransientNotAccepted | IggyError::NotPrimary => {
-                        StatusCode::SERVICE_UNAVAILABLE
-                    }
+                    IggyError::TransientNotAccepted
+                    | IggyError::DeferredPollCapacityExceeded
+                    | IggyError::NotPrimary => StatusCode::SERVICE_UNAVAILABLE,
                     _ => StatusCode::BAD_REQUEST,
                 };
                 let response =
@@ -97,7 +97,9 @@
                 // (`service_unavailable`, `server_busy`).
                 if matches!(
                     error,
-                    IggyError::TransientNotAccepted | IggyError::NotPrimary
+                    IggyError::TransientNotAccepted
+                        | IggyError::DeferredPollCapacityExceeded
+                        | IggyError::NotPrimary
                 ) {
                     with_retry_after(response)
                 } else {
@@ -386,7 +388,12 @@
     ))
 }
 
-fn coded_error_response(status: StatusCode, id: u32, code: &str, reason: &str) -> Response {
+pub(in crate::http) fn coded_error_response(
+    status: StatusCode,
+    id: u32,
+    code: &str,
+    reason: &str,
+) -> Response {
     (
         status,
         Json(ErrorResponse {
@@ -860,6 +867,20 @@
         assert!(response.headers().contains_key(RETRY_AFTER));
     }
 
+    #[tokio::test]
+    async fn deferred_capacity_refusal_preserves_its_code_and_retry_hint() {
+        let response = CustomError::from(IggyError::DeferredPollCapacityExceeded).into_response();
+        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
+        assert!(response.headers().contains_key(RETRY_AFTER));
+        let body = to_bytes(response.into_body(), 1024).await.unwrap();
+        let error: Value = serde_json::from_slice(&body).unwrap();
+        assert_eq!(
+            error["id"],
+            IggyError::DeferredPollCapacityExceeded.as_code()
+        );
+        assert_eq!(error["code"], "deferred_poll_capacity_exceeded");
+    }
+
     #[test]
     fn not_primary_renders_503_with_retry_after() {
         let response = CustomError::from(IggyError::NotPrimary).into_response();
diff --git a/core/server/src/http/forward.rs b/core/server/src/http/forward.rs
index 1bc48c6..00fc229 100644
--- a/core/server/src/http/forward.rs
+++ b/core/server/src/http/forward.rs
@@ -117,14 +117,15 @@
 use crate::dispatch::reads::hold_for_frontier;
 use crate::http::admission::{RelayTargetRefusal, RelayTargets};
 use crate::http::error::{
-    CustomError, PartitionWriteError, ReadError, error_response, forward_busy,
-    gateway_timeout_response, primary_http_socket, relay_refused, with_retry_after,
+    CustomError, PartitionWriteError, ReadError, coded_error_response, error_response,
+    forward_busy, gateway_timeout_response, primary_http_socket, relay_refused, with_retry_after,
 };
 use crate::http::extractor::{bearer_token, credential_key, resolve_credential};
 use crate::http::relay_proof::{RELAY_PROOF_LIFETIME, RelayKey, UnprovenRelay, parse_decimal};
 use crate::http::state::{
     APPLIED_OP_HEADER, DURABILITY_HEADER, FloorReads, ForwardState, HttpInner, VIEW_HEADER,
 };
+use crate::http::wire::DeferredPollQuery;
 use crate::http::{HttpState, close_connection};
 use crate::server_error::ServerError;
 
@@ -160,11 +161,13 @@
 /// The route a metadata floor request proves: it relays no partition request.
 const NO_PARTITION_ROUTE: HeaderValue = HeaderValue::from_static("");
 
-/// One bound for a partition request on the node that received it: the
-/// metadata floor read and hold, every local attempt, primary lookup and relay
-/// attempt, a relayed poll body, the hop target's metadata hold, and the local
-/// lane and reply waits. A relay proof lives as long, so no relay attempt
-/// outlives its proof.
+/// One bound for an immediate partition request on the node that received it:
+/// the metadata floor read and hold, every local attempt, primary lookup and
+/// relay attempt, a relayed poll body, the hop target's metadata hold, and the
+/// local lane and reply waits. A deferred poll takes this bound from its
+/// `request_timeout` instead. Each relay attempt signs a new proof, which the
+/// hop target checks when the request head arrives, so a longer wait needs no
+/// longer proof.
 pub(in crate::http) const PARTITION_REQUEST_DEADLINE: Duration = RELAY_PROOF_LIFETIME;
 
 /// Keeps the hop target's deadline before the contacted node's, so the hop
@@ -190,7 +193,8 @@
 const FORWARD_RETRY_DEADLINE: Duration = Duration::from_secs(30);
 
 /// Cap on concurrent forwards held by this node: metadata forwards and
-/// partition relays. Deliberately its own budget: a forward
+/// partition relays. Relayed deferred polls have a second cap of the same
+/// size. Deliberately its own budget: a forward
 /// parks no reply slot and touches none of the shard bus machinery the
 /// partition-write caps protect, and counting forwards against those caps
 /// would let a slow primary starve this node's own direct clients.
@@ -368,6 +372,8 @@
     headers: HeaderMap,
     path_and_query: String,
     body: Bytes,
+    /// A deferred poll, which holds its relay slots for its whole wait.
+    deferred: bool,
     pub(in crate::http) hop: PartitionHop,
     pub(in crate::http) deadline: Instant,
 }
@@ -484,7 +490,9 @@
         scheme,
         body_limit,
         in_flight: Rc::new(Cell::new(0)),
+        deferred_in_flight: Rc::new(Cell::new(0)),
         relay_targets: RelayTargets::default(),
+        deferred_relay_targets: RelayTargets::default(),
         floor_reads: FloorReads::default(),
     })
 }
@@ -685,7 +693,12 @@
         return AttemptOutcome::Relay(not_admitted());
     }
     if guard.is_none() {
-        *guard = ForwardGuard::admit(&state.forward.in_flight);
+        let in_flight = if request.deferred {
+            &state.forward.deferred_in_flight
+        } else {
+            &state.forward.in_flight
+        };
+        *guard = ForwardGuard::admit(in_flight);
         if guard.is_none() {
             return AttemptOutcome::Relay(forward_busy());
         }
@@ -962,6 +975,7 @@
     state.metadata_watermarks.record(user_id, applied);
 }
 
+#[allow(clippy::too_many_lines)]
 async fn attach_partition_request(state: HttpState, request: Request, next: Next) -> Response {
     let received = Instant::now();
     let hop = match partition_hop(
@@ -978,10 +992,25 @@
         Ok(bearer) => bearer,
         Err(error) => return CustomError::from(error).into_response(),
     };
+    let deferred =
+        request.method() == Method::GET && request.uri().path().ends_with("/messages/deferred");
+    let request_budget = if deferred {
+        let query = match axum::extract::Query::<DeferredPollQuery>::try_from_uri(request.uri()) {
+            Ok(query) => query.0,
+            Err(_) => return CustomError::from(IggyError::InvalidCommand).into_response(),
+        };
+        let options = iggy_common::DeferredPollOptions::from(query);
+        if let Err(error) = options.validate(u32::MAX) {
+            return CustomError::from(error).into_response();
+        }
+        options.request_timeout.get_duration()
+    } else {
+        PARTITION_REQUEST_DEADLINE
+    };
     let deadline = received
         + match hop {
-            PartitionHop::Target(route) => route.budget.min(PARTITION_REQUEST_DEADLINE),
-            PartitionHop::Contacted | PartitionHop::Unrouted => PARTITION_REQUEST_DEADLINE,
+            PartitionHop::Target(route) => route.budget.min(request_budget),
+            PartitionHop::Contacted | PartitionHop::Unrouted => request_budget,
         };
     // The contacted node chose the partition and authenticated the caller
     // from metadata at this op. Before that op is applied here, the topic, the
@@ -1008,9 +1037,14 @@
             verified.user_id,
             verified.user_created_at,
         );
-        match state.forward.relay_targets.admit(credential) {
+        let relay_targets = if deferred {
+            &state.forward.deferred_relay_targets
+        } else {
+            &state.forward.relay_targets
+        };
+        match relay_targets.admit(credential) {
             Ok(guard) => Some(guard),
-            Err(refusal) => return relay_target_refused(refusal),
+            Err(refusal) => return relay_target_refused(refusal, deferred),
         }
     } else {
         None
@@ -1044,6 +1078,7 @@
             .map_or("/", |path_and_query| path_and_query.as_str())
             .to_owned(),
         body: body.clone(),
+        deferred,
         hop,
         deadline,
     };
@@ -1533,18 +1568,26 @@
 
 /// The contacted node relays this answer to its caller as-is: a 429 when the
 /// caller's credential saturated its own share, a 503 under node-wide pressure.
-fn relay_target_refused(refusal: RelayTargetRefusal) -> Response {
-    with_retry_after(match refusal {
-        RelayTargetRefusal::CredentialSaturated => error_response(
+/// A deferred poll carries the id and code of `DeferredPollCapacityExceeded`
+/// instead, because deferred clients back off on that body, not on the status.
+fn relay_target_refused(refusal: RelayTargetRefusal, deferred: bool) -> Response {
+    let (status, code, reason) = match refusal {
+        RelayTargetRefusal::CredentialSaturated => (
             StatusCode::TOO_MANY_REQUESTS,
             "too_many_relayed_requests",
             "credential reached its relayed-request cap on the partition primary; await outstanding requests and retry",
         ),
-        RelayTargetRefusal::BudgetSaturated => error_response(
+        RelayTargetRefusal::BudgetSaturated => (
             StatusCode::SERVICE_UNAVAILABLE,
             "relay_target_busy",
             "partition primary is at its relay-target budget; retry with backoff",
         ),
+    };
+    with_retry_after(if deferred {
+        let capacity = IggyError::DeferredPollCapacityExceeded;
+        coded_error_response(status, capacity.as_code(), capacity.as_string(), reason)
+    } else {
+        error_response(status, code, reason)
     })
 }
 
@@ -1689,6 +1732,7 @@
     use shard::{LifecycleFrame, ShardFrame, shard_channel};
 
     use crate::dispatch::test_support::{create_one_partition_in, create_reader_request};
+    use crate::http::admission::MAX_IN_FLIGHT_RELAY_TARGETS;
     use crate::http::handlers::HTTP_UNREGISTERED_CLIENT_ID;
     use crate::http::reads::tests::{
         READER_CREATED_AT, READER_ID, commit_user_request, read_state, read_state_on,
@@ -2010,6 +2054,7 @@
             headers: forwarded_headers(&HeaderMap::new()),
             path_and_query: format!("{SEND_PATH}?consumer_id=o'brien"),
             body: Bytes::new(),
+            deferred: false,
             hop: PartitionHop::Contacted,
             deadline: Instant::now(),
         };
@@ -2395,6 +2440,152 @@
         );
     }
 
+    #[compio::test]
+    async fn given_full_relay_slots_when_a_deferred_poll_is_relayed_should_refuse_with_the_deferred_capacity_code()
+     {
+        const DEFERRED_POLL_PATH: &str = "/streams/stream/topics/topic/messages/deferred?consumer_id=1&partition_id=0&kind=offset&value=0&count=1";
+        let capacity = IggyError::DeferredPollCapacityExceeded;
+        for (refusal, status) in [
+            (
+                RelayTargetRefusal::CredentialSaturated,
+                StatusCode::TOO_MANY_REQUESTS,
+            ),
+            (
+                RelayTargetRefusal::BudgetSaturated,
+                StatusCode::SERVICE_UNAVAILABLE,
+            ),
+        ] {
+            let (answered, retry_after, body) =
+                refused_relayed_poll(DEFERRED_POLL_PATH, refusal).await;
+            assert_eq!((answered, retry_after), (status, true), "{refusal:?}");
+            // The Rust client reads the id and the Java client the code.
+            assert_eq!(body["id"], capacity.as_code(), "{refusal:?}");
+            assert_eq!(body["code"], capacity.as_string(), "{refusal:?}");
+        }
+    }
+
+    #[compio::test]
+    async fn given_full_relay_slots_when_an_immediate_poll_is_relayed_should_keep_the_relay_refusal()
+     {
+        const POLL_PATH: &str = "/streams/stream/topics/topic/messages?consumer_id=1&partition_id=0&kind=offset&value=0&count=1";
+        for (refusal, status, code) in [
+            (
+                RelayTargetRefusal::CredentialSaturated,
+                StatusCode::TOO_MANY_REQUESTS,
+                "too_many_relayed_requests",
+            ),
+            (
+                RelayTargetRefusal::BudgetSaturated,
+                StatusCode::SERVICE_UNAVAILABLE,
+                "relay_target_busy",
+            ),
+        ] {
+            let (answered, retry_after, body) = refused_relayed_poll(POLL_PATH, refusal).await;
+            assert_eq!((answered, retry_after), (status, true), "{refusal:?}");
+            assert_eq!(body["id"], status.as_u16(), "{refusal:?}");
+            assert_eq!(body["code"], code, "{refusal:?}");
+        }
+    }
+
+    /// Relays a poll at `path` to a hop target whose relay slots are full in the
+    /// way `refusal` names: the share of the caller's credential, or every slot
+    /// of the node. Returns the status, whether `Retry-After` rides along, and
+    /// the body.
+    async fn refused_relayed_poll(
+        path: &str,
+        refusal: RelayTargetRefusal,
+    ) -> (StatusCode, bool, serde_json::Value) {
+        let (sender, shard_inbox, _shard_replies) = shard_channel(0, 1, 1);
+        let state = read_state_on(Some(sender), Some(KEY_MATERIAL));
+        let metadata = state.shard.plane.metadata();
+        let namespace = create_one_partition_in(&metadata.mux_stm);
+        state
+            .shard
+            .shards_table()
+            .insert(namespace, PartitionLocation::new(ShardId::new(0), 0));
+        commit_user_request(
+            &state,
+            READER_CREATED_AT,
+            Operation::CreateUser,
+            &create_reader_request().to_bytes(),
+        );
+        metadata.applied_frontier().advance(READER_CREATED_AT);
+        let route = PartitionRoute {
+            partition_id: 0,
+            context: metadata
+                .mux_stm
+                .streams()
+                .poll_metadata(namespace, None, HTTP_UNREGISTERED_CLIENT_ID)
+                .expect("the partition resolves")
+                .context(READER_CREATED_AT),
+            budget: PARTITION_REQUEST_DEADLINE,
+        };
+        let route = HeaderValue::try_from(route.to_string()).expect("a route is a header value");
+        let proof = RelayKey::derive(KEY_MATERIAL)
+            .prove(&Method::GET, path, &route, IggyTimestamp::now())
+            .expect("a proof is a header value");
+        let bearer = state
+            .jwt
+            .generate(READER_ID, IggyTimestamp::from(READER_CREATED_AT))
+            .expect("a token for the reader")
+            .access_token;
+        let mut headers = marked(&route, Some(&proof));
+        headers.insert(
+            AUTHORIZATION,
+            HeaderValue::try_from(format!("Bearer {bearer}")).expect("a bearer is a header value"),
+        );
+        let credential = match refusal {
+            RelayTargetRefusal::CredentialSaturated => {
+                let Ok(verified) = resolve_credential(&state, &bearer).await else {
+                    panic!("the bearer of the reader verifies");
+                };
+                Some(credential_key(
+                    &bearer,
+                    verified.jwt_id.as_deref(),
+                    verified.user_id,
+                    verified.user_created_at,
+                ))
+            }
+            RelayTargetRefusal::BudgetSaturated => None,
+        };
+        let targets = if path.contains("/messages/deferred") {
+            &state.forward.deferred_relay_targets
+        } else {
+            &state.forward.relay_targets
+        };
+        let mut held = Vec::new();
+        let full = (0..=MAX_IN_FLIGHT_RELAY_TARGETS).find_map(|other| {
+            let key = credential
+                .clone()
+                .unwrap_or_else(|| format!("other-credential-{other}"));
+            targets.admit(key).map(|slot| held.push(slot)).err()
+        });
+        assert_eq!(full, Some(refusal), "the slots fill as the case names");
+        let service = TowerToHyperService::new(router(
+            state.clone(),
+            state.forward.body_limit,
+            None,
+            None,
+            false,
+        ));
+        let response = service
+            .call(received(Method::GET, path, headers))
+            .await
+            .expect("the hop target answers");
+        drop(held);
+        assert!(
+            shard_inbox.try_recv().is_err(),
+            "a refused relay reaches no shard"
+        );
+        let status = response.status();
+        let retry_after = response.headers().contains_key(RETRY_AFTER);
+        let body = to_bytes(response.into_body(), usize::MAX)
+            .await
+            .expect("the refusal body");
+        let body = serde_json::from_slice(&body).expect("an error body");
+        (status, retry_after, body)
+    }
+
     #[test]
     fn each_relay_kind_retries_only_its_proof_of_non_admission() {
         fn reply(status: StatusCode, code: Option<u32>, kind: RelayKind) -> bool {
@@ -2682,6 +2873,7 @@
             headers: HeaderMap::new(),
             path_and_query: SEND_PATH.to_owned(),
             body: Bytes::new(),
+            deferred: false,
             hop,
             deadline: Instant::now() + budget,
         }
diff --git a/core/server/src/http/handlers.rs b/core/server/src/http/handlers.rs
index ccd4673..b274ec2 100644
--- a/core/server/src/http/handlers.rs
+++ b/core/server/src/http/handlers.rs
@@ -114,7 +114,9 @@
 use secrecy::zeroize::Zeroizing;
 use send_wrapper::SendWrapper;
 use serde::Deserialize;
-use shard::{PartitionRead, PartitionReadReply};
+use shard::{
+    DeferredPollContext, DeferredPollRequest, PartitionRead, PartitionReadReply, PollQuotaIdentity,
+};
 
 use crate::dispatch::metadata_changes::metadata_changes_page;
 use crate::dispatch::partition::{
@@ -148,8 +150,8 @@
     submit_committed, submit_write,
 };
 use crate::http::wire::{
-    consumer_offset_wire_request, delete_offset_wire_request, encode_send_messages,
-    poll_wire_request, store_offset_wire_request,
+    DeferredPollQuery, consumer_offset_wire_request, delete_offset_wire_request,
+    encode_send_messages, poll_wire_request, store_offset_wire_request,
 };
 use crate::reply_frame::{build_polled_messages_body, build_raw_pat_reply};
 use crate::responses::connected_client_to_response;
@@ -1166,6 +1168,34 @@
     Ok(StatusCode::NO_CONTENT)
 }
 
+/// `GET /streams/{stream_id}/topics/{topic_id}/messages/deferred`: the poll of
+/// [`poll_messages`] with [`DeferredPollQuery`] limits. The primary holds it until
+/// `min_count` messages are ready, the reply reaches `max_bytes`, or `wait_us`
+/// expires. Invalid limits are refused before the poll is resolved.
+pub(in crate::http) async fn poll_messages_deferred(
+    State(state): State<HttpState>,
+    identity: Identity,
+    Path((stream_id, topic_id)): Path<(String, String)>,
+    Query(query): Query<PollMessages>,
+    Query(wait): Query<DeferredPollQuery>,
+    Query(consistency): Query<ConsistencyQuery>,
+    Extension(request): Extension<Arc<PartitionRequest>>,
+) -> Result<Response, ReadError> {
+    let options = iggy_common::DeferredPollOptions::from(wait);
+    options.validate(query.count).map_err(ReadError::Rejected)?;
+    poll_messages_impl(
+        state,
+        identity,
+        stream_id,
+        topic_id,
+        query,
+        consistency,
+        request,
+        Some(options),
+    )
+    .await
+}
+
 /// `GET /streams/{stream_id}/topics/{topic_id}/messages`: poll a batch of
 /// messages as the same `PolledMessages` JSON the legacy server returns. The
 /// query is the same flattened `PollMessages` shape the legacy server accepts
@@ -1191,6 +1221,35 @@
     Query(consistency): Query<ConsistencyQuery>,
     Extension(request): Extension<Arc<PartitionRequest>>,
 ) -> Result<Response, ReadError> {
+    poll_messages_impl(
+        state,
+        identity,
+        stream_id,
+        topic_id,
+        query,
+        consistency,
+        request,
+        None,
+    )
+    .await
+}
+
+#[expect(
+    clippy::too_many_lines,
+    reason = "Immediate and deferred polls share one authorization, fencing and relay path"
+)]
+#[allow(clippy::too_many_arguments)]
+async fn poll_messages_impl(
+    state: HttpState,
+    identity: Identity,
+    stream_id: String,
+    topic_id: String,
+    query: PollMessages,
+    consistency: ConsistencyQuery,
+    request: Arc<PartitionRequest>,
+    options: Option<iggy_common::DeferredPollOptions>,
+) -> Result<Response, ReadError> {
+    let started = std::time::Instant::now();
     let stream_id = Identifier::from_str_value(&stream_id).map_err(ReadError::Rejected)?;
     let topic_id = Identifier::from_str_value(&topic_id).map_err(ReadError::Rejected)?;
     let (consistency, metadata_floor) = SendWrapper::new(partition_read_consistency(
@@ -1275,19 +1334,47 @@
         // member and names client 0, the HTTP read client.
         fence_poll_context(requested, local, HTTP_UNREGISTERED_CLIENT_ID, partition_id)
             .map_err(ReadError::Rejected)?;
-        let reply = state
-            .shard
-            .partition_read(
-                namespace,
-                PartitionRead::UnboundPollOnPrimary {
-                    consumer,
-                    args: args.clone(),
+        let read = if let Some(options) = options {
+            let mut options = options
+                .remaining(started.elapsed())
+                .map_err(ReadError::Rejected)?;
+            let remaining = request
+                .deadline
+                .saturating_duration_since(std::time::Instant::now());
+            options.request_timeout = options.request_timeout.get_duration().min(remaining).into();
+            // A binding HTTP deadline shortens the wait, not its reply margin.
+            options.max_wait = options
+                .max_wait
+                .get_duration()
+                .min(options.request_timeout.get_duration().saturating_sub(
+                    std::time::Duration::from_micros(
+                        iggy_common::MIN_DEFERRED_POLL_REPLY_MARGIN_US,
+                    ),
+                ))
+                .into();
+            PartitionRead::DeferredPoll(Box::new(DeferredPollRequest::new(
+                consumer,
+                args.clone(),
+                metadata,
+                DeferredPollContext {
+                    session: None,
                     user_id: identity.user_id,
-                    user_created_at: identity.user_created_at.as_micros(),
-                    metadata,
+                    user_created_at: Some(identity.user_created_at.as_micros()),
+                    quota: PollQuotaIdentity::User(identity.user_id),
+                    primary: true,
                 },
-            )
-            .await;
+                options,
+            )))
+        } else {
+            PartitionRead::UnboundPollOnPrimary {
+                consumer,
+                args: args.clone(),
+                user_id: identity.user_id,
+                user_created_at: identity.user_created_at.as_micros(),
+                metadata,
+            }
+        };
+        let reply = state.shard.partition_read(namespace, read).await;
         // The owner may already have auto-committed. At-most-once polls can
         // lose a batch here, but revoked credentials must never receive it.
         authorize_read(&state, &identity, consistency, rule)?;
diff --git a/core/server/src/http/relay_proof.rs b/core/server/src/http/relay_proof.rs
index ebc222f..aaacaf8 100644
--- a/core/server/src/http/relay_proof.rs
+++ b/core/server/src/http/relay_proof.rs
@@ -37,10 +37,11 @@
 use iggy_common::{IggyDuration, IggyTimestamp};
 use ring::hmac;
 
-/// How long a relay proof stays valid, which is also the deadline of the
-/// partition request it relays. Each attempt signs anew, the hop target checks
-/// the proof when the request head arrives, and no attempt outlives the
-/// request deadline, so an older proof could only serve a replay.
+/// How long a relay proof stays valid, which is also the deadline of an
+/// immediate partition request. Each attempt signs anew, and the hop target
+/// checks the proof when the request head arrives, so an older proof could only
+/// serve a replay. A deferred poll can wait longer than this lifetime, because
+/// its proof is checked before the wait.
 ///
 /// Expiry compares the wall clocks of two nodes, which are assumed to agree
 /// within a few seconds (NTP). A hop target refuses a proof made more than
diff --git a/core/server/src/http/state.rs b/core/server/src/http/state.rs
index 59321ec..24ff34c 100644
--- a/core/server/src/http/state.rs
+++ b/core/server/src/http/state.rs
@@ -183,9 +183,10 @@
 /// (pinned-cert TLS when the listener serves HTTPS), the scheme it dials, the
 /// request-body buffer bound, the key that proves a relay comes from a node,
 /// the budget of the relays this node sends, and the separate budget of the
-/// relays it serves as their hop target. Built by
-/// `http::forward::build_forward_state`; lives here so the state hub never
-/// imports the forwarding middleware.
+/// relays it serves as their hop target. A relayed deferred poll holds its
+/// slots for its whole wait, so it takes them from a second pair of budgets.
+/// Built by `http::forward::build_forward_state`; lives here so the state hub
+/// never imports the forwarding middleware.
 pub(in crate::http) struct ForwardState {
     /// Derived from the cluster-wide bearer key material, so `None` when none
     /// exists (no configured JWT secret, no cluster PSK). Then forwarding is
@@ -197,7 +198,9 @@
     pub(in crate::http) scheme: &'static str,
     pub(in crate::http) body_limit: usize,
     pub(in crate::http) in_flight: Rc<Cell<u32>>,
+    pub(in crate::http) deferred_in_flight: Rc<Cell<u32>>,
     pub(in crate::http) relay_targets: RelayTargets,
+    pub(in crate::http) deferred_relay_targets: RelayTargets,
     pub(in crate::http) floor_reads: FloorReads,
 }
 
diff --git a/core/server/src/http/wire.rs b/core/server/src/http/wire.rs
index 83281dd..5943aa6 100644
--- a/core/server/src/http/wire.rs
+++ b/core/server/src/http/wire.rs
@@ -36,6 +36,7 @@
 use iggy_common::{
     Consumer, ConsumerKind, Identifier, IggyError, IggyMessageView, PollMessages, SendMessages,
 };
+use serde::Deserialize;
 use server_common::Message;
 
 /// Encode a validated HTTP `SendMessages` into the `SendMessagesRequest` wire
@@ -120,6 +121,40 @@
     })
 }
 
+/// The limits of `GET .../messages/deferred`. An omitted field takes the default of
+/// [`iggy_common::DeferredPollOptions`].
+#[derive(Debug, Deserialize)]
+#[serde(default)]
+pub(in crate::http) struct DeferredPollQuery {
+    pub wait_us: u64,
+    pub min_count: u32,
+    pub max_bytes: u32,
+    pub request_timeout_us: u64,
+}
+
+impl Default for DeferredPollQuery {
+    fn default() -> Self {
+        let options = iggy_common::DeferredPollOptions::default();
+        Self {
+            wait_us: options.max_wait.as_micros(),
+            min_count: options.min_count,
+            max_bytes: options.max_bytes,
+            request_timeout_us: options.request_timeout.as_micros(),
+        }
+    }
+}
+
+impl From<DeferredPollQuery> for iggy_common::DeferredPollOptions {
+    fn from(query: DeferredPollQuery) -> Self {
+        Self {
+            max_wait: query.wait_us.into(),
+            min_count: query.min_count,
+            max_bytes: query.max_bytes,
+            request_timeout: query.request_timeout_us.into(),
+        }
+    }
+}
+
 /// Map a validated HTTP consumer-offset query onto the wire
 /// `GetConsumerOffsetRequest` the shared TCP resolver consumes. An omitted
 /// `partition_id` defaults to partition 0, matching the legacy server's
diff --git a/core/server/src/session_manager.rs b/core/server/src/session_manager.rs
index cb0de4b..7f4cc36 100644
--- a/core/server/src/session_manager.rs
+++ b/core/server/src/session_manager.rs
@@ -28,10 +28,12 @@
 use crate::cluster_meta::ClusterRoster;
 use ahash::AHashMap;
 use consensus::client_table::SessionAttachment;
+use futures::future::{AbortHandle, AbortRegistration};
 use iggy_binary_protocol::ConsumerSession;
 use iggy_common::IggyError;
 use message_bus::installer::conn_info::ClientTransportKind;
 use shard::ConnectedClientInfo;
+use std::cell::RefCell;
 use std::net::SocketAddr;
 use std::rc::Rc;
 use std::time::{Duration, Instant};
@@ -113,6 +115,7 @@
     /// session it binds.
     pub metadata_watermark: u64,
     consumer_session: Option<(u128, SessionAttachment)>,
+    deferred_requests: AHashMap<u64, (u64, Instant, AbortHandle)>,
 }
 
 /// Bridges transport connections to consensus sessions.
@@ -129,6 +132,7 @@
 /// - Disconnecting one connection leaves the shared session and its peers live.
 pub struct SessionManager {
     connections: AHashMap<u128, Connection>,
+    next_deferred_request: u64,
     /// This shard's copy of the configured cluster roster, served by the
     /// `GetClusterMetadata` read. Lives here because it is the
     /// per-shard context already threaded to the non-replicated read path;
@@ -141,6 +145,7 @@
     pub fn new() -> Self {
         Self {
             connections: AHashMap::new(),
+            next_deferred_request: 0,
             cluster_roster: Rc::new(ClusterRoster::disabled()),
         }
     }
@@ -173,6 +178,7 @@
                 sdk: None,
                 metadata_watermark: 0,
                 consumer_session: None,
+                deferred_requests: AHashMap::new(),
             });
     }
 
@@ -230,6 +236,11 @@
         self.connections
             .iter()
             .filter(|(_, conn)| !matches!(conn.state, ConnectionState::Connected))
+            .filter(|(_, conn)| {
+                conn.deferred_requests
+                    .values()
+                    .all(|(_, deadline, _)| now >= *deadline)
+            })
             .filter(|(_, conn)| now.duration_since(conn.last_heartbeat) > max_age)
             .map(|(&id, _)| id)
             .collect()
@@ -257,16 +268,81 @@
 
     /// Remove a transport connection without ending its durable session.
     pub fn remove_connection(&mut self, connection_id: u128) -> Option<(u128, u64)> {
-        if let Some(conn) = self.connections.remove(&connection_id)
-            && let ConnectionState::Bound {
+        if let Some(conn) = self.connections.remove(&connection_id) {
+            for (_, _, abort) in conn.deferred_requests.into_values() {
+                abort.abort();
+            }
+            if let ConnectionState::Bound {
                 client_id, session, ..
             } = conn.state
-        {
-            return Some((client_id, session));
+            {
+                return Some((client_id, session));
+            }
         }
         None
     }
 
+    /// # Errors
+    /// Rejects a missing connection or an exhausted request identity or watchdog.
+    pub fn begin_deferred_request(
+        sessions: &Rc<RefCell<Self>>,
+        connection_id: u128,
+        wire_request_id: u64,
+        watchdog: Duration,
+        now: Instant,
+    ) -> Result<(DeferredRequestGuard, AbortRegistration), IggyError> {
+        let deadline = now.checked_add(watchdog).ok_or(IggyError::InvalidCommand)?;
+        let (abort, registration) = AbortHandle::new_pair();
+        let mut manager = sessions.borrow_mut();
+        let request_id = manager
+            .next_deferred_request
+            .checked_add(1)
+            .ok_or(IggyError::TransientNotAccepted)?;
+        manager.next_deferred_request = request_id;
+        let connection = manager
+            .connections
+            .get_mut(&connection_id)
+            .ok_or(IggyError::StaleClient)?;
+        connection
+            .deferred_requests
+            .insert(request_id, (wire_request_id, deadline, abort));
+        Ok((
+            DeferredRequestGuard {
+                sessions: Rc::clone(sessions),
+                connection_id,
+                request_id,
+            },
+            registration,
+        ))
+    }
+
+    /// Cancels only requests admitted on this connection with the given wire id.
+    pub fn cancel_deferred_request(
+        &self,
+        connection_id: u128,
+        client: u128,
+        session: u64,
+        wire_request_id: u64,
+    ) -> bool {
+        let Some(connection) = self.connections.get(&connection_id) else {
+            return false;
+        };
+        if !matches!(connection.state, ConnectionState::Bound { client_id, session: bound_session, .. } if client_id == client && bound_session == session)
+            || connection
+                .consumer_session
+                .as_ref()
+                .is_none_or(|(_, attachment)| !attachment.is_valid())
+        {
+            return false;
+        }
+        for (request, _, abort) in connection.deferred_requests.values() {
+            if *request == wire_request_id {
+                abort.abort();
+            }
+        }
+        true
+    }
+
     #[allow(clippy::too_many_arguments)]
     pub fn bind_authenticated_connection(
         &mut self,
@@ -454,6 +530,42 @@
     }
 }
 
+pub struct DeferredRequestGuard {
+    sessions: Rc<RefCell<SessionManager>>,
+    connection_id: u128,
+    request_id: u64,
+}
+
+impl DeferredRequestGuard {
+    pub fn written(&self, now: Instant) {
+        if let Some(connection) = self
+            .sessions
+            .borrow_mut()
+            .connections
+            .get_mut(&self.connection_id)
+            && connection
+                .deferred_requests
+                .get(&self.request_id)
+                .is_some_and(|(_, deadline, _)| now < *deadline)
+        {
+            connection.last_heartbeat = now;
+        }
+    }
+}
+
+impl Drop for DeferredRequestGuard {
+    fn drop(&mut self) {
+        if let Some(connection) = self
+            .sessions
+            .borrow_mut()
+            .connections
+            .get_mut(&self.connection_id)
+        {
+            connection.deferred_requests.remove(&self.request_id);
+        }
+    }
+}
+
 impl Default for SessionManager {
     fn default() -> Self {
         Self::new()
@@ -482,6 +594,8 @@
 
 #[cfg(test)]
 mod tests {
+    use futures::FutureExt;
+
     use super::*;
     use crate::reply_frame::build_empty_reply;
     use consensus::ClientTable;
@@ -830,4 +944,164 @@
         );
         assert_eq!(sessions.client_count(), 0);
     }
+    #[test]
+    fn cancellation_matches_connection_session_and_request_without_aborting_other_waits() {
+        let sessions = Rc::new(RefCell::new(SessionManager::new()));
+        let mut table = registry(CLIENT, USER, EPOCH);
+        for connection in [1, 2] {
+            bind(&mut sessions.borrow_mut(), &mut table, connection);
+        }
+        let (_first, first) = SessionManager::begin_deferred_request(
+            &sessions,
+            1,
+            10,
+            Duration::from_secs(60),
+            Instant::now(),
+        )
+        .unwrap();
+        let (_other, other) = SessionManager::begin_deferred_request(
+            &sessions,
+            1,
+            11,
+            Duration::from_secs(60),
+            Instant::now(),
+        )
+        .unwrap();
+        let (_peer, peer) = SessionManager::begin_deferred_request(
+            &sessions,
+            2,
+            10,
+            Duration::from_secs(60),
+            Instant::now(),
+        )
+        .unwrap();
+        let mut first = Box::pin(futures::future::Abortable::new(
+            std::future::pending::<()>(),
+            first,
+        ));
+        let mut other = Box::pin(futures::future::Abortable::new(
+            std::future::pending::<()>(),
+            other,
+        ));
+        let mut peer = Box::pin(futures::future::Abortable::new(
+            std::future::pending::<()>(),
+            peer,
+        ));
+        assert!(
+            !sessions
+                .borrow()
+                .cancel_deferred_request(1, CLIENT + 1, EPOCH, 10)
+        );
+        assert!(
+            !sessions
+                .borrow()
+                .cancel_deferred_request(1, CLIENT, EPOCH + 1, 10)
+        );
+        assert!(first.as_mut().now_or_never().is_none());
+        assert!(
+            sessions
+                .borrow()
+                .cancel_deferred_request(1, CLIENT, EPOCH, 10)
+        );
+        assert!(first.as_mut().now_or_never().unwrap().is_err());
+        assert!(other.as_mut().now_or_never().is_none());
+        assert!(peer.as_mut().now_or_never().is_none());
+    }
+
+    #[test]
+    fn deferred_guard_is_connection_local_finite_and_refreshes_only_after_write() {
+        let sessions = Rc::new(RefCell::new(SessionManager::new()));
+        let old = Instant::now().checked_sub(Duration::from_secs(30)).unwrap();
+        let mut table = registry(CLIENT, USER, EPOCH);
+        for connection in [1, 2] {
+            let mut manager = sessions.borrow_mut();
+            bind(&mut manager, &mut table, connection);
+            manager
+                .connections
+                .get_mut(&connection)
+                .unwrap()
+                .last_heartbeat = old;
+        }
+        let (guard, _) = SessionManager::begin_deferred_request(
+            &sessions,
+            2,
+            10,
+            Duration::from_secs(60),
+            Instant::now(),
+        )
+        .unwrap();
+        let now = Instant::now();
+        assert_eq!(
+            sessions
+                .borrow()
+                .collect_stale(Duration::from_secs(10), now),
+            [1]
+        );
+        assert_eq!(sessions.borrow().connections[&1].last_heartbeat, old);
+        assert_eq!(
+            sessions
+                .borrow()
+                .collect_stale(Duration::from_secs(10), now + Duration::from_secs(61))
+                .len(),
+            2
+        );
+        let (concurrent, _) = SessionManager::begin_deferred_request(
+            &sessions,
+            2,
+            10,
+            Duration::from_secs(60),
+            Instant::now(),
+        )
+        .unwrap();
+        drop(concurrent);
+        assert_eq!(sessions.borrow().connections[&2].deferred_requests.len(), 1);
+        guard.written(Instant::now());
+        drop(guard);
+        assert_eq!(
+            sessions
+                .borrow()
+                .collect_stale(Duration::from_secs(10), Instant::now()),
+            [1]
+        );
+        assert_eq!(sessions.borrow().connections[&1].last_heartbeat, old);
+        let (failed, registration) = SessionManager::begin_deferred_request(
+            &sessions,
+            1,
+            10,
+            Duration::from_secs(60),
+            Instant::now(),
+        )
+        .unwrap();
+        sessions.borrow_mut().remove_connection(1);
+        let cancelled = futures::future::Abortable::new(std::future::pending::<()>(), registration);
+        assert!(cancelled.now_or_never().unwrap().is_err());
+        drop(failed);
+    }
+
+    #[test]
+    fn dropped_deferred_guard_does_not_extend_heartbeat() {
+        let sessions = Rc::new(RefCell::new(SessionManager::new()));
+        sessions.borrow_mut().ensure_connection(
+            1,
+            addr(5000),
+            ClientTransportKind::Tcp,
+            Instant::now(),
+        );
+        let before = sessions.borrow().connections[&1].last_heartbeat;
+        let (guard, _) = SessionManager::begin_deferred_request(
+            &sessions,
+            1,
+            10,
+            Duration::from_secs(1),
+            Instant::now(),
+        )
+        .unwrap();
+        drop(guard);
+        assert_eq!(sessions.borrow().connections[&1].last_heartbeat, before);
+        assert!(
+            sessions.borrow().connections[&1]
+                .deferred_requests
+                .is_empty()
+        );
+    }
 }
diff --git a/core/server_common/src/consensus_message.rs b/core/server_common/src/consensus_message.rs
index 8487b9b..c682356 100644
--- a/core/server_common/src/consensus_message.rs
+++ b/core/server_common/src/consensus_message.rs
@@ -18,6 +18,7 @@
 use crate::iobuf::{Frozen, Owned};
 use crate::sharding::METADATA_GROUP;
 use aligned_vec::{AVec, ConstAlign};
+use futures::channel::oneshot;
 use iggy_binary_protocol::{
     Command, CommitHeader, ConsensusError, ConsensusHeader, ConsumerSessionHeartbeatHeader,
     DoViewChangeHeader, ForwardCompletionHeader, ForwardLogoutHeader, ForwardLogoutResultHeader,
@@ -29,6 +30,7 @@
     frame_checksum_bytes, prepare_identity_checksum_bytes,
 };
 use smallvec::SmallVec;
+use std::sync::{Arc, Mutex};
 use std::{
     marker::PhantomData,
     mem::{offset_of, size_of},
@@ -86,12 +88,41 @@
 #[derive(Debug, Clone)]
 pub struct ResponseBacking {
     fragments: ResponseFragments,
+    write_receipt: Option<WriteReceipt>,
+}
+
+/// Completed by the transport only after the whole frame is written and flushed.
+/// Dropping every copy without completion cancels the observer.
+#[derive(Debug, Clone)]
+pub struct WriteReceipt(Arc<Mutex<Option<oneshot::Sender<()>>>>);
+
+impl WriteReceipt {
+    pub fn complete(self) {
+        if let Some(sender) = self
+            .0
+            .lock()
+            .unwrap_or_else(std::sync::PoisonError::into_inner)
+            .take()
+        {
+            let _ = sender.send(());
+        }
+    }
 }
 
 impl RequestBackingKind for RequestBacking {}
 impl ResponseBackingKind for ResponseBacking {}
 
 impl ResponseBacking {
+    pub fn track_write(&mut self) -> oneshot::Receiver<()> {
+        let (sender, receiver) = oneshot::channel();
+        self.write_receipt = Some(WriteReceipt(Arc::new(Mutex::new(Some(sender)))));
+        receiver
+    }
+
+    pub fn take_write_receipt(&mut self) -> Option<WriteReceipt> {
+        self.write_receipt.take()
+    }
+
     /// The fragment carrying the frame header.
     #[must_use]
     pub fn first(&self) -> &Frozen<MESSAGE_ALIGN> {
@@ -138,6 +169,7 @@
     fn from(frozen: Frozen<MESSAGE_ALIGN>) -> Self {
         Self {
             fragments: smallvec::smallvec![frozen],
+            write_receipt: None,
         }
     }
 }
@@ -607,7 +639,12 @@
             });
         }
 
-        Ok(unsafe { Self::from_backing_unchecked(ResponseBacking { fragments }) })
+        Ok(unsafe {
+            Self::from_backing_unchecked(ResponseBacking {
+                fragments,
+                write_receipt: None,
+            })
+        })
     }
 }
 
diff --git a/core/server_common/src/iobuf.rs b/core/server_common/src/iobuf.rs
index 0a75236..0386938 100644
--- a/core/server_common/src/iobuf.rs
+++ b/core/server_common/src/iobuf.rs
@@ -287,6 +287,19 @@
 }
 
 impl<const ALIGN: usize> Frozen<ALIGN> {
+    /// Bytes pinned by this view, including its allocation's control block.
+    pub fn allocation_bytes(&self) -> usize {
+        self.backing_capacity()
+            .saturating_add(size_of::<ControlBlock>())
+    }
+
+    /// Identity of the backing allocation, equal for every view that
+    /// [`Self::shares_allocation`] with this one. A live view keeps the
+    /// allocation, so the identity cannot be reused while one exists.
+    pub fn allocation_id(&self) -> usize {
+        self.inner.ctrlb.as_ptr().addr()
+    }
+
     pub fn as_slice(&self) -> &[u8] {
         self.inner.as_slice()
     }
diff --git a/core/shard/src/config.rs b/core/shard/src/config.rs
index 0406210..4cf50b4 100644
--- a/core/shard/src/config.rs
+++ b/core/shard/src/config.rs
@@ -46,3 +46,27 @@
         }
     }
 }
+
+/// Owner-local deferred poll limits. Running reads retain their byte reservation
+/// until I/O and completion delivery end, even if their caller disconnects.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct DeferredPollConfig {
+    pub max_wait_us: u64,
+    pub max_pending: usize,
+    pub max_pending_per_session: usize,
+    pub max_read_bytes: usize,
+    pub max_inflight_bytes: usize,
+}
+
+/// The defaults of `core/server/config.toml`. A server boot test pins that the two match.
+impl Default for DeferredPollConfig {
+    fn default() -> Self {
+        Self {
+            max_wait_us: 30_000_000,
+            max_pending: 1024,
+            max_pending_per_session: 64,
+            max_read_bytes: 16 * 1024 * 1024,
+            max_inflight_bytes: 64 * 1024 * 1024,
+        }
+    }
+}
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index e6b1662..527ffae 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -29,6 +29,9 @@
 
 pub use config::CoordinatorConfig;
 pub use host::{NoopHost, ShardHost};
+pub use poll::deferred::{
+    DeferredPollContext, DeferredPollRequest, PollQuotaIdentity, ReadinessTarget, ReadinessWait,
+};
 pub use poll::{ConsumerAttachment, PollCompleted};
 pub use router::CONSENSUS_TICK_INTERVAL;
 
@@ -85,7 +88,7 @@
 use server_common::{MESSAGE_ALIGN, Message, MessageBag, iobuf::Frozen};
 use shards_table::ShardsTable;
 use std::cell::{Cell, RefCell};
-use std::collections::{BTreeMap, HashMap, VecDeque};
+use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
 use std::future::Future;
 use std::rc::Rc;
 use std::sync::Arc;
@@ -397,6 +400,7 @@
 #[derive(Debug)]
 pub enum PartitionRead {
     Primary,
+    DeferredPoll(Box<DeferredPollRequest>),
     SessionRetired {
         identity: iggy_binary_protocol::requests::system::SessionIdentity,
         created_revision: u64,
@@ -467,7 +471,8 @@
     #[must_use]
     pub(crate) const fn reads_messages_or_offset(&self) -> bool {
         match self {
-            Self::ConsumerOffset { .. }
+            Self::DeferredPoll(_)
+            | Self::ConsumerOffset { .. }
             | Self::ConsumerOffsetOnPrimary { .. }
             | Self::ExternalGroupOffsetOnPrimary { .. }
             | Self::PollOnPrimary { .. }
@@ -542,6 +547,34 @@
     /// (unknown, tombstoned, or mid-reconcile). Callers surface an error
     /// instead of an empty result.
     NotFound,
+    /// Reply to a [`LifecycleFrame::ReadinessWait`] from the owner `shard`:
+    /// `partitions` hold a message at their start point. If nothing was ready
+    /// at admission, `parked` names the waiter that the origin must cancel.
+    Ready {
+        shard: u16,
+        parked: Option<u64>,
+        partitions: Vec<u32>,
+    },
+}
+
+/// Cancels the waiters that a readiness wait left on owner shards. A cancel
+/// that does not fit into a full inbox is lost. The owner then drops that
+/// waiter at its deadline, at maintenance or at its next evaluation.
+struct ParkedReadiness<'a> {
+    senders: &'a [TaggedSender],
+    waiters: BTreeMap<u16, u64>,
+}
+
+impl Drop for ParkedReadiness<'_> {
+    fn drop(&mut self) {
+        for (shard, waiter) in &self.waiters {
+            if let Some(sender) = self.senders.get(usize::from(*shard)) {
+                let _ = sender.try_send(ShardFrame::lifecycle(LifecycleFrame::ReadinessCancel {
+                    waiter: *waiter,
+                }));
+            }
+        }
+    }
 }
 
 /// Reply budget for [`IggyShard::partition_read`]. Bounds a wedged owning shard;
@@ -932,6 +965,17 @@
         read: PartitionRead,
         reply: Sender<PartitionReadReply>,
     },
+    /// Evaluate a readiness wait on the shard that owns its targets. The owner
+    /// answers once at admission and at most once more. See
+    /// [`IggyShard::wait_for_messages`].
+    ReadinessWait {
+        wait: ReadinessWait,
+        reply: Sender<PartitionReadReply>,
+    },
+    /// Drop a parked readiness waiter that its origin no longer needs.
+    ReadinessCancel {
+        waiter: u64,
+    },
     /// Admit a partition write (`SendMessages` / consumer-offset write) on
     /// the shard owning its namespace, carrying the channel its committed
     /// reply travels back on. The partition plane cannot route a reply by
@@ -1647,6 +1691,7 @@
     poll_completions: poll::completion::PollCompletionLane,
     partition_io: partition_io::PartitionIoLane<SB>,
     cooperative_events: Cell<usize>,
+    deferred_polls: poll::deferred::DeferredPolls,
 
     /// Partition namespace -> owning shard lookup.
     shards_table: T,
@@ -1984,6 +2029,7 @@
             poll_completions,
             partition_io,
             cooperative_events: Cell::new(0),
+            deferred_polls: poll::deferred::DeferredPolls::new(metrics.deferred_polls.clone()),
             shards_table,
             partition_consensus,
             coordinator,
@@ -2312,7 +2358,8 @@
     /// request, so this read cannot advance progress and is safe to retry.
     ///
     /// `None` means submission succeeded but the reply sender was dropped or
-    /// `PARTITION_READ_TIMEOUT` expired. A timeout drops the reply receiver
+    /// the reply budget expired: `PARTITION_READ_TIMEOUT`, or the request
+    /// timeout of a deferred poll, which also sets its wait deadline. A timeout drops the reply receiver
     /// without canceling a queued request or detached read. The owner discards
     /// a poll completion if it observes disconnection before admission. If
     /// timeout races with that check, completion can still advance progress and
@@ -2323,8 +2370,25 @@
     pub async fn partition_read(
         &self,
         namespace: IggyNamespace,
-        read: PartitionRead,
+        mut read: PartitionRead,
     ) -> Option<PartitionReadReply> {
+        let timeout = if let PartitionRead::DeferredPoll(request) = &mut read {
+            let now = self.bus.monotonic_micros();
+            let wait = self
+                .deferred_polls
+                .wait_us(request.options.max_wait.as_micros());
+            let (Some(deadline), Some(request_deadline)) = (
+                now.checked_add(wait),
+                now.checked_add(request.options.request_timeout.as_micros()),
+            ) else {
+                return Some(PartitionReadReply::Rejected(IggyError::InvalidCommand));
+            };
+            request.deadline = deadline;
+            request.request_deadline = request_deadline;
+            request.options.request_timeout.get_duration()
+        } else {
+            PARTITION_READ_TIMEOUT
+        };
         let target = self.shards_table.shard_for(namespace).or_else(|| {
             matches!(read, PartitionRead::SessionRetired { .. }).then(|| {
                 crate::shards_table::calculate_shard_assignment(&namespace, self.shard_count)
@@ -2361,7 +2425,7 @@
                 IggyError::TransientNotAccepted,
             ));
         }
-        match bus_timeout(&self.bus, PARTITION_READ_TIMEOUT, reply_rx.recv()).await {
+        match bus_timeout(&self.bus, timeout, reply_rx.recv()).await {
             Some(Ok(reply)) => Some(reply),
             Some(Err(_)) => {
                 tracing::warn!(
@@ -2382,6 +2446,100 @@
         }
     }
 
+    /// Wait until at least one target holds a message at its start point.
+    ///
+    /// Sends one [`LifecycleFrame::ReadinessWait`] to each owner shard. Every
+    /// owner answers once at admission, so the result covers all targets that
+    /// are ready at the start, also for a zero wait. If none is ready, the
+    /// first later readiness ends the wait. An empty list means that the wait
+    /// expired.
+    ///
+    /// # Errors
+    /// Returns the first owner rejection. A target that no shard owns, or an
+    /// owner inbox that refuses the frame, returns `TransientNotAccepted`. An
+    /// owner that does not answer its admission within `PARTITION_READ_TIMEOUT`
+    /// returns `ShardCommunicationError`, unless another target is ready.
+    #[allow(clippy::future_not_send)]
+    pub async fn wait_for_messages(
+        &self,
+        context: DeferredPollContext,
+        targets: Vec<ReadinessTarget>,
+        wait: std::time::Duration,
+    ) -> Result<Vec<u32>, IggyError> {
+        let deadline = u64::try_from(wait.as_micros())
+            .ok()
+            .and_then(|wait| self.bus.monotonic_micros().checked_add(wait))
+            .ok_or(IggyError::InvalidCommand)?;
+        let mut owners: BTreeMap<u16, Vec<ReadinessTarget>> = BTreeMap::new();
+        for target in targets {
+            let shard = self
+                .shards_table
+                .shard_for(target.namespace)
+                .ok_or(IggyError::TransientNotAccepted)?;
+            owners.entry(shard).or_default().push(target);
+        }
+        // An owner answers at admission and at most once more.
+        let (reply, replies) = channel::<PartitionReadReply>(2 * owners.len());
+        let mut parked = ParkedReadiness {
+            senders: &self.senders,
+            waiters: BTreeMap::new(),
+        };
+        let mut admitting = BTreeSet::new();
+        for (shard, targets) in owners {
+            let frame = ShardFrame::lifecycle(LifecycleFrame::ReadinessWait {
+                wait: ReadinessWait {
+                    context: context.clone(),
+                    targets,
+                    deadline,
+                },
+                reply: reply.clone(),
+            });
+            self.senders
+                .get(usize::from(shard))
+                .ok_or(IggyError::TransientNotAccepted)?
+                .try_send(frame)
+                .map_err(|_| IggyError::TransientNotAccepted)?;
+            admitting.insert(shard);
+        }
+        drop(reply);
+        let mut ready = Vec::new();
+        loop {
+            let mut budget = std::time::Duration::from_micros(
+                deadline.saturating_sub(self.bus.monotonic_micros()),
+            );
+            // Every admission answer counts, even after a short wait expires.
+            if !admitting.is_empty() {
+                budget = budget.max(PARTITION_READ_TIMEOUT);
+            }
+            let reply = bus_timeout(&self.bus, budget, replies.recv()).await;
+            match reply {
+                Some(Ok(PartitionReadReply::Ready {
+                    shard,
+                    parked: waiter,
+                    partitions,
+                })) => {
+                    if admitting.remove(&shard) {
+                        if let Some(waiter) = waiter {
+                            parked.waiters.insert(shard, waiter);
+                        }
+                    } else {
+                        parked.waiters.remove(&shard);
+                    }
+                    ready.extend(partitions);
+                    if admitting.is_empty() && !ready.is_empty() {
+                        return Ok(ready);
+                    }
+                }
+                Some(Ok(PartitionReadReply::Rejected(error))) => return Err(error),
+                // Owners drop an expired waiter without an answer.
+                Some(Err(_)) | None if admitting.is_empty() || !ready.is_empty() => {
+                    return Ok(ready);
+                }
+                Some(_) | None => return Err(IggyError::ShardCommunicationError),
+            }
+        }
+    }
+
     /// Admit a partition write on the shard owning `namespace`. Routes through
     /// the shards table exactly like [`Self::partition_read`], self-sends
     /// included, so a locally-owned partition takes the same path.
@@ -2586,6 +2744,7 @@
             shard_count: 1,
             inbox,
             reply_inbox,
+            deferred_polls: poll::deferred::DeferredPolls::new(metrics.deferred_polls.clone()),
             poll_completions: poll::completion::PollCompletionLane::new(
                 POLL_COMPLETION_CAPACITY,
                 &metrics,
diff --git a/core/shard/src/metrics.rs b/core/shard/src/metrics.rs
index 50e2c74..58c7028 100644
--- a/core/shard/src/metrics.rs
+++ b/core/shard/src/metrics.rs
@@ -237,6 +237,63 @@
     REASONS.iter().position(|r| *r == s)
 }
 
+#[derive(Clone, Default)]
+pub(crate) struct DeferredPollMetrics {
+    pub pending: Gauge,
+    pub inflight_bytes: Gauge,
+    pub rejected: Counter,
+    pub failed: Counter,
+    pub cancelled: Counter,
+    pub expired: Counter,
+    pub filled: Counter,
+    pub late: Counter,
+}
+
+impl DeferredPollMetrics {
+    fn register(&self, registry: &mut Registry) {
+        registry.register(
+            "deferred_poll_pending",
+            "pending deferred polls",
+            self.pending.clone(),
+        );
+        registry.register(
+            "deferred_poll_inflight_bytes",
+            "reserved bytes for running or queued deferred reads",
+            self.inflight_bytes.clone(),
+        );
+        registry.register(
+            "deferred_poll_rejected",
+            "deferred polls rejected before admission",
+            self.rejected.clone(),
+        );
+        registry.register(
+            "deferred_poll_failed",
+            "admitted deferred polls rejected by a read error, fence, read budget refusal or request deadline",
+            self.failed.clone(),
+        );
+        registry.register(
+            "deferred_poll_cancelled",
+            "deferred polls cancelled by their caller",
+            self.cancelled.clone(),
+        );
+        registry.register(
+            "deferred_poll_expired",
+            "deferred polls completed at their deadline",
+            self.expired.clone(),
+        );
+        registry.register(
+            "deferred_poll_filled",
+            "deferred polls answered before their wait deadline",
+            self.filled.clone(),
+        );
+        registry.register(
+            "deferred_poll_late",
+            "deferred read completions discarded after expiry or cancellation",
+            self.late.clone(),
+        );
+    }
+}
+
 /// Per-shard metric handles.
 ///
 /// Cheap to clone (`Arc` of a `Family` under the hood). Each shard owns
@@ -265,6 +322,7 @@
     partition_io_fenced: Counter,
     partition_io_timeouts: Counter,
 
+    pub(crate) deferred_polls: DeferredPollMetrics,
     partition_wal_disk_bytes: Gauge,
     partition_wal_retained_bytes: Gauge,
     partition_wal_queued_bytes: Gauge,
@@ -343,6 +401,7 @@
             partition_io_quarantined_jobs: Gauge::default(),
             partition_io_fenced: Counter::default(),
             partition_io_timeouts: Counter::default(),
+            deferred_polls: DeferredPollMetrics::default(),
             partition_wal_disk_bytes: Gauge::default(),
             partition_wal_retained_bytes: Gauge::default(),
             partition_wal_queued_bytes: Gauge::default(),
@@ -952,6 +1011,7 @@
     #[allow(clippy::too_many_lines)]
     pub fn register(&self, registry: &mut Registry) {
         self.register_persistence(registry);
+        self.deferred_polls.register(registry);
         registry.register(
             "partition_io_capacity",
             "configured partition file-job slots per shard",
diff --git a/core/shard/src/poll.rs b/core/shard/src/poll.rs
index 1005449..1cdf2b5 100644
--- a/core/shard/src/poll.rs
+++ b/core/shard/src/poll.rs
@@ -47,6 +47,7 @@
 pub mod completion;
 #[cfg(test)]
 mod completion_tests;
+pub mod deferred;
 #[cfg(test)]
 mod test_support;
 #[cfg(test)]
@@ -95,14 +96,23 @@
     namespace: IggyNamespace,
     /// Snapshot facts that have not yet been accepted as consumer progress.
     result: PollReadResult,
-    /// Return path for the accepted read or a rejection.
-    reply: Sender<PartitionReadReply>,
-    attachment: Option<PollAttachment>,
+    target: PollTarget,
     /// Inbox enqueue time for disk diagnostics, or `None` for resident completion.
     #[cfg(feature = "poll-diagnostics")]
     queued_at: Option<std::time::Instant>,
 }
 
+pub enum PollTarget {
+    Immediate {
+        reply: Sender<PartitionReadReply>,
+        attachment: Option<PollAttachment>,
+    },
+    Deferred {
+        request_id: u64,
+        _reservation: deferred::ReadReservation,
+    },
+}
+
 impl<B, MJ, S, M, T, SB> IggyShard<B, MJ, S, M, T, SB>
 where
     B: MessageBus + 'static,
@@ -156,6 +166,10 @@
         read: PartitionRead,
         reply: Sender<PartitionReadReply>,
     ) {
+        if let PartitionRead::DeferredPoll(request) = read {
+            self.admit_deferred_poll(namespace, *request, &reply);
+            return;
+        }
         let partitions = self.plane.partitions();
         if matches!(read, PartitionRead::SessionRetired { .. }) {
             let failed_revision = partitions.failed_revision(&namespace).or_else(|| {
@@ -315,6 +329,7 @@
                     )
                 })
                 .unwrap_or(PartitionReadReply::NotFound),
+            PartitionRead::DeferredPoll(_) => unreachable!("deferred admission handled above"),
             PartitionRead::Primary => partitions
                 .with_partition(&namespace, |partition| {
                     let consensus = partition.consensus();
@@ -380,8 +395,7 @@
                         self.on_poll_completed(PollCompleted {
                             namespace,
                             result: plan.execute_resident(),
-                            reply,
-                            attachment,
+                            target: PollTarget::Immediate { reply, attachment },
                             #[cfg(feature = "poll-diagnostics")]
                             queued_at: None,
                         })
@@ -450,8 +464,7 @@
         let PollCompleted {
             namespace,
             result,
-            reply,
-            attachment,
+            target,
             #[cfg(feature = "poll-diagnostics")]
             queued_at,
         } = completion;
@@ -466,6 +479,16 @@
                 "partition poll completion"
             );
         }
+        let (reply, attachment) = match target {
+            PollTarget::Immediate { reply, attachment } => (reply, attachment),
+            PollTarget::Deferred {
+                request_id,
+                _reservation,
+            } => {
+                self.on_deferred_poll_completed(request_id, result).await;
+                return;
+            }
+        };
         if reply.is_disconnected() {
             return;
         }
@@ -509,6 +532,16 @@
                 return;
             }
         }
+        self.accept_poll_result(namespace, result, reply).await;
+    }
+
+    #[allow(clippy::future_not_send)]
+    async fn accept_poll_result(
+        &self,
+        namespace: IggyNamespace,
+        result: PollReadResult,
+        reply: Sender<PartitionReadReply>,
+    ) {
         let partitions = self.plane.partitions();
         let consumer_kind = result.consumer_kind();
         match partitions.complete_poll(&namespace, result) {
diff --git a/core/shard/src/poll/completion.rs b/core/shard/src/poll/completion.rs
index 0fc4eeb..5e058c0 100644
--- a/core/shard/src/poll/completion.rs
+++ b/core/shard/src/poll/completion.rs
@@ -37,7 +37,7 @@
 use partitions::PollReadResult;
 use server_common::sharding::IggyNamespace;
 
-use super::{PollAttachment, PollCompleted};
+use super::{PollAttachment, PollCompleted, PollTarget};
 use crate::coordinator::classify_try_send_err;
 use crate::metrics::{FrameDropMetrics, ShardMetrics, frame_drop_reason, frame_drop_variant};
 use crate::{PartitionReadReply, Receiver, Sender, channel};
@@ -79,29 +79,60 @@
         reply: Sender<PartitionReadReply>,
         attachment: Option<PollAttachment>,
     ) -> Option<PollCompletionSender> {
+        let slot = match self.reserve_slot() {
+            Ok(slot) => slot,
+            Err(reason) => {
+                reject(&reply, &self.state.metrics, reason);
+                return None;
+            }
+        };
+        Some(PollCompletionSender {
+            inbox: self.sender.clone(),
+            slot,
+            namespace,
+            target: PollTarget::Immediate { reply, attachment },
+        })
+    }
+
+    pub(crate) fn try_reserve_deferred(
+        &self,
+        namespace: IggyNamespace,
+        request_id: u64,
+        reservation: super::deferred::ReadReservation,
+    ) -> Result<PollCompletionSender, IggyError> {
+        let slot = self.reserve_slot().map_err(|reason| {
+            self.state
+                .metrics
+                .record(frame_drop_variant::PARTITION_POLL_COMPLETION, reason);
+            if reason == frame_drop_reason::FULL {
+                IggyError::DeferredPollCapacityExceeded
+            } else {
+                IggyError::TransientNotAccepted
+            }
+        })?;
+        Ok(PollCompletionSender {
+            inbox: self.sender.clone(),
+            slot,
+            namespace,
+            target: PollTarget::Deferred {
+                request_id,
+                _reservation: reservation,
+            },
+        })
+    }
+
+    fn reserve_slot(&self) -> Result<CompletionSlot, &'static str> {
         if self.state.closed.load(Ordering::Relaxed) || self.sender.is_disconnected() {
-            reject(&reply, &self.state.metrics, frame_drop_reason::DISCONNECTED);
-            return None;
+            return Err(frame_drop_reason::DISCONNECTED);
         }
-        if self
-            .state
+        self.state
             .reserved
             .try_update(Ordering::Relaxed, Ordering::Relaxed, |reserved| {
                 (reserved < self.state.capacity).then_some(reserved + 1)
             })
-            .is_err()
-        {
-            reject(&reply, &self.state.metrics, frame_drop_reason::FULL);
-            return None;
-        }
-        Some(PollCompletionSender {
-            inbox: self.sender.clone(),
-            slot: CompletionSlot {
-                state: self.state.clone(),
-            },
-            namespace,
-            reply,
-            attachment,
+            .map_err(|_| frame_drop_reason::FULL)?;
+        Ok(CompletionSlot {
+            state: self.state.clone(),
         })
     }
 
@@ -153,8 +184,7 @@
     inbox: Sender<QueuedCompletion>,
     slot: CompletionSlot,
     namespace: IggyNamespace,
-    reply: Sender<PartitionReadReply>,
-    attachment: Option<PollAttachment>,
+    target: PollTarget,
 }
 
 impl PollCompletionSender {
@@ -163,8 +193,8 @@
     /// owner rejects the result and releases the reservation.
     pub(crate) fn complete(self, result: PollReadResult) {
         if self.slot.state.closed.load(Ordering::Relaxed) || self.inbox.is_disconnected() {
-            reject(
-                &self.reply,
+            reject_target(
+                &self.target,
                 &self.slot.state.metrics,
                 frame_drop_reason::DISCONNECTED,
             );
@@ -174,8 +204,7 @@
             result: Box::new(PollCompleted {
                 namespace: self.namespace,
                 result,
-                reply: self.reply,
-                attachment: self.attachment,
+                target: self.target,
                 #[cfg(feature = "poll-diagnostics")]
                 queued_at: Some(std::time::Instant::now()),
             }),
@@ -190,8 +219,8 @@
                 }
                 TrySendError::Disconnected(completion) => completion,
             };
-            reject(
-                &completion.result.reply,
+            reject_target(
+                &completion.result.target,
                 &completion.slot.state.metrics,
                 reason,
             );
@@ -226,6 +255,15 @@
     ));
 }
 
+fn reject_target(target: &PollTarget, metrics: &FrameDropMetrics, reason: &'static str) {
+    match target {
+        PollTarget::Immediate { reply, .. } => reject(reply, metrics, reason),
+        PollTarget::Deferred { .. } => {
+            metrics.record(frame_drop_variant::PARTITION_POLL_COMPLETION, reason);
+        }
+    }
+}
+
 struct LaneState {
     capacity: usize,
     /// Includes slots held by running reads and by results awaiting dequeue.
diff --git a/core/shard/src/poll/deferred.rs b/core/shard/src/poll/deferred.rs
new file mode 100644
index 0000000..5cc54b8
--- /dev/null
+++ b/core/shard/src/poll/deferred.rs
@@ -0,0 +1,1351 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Deferred reads remain provisional until the owner selects one result.
+//! One registry owns readiness deadlines and quotas. Detached readers
+//! carry only a request identity and a reservation that survives cancellation.
+//! Readiness waits share the registry, because it owns the one poll notifier
+//! of each partition.
+
+use std::cell::RefCell;
+use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
+use std::ops::Bound::{Excluded, Unbounded};
+use std::rc::Rc;
+use std::sync::Arc;
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::task::{Context, Poll, Waker};
+
+use consensus::client_table::SessionAttachment;
+use consensus::{Consensus, MetadataHandle, PartitionsHandle};
+use iggy_binary_protocol::responses::messages::poll_messages::POLL_RESPONSE_HEADER_SIZE;
+use iggy_common::{DeferredPollOptions, IggyError, PollingKind, PollingStrategy};
+use journal::superblock::SuperblockStore;
+use message_bus::MessageBus;
+use metadata::impls::metadata::StreamsFrontend;
+use metadata::stm::stream::PollMetadata;
+use partitions::{
+    DISK_POLL_CHUNK_MAX, Partition, PollReadResult, PollVisibility, PollingArgs, PollingConsumer,
+};
+use server_common::poll::PollHistoryId;
+use server_common::sharding::IggyNamespace;
+
+use crate::config::DeferredPollConfig;
+use crate::metrics::DeferredPollMetrics;
+use crate::shards_table::ShardsTable;
+use crate::{IggyShard, PartitionReadReply, Sender};
+use prometheus_client::metrics::gauge::Gauge;
+
+const SERVICE_BUDGET: usize = 8;
+const READ_BUDGET_PARTS: usize = 3;
+/// The smallest part of a disk read reservation. One disk read can take a
+/// chunk of up to [`DISK_POLL_CHUNK_MAX`] whatever its reply size, so a
+/// smaller part would let polls with a small `max_bytes` start more reads than
+/// the budget can hold.
+#[allow(clippy::cast_possible_truncation)]
+const READ_PART_MIN_BYTES: usize = DISK_POLL_CHUNK_MAX as usize;
+const MAINTENANCE_BUDGET: usize = 16;
+
+/// Auxiliary connections are charged to their parent session. HTTP requests
+/// without a binary session are charged to their authenticated user.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub enum PollQuotaIdentity {
+    Session(u128),
+    User(u32),
+}
+
+/// The caller of a deferred request. A readiness wait shares one context
+/// across its targets.
+#[derive(Debug, Clone)]
+pub struct DeferredPollContext {
+    pub session: Option<SessionAttachment>,
+    pub user_id: u32,
+    pub user_created_at: Option<u64>,
+    pub quota: PollQuotaIdentity,
+    pub primary: bool,
+}
+
+/// A deferred poll as the origin resolved it.
+///
+/// The origin shard sets both deadlines in `partition_read` from the bus
+/// clock, which every shard of the process shares, before it routes the
+/// request to the owner. A request that skips `partition_read` keeps the
+/// zero deadlines of `new` and expires at once.
+#[derive(Debug)]
+pub struct DeferredPollRequest {
+    pub consumer: PollingConsumer,
+    pub args: PollingArgs,
+    pub metadata: PollMetadata,
+    pub context: DeferredPollContext,
+    pub options: DeferredPollOptions,
+    pub(crate) deadline: u64,
+    pub(crate) request_deadline: u64,
+}
+
+impl DeferredPollRequest {
+    #[must_use]
+    pub const fn new(
+        consumer: PollingConsumer,
+        args: PollingArgs,
+        metadata: PollMetadata,
+        context: DeferredPollContext,
+        options: DeferredPollOptions,
+    ) -> Self {
+        Self {
+            consumer,
+            args,
+            metadata,
+            context,
+            options,
+            deadline: 0,
+            request_deadline: 0,
+        }
+    }
+}
+
+/// One partition of a readiness wait, resolved and authorized by the origin.
+#[derive(Debug)]
+pub struct ReadinessTarget {
+    pub namespace: IggyNamespace,
+    pub partition_id: u32,
+    pub consumer: PollingConsumer,
+    /// `Offset` or `Next`, the start point of a poll with the same strategy.
+    pub strategy: PollingStrategy,
+    pub metadata: PollMetadata,
+}
+
+/// The targets of one readiness wait that a single owner shard holds. They
+/// name one stream and topic, so one check of `context` covers all of them.
+#[derive(Debug)]
+pub struct ReadinessWait {
+    pub context: DeferredPollContext,
+    pub targets: Vec<ReadinessTarget>,
+    pub deadline: u64,
+}
+
+/// A readiness waiter answers once. Its targets are sorted by namespace.
+/// `admitted` holds the history and view of each target at admission, and a
+/// change that an evaluation finds rejects the waiter.
+struct ReadinessWaiter {
+    wait: ReadinessWait,
+    admitted: Vec<(PollHistoryId, u32)>,
+    /// Namespaces that changed since the last probe. A commit makes only its
+    /// own target ready, so a probe evaluates these targets.
+    dirty: Vec<IggyNamespace>,
+    /// Maintenance evaluates every target in turn: `swept` targets of its
+    /// current pass are done, and the next probe evaluates `sweep` more.
+    swept: usize,
+    sweep: usize,
+    reply: Sender<PartitionReadReply>,
+}
+
+struct Waiter {
+    namespace: IggyNamespace,
+    request: DeferredPollRequest,
+    /// Admission turned this `Next` poll into an `Offset` poll that starts
+    /// after the stored offset.
+    next: bool,
+    reply: Sender<PartitionReadReply>,
+    history: PollHistoryId,
+    view: u32,
+    /// The visibility at the last read, `None` before the first read.
+    last_visibility: Option<PollVisibility>,
+    running: bool,
+    terminal: bool,
+    /// When a full read budget first refused this waiter. It keeps the
+    /// waiter's place in the read queue until one of its reads starts.
+    refused_at: Option<u64>,
+    /// At most what a read from the start through `last_visibility` selects.
+    selected: Selection,
+}
+
+impl Waiter {
+    /// At most what a read from the start selects at `visibility`. Offsets
+    /// are consecutive, so the offsets committed after the last read bound
+    /// the messages that a read can add, whatever the polling kind.
+    fn selection_at(&self, visibility: PollVisibility) -> Option<Selection> {
+        let last = self.last_visibility?;
+        let args = &self.request.args;
+        let mut from = last.offset.map_or(0, |offset| offset.saturating_add(1));
+        if args.kind == PollingKind::Offset {
+            from = from.max(args.value);
+        }
+        let messages = visibility
+            .offset
+            .map_or(0, |offset| offset.saturating_add(1).saturating_sub(from));
+        let bytes = visibility
+            .committed_bytes
+            .saturating_sub(last.committed_bytes);
+        Some(Selection {
+            messages: u32::try_from(messages).map_or(u32::MAX, |messages| {
+                self.selected.messages.saturating_add(messages)
+            }),
+            bytes: usize::try_from(bytes).map_or(usize::MAX, |bytes| {
+                self.selected.bytes.saturating_add(bytes)
+            }),
+        })
+    }
+
+    /// Whether a probe at `visibility` only parks the waiter again, because
+    /// its read runs or because a read from the start could not finish. In
+    /// the second case the waiter moves its bound to `visibility`: what was
+    /// committed after the last read bounds what a read adds.
+    fn parks_at(&mut self, visibility: PollVisibility, max_read_bytes: usize) -> bool {
+        if !self.running
+            && !self.terminal
+            && self.last_visibility != Some(visibility)
+            && let Some(selected) = self.selection_at(visibility)
+            && !selected.finishes_read(&self.request.options, max_read_bytes)
+        {
+            self.last_visibility = Some(visibility);
+            self.selected = selected;
+        }
+        self.running || (!self.terminal && self.last_visibility == Some(visibility))
+    }
+}
+
+#[derive(Clone, Copy, Default)]
+struct Selection {
+    messages: u32,
+    /// Counted as `PollReadResult::limit_bytes` counts them.
+    bytes: usize,
+}
+
+impl Selection {
+    /// The reasons of `accept_deferred_result` that do not depend on time. A
+    /// disk read that its walk budget would stop earlier is not predicted, and
+    /// it runs when one of these holds or when the wait expires.
+    fn finishes_read(self, options: &DeferredPollOptions, max_read_bytes: usize) -> bool {
+        self.messages >= options.min_count
+            || self.bytes >= reply_byte_limit(options, max_read_bytes)
+    }
+}
+
+/// The selection bytes that a reply to `options` keeps, counted as
+/// `PollReadResult::limit_bytes` counts them.
+fn reply_byte_limit(options: &DeferredPollOptions, max_read_bytes: usize) -> usize {
+    (options.max_bytes as usize)
+        .min(max_read_bytes)
+        .saturating_sub(POLL_RESPONSE_HEADER_SIZE)
+}
+
+/// Only a watched partition has a notifier, so only its commits mark it.
+#[derive(Default)]
+struct Signals {
+    queued: HashSet<IggyNamespace>,
+    dirty: VecDeque<IggyNamespace>,
+    waker: Option<Waker>,
+}
+
+impl Signals {
+    fn mark(&mut self, namespace: IggyNamespace) {
+        if self.queued.insert(namespace) {
+            self.dirty.push_back(namespace);
+            if let Some(waker) = self.waker.take() {
+                waker.wake();
+            }
+        }
+    }
+}
+
+#[derive(Default)]
+struct State {
+    next_id: u64,
+    waiters: BTreeMap<u64, Waiter>,
+    readiness: BTreeMap<u64, ReadinessWaiter>,
+    partitions: BTreeMap<IggyNamespace, BTreeSet<u64>>,
+    deadlines: BTreeSet<(u64, u64)>,
+    sessions: BTreeMap<PollQuotaIdentity, usize>,
+    ready: VecDeque<u64>,
+    queued: BTreeSet<u64>,
+    /// Parked waiters whose read the budget refused, by `(refused_at, id)`.
+    read_queue: BTreeSet<(u64, u64)>,
+    maintenance_cursor: u64,
+    closed: bool,
+}
+
+impl State {
+    fn enqueue(&mut self, id: u64) {
+        if (self.waiters.contains_key(&id) || self.readiness.contains_key(&id))
+            && self.queued.insert(id)
+        {
+            self.ready.push_back(id);
+        }
+    }
+
+    fn pending(&self) -> usize {
+        self.waiters.len() + self.readiness.len()
+    }
+
+    /// Returns a new waiter id if the registry and the session quota have room.
+    fn admit(
+        &mut self,
+        quota: PollQuotaIdentity,
+        config: &DeferredPollConfig,
+    ) -> Result<u64, IggyError> {
+        if self.closed {
+            return Err(IggyError::TransientNotAccepted);
+        }
+        if self.pending() >= config.max_pending
+            || self.sessions.get(&quota).copied().unwrap_or(0) >= config.max_pending_per_session
+        {
+            return Err(IggyError::DeferredPollCapacityExceeded);
+        }
+        let id = self
+            .next_id
+            .checked_add(1)
+            .ok_or(IggyError::TransientNotAccepted)?;
+        self.next_id = id;
+        Ok(id)
+    }
+
+    fn ids_after(&self, cursor: u64) -> Vec<u64> {
+        let after = (Excluded(cursor), Unbounded);
+        let mut ids: Vec<_> = self
+            .waiters
+            .range(after)
+            .map(|(id, _)| *id)
+            .take(MAINTENANCE_BUDGET)
+            .chain(
+                self.readiness
+                    .range(after)
+                    .map(|(id, _)| *id)
+                    .take(MAINTENANCE_BUDGET),
+            )
+            .collect();
+        ids.sort_unstable();
+        ids.truncate(MAINTENANCE_BUDGET);
+        ids
+    }
+}
+
+pub struct DeferredPolls {
+    metrics: DeferredPollMetrics,
+    config: RefCell<DeferredPollConfig>,
+    state: RefCell<State>,
+    signals: Rc<RefCell<Signals>>,
+    inflight_bytes: Arc<AtomicUsize>,
+}
+
+impl DeferredPolls {
+    pub(crate) fn new(metrics: DeferredPollMetrics) -> Self {
+        Self {
+            metrics,
+            config: RefCell::new(DeferredPollConfig::default()),
+            state: RefCell::new(State::default()),
+            signals: Rc::new(RefCell::new(Signals::default())),
+            inflight_bytes: Arc::new(AtomicUsize::new(0)),
+        }
+    }
+
+    /// The wait a request gets. A client wait only bounds it, so a wait above
+    /// this server's limit waits for the limit instead of failing.
+    pub(crate) fn wait_us(&self, wait_us: u64) -> u64 {
+        wait_us.min(self.config.borrow().max_wait_us)
+    }
+
+    pub(crate) fn earliest_deadline(&self) -> Option<u64> {
+        self.state
+            .borrow()
+            .deadlines
+            .first()
+            .map(|(deadline, _)| *deadline)
+    }
+
+    pub(crate) fn poll_ready(&self, context: &Context<'_>) -> Poll<()> {
+        let mut signals = self.signals.borrow_mut();
+        let state = self.state.borrow();
+        // Freed read room wakes nobody, so a refused read that can start
+        // keeps the pump turning.
+        if !signals.dirty.is_empty() || !state.ready.is_empty() || self.head_read_fits(&state) {
+            Poll::Ready(())
+        } else {
+            signals.waker = Some(context.waker().clone());
+            Poll::Pending
+        }
+    }
+
+    /// A tick probes polls and readiness targets up to `MAINTENANCE_BUDGET`,
+    /// so a large wait spreads over several ticks. The cursor stays before a
+    /// wait that the budget covers in part, and the next tick resumes it.
+    pub(crate) fn maintenance(&self) {
+        let mut state = self.state.borrow_mut();
+        let mut ids = state.ids_after(state.maintenance_cursor);
+        if ids.is_empty() {
+            ids = state.ids_after(0);
+        }
+        let mut budget = MAINTENANCE_BUDGET;
+        for id in ids {
+            if budget == 0 {
+                break;
+            }
+            let partial = if let Some(waiter) = state.readiness.get_mut(&id) {
+                let remaining = waiter
+                    .wait
+                    .targets
+                    .len()
+                    .saturating_sub(waiter.swept + waiter.sweep);
+                let sweep = remaining.min(budget);
+                waiter.sweep += sweep;
+                budget -= sweep;
+                sweep < remaining
+            } else {
+                budget -= 1;
+                false
+            };
+            state.enqueue(id);
+            if partial {
+                break;
+            }
+            state.maintenance_cursor = id;
+        }
+    }
+
+    pub(crate) const fn pump_guard(&self) -> PumpGuard<'_> {
+        PumpGuard(self)
+    }
+
+    pub(crate) fn close(&self) {
+        let mut state = self.state.borrow_mut();
+        state.closed = true;
+        for reply in state
+            .waiters
+            .values()
+            .map(|waiter| &waiter.reply)
+            .chain(state.readiness.values().map(|waiter| &waiter.reply))
+        {
+            let _ = reply.try_send(PartitionReadReply::Rejected(
+                IggyError::TransientNotAccepted,
+            ));
+        }
+        state.waiters.clear();
+        state.readiness.clear();
+        state.partitions.clear();
+        state.deadlines.clear();
+        state.sessions.clear();
+        state.ready.clear();
+        state.queued.clear();
+        state.read_queue.clear();
+        self.metrics.pending.set(0);
+        *self.signals.borrow_mut() = Signals::default();
+    }
+
+    /// The budget that a disk read of `options` reserves. Its snapshot, disk
+    /// selection and index loading can coexist, so it reserves one part as
+    /// large as its reply for each, at least [`READ_PART_MIN_BYTES`], up to
+    /// `max_read_bytes` in total.
+    fn read_bytes(&self, options: &DeferredPollOptions) -> usize {
+        (options.max_bytes as usize)
+            .max(READ_PART_MIN_BYTES)
+            .saturating_mul(READ_BUDGET_PARTS)
+            .min(self.config.borrow().max_read_bytes)
+    }
+
+    fn has_read_room(&self, bytes: usize) -> bool {
+        Self::with_read(
+            &self.config.borrow(),
+            self.inflight_bytes.load(Ordering::Relaxed),
+            bytes,
+        )
+        .is_some()
+    }
+
+    /// Whether the read at the head of the read queue fits the free budget.
+    /// Only that read can start, so a large one keeps the smaller reads
+    /// behind it waiting. A head without a waiter fits, so that the service
+    /// loop drops it instead of stalling the queue.
+    fn head_read_fits(&self, state: &State) -> bool {
+        state.read_queue.first().is_some_and(|(_, id)| {
+            state
+                .waiters
+                .get(id)
+                .is_none_or(|waiter| self.has_read_room(self.read_bytes(&waiter.request.options)))
+        })
+    }
+
+    /// Takes a waiter out of the read queue for a probe. Only a read that the
+    /// budget refuses again puts it back, at the same place.
+    fn take_read_place(&self, id: u64, waiter: &mut Waiter) -> Option<u64> {
+        let refused_at = waiter.refused_at.take()?;
+        self.state.borrow_mut().read_queue.remove(&(refused_at, id));
+        Some(refused_at)
+    }
+
+    /// Whether a read at `place` that reserves `bytes` can start now. A read
+    /// starts only ahead of every refused read, so that a waiter whose read
+    /// keeps coming back cannot take the budget from a refused one.
+    fn read_turn(&self, place: (u64, u64), bytes: usize) -> bool {
+        self.state
+            .borrow()
+            .read_queue
+            .first()
+            .is_none_or(|first| place < *first)
+            && self.has_read_room(bytes)
+    }
+
+    fn reserve_read_in_turn(&self, place: (u64, u64), bytes: usize) -> Option<ReadReservation> {
+        self.read_turn(place, bytes)
+            .then(|| self.reserve_read(bytes).ok())
+            .flatten()
+    }
+
+    /// Parks a waiter whose read found no room at `place` in the read queue.
+    fn queue_read(&self, id: u64, mut waiter: Waiter, place: (u64, u64)) {
+        waiter.refused_at = Some(place.0);
+        let mut state = self.state.borrow_mut();
+        state.read_queue.insert(place);
+        state.waiters.insert(id, waiter);
+    }
+
+    fn reserve_read(&self, bytes: usize) -> Result<ReadReservation, IggyError> {
+        let config = *self.config.borrow();
+        self.inflight_bytes
+            .try_update(Ordering::Relaxed, Ordering::Relaxed, |used| {
+                Self::with_read(&config, used, bytes)
+            })
+            .map_err(|_| IggyError::DeferredPollCapacityExceeded)?;
+        self.metrics
+            .inflight_bytes
+            .inc_by(i64::try_from(bytes).unwrap_or(i64::MAX));
+        Ok(ReadReservation {
+            gauge: self.metrics.inflight_bytes.clone(),
+            bytes,
+            used: Arc::clone(&self.inflight_bytes),
+        })
+    }
+
+    /// The in-flight read bytes after one more reservation of `bytes`, if
+    /// they fit.
+    fn with_read(config: &DeferredPollConfig, used: usize, bytes: usize) -> Option<usize> {
+        used.checked_add(bytes)
+            .filter(|next| *next <= config.max_inflight_bytes)
+    }
+
+    /// Returns the namespaces that no waiter watches any more.
+    fn remove(
+        &self,
+        id: u64,
+        deadlines: &[u64],
+        quota: PollQuotaIdentity,
+        namespaces: impl IntoIterator<Item = IggyNamespace>,
+    ) -> Vec<IggyNamespace> {
+        let mut state = self.state.borrow_mut();
+        for deadline in deadlines {
+            state.deadlines.remove(&(*deadline, id));
+        }
+        state.queued.remove(&id);
+        state.ready.retain(|queued| *queued != id);
+        state.read_queue.retain(|(_, queued)| *queued != id);
+        self.metrics.pending.dec();
+        if let Some(count) = state.sessions.get_mut(&quota) {
+            *count -= 1;
+            if *count == 0 {
+                state.sessions.remove(&quota);
+            }
+        }
+        let mut unwatched = Vec::new();
+        for namespace in namespaces {
+            let empty = state.partitions.get_mut(&namespace).is_some_and(|ids| {
+                ids.remove(&id);
+                ids.is_empty()
+            });
+            if empty {
+                state.partitions.remove(&namespace);
+                unwatched.push(namespace);
+            }
+        }
+        unwatched
+    }
+}
+
+pub struct PumpGuard<'a>(&'a DeferredPolls);
+
+impl Drop for PumpGuard<'_> {
+    fn drop(&mut self) {
+        self.0.close();
+    }
+}
+
+/// May cross the completion channel; it accounts for running and queued reads.
+pub struct ReadReservation {
+    gauge: Gauge,
+    bytes: usize,
+    used: Arc<AtomicUsize>,
+}
+
+impl Drop for ReadReservation {
+    fn drop(&mut self) {
+        self.used.fetch_sub(self.bytes, Ordering::Relaxed);
+        self.gauge
+            .dec_by(i64::try_from(self.bytes).unwrap_or(i64::MAX));
+    }
+}
+
+impl<B, MJ, S, M, T, SB> IggyShard<B, MJ, S, M, T, SB>
+where
+    B: MessageBus + 'static,
+    T: ShardsTable,
+    M: StreamsFrontend,
+    SB: SuperblockStore,
+{
+    pub fn set_deferred_poll_config(&self, config: DeferredPollConfig) {
+        *self.deferred_polls.config.borrow_mut() = config;
+    }
+
+    pub fn deferred_wait_us(&self, wait_us: u64) -> u64 {
+        self.deferred_polls.wait_us(wait_us)
+    }
+
+    fn validate_deferred_context(
+        &self,
+        namespace: IggyNamespace,
+        request: &DeferredPollRequest,
+    ) -> Result<(PollHistoryId, PollVisibility, u32), IggyError> {
+        self.validate_caller(namespace, &request.context)?;
+        self.validate_partition(
+            namespace,
+            request.context.primary,
+            &request.metadata,
+            request.consumer,
+        )
+    }
+
+    /// The checks of the caller, the same for every partition of one stream
+    /// and topic.
+    fn validate_caller(
+        &self,
+        namespace: IggyNamespace,
+        context: &DeferredPollContext,
+    ) -> Result<(), IggyError> {
+        if context
+            .session
+            .as_ref()
+            .is_some_and(|session| !session.is_valid())
+        {
+            return Err(IggyError::StaleClient);
+        }
+        if !self
+            .plane
+            .metadata()
+            .applied_frontier()
+            .partition_requests_allowed(self.bus.monotonic_now())
+        {
+            return Err(IggyError::TransientNotAccepted);
+        }
+        let metadata = &self.plane.metadata().mux_stm;
+        if context.user_created_at.is_some_and(|created_at| {
+            !metadata
+                .users()
+                .read(|users| users.is_active_account(context.user_id, created_at))
+        }) {
+            return Err(IggyError::Unauthenticated);
+        }
+        metadata.users().authorize(|permissioner| {
+            permissioner.poll_messages(context.user_id, namespace.stream_id(), namespace.topic_id())
+        })?;
+        Ok(())
+    }
+
+    /// The checks of one partition, after those of its caller.
+    fn validate_partition(
+        &self,
+        namespace: IggyNamespace,
+        primary: bool,
+        metadata: &PollMetadata,
+        consumer: PollingConsumer,
+    ) -> Result<(PollHistoryId, PollVisibility, u32), IggyError> {
+        if !metadata.is_valid(self.plane.metadata().mux_stm.streams(), namespace) {
+            return Err(IggyError::TransientNotAccepted);
+        }
+        self.plane
+            .partitions()
+            .with_partition(&namespace, |partition| {
+                let consensus = partition.consensus();
+                if partition.history_deleted() {
+                    return Err(IggyError::HistoryUnavailable);
+                }
+                if primary && consensus.is_normal_backup() {
+                    return Err(IggyError::NotPrimary);
+                }
+                if let PollingConsumer::ConsumerGroup(group, _) = consumer
+                    && let Some(error) = super::group_owner_rejection(
+                        namespace,
+                        group as u64,
+                        partition.consumer_group_owner(group as u64),
+                        metadata,
+                    )
+                {
+                    return Err(error);
+                }
+                if partition.fatal().is_some()
+                    || partition.read_history_is_changing()
+                    || (primary && !consensus.applied_view_start())
+                    || partition.requires_state_transfer()
+                    || !consensus.is_normal()
+                    || consensus.is_transferring()
+                    || (primary && !consensus.is_primary())
+                    || !metadata.matches_partition(self.shards_table.epoch_for(namespace))
+                {
+                    return Err(IggyError::TransientNotAccepted);
+                }
+                let (history, visibility) = partition.poll_visibility();
+                Ok((history, visibility, consensus.view()))
+            })
+            .ok_or(IggyError::TransientNotAccepted)?
+    }
+
+    /// The visibility of the partition, while its history and view are those
+    /// of the admission. A `Next` poll also needs the stored offset of its
+    /// admission: another session can store an offset while it waits, and a
+    /// commit of this poll would then pass over a rewind.
+    fn validate_waiter(&self, waiter: &Waiter) -> Result<PollVisibility, IggyError> {
+        let (history, visibility, view) =
+            self.validate_deferred_context(waiter.namespace, &waiter.request)?;
+        if history != waiter.history
+            || view != waiter.view
+            || (waiter.next
+                && self.next_offset(waiter.namespace, waiter.request.consumer)?
+                    != waiter.request.args.value)
+        {
+            return Err(IggyError::TransientNotAccepted);
+        }
+        Ok(visibility)
+    }
+
+    /// Where a `Next` poll of `consumer` starts: after its stored offset.
+    fn next_offset(
+        &self,
+        namespace: IggyNamespace,
+        consumer: PollingConsumer,
+    ) -> Result<u64, IggyError> {
+        self.plane
+            .partitions()
+            .with_partition(&namespace, |partition| {
+                partition.get_consumer_offset(consumer)
+            })
+            .ok_or(IggyError::TransientNotAccepted)?
+            .map_or(Ok(0), |offset| {
+                offset
+                    .checked_add(1)
+                    .ok_or(IggyError::InvalidOffset(offset))
+            })
+    }
+
+    /// The pump services the admitted waiter at the top of its next turn.
+    #[allow(clippy::too_many_lines)]
+    pub(super) fn admit_deferred_poll(
+        &self,
+        namespace: IggyNamespace,
+        mut request: DeferredPollRequest,
+        reply: &Sender<PartitionReadReply>,
+    ) {
+        if reply.is_disconnected() {
+            return;
+        }
+        let admission = (|| {
+            request.options.validate(request.args.count)?;
+            let (history, _, view) = self.validate_deferred_context(namespace, &request)?;
+            let config = *self.deferred_polls.config.borrow();
+            let mut state = self.deferred_polls.state.borrow_mut();
+            let id = state.admit(request.context.quota, &config)?;
+            let next = request.args.kind == PollingKind::Next;
+            if next {
+                request.args.kind = PollingKind::Offset;
+                request.args.value = self.next_offset(namespace, request.consumer)?;
+            }
+            self.register_waiter(
+                &mut state,
+                id,
+                (request.context.quota, request.deadline),
+                [namespace],
+            );
+            state.waiters.insert(
+                id,
+                Waiter {
+                    namespace,
+                    request,
+                    next,
+                    reply: reply.clone(),
+                    history,
+                    view,
+                    last_visibility: None,
+                    running: false,
+                    terminal: false,
+                    refused_at: None,
+                    selected: Selection::default(),
+                },
+            );
+            state.enqueue(id);
+            Ok(())
+        })();
+        if let Err(error) = admission {
+            self.metrics.deferred_polls.rejected.inc();
+            let _ = reply.try_send(PartitionReadReply::Rejected(error));
+        }
+    }
+
+    #[allow(clippy::future_not_send)]
+    pub(crate) async fn service_deferred_polls(&self) {
+        for _ in 0..SERVICE_BUDGET {
+            let now = self.bus.monotonic_micros();
+            let next = {
+                let mut state = self.deferred_polls.state.borrow_mut();
+                if let Some(&(deadline, id)) = state.deadlines.first()
+                    && deadline <= now
+                {
+                    Some(id)
+                } else if self.deferred_polls.head_read_fits(&state)
+                    && let Some((_, id)) = state.read_queue.pop_first()
+                {
+                    // Freed room goes to the read that the budget refused
+                    // first, ahead of the other wakes.
+                    Some(id)
+                } else {
+                    let namespace = {
+                        let mut signals = self.deferred_polls.signals.borrow_mut();
+                        let namespace = signals.dirty.pop_front();
+                        if let Some(namespace) = namespace {
+                            signals.queued.remove(&namespace);
+                        }
+                        namespace
+                    };
+                    if let Some(namespace) = namespace {
+                        // A commit queues a poll only if its history or view
+                        // changed or if its probe can read. One read of the
+                        // partition decides that for every poll. Each probe still
+                        // validates before its read and commit, and maintenance
+                        // and the deadline still probe the polls that stay parked.
+                        let current =
+                            self.plane
+                                .partitions()
+                                .with_partition(&namespace, |partition| {
+                                    let (history, visibility) = partition.poll_visibility();
+                                    (history, visibility, partition.consensus().view())
+                                });
+                        let max_read_bytes = self.deferred_polls.config.borrow().max_read_bytes;
+                        let ids: Vec<_> = state
+                            .partitions
+                            .get(&namespace)
+                            .into_iter()
+                            .flat_map(|ids| ids.iter().copied())
+                            .collect();
+                        for id in ids {
+                            if let Some((history, visibility, view)) = current
+                                && let Some(waiter) = state.waiters.get_mut(&id)
+                                && waiter.history == history
+                                && waiter.view == view
+                                && waiter.parks_at(visibility, max_read_bytes)
+                            {
+                                continue;
+                            }
+                            if let Some(waiter) = state.readiness.get_mut(&id)
+                                && !waiter.dirty.contains(&namespace)
+                            {
+                                waiter.dirty.push(namespace);
+                            }
+                            state.enqueue(id);
+                        }
+                    }
+                    let next = state.ready.pop_front();
+                    if let Some(id) = next {
+                        state.queued.remove(&id);
+                    }
+                    next
+                }
+            };
+            let Some(id) = next else { break };
+            let waiter = self.deferred_polls.state.borrow_mut().waiters.remove(&id);
+            if let Some(waiter) = waiter {
+                self.probe_deferred_poll(id, waiter).await;
+                continue;
+            }
+            let waiter = self.deferred_polls.state.borrow_mut().readiness.remove(&id);
+            if let Some(waiter) = waiter {
+                self.probe_readiness(id, waiter);
+            }
+        }
+    }
+
+    #[allow(clippy::future_not_send)]
+    async fn probe_deferred_poll(&self, id: u64, mut waiter: Waiter) {
+        let refused_at = self.deferred_polls.take_read_place(id, &mut waiter);
+        if waiter.reply.is_disconnected() {
+            self.metrics.deferred_polls.cancelled.inc();
+            self.remove_deferred_poll(id, &waiter);
+            return;
+        }
+        let visibility = match self.validate_waiter(&waiter) {
+            Ok(visibility) => visibility,
+            Err(error) => {
+                self.reject_deferred_poll(id, &waiter, error);
+                return;
+            }
+        };
+        if self.bus.monotonic_micros() >= waiter.request.request_deadline {
+            self.reject_deferred_poll(id, &waiter, IggyError::TransientNotCommitted);
+            return;
+        }
+        if self.bus.monotonic_micros() >= waiter.request.deadline {
+            waiter.terminal = true;
+            let mut state = self.deferred_polls.state.borrow_mut();
+            state.deadlines.remove(&(waiter.request.deadline, id));
+            state
+                .deadlines
+                .insert((waiter.request.request_deadline, id));
+        }
+        if waiter.parks_at(
+            visibility,
+            self.deferred_polls.config.borrow().max_read_bytes,
+        ) {
+            self.deferred_polls
+                .state
+                .borrow_mut()
+                .waiters
+                .insert(id, waiter);
+            return;
+        }
+        // A busy budget only delays the read. The waiter keeps its last
+        // visibility, so that its next probe does not skip the read. A read
+        // that the budget refused needed the disk, so until its turn it
+        // waits without building another plan.
+        let place = (
+            refused_at.unwrap_or_else(|| self.bus.monotonic_micros()),
+            id,
+        );
+        let read_bytes = self.deferred_polls.read_bytes(&waiter.request.options);
+        if refused_at.is_some() && !self.deferred_polls.read_turn(place, read_bytes) {
+            self.deferred_polls.queue_read(id, waiter, place);
+            return;
+        }
+        let Some(plan) = self.plane.partitions().build_poll_snapshot(
+            &waiter.namespace,
+            waiter.request.consumer,
+            &waiter.request.args,
+        ) else {
+            self.reject_deferred_poll(id, &waiter, IggyError::TransientNotAccepted);
+            return;
+        };
+        if !plan.needs_off_pump_io() {
+            waiter.last_visibility = Some(visibility);
+            let result = plan.execute_resident();
+            self.accept_deferred_result(id, waiter, result).await;
+            return;
+        }
+        let Some(reservation) = self.deferred_polls.reserve_read_in_turn(place, read_bytes) else {
+            self.deferred_polls.queue_read(id, waiter, place);
+            return;
+        };
+        // Snapshot, disk selection and index loading can coexist.
+        let max_bytes = reservation.bytes / READ_BUDGET_PARTS;
+        let max_selected_bytes = reply_byte_limit(
+            &waiter.request.options,
+            self.deferred_polls.config.borrow().max_read_bytes,
+        );
+        let completion =
+            match self
+                .poll_completions
+                .try_reserve_deferred(waiter.namespace, id, reservation)
+            {
+                Ok(completion) => completion,
+                Err(error) => {
+                    self.reject_deferred_poll(id, &waiter, error);
+                    return;
+                }
+            };
+        waiter.last_visibility = Some(visibility);
+        waiter.running = true;
+        self.deferred_polls
+            .state
+            .borrow_mut()
+            .waiters
+            .insert(id, waiter);
+        self.bus.spawn(async move {
+            completion.complete(plan.execute_with_limit(max_bytes, max_selected_bytes).await);
+        });
+    }
+
+    #[allow(clippy::future_not_send)]
+    pub(super) async fn on_deferred_poll_completed(&self, id: u64, result: PollReadResult) {
+        let waiter = self.deferred_polls.state.borrow_mut().waiters.remove(&id);
+        if let Some(mut waiter) = waiter {
+            waiter.running = false;
+            self.accept_deferred_result(id, waiter, result).await;
+        } else {
+            self.metrics.deferred_polls.late.inc();
+        }
+    }
+
+    #[allow(clippy::future_not_send)]
+    async fn accept_deferred_result(&self, id: u64, mut waiter: Waiter, result: PollReadResult) {
+        if waiter.reply.is_disconnected() {
+            self.metrics.deferred_polls.cancelled.inc();
+            self.remove_deferred_poll(id, &waiter);
+            return;
+        }
+        if self.bus.monotonic_micros() >= waiter.request.request_deadline {
+            self.reject_deferred_poll(id, &waiter, IggyError::TransientNotCommitted);
+            return;
+        }
+        let visibility = match self.validate_waiter(&waiter) {
+            Ok(visibility) => visibility,
+            Err(error) => {
+                self.reject_deferred_poll(id, &waiter, error);
+                return;
+            }
+        };
+        let result = match result.checked() {
+            Ok(result) => result,
+            Err(error) => {
+                self.reject_deferred_poll(id, &waiter, error);
+                return;
+            }
+        };
+        let max_read_bytes = self.deferred_polls.config.borrow().max_read_bytes;
+        let (mut result, byte_limited) =
+            match result.limit_bytes(waiter.request.options.max_bytes as usize, max_read_bytes) {
+                Ok(result) => result,
+                Err(error) => {
+                    self.reject_deferred_poll(id, &waiter, error);
+                    return;
+                }
+            };
+        if result.message_count() >= waiter.request.options.min_count
+            || byte_limited
+            || result.truncated()
+            || waiter.terminal
+            || self.bus.monotonic_micros() >= waiter.request.deadline
+        {
+            // Charge what the reply carries, not what the read walked over. A
+            // selection that borrows journal buffers can still pin more than
+            // the reservation, so copy it out instead of refusing a serveable
+            // poll.
+            if result.retained_bytes() > max_read_bytes {
+                result = result.compacted();
+            }
+            self.finish_deferred_poll(id, waiter, result).await;
+            return;
+        }
+        waiter.selected = Selection {
+            messages: result.message_count(),
+            bytes: result.encoded_bytes(),
+        };
+        let changed = waiter.last_visibility != Some(visibility);
+        let mut state = self.deferred_polls.state.borrow_mut();
+        state.waiters.insert(id, waiter);
+        if changed {
+            state.enqueue(id);
+        }
+    }
+
+    fn remove_deferred_poll(&self, id: u64, waiter: &Waiter) {
+        let request = &waiter.request;
+        self.unwatch_partitions(self.deferred_polls.remove(
+            id,
+            &[request.deadline, request.request_deadline],
+            request.context.quota,
+            [waiter.namespace],
+        ));
+    }
+
+    /// Counts the waiter toward its quota and makes a visibility change of
+    /// any of `namespaces` wake it.
+    fn register_waiter(
+        &self,
+        state: &mut State,
+        id: u64,
+        (quota, deadline): (PollQuotaIdentity, u64),
+        namespaces: impl IntoIterator<Item = IggyNamespace>,
+    ) {
+        self.metrics.deferred_polls.pending.inc();
+        *state.sessions.entry(quota).or_default() += 1;
+        state.deadlines.insert((deadline, id));
+        for namespace in namespaces {
+            state.partitions.entry(namespace).or_default().insert(id);
+            let signals = Rc::downgrade(&self.deferred_polls.signals);
+            if let Some(partition) = self.plane.partitions().get_mut_by_ns(&namespace) {
+                partition.set_poll_notifier(Some(Rc::new(move || {
+                    if let Some(signals) = signals.upgrade() {
+                        signals.borrow_mut().mark(namespace);
+                    }
+                })));
+            }
+        }
+    }
+
+    fn unwatch_partitions(&self, namespaces: Vec<IggyNamespace>) {
+        for namespace in namespaces {
+            if let Some(partition) = self.plane.partitions().get_mut_by_ns(&namespace) {
+                partition.set_poll_notifier(None);
+            }
+        }
+    }
+
+    fn reject_deferred_poll(&self, id: u64, waiter: &Waiter, error: IggyError) {
+        self.metrics.deferred_polls.failed.inc();
+        self.remove_deferred_poll(id, waiter);
+        let _ = waiter.reply.try_send(PartitionReadReply::Rejected(error));
+    }
+
+    #[allow(clippy::future_not_send)]
+    async fn finish_deferred_poll(&self, id: u64, waiter: Waiter, result: PollReadResult) {
+        if self.bus.monotonic_micros() >= waiter.request.request_deadline {
+            self.reject_deferred_poll(id, &waiter, IggyError::TransientNotCommitted);
+            return;
+        }
+        if let Err(error) = self.validate_waiter(&waiter) {
+            self.reject_deferred_poll(id, &waiter, error);
+            return;
+        }
+        self.remove_deferred_poll(id, &waiter);
+        if waiter.reply.is_disconnected() {
+            self.metrics.deferred_polls.cancelled.inc();
+            return;
+        }
+        let before_deadline = self.bus.monotonic_micros() < waiter.request.deadline;
+        if before_deadline {
+            self.metrics.deferred_polls.filled.inc();
+        } else {
+            self.metrics.deferred_polls.expired.inc();
+        }
+        self.accept_poll_result(waiter.namespace, result, waiter.reply)
+            .await;
+    }
+
+    /// Answers at once with the ready targets. If none is ready and the wait
+    /// did not expire, parks one waiter for all targets and answers with its
+    /// id, so that the origin can cancel it.
+    pub(crate) fn admit_readiness_wait(
+        &self,
+        wait: ReadinessWait,
+        reply: &Sender<PartitionReadReply>,
+    ) {
+        if reply.is_disconnected() {
+            return;
+        }
+        let answer = self
+            .park_readiness_wait(wait, reply)
+            .unwrap_or_else(|error| {
+                self.metrics.deferred_polls.rejected.inc();
+                PartitionReadReply::Rejected(error)
+            });
+        let _ = reply.try_send(answer);
+    }
+
+    pub(crate) fn cancel_readiness_wait(&self, id: u64) {
+        let waiter = self.deferred_polls.state.borrow_mut().readiness.remove(&id);
+        if let Some(waiter) = waiter {
+            self.metrics.deferred_polls.cancelled.inc();
+            self.remove_readiness_wait(id, &waiter);
+        }
+    }
+
+    fn park_readiness_wait(
+        &self,
+        mut wait: ReadinessWait,
+        reply: &Sender<PartitionReadReply>,
+    ) -> Result<PartitionReadReply, IggyError> {
+        let first = wait
+            .targets
+            .first()
+            .ok_or(IggyError::InvalidCommand)?
+            .namespace;
+        if wait.targets.iter().any(|target| {
+            target.namespace.stream_id() != first.stream_id()
+                || target.namespace.topic_id() != first.topic_id()
+        }) {
+            return Err(IggyError::InvalidCommand);
+        }
+        self.validate_caller(first, &wait.context)?;
+        // A probe finds the target of a changed namespace by binary search.
+        wait.targets.sort_unstable_by_key(|target| target.namespace);
+        let mut admitted = Vec::with_capacity(wait.targets.len());
+        let mut partitions = Vec::new();
+        for target in &wait.targets {
+            let (history, view, ready) = self.evaluate_readiness(wait.context.primary, target)?;
+            admitted.push((history, view));
+            if ready {
+                partitions.push(target.partition_id);
+            }
+        }
+        let expired = self.bus.monotonic_micros() >= wait.deadline;
+        if !partitions.is_empty() || expired {
+            if partitions.is_empty() {
+                self.metrics.deferred_polls.expired.inc();
+            } else {
+                self.metrics.deferred_polls.filled.inc();
+            }
+            return Ok(PartitionReadReply::Ready {
+                shard: self.id,
+                parked: None,
+                partitions,
+            });
+        }
+        let config = *self.deferred_polls.config.borrow();
+        let mut state = self.deferred_polls.state.borrow_mut();
+        let quota = wait.context.quota;
+        let id = state.admit(quota, &config)?;
+        self.register_waiter(
+            &mut state,
+            id,
+            (quota, wait.deadline),
+            wait.targets.iter().map(|target| target.namespace),
+        );
+        state.readiness.insert(
+            id,
+            ReadinessWaiter {
+                wait,
+                admitted,
+                dirty: Vec::new(),
+                swept: 0,
+                sweep: 0,
+                reply: reply.clone(),
+            },
+        );
+        Ok(PartitionReadReply::Ready {
+            shard: self.id,
+            parked: Some(id),
+            partitions,
+        })
+    }
+
+    /// Returns the history and view of the target, and whether a poll with its
+    /// strategy returns a message now. This is the empty-plan test of
+    /// `build_poll_plan`. The caller of the wait is checked before.
+    fn evaluate_readiness(
+        &self,
+        primary: bool,
+        target: &ReadinessTarget,
+    ) -> Result<(PollHistoryId, u32, bool), IggyError> {
+        let (history, visibility, view) =
+            self.validate_partition(target.namespace, primary, &target.metadata, target.consumer)?;
+        let start = match target.strategy.kind {
+            PollingKind::Offset => target.strategy.value,
+            PollingKind::Next => self.next_offset(target.namespace, target.consumer)?,
+            _ => return Err(IggyError::InvalidCommand),
+        };
+        Ok((
+            history,
+            view,
+            visibility.offset.is_some_and(|commit| start <= commit),
+        ))
+    }
+
+    fn probe_readiness(&self, id: u64, mut waiter: ReadinessWaiter) {
+        if waiter.reply.is_disconnected() {
+            self.metrics.deferred_polls.cancelled.inc();
+            self.remove_readiness_wait(id, &waiter);
+            return;
+        }
+        // The origin answers an expired wait itself.
+        if self.bus.monotonic_micros() >= waiter.wait.deadline {
+            self.metrics.deferred_polls.expired.inc();
+            self.remove_readiness_wait(id, &waiter);
+            return;
+        }
+        let targets = &waiter.wait.targets;
+        // A commit marks only its own namespace. The probe evaluates the
+        // targets of marked namespaces, also of those that the pump has not
+        // taken yet, and the targets that maintenance assigned. Maintenance
+        // reaches every other target in turn. A lookup per target keeps the
+        // cost of the marks that the shard still holds within the wait size.
+        let mut evaluated: Vec<usize> = {
+            let signals = self.deferred_polls.signals.borrow();
+            waiter
+                .dirty
+                .iter()
+                .filter_map(|namespace| {
+                    targets
+                        .binary_search_by_key(namespace, |target| target.namespace)
+                        .ok()
+                })
+                .chain(
+                    targets
+                        .iter()
+                        .enumerate()
+                        .filter(|(_, target)| signals.queued.contains(&target.namespace))
+                        .map(|(index, _)| index),
+                )
+                .chain(waiter.swept..waiter.swept + waiter.sweep)
+                .collect()
+        };
+        evaluated.sort_unstable();
+        evaluated.dedup();
+        let context = &waiter.wait.context;
+        let partitions = targets
+            .first()
+            .map_or(Ok(()), |first| {
+                self.validate_caller(first.namespace, context)
+            })
+            .and_then(|()| {
+                evaluated
+                    .iter()
+                    .filter_map(|index| targets.get(*index).zip(waiter.admitted.get(*index)))
+                    .filter_map(|(target, admitted)| {
+                        match self.evaluate_readiness(context.primary, target) {
+                            Ok((history, view, ready)) if (history, view) == *admitted => {
+                                ready.then_some(Ok(target.partition_id))
+                            }
+                            Ok(_) => Some(Err(IggyError::TransientNotAccepted)),
+                            Err(error) => Some(Err(error)),
+                        }
+                    })
+                    .collect::<Result<Vec<_>, _>>()
+            });
+        let answer = match partitions {
+            Ok(partitions) if partitions.is_empty() => {
+                waiter.dirty.clear();
+                waiter.swept += waiter.sweep;
+                waiter.sweep = 0;
+                if waiter.swept >= waiter.wait.targets.len() {
+                    waiter.swept = 0;
+                }
+                self.deferred_polls
+                    .state
+                    .borrow_mut()
+                    .readiness
+                    .insert(id, waiter);
+                return;
+            }
+            Ok(partitions) => {
+                self.metrics.deferred_polls.filled.inc();
+                PartitionReadReply::Ready {
+                    shard: self.id,
+                    parked: None,
+                    partitions,
+                }
+            }
+            Err(error) => {
+                self.metrics.deferred_polls.failed.inc();
+                PartitionReadReply::Rejected(error)
+            }
+        };
+        self.remove_readiness_wait(id, &waiter);
+        let _ = waiter.reply.try_send(answer);
+    }
+
+    fn remove_readiness_wait(&self, id: u64, waiter: &ReadinessWaiter) {
+        self.unwatch_partitions(self.deferred_polls.remove(
+            id,
+            &[waiter.wait.deadline],
+            waiter.wait.context.quota,
+            waiter.wait.targets.iter().map(|target| target.namespace),
+        ));
+    }
+}
+
+#[cfg(test)]
+#[path = "deferred_tests.rs"]
+mod tests;
diff --git a/core/shard/src/poll/deferred_tests.rs b/core/shard/src/poll/deferred_tests.rs
new file mode 100644
index 0000000..3a66f1a
--- /dev/null
+++ b/core/shard/src/poll/deferred_tests.rs
@@ -0,0 +1,1565 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#![allow(clippy::future_not_send)]
+
+use std::time::Duration;
+
+use super::*;
+use consensus::{ClientTable, PartitionsHandle, build_reply_message_with};
+use iggy_binary_protocol::requests::partitions::InstallConsumerGroupOwnerRequest;
+use iggy_binary_protocol::{Operation, PrepareHeader};
+use iggy_common::{
+    ConsumerGroupId, ConsumerKind, ConsumerOffset, DEFAULT_POLL_MAX_BYTES, IggyByteSize,
+    IggyTimestamp,
+};
+use metadata::IggyMetadata;
+use metadata::stm::consumer_group::{ConsumerGroup, ConsumerGroupMember};
+use metadata::stm::stream::{Partition as MetadataPartition, Stream, StreamsInner, Topic};
+use metadata::stm::user::Users;
+use partitions::IggyPartitions;
+use server_common::send_messages::decode_batch_slice;
+use server_common::sharding::{PartitionLocation, ShardId};
+
+use crate::poll::completion::PollCompletionLane;
+use crate::poll::test_support::{
+    PollTestMetadata, commit_messages, install_group_owner, partition_with_messages,
+};
+use crate::poll::timeout_tests::{PollTestBus, evict_messages_for_disk_dispatch};
+use crate::shards_table::PapayaShardsTable;
+use crate::{
+    LifecycleFrame, PARTITION_READ_TIMEOUT, PartitionConsensusConfig, Receiver, ReplicaTopology,
+    ShardFrame, ShardIdentity, channel, shard_channel,
+};
+
+const DEADLINE: u64 = 100;
+const REQUEST_DEADLINE: u64 = DEADLINE + iggy_common::MIN_DEFERRED_POLL_REPLY_MARGIN_US;
+const CLIENT: u128 = 41;
+/// The metadata op that installs the fixture's consumer group owners.
+const METADATA_OP: u64 = 1;
+fn namespace() -> IggyNamespace {
+    partition_namespace(0)
+}
+
+fn partition_namespace(partition: usize) -> IggyNamespace {
+    IggyNamespace::new(0, 0, partition)
+}
+type TestShard = IggyShard<PollTestBus, (), (), PollTestMetadata, PapayaShardsTable>;
+
+async fn owner(payloads: &[&str]) -> TestShard {
+    owner_of(&[payloads]).await
+}
+
+/// One owner shard with a partition for each entry of `partitions`.
+async fn owner_of(partitions: &[&[&str]]) -> TestShard {
+    let bus = PollTestBus::default();
+    let mut plane = None;
+    let mut topic = Topic::default();
+    let routes = PapayaShardsTable::new();
+    for (id, payloads) in partitions.iter().enumerate() {
+        let namespace = partition_namespace(id);
+        let (partition, config) = partition_with_messages(&bus, namespace, payloads).await;
+        plane
+            .get_or_insert_with(|| IggyPartitions::new(ShardId::new(0), config))
+            .insert(namespace, partition);
+        topic.partitions.push(MetadataPartition::new(
+            id,
+            namespace.inner(),
+            IggyTimestamp::default(),
+            1,
+            0,
+        ));
+        routes.insert(namespace, PartitionLocation::new(ShardId::new(0), 1));
+    }
+    let partitions_plane = plane.expect("fixture requires a partition");
+    let mut inner = StreamsInner::default();
+    let mut stream = Stream::default();
+    let mut group = ConsumerGroup::new(0, Arc::from("group"));
+    let mut member = ConsumerGroupMember::new(0, CLIENT);
+    member.session = Some(1);
+    group.members.insert(member);
+    group.rebalance_members(&topic.partitions, METADATA_OP, 1);
+    let config = partitions_plane.config().clone();
+    for id in 0..partitions.len() {
+        let assignment = &group.assignments[&id];
+        let pending = assignment.pending.unwrap();
+        let installation = InstallConsumerGroupOwnerRequest {
+            incarnation: assignment.incarnation,
+            group_id: 0,
+            owner: pending.owner,
+            metadata_op: pending.metadata_op,
+        };
+        let partition = partitions_plane
+            .get_mut_by_ns(&partition_namespace(id))
+            .unwrap();
+        let op = install_group_owner(partition, &config, installation).await;
+        assert!(group.complete_revocation(id, &installation, op));
+    }
+    topic.consumer_groups.insert(0, group);
+    stream.topics.insert(topic);
+    inner.items.insert(stream);
+    let users = Users::default();
+    users.ensure_root_user("iggy", "password-hash");
+    let metadata = IggyMetadata::new(
+        None,
+        None,
+        None,
+        None,
+        PollTestMetadata::new((users, (inner.into(), ()))),
+        None,
+    );
+    TestShard::without_inbox(
+        ShardIdentity::new(0, "deferred-test".to_string()),
+        bus.clone(),
+        metadata,
+        partitions_plane,
+        routes,
+        PartitionConsensusConfig::new(1, ReplicaTopology::new(0, 3), bus),
+    )
+}
+
+fn request(owner: &TestShard, count: u32, group: bool, auto_commit: bool) -> DeferredPollRequest {
+    let metadata = owner
+        .plane
+        .metadata()
+        .mux_stm
+        .streams()
+        .poll_metadata(namespace(), group.then_some(0), CLIENT)
+        .unwrap();
+    let mut request = DeferredPollRequest::new(
+        if group {
+            PollingConsumer::ConsumerGroup(0, 0)
+        } else {
+            PollingConsumer::Consumer(0, 0)
+        },
+        PollingArgs {
+            kind: PollingKind::Next,
+            value: 0,
+            count,
+            auto_commit,
+        },
+        metadata,
+        DeferredPollContext {
+            session: None,
+            user_id: 0,
+            user_created_at: None,
+            quota: PollQuotaIdentity::Session(CLIENT),
+            primary: false,
+        },
+        iggy_common::DeferredPollOptions {
+            max_wait: DEADLINE.into(),
+            min_count: count,
+            request_timeout: REQUEST_DEADLINE.into(),
+            ..Default::default()
+        },
+    );
+    request.deadline = DEADLINE;
+    request.request_deadline = REQUEST_DEADLINE;
+    request
+}
+
+async fn submit(owner: &TestShard, request: DeferredPollRequest) -> Receiver<PartitionReadReply> {
+    let (reply, replies) = channel(1);
+    owner.admit_deferred_poll(namespace(), request, &reply);
+    owner.service_deferred_polls().await;
+    replies
+}
+
+fn offsets(replies: &Receiver<PartitionReadReply>) -> Vec<u64> {
+    let PartitionReadReply::Poll { fragments, .. } = replies.try_recv().expect("poll completed")
+    else {
+        panic!("poll was rejected");
+    };
+    let bytes: Vec<u8> = fragments
+        .iter()
+        .flat_map(|fragment| fragment.as_slice().iter().copied())
+        .collect();
+    let mut bytes = bytes.as_slice();
+    let mut offsets = Vec::new();
+    while !bytes.is_empty() {
+        let batch = decode_batch_slice(bytes).unwrap();
+        offsets.extend(
+            batch
+                .iter()
+                .map(|message| batch.header.base_offset + u64::from(message.header.offset_delta)),
+        );
+        bytes = &bytes[batch.header.total_size()..];
+    }
+    offsets
+}
+
+fn pending(replies: &Receiver<PartitionReadReply>) {
+    assert!(matches!(
+        replies.try_recv(),
+        Err(crossfire::TryRecvError::Empty)
+    ));
+}
+
+/// What a disk read of the fixture's `request` reserves.
+fn read_bytes(owner: &TestShard) -> usize {
+    owner
+        .deferred_polls
+        .read_bytes(&request(owner, 1, false, false).options)
+}
+
+/// Fills the read budget with reservations the size of a fixture read, so
+/// that dropping one makes room for exactly one such read.
+fn hold_read_budget(owner: &TestShard) -> Vec<ReadReservation> {
+    let bytes = read_bytes(owner);
+    std::iter::from_fn(|| owner.deferred_polls.reserve_read(bytes).ok()).collect()
+}
+
+#[compio::test]
+async fn deferred_completion_capacity_is_distinct_from_a_closed_lane() {
+    let owner = owner(&["first"]).await;
+    let lane = PollCompletionLane::new(1, &owner.metrics);
+    let reserve = || {
+        owner
+            .deferred_polls
+            .reserve_read(read_bytes(&owner))
+            .unwrap()
+    };
+    let active = lane
+        .try_reserve_deferred(namespace(), 1, reserve())
+        .unwrap();
+    assert!(matches!(
+        lane.try_reserve_deferred(namespace(), 2, reserve()),
+        Err(IggyError::DeferredPollCapacityExceeded)
+    ));
+    lane.close();
+    assert!(matches!(
+        lane.try_reserve_deferred(namespace(), 3, reserve()),
+        Err(IggyError::TransientNotAccepted)
+    ));
+    drop(active);
+    assert_eq!(
+        owner.deferred_polls.inflight_bytes.load(Ordering::Relaxed),
+        0
+    );
+}
+
+#[compio::test]
+async fn resident_poll_completes_while_disk_reads_hold_the_whole_read_budget() {
+    let owner = owner(&["first", "second"]).await;
+    let held = hold_read_budget(&owner);
+    assert!(!held.is_empty());
+    assert!(matches!(
+        owner.deferred_polls.reserve_read(read_bytes(&owner)),
+        Err(IggyError::DeferredPollCapacityExceeded)
+    ));
+    let replies = submit(&owner, request(&owner, 2, false, false)).await;
+    assert_eq!(offsets(&replies), [0, 1]);
+}
+
+#[compio::test]
+async fn freed_read_budget_starts_refused_disk_reads_in_refusal_order() {
+    let owner = owner(&["first"]).await;
+    // One message is below `min_count`, so the older waiter parks after a
+    // resident read and needs a disk read once the journal drops the batch.
+    let older = submit(&owner, request(&owner, 2, false, false)).await;
+    pending(&older);
+    evict_messages_for_disk_dispatch(&owner, namespace());
+    let mut held = hold_read_budget(&owner);
+    let refused = submit(&owner, request(&owner, 2, false, false)).await;
+    pending(&refused);
+    assert_eq!(
+        owner.deferred_polls.state.borrow().read_queue,
+        BTreeSet::from([(0, 2)])
+    );
+
+    drop(held.pop());
+    owner.bus.now.set(DEADLINE);
+    owner.service_deferred_polls().await;
+    {
+        let state = owner.deferred_polls.state.borrow();
+        assert!(state.waiters[&2].running, "the read refused first starts");
+        assert!(
+            !state.waiters[&1].running,
+            "an older waiter queues behind a refused read"
+        );
+        assert_eq!(state.read_queue, BTreeSet::from([(DEADLINE, 1)]));
+        assert!(state.ready.is_empty(), "a full budget wakes nobody");
+    }
+
+    // Dropping the captured read frees its completion slot and its
+    // reservation, as a finished read does.
+    owner.bus.spawned_tasks.borrow_mut().clear();
+    assert!(
+        owner
+            .deferred_polls
+            .poll_ready(&Context::from_waker(Waker::noop()))
+            .is_ready(),
+        "freed room wakes the pump"
+    );
+    owner.service_deferred_polls().await;
+    let state = owner.deferred_polls.state.borrow();
+    assert!(state.waiters[&1].running, "freed room wakes the next read");
+    assert!(state.read_queue.is_empty());
+}
+
+#[compio::test]
+async fn freed_read_budget_starts_a_refused_read_ahead_of_other_wakes() {
+    let owner = owner(&["first"]).await;
+    evict_messages_for_disk_dispatch(&owner, namespace());
+    let mut held = hold_read_budget(&owner);
+    // Admitted first, these waits take the lower ids, so a maintenance wake
+    // queues them ahead of the refused read and fills a whole service turn.
+    let waits: Vec<_> = (0..SERVICE_BUDGET)
+        .map(|_| {
+            wait(
+                &owner,
+                vec![readiness_target(&owner, 0, PollingStrategy::offset(1))],
+            )
+        })
+        .collect();
+    let refused = submit(&owner, request(&owner, 2, false, false)).await;
+    let refused_id = u64::try_from(SERVICE_BUDGET).unwrap() + 1;
+    assert_eq!(
+        owner.deferred_polls.state.borrow().read_queue,
+        BTreeSet::from([(0, refused_id)])
+    );
+
+    drop(held.pop());
+    owner.deferred_polls.maintenance();
+    owner.service_deferred_polls().await;
+    pending(&refused);
+    let state = owner.deferred_polls.state.borrow();
+    assert!(
+        state.waiters[&refused_id].running,
+        "freed room goes to the refused read first"
+    );
+    assert!(state.read_queue.is_empty());
+    assert_eq!(state.readiness.len(), waits.len());
+}
+
+#[compio::test]
+async fn cancelled_refused_read_does_not_hold_back_later_reads() {
+    let owner = owner(&["first"]).await;
+    evict_messages_for_disk_dispatch(&owner, namespace());
+    let mut held = hold_read_budget(&owner);
+    let cancelled = submit(&owner, request(&owner, 2, false, false)).await;
+    let later = submit(&owner, request(&owner, 2, false, false)).await;
+    assert_eq!(owner.deferred_polls.state.borrow().read_queue.len(), 2);
+
+    drop(cancelled);
+    drop(held.pop());
+    owner.service_deferred_polls().await;
+    pending(&later);
+    let state = owner.deferred_polls.state.borrow();
+    assert!(!state.waiters.contains_key(&1));
+    assert!(state.waiters[&2].running);
+    assert!(state.read_queue.is_empty());
+}
+
+#[compio::test]
+async fn disk_reads_reserve_three_parts_of_their_max_bytes_or_one_chunk() {
+    // A small max_bytes must not let more reads start than chunk-sized parts fit.
+    for (max_bytes, part) in [
+        (DEFAULT_POLL_MAX_BYTES, DEFAULT_POLL_MAX_BYTES as usize),
+        (4 << 10, READ_PART_MIN_BYTES),
+    ] {
+        let mut owner = owner(&["first"]).await;
+        let per_read = READ_BUDGET_PARTS * part;
+        let reads = owner.deferred_polls.config.borrow().max_inflight_bytes / per_read;
+        owner.poll_completions = PollCompletionLane::new(reads + 1, &owner.metrics);
+        evict_messages_for_disk_dispatch(&owner, namespace());
+        let mut replies = Vec::with_capacity(reads + 1);
+        for _ in 0..=reads {
+            let mut request = request(&owner, 2, false, false);
+            request.options.max_bytes = max_bytes;
+            replies.push(submit(&owner, request).await);
+        }
+        replies.iter().for_each(pending);
+        let state = owner.deferred_polls.state.borrow();
+        assert_eq!(
+            state
+                .waiters
+                .values()
+                .filter(|waiter| waiter.running)
+                .count(),
+            reads,
+            "every read that fits the budget starts, max_bytes {max_bytes}"
+        );
+        assert_eq!(
+            state.read_queue.len(),
+            1,
+            "the read past the budget waits, max_bytes {max_bytes}"
+        );
+        assert_eq!(
+            owner.deferred_polls.inflight_bytes.load(Ordering::Relaxed),
+            reads * per_read,
+            "max_bytes {max_bytes}"
+        );
+    }
+}
+
+#[compio::test]
+async fn disk_read_reserves_at_most_max_read_bytes() {
+    let mut owner = owner(&["first"]).await;
+    let max_read_bytes = owner.deferred_polls.config.borrow().max_read_bytes;
+    let ceiling = u32::try_from(max_read_bytes.div_ceil(READ_BUDGET_PARTS)).unwrap();
+    let cases = [
+        (ceiling - 1, READ_BUDGET_PARTS * (ceiling - 1) as usize),
+        (ceiling, max_read_bytes),
+        (u32::MAX, max_read_bytes),
+    ];
+    owner.poll_completions = PollCompletionLane::new(cases.len(), &owner.metrics);
+    evict_messages_for_disk_dispatch(&owner, namespace());
+    let mut replies = Vec::with_capacity(cases.len());
+    for (max_bytes, reserved) in cases {
+        let before = owner.deferred_polls.inflight_bytes.load(Ordering::Relaxed);
+        let mut request = request(&owner, 2, false, false);
+        request.options.max_bytes = max_bytes;
+        replies.push(submit(&owner, request).await);
+        assert_eq!(
+            owner.deferred_polls.inflight_bytes.load(Ordering::Relaxed) - before,
+            reserved,
+            "max_bytes {max_bytes}"
+        );
+    }
+    replies.iter().for_each(pending);
+    let held = owner.deferred_polls.inflight_bytes.load(Ordering::Relaxed);
+    assert_eq!(
+        owner.deferred_polls.metrics.inflight_bytes.get(),
+        i64::try_from(held).unwrap()
+    );
+
+    // Dropping the captured reads releases each reservation by its own size.
+    owner.bus.spawned_tasks.borrow_mut().clear();
+    assert_eq!(
+        owner.deferred_polls.inflight_bytes.load(Ordering::Relaxed),
+        0
+    );
+    assert_eq!(owner.deferred_polls.metrics.inflight_bytes.get(), 0);
+}
+
+#[compio::test]
+async fn refused_large_read_keeps_smaller_reads_behind_it_waiting() {
+    let mut owner = owner(&["first"]).await;
+    owner.poll_completions = PollCompletionLane::new(2, &owner.metrics);
+    evict_messages_for_disk_dispatch(&owner, namespace());
+    let config = *owner.deferred_polls.config.borrow();
+    // Room for a fixture read, but not for one at `max_read_bytes`.
+    let held = owner
+        .deferred_polls
+        .reserve_read(config.max_inflight_bytes - read_bytes(&owner))
+        .unwrap();
+    let mut large = request(&owner, 2, false, false);
+    large.options.max_bytes = u32::try_from(config.max_read_bytes).unwrap();
+    let large = submit(&owner, large).await;
+    let small = submit(&owner, request(&owner, 2, false, false)).await;
+    {
+        let state = owner.deferred_polls.state.borrow();
+        assert_eq!(state.read_queue, BTreeSet::from([(0, 1), (0, 2)]));
+        assert!(
+            !state.waiters[&2].running,
+            "a smaller read that fits does not overtake the refused one"
+        );
+    }
+    let context = Context::from_waker(Waker::noop());
+    assert!(
+        owner.deferred_polls.poll_ready(&context).is_pending(),
+        "room for a smaller read wakes nobody"
+    );
+
+    drop(held);
+    assert!(
+        owner.deferred_polls.poll_ready(&context).is_ready(),
+        "room for the head read wakes the pump"
+    );
+    owner.service_deferred_polls().await;
+    pending(&large);
+    pending(&small);
+    let state = owner.deferred_polls.state.borrow();
+    assert!(state.waiters[&1].running);
+    assert!(state.waiters[&2].running);
+    assert!(state.read_queue.is_empty());
+}
+
+/// Parks `request` after a first disk read that selected only offset 0, with
+/// that message evicted from the journal.
+async fn parked_after_disk_read(
+    owner: &TestShard,
+    request: DeferredPollRequest,
+) -> Receiver<PartitionReadReply> {
+    let first_read = resident_read(owner, request.consumer, request.args.count);
+    evict_messages_for_disk_dispatch(owner, namespace());
+    let replies = submit(owner, request).await;
+    assert_eq!(owner.bus.spawned_tasks.borrow().len(), 1, "a disk read");
+    // Dropping the captured read frees its completion slot and reservation,
+    // and the result arrives as a read of the evicted message would.
+    owner.bus.spawned_tasks.borrow_mut().clear();
+    owner.on_deferred_poll_completed(1, first_read).await;
+    pending(&replies);
+    replies
+}
+
+fn resident_read(owner: &TestShard, consumer: PollingConsumer, count: u32) -> PollReadResult {
+    owner
+        .plane
+        .partitions()
+        .build_poll_snapshot(
+            &namespace(),
+            consumer,
+            &PollingArgs::new(PollingStrategy::offset(0), count, false),
+        )
+        .unwrap()
+        .execute_resident()
+}
+
+async fn commit(owner: &TestShard, payloads: &[&str], request: u64) {
+    let partitions = owner.plane.partitions();
+    let mut partition = partitions.remove(&namespace()).unwrap();
+    commit_messages(
+        &mut partition,
+        partitions.config(),
+        namespace(),
+        payloads,
+        (request, METADATA_OP),
+    )
+    .await;
+    partitions.insert(namespace(), partition);
+}
+
+/// Commits `payloads` and, like a persisted topic on one replica, can drop
+/// them from the journal in the same step.
+async fn commit_and_evict(owner: &TestShard, payloads: &[&str], request: u64, evict: bool) {
+    commit(owner, payloads, request).await;
+    if evict {
+        evict_messages_for_disk_dispatch(owner, namespace());
+    }
+}
+
+/// Routes timestamp polls to the disk, as segment bytes do.
+fn persist_segment_bytes(owner: &TestShard) {
+    let partitions = owner.plane.partitions();
+    let mut partition = partitions.remove(&namespace()).unwrap();
+    partition.log.active_segment_mut().size = IggyByteSize::from(1);
+    partitions.insert(namespace(), partition);
+}
+
+#[compio::test]
+async fn waiter_reads_from_disk_again_only_once_new_messages_can_reach_min_count() {
+    for evict in [false, true] {
+        let owner = owner(&["first"]).await;
+        let replies = parked_after_disk_read(&owner, request(&owner, 3, false, false)).await;
+
+        commit_and_evict(&owner, &["second"], 2, evict).await;
+        owner.service_deferred_polls().await;
+        pending(&replies);
+        assert!(
+            owner.bus.spawned_tasks.borrow().is_empty(),
+            "two of three messages start no read, evicted: {evict}"
+        );
+        assert_eq!(
+            owner.deferred_polls.state.borrow().waiters[&1]
+                .last_visibility
+                .map(|visibility| visibility.offset),
+            Some(Some(1))
+        );
+
+        commit_and_evict(&owner, &["third"], 3, evict).await;
+        owner.service_deferred_polls().await;
+        assert_eq!(
+            owner.bus.spawned_tasks.borrow().len(),
+            1,
+            "three messages start a read, evicted: {evict}"
+        );
+    }
+}
+
+#[compio::test]
+async fn timestamp_waiter_reads_again_only_once_new_messages_can_reach_min_count() {
+    let owner = owner(&["first"]).await;
+    let mut request = request(&owner, 3, false, false);
+    request.args = PollingArgs::new(PollingStrategy::timestamp(IggyTimestamp::from(0)), 3, false);
+    persist_segment_bytes(&owner);
+    let replies = parked_after_disk_read(&owner, request).await;
+
+    commit(&owner, &["second"], 2).await;
+    owner.service_deferred_polls().await;
+    pending(&replies);
+    assert!(
+        owner.bus.spawned_tasks.borrow().is_empty(),
+        "two of three messages start no read"
+    );
+
+    commit(&owner, &["third"], 3).await;
+    owner.service_deferred_polls().await;
+    assert_eq!(owner.bus.spawned_tasks.borrow().len(), 1);
+}
+
+#[compio::test]
+async fn waiter_below_min_count_reads_again_once_new_bytes_reach_the_byte_limit() {
+    // Payloads of one length keep every batch at `batch_bytes`.
+    let owner = owner(&["first"]).await;
+    let mut request = request(&owner, 100, false, false);
+    let batch_bytes = resident_read(&owner, request.consumer, 1).encoded_bytes();
+    request.options.max_bytes =
+        u32::try_from(POLL_RESPONSE_HEADER_SIZE + batch_bytes * 5 / 2).unwrap();
+    let replies = parked_after_disk_read(&owner, request).await;
+
+    commit(&owner, &["third"], 2).await;
+    owner.service_deferred_polls().await;
+    pending(&replies);
+    assert!(
+        owner.bus.spawned_tasks.borrow().is_empty(),
+        "two batches stay below the byte limit"
+    );
+
+    commit(&owner, &["fifth"], 3).await;
+    owner.service_deferred_polls().await;
+    assert_eq!(
+        owner.bus.spawned_tasks.borrow().len(),
+        1,
+        "three batches reach the byte limit before min_count"
+    );
+}
+
+#[compio::test]
+async fn commit_below_min_count_probes_no_parked_waiter() {
+    // One waiter more than a pump turn serves, so a probe of each would leave
+    // one queued.
+    const WAITERS: usize = SERVICE_BUDGET + 1;
+    for reach_min_count in [true, false] {
+        let owner = owner(&["first"]).await;
+        let mut waiters = Vec::with_capacity(WAITERS);
+        for _ in 0..WAITERS {
+            waiters.push(submit(&owner, request(&owner, 3, false, false)).await);
+        }
+
+        commit(&owner, &["second"], 2).await;
+        owner.service_deferred_polls().await;
+        waiters.iter().for_each(pending);
+        {
+            let state = owner.deferred_polls.state.borrow();
+            assert!(
+                state.ready.is_empty(),
+                "two of three messages probe no waiter"
+            );
+            assert!(
+                state.waiters.values().all(|waiter| {
+                    waiter.last_visibility.map(|visibility| visibility.offset) == Some(Some(1))
+                }),
+                "each waiter counts the second message"
+            );
+        }
+
+        if reach_min_count {
+            commit(&owner, &["third"], 3).await;
+        } else {
+            owner.bus.now.set(DEADLINE);
+        }
+        owner.service_deferred_polls().await;
+        owner.service_deferred_polls().await;
+        let expected: &[u64] = if reach_min_count { &[0, 1, 2] } else { &[0, 1] };
+        for replies in &waiters {
+            assert_eq!(
+                offsets(replies),
+                expected,
+                "min_count reached: {reach_min_count}"
+            );
+        }
+    }
+}
+
+#[compio::test]
+async fn replacement_history_rejects_parked_poll_on_its_next_signal() {
+    let owner = owner(&["old"]).await;
+    let replies = submit(&owner, request(&owner, 2, false, false)).await;
+    let (replacement, _) = partition_with_messages(&owner.bus, namespace(), &["new"]).await;
+    owner.plane.partitions().insert(namespace(), replacement);
+    // A partition publishes its visibility when it replaces its history.
+    owner.deferred_polls.signals.borrow_mut().mark(namespace());
+    owner.service_deferred_polls().await;
+    assert!(matches!(
+        replies.try_recv(),
+        Ok(PartitionReadReply::Rejected(
+            IggyError::TransientNotAccepted
+        ))
+    ));
+}
+
+#[compio::test]
+async fn read_budget_caps_a_resident_reply_after_its_first_message() {
+    let owner = owner(&["first", "second"]).await;
+    owner.set_deferred_poll_config(DeferredPollConfig {
+        max_read_bytes: 1,
+        ..Default::default()
+    });
+    let replies = submit(&owner, request(&owner, 2, false, false)).await;
+    assert_eq!(offsets(&replies), [0]);
+}
+
+#[compio::test]
+async fn full_result_completes_before_deadline() {
+    let owner = owner(&["first", "second"]).await;
+    let replies = submit(&owner, request(&owner, 2, false, false)).await;
+    assert_eq!(offsets(&replies), [0, 1]);
+    assert!(owner.deferred_polls.state.borrow().waiters.is_empty());
+    assert_eq!(
+        owner.deferred_polls.inflight_bytes.load(Ordering::Relaxed),
+        0
+    );
+}
+
+#[compio::test]
+async fn partial_result_waits_until_deadline_without_progress() {
+    for group in [false, true] {
+        let owner = owner(&["first", "second"]).await;
+        let request = request(&owner, 3, group, false);
+        let consumer = request.consumer;
+        let replies = submit(&owner, request).await;
+        pending(&replies);
+        assert_eq!(
+            owner
+                .plane
+                .partitions()
+                .consumer_offset_read(&namespace(), consumer)
+                .unwrap()
+                .0,
+            None
+        );
+        owner.bus.now.set(DEADLINE - 1);
+        owner.service_deferred_polls().await;
+        pending(&replies);
+        owner.bus.now.set(DEADLINE);
+        owner.service_deferred_polls().await;
+        assert_eq!(offsets(&replies), [0, 1]);
+        assert!(
+            owner.bus.spawned_tasks.borrow().is_empty(),
+            "resident expiry does not require IO"
+        );
+        assert!(owner.deferred_polls.state.borrow().deadlines.is_empty());
+    }
+}
+
+#[compio::test]
+async fn readiness_expiry_returns_committed_data_even_after_the_wait() {
+    for arrival in [DEADLINE - 1, DEADLINE, DEADLINE + 1] {
+        let owner = owner(&["first"]).await;
+        owner
+            .plane
+            .partitions()
+            .get_mut_by_ns(&namespace())
+            .unwrap()
+            .set_offset_space_used(false);
+        let replies = submit(&owner, request(&owner, 1, false, true)).await;
+        pending(&replies);
+        owner.bus.now.set(arrival);
+        owner
+            .plane
+            .partitions()
+            .get_mut_by_ns(&namespace())
+            .unwrap()
+            .set_offset_space_used(true);
+        owner.service_deferred_polls().await;
+        assert_eq!(offsets(&replies), vec![0]);
+    }
+}
+
+#[compio::test]
+async fn expired_or_cancelled_waiter_discards_queued_read_without_progress() {
+    for cancel in [false, true] {
+        let owner = owner(&["first"]).await;
+        let request = request(&owner, 2, false, true);
+        let consumer = request.consumer;
+        let replies = submit(&owner, request).await;
+        let id = *owner
+            .deferred_polls
+            .state
+            .borrow()
+            .waiters
+            .keys()
+            .next()
+            .unwrap();
+        let result = owner
+            .plane
+            .partitions()
+            .build_poll_snapshot(
+                &namespace(),
+                consumer,
+                &PollingArgs {
+                    kind: PollingKind::First,
+                    value: 0,
+                    count: 1,
+                    auto_commit: true,
+                },
+            )
+            .unwrap()
+            .execute_resident();
+        // A read dispatched before expiry still owns its byte reservation after cancellation.
+        let reservation = owner
+            .deferred_polls
+            .reserve_read(read_bytes(&owner))
+            .unwrap();
+        owner
+            .poll_completions
+            .try_reserve_deferred(namespace(), id, reservation)
+            .unwrap()
+            .complete(result);
+        owner.bus.now.set(DEADLINE);
+        if cancel {
+            drop(replies);
+        } else {
+            owner.service_deferred_polls().await;
+            assert_eq!(offsets(&replies), [0]);
+        }
+        assert!(owner.deferred_polls.inflight_bytes.load(Ordering::Relaxed) > 0);
+        let completion = owner.poll_completions.try_recv().unwrap();
+        assert!(owner.deferred_polls.inflight_bytes.load(Ordering::Relaxed) > 0);
+        owner.on_poll_completed(*completion).await;
+        assert!(owner.deferred_polls.state.borrow().waiters.is_empty());
+        assert_eq!(
+            owner.deferred_polls.inflight_bytes.load(Ordering::Relaxed),
+            0
+        );
+        if cancel {
+            assert_eq!(
+                owner
+                    .plane
+                    .partitions()
+                    .consumer_offset_read(&namespace(), consumer)
+                    .unwrap()
+                    .0,
+                None
+            );
+        }
+    }
+}
+
+#[compio::test]
+async fn replacement_history_invalidates_pending_manual_poll() {
+    let owner = owner(&["old"]).await;
+    let replies = submit(&owner, request(&owner, 2, false, false)).await;
+    let (replacement, _) = partition_with_messages(&owner.bus, namespace(), &["new"]).await;
+    owner.plane.partitions().insert(namespace(), replacement);
+    owner.bus.now.set(DEADLINE);
+    owner.service_deferred_polls().await;
+    assert!(matches!(
+        replies.try_recv(),
+        Ok(PartitionReadReply::Rejected(
+            IggyError::TransientNotAccepted
+        ))
+    ));
+}
+
+/// Applies an explicit offset store of another session. Unlike the commit of
+/// a poll, it can move the offset back.
+fn store_offset(owner: &TestShard, consumer: PollingConsumer, offset: u64) {
+    owner
+        .plane
+        .partitions()
+        .with_partition(&namespace(), |partition| match consumer {
+            PollingConsumer::Consumer(id, _) => {
+                let stored = ConsumerOffset::new(
+                    ConsumerKind::Consumer,
+                    u32::try_from(id).unwrap(),
+                    offset,
+                    String::new(),
+                );
+                partition.consumer_offsets.pin().insert(id, stored);
+            }
+            PollingConsumer::ConsumerGroup(id, _) => {
+                let stored = ConsumerOffset::new(
+                    ConsumerKind::ConsumerGroup,
+                    u32::try_from(id).unwrap(),
+                    offset,
+                    String::new(),
+                );
+                partition
+                    .consumer_group_offsets
+                    .pin()
+                    .insert(ConsumerGroupId(id), stored);
+            }
+        })
+        .unwrap();
+}
+
+fn stored_offset(owner: &TestShard, consumer: PollingConsumer) -> Option<u64> {
+    owner
+        .plane
+        .partitions()
+        .consumer_offset_read(&namespace(), consumer)
+        .unwrap()
+        .0
+}
+
+#[compio::test]
+async fn offset_that_another_session_stores_rejects_parked_next_poll() {
+    // A rewind asks for messages again, and a store ahead marks them read.
+    for stored in [0, 2] {
+        for group in [false, true] {
+            for exit in ["message", "deadline"] {
+                let owner = owner(&["a", "b", "c", "d"]).await;
+                let poll = request(&owner, 3, group, true);
+                let consumer = poll.consumer;
+                store_offset(&owner, consumer, 1);
+                // Two of the three messages it needs, so it parks at offset 2.
+                let replies = submit(&owner, poll).await;
+                pending(&replies);
+
+                store_offset(&owner, consumer, stored);
+                if exit == "message" {
+                    commit(&owner, &["e"], 2).await;
+                } else {
+                    owner.bus.now.set(DEADLINE);
+                }
+                owner.service_deferred_polls().await;
+
+                let reply = replies.try_recv();
+                assert!(
+                    matches!(
+                        reply,
+                        Ok(PartitionReadReply::Rejected(
+                            IggyError::TransientNotAccepted
+                        ))
+                    ),
+                    "a read from offset 2 passes over the store of {stored}, exit {exit}: {reply:?}"
+                );
+                assert_eq!(
+                    stored_offset(&owner, consumer),
+                    Some(stored),
+                    "the refused poll commits nothing"
+                );
+                assert!(owner.deferred_polls.state.borrow().waiters.is_empty());
+                if stored == 0 {
+                    assert_eq!(
+                        offsets(&submit(&owner, request(&owner, 3, group, true)).await),
+                        [1, 2, 3],
+                        "the poll again reads the rewound messages"
+                    );
+                }
+            }
+        }
+    }
+}
+
+#[compio::test]
+async fn offset_that_another_session_stores_again_lets_parked_next_poll_finish() {
+    let owner = owner(&["a", "b", "c", "d"]).await;
+    let poll = request(&owner, 3, false, true);
+    let consumer = poll.consumer;
+    store_offset(&owner, consumer, 1);
+    let replies = submit(&owner, poll).await;
+    pending(&replies);
+
+    store_offset(&owner, consumer, 1);
+    commit(&owner, &["e"], 2).await;
+    owner.service_deferred_polls().await;
+
+    assert_eq!(offsets(&replies), [2, 3, 4]);
+    assert_eq!(stored_offset(&owner, consumer), Some(4));
+}
+
+#[compio::test]
+async fn full_shard_quota_rejects_another_session_without_poll_progress() {
+    let owner = owner(&["first"]).await;
+    owner.set_deferred_poll_config(DeferredPollConfig {
+        max_pending: 1,
+        max_pending_per_session: 1,
+        ..Default::default()
+    });
+    let first = submit(&owner, request(&owner, 2, false, false)).await;
+    let mut other = request(&owner, 2, false, true);
+    other.context.quota = PollQuotaIdentity::Session(CLIENT + 1);
+    let consumer = other.consumer;
+    let refused = submit(&owner, other).await;
+    assert!(matches!(
+        refused.try_recv(),
+        Ok(PartitionReadReply::Rejected(
+            IggyError::DeferredPollCapacityExceeded
+        ))
+    ));
+    assert_eq!(
+        owner
+            .plane
+            .partitions()
+            .consumer_offset_read(&namespace(), consumer)
+            .unwrap()
+            .0,
+        None
+    );
+    drop(first);
+    owner.deferred_polls.maintenance();
+    owner.service_deferred_polls().await;
+    assert_released(&owner);
+}
+
+#[compio::test]
+async fn session_quota_and_cancellation_release_all_indices() {
+    let owner = owner(&["first"]).await;
+    owner.set_deferred_poll_config(DeferredPollConfig {
+        max_pending_per_session: 1,
+        ..Default::default()
+    });
+    let first = submit(&owner, request(&owner, 2, false, false)).await;
+    let second = submit(&owner, request(&owner, 2, false, false)).await;
+    assert!(matches!(
+        second.try_recv(),
+        Ok(PartitionReadReply::Rejected(
+            IggyError::DeferredPollCapacityExceeded
+        ))
+    ));
+    drop(first);
+    owner.deferred_polls.maintenance();
+    owner.service_deferred_polls().await;
+    let state = owner.deferred_polls.state.borrow();
+    assert!(state.waiters.is_empty());
+    assert!(state.sessions.is_empty());
+    assert!(state.partitions.is_empty());
+    assert!(state.deadlines.is_empty());
+    assert!(state.ready.is_empty());
+}
+
+#[compio::test]
+async fn readiness_minimum_is_independent_of_batch_count() {
+    let owner = owner(&["available"]).await;
+    let mut request = request(&owner, 100, false, false);
+    request.options.min_count = 1;
+    let replies = submit(&owner, request).await;
+    assert_eq!(offsets(&replies), [0]);
+}
+
+#[compio::test]
+async fn read_crossing_readiness_deadline_finishes_within_request_budget() {
+    for completed_at in [DEADLINE + 1, REQUEST_DEADLINE] {
+        let owner = owner(&["read-from-snapshot"]).await;
+        let request = request(&owner, 2, false, true);
+        let consumer = request.consumer;
+        let replies = submit(&owner, request).await;
+        let id = *owner
+            .deferred_polls
+            .state
+            .borrow()
+            .waiters
+            .keys()
+            .next()
+            .unwrap();
+        owner
+            .deferred_polls
+            .state
+            .borrow_mut()
+            .waiters
+            .get_mut(&id)
+            .unwrap()
+            .running = true;
+        let result = owner
+            .plane
+            .partitions()
+            .build_poll_snapshot(
+                &namespace(),
+                consumer,
+                &PollingArgs {
+                    kind: PollingKind::First,
+                    value: 0,
+                    count: 1,
+                    auto_commit: true,
+                },
+            )
+            .unwrap()
+            .execute_resident();
+        let reservation = owner
+            .deferred_polls
+            .reserve_read(read_bytes(&owner))
+            .unwrap();
+        let completion = owner
+            .poll_completions
+            .try_reserve_deferred(namespace(), id, reservation)
+            .unwrap();
+        owner.bus.now.set(DEADLINE);
+        owner.service_deferred_polls().await;
+        pending(&replies);
+        owner.bus.now.set(completed_at);
+        completion.complete(result);
+        let completion = owner.poll_completions.try_recv().unwrap();
+        owner.on_poll_completed(*completion).await;
+        if completed_at < REQUEST_DEADLINE {
+            assert_eq!(offsets(&replies), [0]);
+        } else {
+            assert!(matches!(
+                replies.try_recv(),
+                Ok(PartitionReadReply::Rejected(
+                    IggyError::TransientNotCommitted
+                ))
+            ));
+            assert_eq!(
+                owner
+                    .plane
+                    .partitions()
+                    .consumer_offset_read(&namespace(), consumer)
+                    .unwrap()
+                    .0,
+                None
+            );
+        }
+        assert!(owner.deferred_polls.state.borrow().waiters.is_empty());
+        assert_eq!(
+            owner.deferred_polls.inflight_bytes.load(Ordering::Relaxed),
+            0
+        );
+    }
+}
+
+#[compio::test]
+async fn poll_admission_is_independent_of_resident_journal_size() {
+    // 1.5 MiB resident, one message polled: the read budget bounds the reply,
+    // not how much the partition holds.
+    let payload = "x".repeat(768 * 1024);
+    let owner = owner(&[payload.as_str(); 2]).await;
+    let replies = submit(&owner, request(&owner, 1, false, false)).await;
+    assert_eq!(offsets(&replies), [0]);
+}
+
+fn readiness_target(
+    owner: &TestShard,
+    partition: usize,
+    strategy: PollingStrategy,
+) -> ReadinessTarget {
+    let namespace = partition_namespace(partition);
+    let metadata = owner
+        .plane
+        .metadata()
+        .mux_stm
+        .streams()
+        .poll_metadata(namespace, None, CLIENT)
+        .unwrap();
+    ReadinessTarget {
+        namespace,
+        partition_id: u32::try_from(partition).unwrap(),
+        consumer: PollingConsumer::Consumer(0, partition),
+        strategy,
+        metadata,
+    }
+}
+
+fn readiness_caller() -> DeferredPollContext {
+    DeferredPollContext {
+        session: None,
+        user_id: 0,
+        user_created_at: None,
+        quota: PollQuotaIdentity::Session(CLIENT),
+        primary: true,
+    }
+}
+
+fn wait(owner: &TestShard, targets: Vec<ReadinessTarget>) -> Receiver<PartitionReadReply> {
+    let (reply, replies) = channel(2);
+    owner.admit_readiness_wait(
+        ReadinessWait {
+            context: readiness_caller(),
+            targets,
+            deadline: DEADLINE,
+        },
+        &reply,
+    );
+    replies
+}
+
+/// The parked waiter and the ready partitions of the next answer.
+fn answer(replies: &Receiver<PartitionReadReply>) -> (Option<u64>, Vec<u32>) {
+    let Ok(PartitionReadReply::Ready {
+        shard: 0,
+        parked,
+        partitions,
+    }) = replies.try_recv()
+    else {
+        panic!("expected a readiness answer from shard 0");
+    };
+    (parked, partitions)
+}
+
+fn set_committed(owner: &TestShard, partition: usize, committed: bool) {
+    owner
+        .plane
+        .partitions()
+        .get_mut_by_ns(&partition_namespace(partition))
+        .unwrap()
+        .set_offset_space_used(committed);
+}
+
+fn assert_released(owner: &TestShard) {
+    let state = owner.deferred_polls.state.borrow();
+    assert!(state.readiness.is_empty());
+    assert!(state.sessions.is_empty());
+    assert!(state.partitions.is_empty());
+    assert!(state.deadlines.is_empty());
+    assert!(state.ready.is_empty());
+    assert!(state.read_queue.is_empty());
+}
+
+#[compio::test]
+async fn readiness_answers_ready_targets_at_admission_without_parking() {
+    let owner = owner_of(&[&["first"], &["first", "second"]]).await;
+    let replies = wait(
+        &owner,
+        vec![
+            readiness_target(&owner, 0, PollingStrategy::offset(1)),
+            readiness_target(&owner, 1, PollingStrategy::offset(1)),
+        ],
+    );
+    assert_eq!(answer(&replies), (None, vec![1]));
+    assert_released(&owner);
+}
+
+#[compio::test]
+async fn expired_readiness_answers_at_admission_without_parking() {
+    let owner = owner(&["first"]).await;
+    owner.bus.now.set(DEADLINE);
+    let replies = wait(
+        &owner,
+        vec![readiness_target(&owner, 0, PollingStrategy::offset(1))],
+    );
+    assert_eq!(answer(&replies), (None, Vec::new()));
+    assert_released(&owner);
+}
+
+#[compio::test]
+async fn parked_readiness_reports_every_ready_target_once() {
+    let owner = owner_of(&[&["first"], &["first"]]).await;
+    set_committed(&owner, 0, false);
+    set_committed(&owner, 1, false);
+    let replies = wait(
+        &owner,
+        vec![
+            readiness_target(&owner, 0, PollingStrategy::offset(0)),
+            readiness_target(&owner, 1, PollingStrategy::offset(0)),
+        ],
+    );
+    let (parked, partitions) = answer(&replies);
+    assert!(parked.is_some() && partitions.is_empty());
+    owner.service_deferred_polls().await;
+    pending(&replies);
+    set_committed(&owner, 0, true);
+    set_committed(&owner, 1, true);
+    owner.service_deferred_polls().await;
+    assert_eq!(answer(&replies), (None, vec![0, 1]));
+    assert!(
+        matches!(
+            replies.try_recv(),
+            Err(crossfire::TryRecvError::Disconnected)
+        ),
+        "a waiter answers once"
+    );
+    assert_released(&owner);
+}
+
+#[compio::test]
+async fn next_readiness_starts_after_the_stored_offset() {
+    let owner = owner(&["first"]).await;
+    let before = wait(
+        &owner,
+        vec![readiness_target(&owner, 0, PollingStrategy::next())],
+    );
+    assert_eq!(answer(&before), (None, vec![0]));
+    assert_eq!(
+        offsets(&submit(&owner, request(&owner, 1, false, true)).await),
+        [0]
+    );
+    let after = wait(
+        &owner,
+        vec![readiness_target(&owner, 0, PollingStrategy::next())],
+    );
+    let (parked, partitions) = answer(&after);
+    assert!(parked.is_some() && partitions.is_empty());
+}
+
+#[compio::test]
+async fn readiness_waiter_leaves_on_cancel_disconnect_or_deadline() {
+    for exit in ["cancel", "disconnect", "deadline"] {
+        let owner = owner(&["first"]).await;
+        let replies = wait(
+            &owner,
+            vec![readiness_target(&owner, 0, PollingStrategy::offset(1))],
+        );
+        let (Some(id), _) = answer(&replies) else {
+            panic!("nothing is ready, so the waiter parks");
+        };
+        match exit {
+            "cancel" => owner.cancel_readiness_wait(id),
+            "disconnect" => {
+                drop(replies);
+                owner.deferred_polls.maintenance();
+                owner.service_deferred_polls().await;
+            }
+            _ => {
+                owner.bus.now.set(DEADLINE);
+                owner.service_deferred_polls().await;
+                assert!(
+                    matches!(
+                        replies.try_recv(),
+                        Err(crossfire::TryRecvError::Disconnected)
+                    ),
+                    "the origin answers an expired wait"
+                );
+            }
+        }
+        assert_released(&owner);
+    }
+}
+
+#[compio::test]
+async fn replacement_history_rejects_parked_readiness() {
+    let owner = owner(&["old"]).await;
+    let replies = wait(
+        &owner,
+        vec![readiness_target(&owner, 0, PollingStrategy::offset(1))],
+    );
+    assert!(answer(&replies).0.is_some());
+    let (replacement, _) =
+        partition_with_messages(&owner.bus, namespace(), &["new", "newer"]).await;
+    owner.plane.partitions().insert(namespace(), replacement);
+    owner.deferred_polls.maintenance();
+    owner.service_deferred_polls().await;
+    assert!(matches!(
+        replies.try_recv(),
+        Ok(PartitionReadReply::Rejected(
+            IggyError::TransientNotAccepted
+        ))
+    ));
+    assert_released(&owner);
+}
+
+/// An owner whose partitions each hold one message, and a parked wait for
+/// the next message of every partition.
+async fn parked_wait_on(partitions: usize) -> (TestShard, Receiver<PartitionReadReply>) {
+    let owner = owner_of(&vec![&["first"][..]; partitions]).await;
+    let targets = (0..partitions)
+        .map(|partition| readiness_target(&owner, partition, PollingStrategy::offset(1)))
+        .collect();
+    let replies = wait(&owner, targets);
+    assert!(answer(&replies).0.is_some());
+    (owner, replies)
+}
+
+/// The partition refuses reads but marks nothing, so only an evaluation of
+/// its target finds it.
+fn begin_state_transfer(owner: &TestShard, partition: usize) {
+    owner
+        .plane
+        .partitions()
+        .with_partition(&partition_namespace(partition), |partition| {
+            partition.consensus().begin_state_transfer_await();
+        })
+        .unwrap();
+}
+
+#[compio::test]
+async fn commit_evaluates_only_the_readiness_target_of_its_partition() {
+    let (owner, replies) = parked_wait_on(MAINTENANCE_BUDGET + 1).await;
+    begin_state_transfer(&owner, MAINTENANCE_BUDGET);
+    commit(&owner, &["second"], 2).await;
+    owner.service_deferred_polls().await;
+    assert_eq!(answer(&replies), (None, vec![0]));
+    assert_released(&owner);
+}
+
+#[compio::test]
+async fn maintenance_evaluates_a_large_readiness_wait_one_budget_per_tick() {
+    let (owner, replies) = parked_wait_on(MAINTENANCE_BUDGET + 1).await;
+    let tick = async || {
+        owner.deferred_polls.maintenance();
+        owner.service_deferred_polls().await;
+    };
+    // The first pass takes two ticks. The second starts again at the first
+    // target, so it reaches the last one on the fourth tick.
+    tick().await;
+    tick().await;
+    begin_state_transfer(&owner, MAINTENANCE_BUDGET);
+    tick().await;
+    pending(&replies);
+    tick().await;
+    assert!(matches!(
+        replies.try_recv(),
+        Ok(PartitionReadReply::Rejected(
+            IggyError::TransientNotAccepted
+        ))
+    ));
+    assert_released(&owner);
+}
+
+#[compio::test]
+async fn stale_session_rejects_parked_readiness_on_a_commit() {
+    let owner = owner(&["first"]).await;
+    let mut table = ClientTable::new(1);
+    let registration = PrepareHeader {
+        client: CLIENT,
+        operation: Operation::Register,
+        op: 1,
+        ..Default::default()
+    };
+    table
+        .commit_register(
+            CLIENT,
+            0,
+            [0x5a; 32],
+            build_reply_message_with(&registration, 0, |_| {}),
+        )
+        .unwrap();
+    let session = table.attach_session(CLIENT, 1, 0).unwrap();
+    let (reply, replies) = channel(2);
+    owner.admit_readiness_wait(
+        ReadinessWait {
+            context: DeferredPollContext {
+                session: Some(session),
+                ..readiness_caller()
+            },
+            targets: vec![readiness_target(&owner, 0, PollingStrategy::offset(1))],
+            deadline: DEADLINE,
+        },
+        &reply,
+    );
+    assert!(answer(&replies).0.is_some());
+    drop(table);
+    commit(&owner, &["second"], 2).await;
+    owner.service_deferred_polls().await;
+    assert!(matches!(
+        replies.try_recv(),
+        Ok(PartitionReadReply::Rejected(IggyError::StaleClient))
+    ));
+    assert_released(&owner);
+}
+
+#[compio::test]
+async fn readiness_refuses_targets_of_more_than_one_topic() {
+    let owner = owner(&["first"]).await;
+    let mut other_topic = readiness_target(&owner, 0, PollingStrategy::offset(1));
+    other_topic.namespace = IggyNamespace::new(0, 1, 0);
+    let replies = wait(
+        &owner,
+        vec![
+            readiness_target(&owner, 0, PollingStrategy::offset(1)),
+            other_topic,
+        ],
+    );
+    assert!(matches!(
+        replies.try_recv(),
+        Ok(PartitionReadReply::Rejected(IggyError::InvalidCommand))
+    ));
+    assert_released(&owner);
+}
+
+#[compio::test]
+async fn readiness_quota_counts_parked_waiters_only() {
+    let owner = owner(&["first"]).await;
+    owner.set_deferred_poll_config(DeferredPollConfig {
+        max_pending_per_session: 1,
+        ..Default::default()
+    });
+    let parked = wait(
+        &owner,
+        vec![readiness_target(&owner, 0, PollingStrategy::offset(1))],
+    );
+    assert!(answer(&parked).0.is_some());
+    for refused in [
+        wait(
+            &owner,
+            vec![readiness_target(&owner, 0, PollingStrategy::offset(1))],
+        ),
+        submit(&owner, request(&owner, 2, false, false)).await,
+    ] {
+        assert!(matches!(
+            refused.try_recv(),
+            Ok(PartitionReadReply::Rejected(
+                IggyError::DeferredPollCapacityExceeded
+            ))
+        ));
+    }
+    let ready = wait(
+        &owner,
+        vec![readiness_target(&owner, 0, PollingStrategy::offset(0))],
+    );
+    assert_eq!(answer(&ready), (None, vec![0]));
+}
+
+#[compio::test]
+async fn fan_out_merges_admissions_and_cancels_what_stays_parked() {
+    for (later, duration) in [
+        (false, PARTITION_READ_TIMEOUT),
+        // A zero wait still collects every admission answer.
+        (false, Duration::ZERO),
+        (true, PARTITION_READ_TIMEOUT),
+    ] {
+        let mut owner = owner_of(&[&["first"], &["first"]]).await;
+        let (local, local_inbox, _) = shard_channel(0, 4, 1);
+        let (remote, remote_inbox, _) = shard_channel(1, 4, 1);
+        owner.attach_senders(vec![local, remote]);
+        owner.shards_table.insert(
+            partition_namespace(1),
+            PartitionLocation::new(ShardId::new(1), 1),
+        );
+        // Partition 0 is ready now unless the remote shard answers later.
+        let start = u64::from(later);
+        let targets = vec![
+            readiness_target(&owner, 0, PollingStrategy::offset(start)),
+            readiness_target(&owner, 1, PollingStrategy::offset(0)),
+        ];
+        let owners = async {
+            let Ok(ShardFrame::Lifecycle(LifecycleFrame::ReadinessWait { wait, reply })) =
+                local_inbox.recv().await
+            else {
+                panic!("shard 0 holds partition 0");
+            };
+            owner.admit_readiness_wait(wait, &reply);
+            let Ok(ShardFrame::Lifecycle(LifecycleFrame::ReadinessWait { wait, reply })) =
+                remote_inbox.recv().await
+            else {
+                panic!("shard 1 holds partition 1");
+            };
+            assert_eq!(wait.targets.len(), 1);
+            for answer in [Some((Some(7), Vec::new())), later.then(|| (None, vec![1]))]
+                .into_iter()
+                .flatten()
+            {
+                reply
+                    .try_send(PartitionReadReply::Ready {
+                        shard: 1,
+                        parked: answer.0,
+                        partitions: answer.1,
+                    })
+                    .unwrap();
+            }
+        };
+        let (ready, ()) = futures::join!(
+            owner.wait_for_messages(readiness_caller(), targets, duration),
+            owners
+        );
+        if later {
+            assert_eq!(ready.unwrap(), [1]);
+            let Ok(ShardFrame::Lifecycle(LifecycleFrame::ReadinessCancel { waiter })) =
+                local_inbox.try_recv()
+            else {
+                panic!("the local waiter stays parked and is cancelled");
+            };
+            owner.cancel_readiness_wait(waiter);
+            assert!(
+                remote_inbox.try_recv().is_err(),
+                "the remote waiter answered"
+            );
+        } else {
+            assert_eq!(ready.unwrap(), [0]);
+            assert!(local_inbox.try_recv().is_err(), "nothing parked locally");
+            assert!(matches!(
+                remote_inbox.try_recv(),
+                Ok(ShardFrame::Lifecycle(LifecycleFrame::ReadinessCancel {
+                    waiter: 7
+                }))
+            ));
+        }
+        assert_released(&owner);
+    }
+}
diff --git a/core/shard/src/poll/test_support.rs b/core/shard/src/poll/test_support.rs
index 78faabf..38a0ec8 100644
--- a/core/shard/src/poll/test_support.rs
+++ b/core/shard/src/poll/test_support.rs
@@ -48,6 +48,7 @@
         path_layout: PartitionPathLayout::default(),
     }
 }
+const SEGMENT_FLOOR: u64 = 1024 * 1024;
 
 /// Commit one batch starting at offset zero and keep it resident. Each call
 /// creates an independent partition history, even for the same namespace.
@@ -70,8 +71,16 @@
     let cluster_id = 1;
     let replica_id = 0;
     let replica_count = 3;
-    let config = partitions_config();
-    let segment_size = config.segment_size;
+    // The batch must fit one segment or it never materialises, and it must stay
+    // under the flush threshold or it stops being resident. Doubling the payload
+    // total covers framing; the floor keeps small fixtures where they were.
+    let payload_bytes: u64 = payloads.iter().map(|payload| payload.len() as u64).sum();
+    let segment_size = IggyByteSize::from(SEGMENT_FLOOR.max(payload_bytes.saturating_mul(2)));
+    let config = PartitionsConfig {
+        segment_size,
+        size_of_messages_required_to_save: segment_size,
+        ..partitions_config()
+    };
     let consensus = VsrConsensus::new(
         cluster_id,
         replica_id,
@@ -93,6 +102,24 @@
         ..Default::default()
     });
 
+    commit_messages(&mut partition, &config, namespace, payloads, (1, 0)).await;
+    assert_eq!(partition.consensus().commit_min(), 1);
+    assert_eq!(partition.offsets().commit_offset, payloads.len() as u64 - 1);
+
+    (*partition, config)
+}
+
+/// Append `payloads` as one batch from the fixture client and commit it.
+/// `request` numbers the batch among the client's requests, and
+/// `metadata_watermark` must reach the partition's metadata fences.
+#[allow(clippy::future_not_send)]
+pub(super) async fn commit_messages<B: MessageBus + Clone>(
+    partition: &mut IggyPartition<B>,
+    config: &PartitionsConfig,
+    namespace: IggyNamespace,
+    payloads: &[&str],
+    (request, metadata_watermark): (u64, u64),
+) {
     assert!(!payloads.is_empty(), "fixture requires messages");
     let mut messages = IggyMessages::with_capacity(payloads.len());
     for (index, payload) in payloads.iter().enumerate() {
@@ -113,25 +140,27 @@
             operation: Operation::SendMessages,
             client: 1,
             session: 1,
-            request: 1,
+            request,
             group: namespace.inner(),
-            partition_incarnation: created_revision,
+            partition_incarnation: partition.created_revision(),
+            metadata_watermark,
             ..Default::default()
         })
         .expect("encode append request");
     let (append_reply, append_result) = oneshot_channel();
+    let op = partition.consensus().sequencer().current_sequence() + 1;
     partition.on_request(append, Some(append_reply)).await;
-    assert_eq!(partition.consensus().sequencer().current_sequence(), 1);
-    partition.consensus().advance_commit_max(1);
-    partition.commit_journal(&config).await;
-    assert_eq!(partition.consensus().commit_min(), 1);
+    assert_eq!(
+        partition.consensus().sequencer().current_sequence(),
+        op,
+        "fixture append was prepared"
+    );
+    partition.consensus().advance_commit_max(op);
+    partition.commit_journal(config).await;
     assert!(
         matches!(append_result.now_or_never(), Some(Ok(_))),
         "fixture append was committed"
     );
-    assert_eq!(partition.offsets().commit_offset, payloads.len() as u64 - 1);
-
-    (*partition, config)
 }
 
 #[allow(clippy::future_not_send)]
diff --git a/core/shard/src/poll/timeout_tests.rs b/core/shard/src/poll/timeout_tests.rs
index addfbf6..09b3584 100644
--- a/core/shard/src/poll/timeout_tests.rs
+++ b/core/shard/src/poll/timeout_tests.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use std::cell::RefCell;
+use std::cell::{Cell, RefCell};
 use std::pin::Pin;
 use std::rc::Rc;
 use std::time::Duration;
@@ -437,16 +437,15 @@
     assert_eq!(committed, None);
 }
 
-/// Remove the fixture's only resident batch to select disk dispatch without
+/// Remove the fixture's resident batches to select disk dispatch without
 /// creating files. Captured tasks must stay unpolled: these tests establish
 /// admission behavior, while real disk reads are covered in partition tests.
-fn evict_messages_for_disk_dispatch(owner: &PollTestShard, namespace: IggyNamespace) {
+pub(super) fn evict_messages_for_disk_dispatch(owner: &PollTestShard, namespace: IggyNamespace) {
     let partitions = owner.plane.partitions();
     let partition = partitions
         .remove(&namespace)
         .expect("fixture partition exists");
-    let retained = partition.log.journal().inner.evict_prefix(1);
-    assert!(retained.is_empty(), "fixture has exactly one batch");
+    partition.log.journal().inner.evict_prefix(usize::MAX);
     assert!(
         partition
             .log
@@ -503,12 +502,17 @@
 /// executing their synthetic disk plans.
 #[derive(Clone, Default)]
 pub struct PollTestBus {
+    pub(super) now: Rc<Cell<u64>>,
     next_timeout: Rc<RefCell<Option<oneshot::Receiver<()>>>>,
     pub(crate) spawned_tasks: Rc<RefCell<Vec<CapturedTask>>>,
 }
 
 #[allow(clippy::future_not_send)]
 impl MessageBus for PollTestBus {
+    fn monotonic_micros(&self) -> u64 {
+        self.now.get()
+    }
+
     fn spawn(&self, future: impl Future<Output = ()> + 'static) {
         self.spawned_tasks.borrow_mut().push(Box::pin(future));
     }
diff --git a/core/shard/src/router.rs b/core/shard/src/router.rs
index 9be4d7e..2a2c2a3 100644
--- a/core/shard/src/router.rs
+++ b/core/shard/src/router.rs
@@ -324,6 +324,7 @@
         SB: 'static,
     {
         let _completion_pump = self.poll_completions.pump_guard();
+        let _deferred_pump = self.deferred_polls.pump_guard();
         self.plane.partitions().set_io_notifier(
             self.partition_io.notifier(),
             self.partition_io.limits.bytes_max(),
@@ -367,7 +368,29 @@
         // backoff on every pump turn while the shutdown channel is empty.
         let mut stop_signal = std::pin::pin!(stop.recv().fuse());
         let mut fatal: Option<FatalCommit> = None;
+        let deadline_timer = |deadline: Option<u64>| {
+            async move {
+                if let Some(deadline) = deadline {
+                    self.bus
+                        .sleep(std::time::Duration::from_micros(
+                            deadline.saturating_sub(self.bus.monotonic_micros()),
+                        ))
+                        .await;
+                } else {
+                    std::future::pending::<()>().await;
+                }
+            }
+            .fuse()
+        };
+        let mut armed_deadline = None;
+        let mut deferred_deadline = std::pin::pin!(deadline_timer(None));
         loop {
+            self.service_deferred_polls().await;
+            let next_deadline = self.deferred_polls.earliest_deadline();
+            if next_deadline != armed_deadline {
+                armed_deadline = next_deadline;
+                deferred_deadline.set(deadline_timer(next_deadline));
+            }
             // `select_biased!`, not `select!`: the unbiased macro draws its
             // arm order from a process-random thread-local PRNG, which the
             // deterministic simulator cannot seed. The listed order is the
@@ -408,9 +431,14 @@
                     // a quiet shard until the next inbound frame's tail
                     // drain; parked partition frames then never re-dispatch.
                     self.apply_reconcile_ops();
+                    self.deferred_polls.maintenance();
                     consensus_tick.set(rearm_tick());
                     self.process_loopback(&mut loopback_buf).await;
                 }
+                () = deferred_deadline.as_mut() => {
+                    armed_deadline = None;
+                    deferred_deadline.set(deadline_timer(None));
+                }
                 frame = poll_fn(|_| {
                     self.pop_redispatched_frame().map_or(Poll::Pending, Poll::Ready)
                 }).fuse() => {
@@ -492,6 +520,7 @@
                         Err(_) => break,
                     }
                 }
+                () = poll_fn(|context| self.deferred_polls.poll_ready(context)).fuse() => {}
                 () = poll_fn(|context| {
                     self.plane.partitions().register_loopback_waker(context.waker());
                     self.partition_io.register_waker(context.waker());
@@ -509,6 +538,7 @@
         }
 
         self.poll_completions.close();
+        self.deferred_polls.close();
 
         // A stop can win the select immediately after a frame fenced a
         // partition, before the next tick observes it. Preserve that fault so
@@ -983,6 +1013,12 @@
             } => {
                 self.on_partition_read(namespace, read, reply).await;
             }
+            LifecycleFrame::ReadinessWait { wait, reply } => {
+                self.admit_readiness_wait(wait, &reply);
+            }
+            LifecycleFrame::ReadinessCancel { waiter } => {
+                self.cancel_readiness_wait(waiter);
+            }
             LifecycleFrame::PartitionSubmit {
                 request,
                 reply,
diff --git a/core/simulator/src/bus.rs b/core/simulator/src/bus.rs
index 15ffd1d..a0da21e 100644
--- a/core/simulator/src/bus.rs
+++ b/core/simulator/src/bus.rs
@@ -273,6 +273,11 @@
     fn monotonic_now(&self) -> Instant {
         self.timer.monotonic_now()
     }
+
+    fn monotonic_micros(&self) -> u64 {
+        u64::try_from(Duration::from_nanos(self.timer.now().as_nanos()).as_micros())
+            .unwrap_or(u64::MAX)
+    }
 }
 
 /// Newtype wrapper for shared [`SimOutbox`] that implements [`MessageBus`]
@@ -335,6 +340,10 @@
     fn monotonic_now(&self) -> Instant {
         self.0.monotonic_now()
     }
+
+    fn monotonic_micros(&self) -> u64 {
+        self.0.monotonic_micros()
+    }
 }
 
 /// The router's dispatch/pump impl block requires `ConnectionInstaller`
diff --git a/examples/java/README.md b/examples/java/README.md
index 74259ce..ba6971d 100644
--- a/examples/java/README.md
+++ b/examples/java/README.md
@@ -132,10 +132,10 @@
 
 Shows:
 
-- Backpressure management (don't poll faster than you can process)
+- Deferred polling, so the server holds the request until messages are ready
+- An idle limit that stops the consumer after a quiet period
 - Error recovery with exponential backoff
 - Thread pool separation (Netty I/O threads vs. processing threads)
-- Offset-based polling with CompletableFuture
 
 **CRITICAL ASYNC PATTERN - Thread Pool Management:**
 
diff --git a/examples/java/src/main/java/org/apache/iggy/examples/async/AsyncConsumer.java b/examples/java/src/main/java/org/apache/iggy/examples/async/AsyncConsumer.java
index 948aeb2..361a1b3 100644
--- a/examples/java/src/main/java/org/apache/iggy/examples/async/AsyncConsumer.java
+++ b/examples/java/src/main/java/org/apache/iggy/examples/async/AsyncConsumer.java
@@ -24,6 +24,7 @@
 import org.apache.iggy.consumergroup.Consumer;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.PolledMessages;
 import org.apache.iggy.message.PollingStrategy;
@@ -32,12 +33,14 @@
 
 import java.math.BigInteger;
 import java.nio.charset.StandardCharsets;
+import java.time.Duration;
 import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.atomic.AtomicReference;
 
 /**
@@ -45,7 +48,7 @@
  *
  * <p>Demonstrates advanced async message consumption patterns including:
  * <ul>
- *   <li>Non-blocking continuous polling</li>
+ *   <li>Deferred polling: the server holds each poll until data is ready</li>
  *   <li>Backpressure management (don't poll faster than you can process)</li>
  *   <li>Error recovery with exponential backoff</li>
  *   <li>Offloading CPU-intensive work from Netty threads</li>
@@ -87,9 +90,12 @@
 
     // Polling configuration
     private static final int POLL_BATCH_SIZE = 100;
-    private static final int POLL_INTERVAL_MS = 1000;
     private static final int BATCHES_LIMIT = 5; // Exit after receiving this many batches
-    private static final int MAX_EMPTY_POLLS = 5; // Exit if no messages after consecutive empty polls
+    // The server holds each poll for the readiness wait, so the client never
+    // sleeps between polls. Idle time is measured directly instead of counting
+    // empty polls, whose meaning changes with the wait.
+    private static final Duration IDLE_LIMIT = Duration.ofSeconds(5);
+    private static final DeferredPollOptions POLL_OPTIONS = DeferredPollOptions.defaults();
 
     // Error recovery configuration
     private static final int MAX_RETRY_ATTEMPTS = 5;
@@ -179,7 +185,7 @@
         log.info("Starting async polling loop (limit: {} batches)...", BATCHES_LIMIT);
 
         AtomicInteger totalReceived = new AtomicInteger(0);
-        AtomicInteger emptyPolls = new AtomicInteger(0);
+        AtomicLong lastProgressNanos = new AtomicLong(System.nanoTime());
         AtomicInteger consumedBatches = new AtomicInteger(0);
         AtomicReference<BigInteger> offset = new AtomicReference<>(BigInteger.ZERO);
 
@@ -189,15 +195,16 @@
         // until we've finished processing the current batch.
 
         CompletableFuture<Void> pollingLoop = new CompletableFuture<>();
-        pollBatch(client, processingPool, totalReceived, emptyPolls, consumedBatches, offset, 0, pollingLoop);
+        pollBatch(client, processingPool, totalReceived, lastProgressNanos, consumedBatches, offset, 0, pollingLoop);
         return pollingLoop;
     }
 
+    @SuppressWarnings("checkstyle:ParameterNumber")
     private static void pollBatch(
             AsyncIggyTcpClient client,
             ExecutorService processingPool,
             AtomicInteger totalReceived,
-            AtomicInteger emptyPolls,
+            AtomicLong lastProgressNanos,
             AtomicInteger consumedBatches,
             AtomicReference<BigInteger> offset,
             int retryAttempt,
@@ -216,14 +223,15 @@
         Consumer consumer = Consumer.of(CONSUMER_ID);
 
         client.messages()
-                .pollMessages(
+                .pollMessagesDeferred(
                         streamId,
                         topicId,
                         Optional.of(PARTITION_ID),
                         consumer,
                         PollingStrategy.offset(offset.get()),
                         (long) POLL_BATCH_SIZE,
-                        false)
+                        false,
+                        POLL_OPTIONS)
                 .thenComposeAsync(
                         polled -> {
                             // OFFLOAD TO PROCESSING POOL:
@@ -241,33 +249,31 @@
                                                     .offset()
                                                     .add(BigInteger.ONE));
                                             consumedBatches.incrementAndGet();
-                                            emptyPolls.set(0);
+                                            lastProgressNanos.set(System.nanoTime());
                                         });
-                            } else {
-                                int empty = emptyPolls.incrementAndGet();
-                                if (empty >= MAX_EMPTY_POLLS) {
-                                    log.info("No more messages after {} empty polls, finishing.", MAX_EMPTY_POLLS);
-                                    running = false;
-                                    return CompletableFuture.completedFuture(null);
-                                }
-                                log.info("Caught up - no new messages. Waiting...");
-                                // Sleep without blocking Netty threads
-                                return CompletableFuture.runAsync(
-                                        () -> {
-                                            try {
-                                                Thread.sleep(POLL_INTERVAL_MS);
-                                            } catch (InterruptedException e) {
-                                                Thread.currentThread().interrupt();
-                                            }
-                                        },
-                                        processingPool);
                             }
+                            // An empty reply means the server waited and the topic
+                            // stayed quiet, so the next poll goes out immediately.
+                            if (System.nanoTime() - lastProgressNanos.get() >= IDLE_LIMIT.toNanos()) {
+                                log.info("No new messages for {}, finishing.", IDLE_LIMIT);
+                                running = false;
+                            } else {
+                                log.info("Caught up - no new messages.");
+                            }
+                            return CompletableFuture.completedFuture(null);
                         },
                         processingPool)
                 .thenRun(() -> {
                     // SUCCESS: Reset retry counter and schedule next poll
                     pollBatch(
-                            client, processingPool, totalReceived, emptyPolls, consumedBatches, offset, 0, loopFuture);
+                            client,
+                            processingPool,
+                            totalReceived,
+                            lastProgressNanos,
+                            consumedBatches,
+                            offset,
+                            0,
+                            loopFuture);
                 })
                 .exceptionally(e -> {
                     // ERROR RECOVERY WITH EXPONENTIAL BACKOFF:
@@ -294,7 +300,7 @@
                                             client,
                                             processingPool,
                                             totalReceived,
-                                            emptyPolls,
+                                            lastProgressNanos,
                                             consumedBatches,
                                             offset,
                                             retryAttempt + 1,
diff --git a/examples/java/src/test/java/org/apache/iggy/examples/ConsumerOffsetTest.java b/examples/java/src/test/java/org/apache/iggy/examples/ConsumerOffsetTest.java
index e2dd438..9a09233 100644
--- a/examples/java/src/test/java/org/apache/iggy/examples/ConsumerOffsetTest.java
+++ b/examples/java/src/test/java/org/apache/iggy/examples/ConsumerOffsetTest.java
@@ -32,6 +32,7 @@
 import org.apache.iggy.examples.tcptls.consumer.TcpTlsConsumer;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.HeaderKey;
 import org.apache.iggy.message.HeaderValue;
 import org.apache.iggy.message.Message;
@@ -41,6 +42,7 @@
 import org.apache.iggy.message.PollingStrategy;
 import org.apache.iggy.message.SendMessagesResponse;
 import org.apache.iggy.partition.PartitionContext;
+import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Timeout;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
@@ -58,15 +60,18 @@
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.IntStream;
 import java.util.stream.Stream;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
-@Timeout(15)
+@Timeout(30)
 class ConsumerOffsetTest {
     private static final int MAX_BATCH_SIZE = 2;
     private static final int MESSAGES_COUNT = 10;
+    private static final int CONSUMER_TIMEOUT_SECONDS = 20;
+    private static final long IDLE_POLL_MILLIS = 100;
 
     @ParameterizedTest(name = "{0}, first retained offset {1}")
     @MethodSource("blockingConsumers")
@@ -91,36 +96,71 @@
     void shouldAdvanceAsynchronouslyOnlyAfterProcessing(int firstOffset, boolean failFirstProcessing) throws Exception {
         var retained = new RetainedMessages(firstOffset, MESSAGES_COUNT, false);
         List<BigInteger> requestedOffsets = new ArrayList<>();
+        List<DeferredPollOptions> requestedOptions = new ArrayList<>();
+        var messages = new DeferredMessages((strategy, count, options) -> {
+            requestedOffsets.add(strategy.value());
+            requestedOptions.add(options);
+            if (failFirstProcessing && requestedOffsets.size() == 1) {
+                var message = retained.retained.get(0);
+                var invalid = new Message(message.header(), null, message.userHeaders());
+                return CompletableFuture.completedFuture(new PolledMessages(
+                        0L, message.header().offset(), 1L, List.of(invalid), PartitionContext.EMPTY));
+            }
+            return CompletableFuture.completedFuture(retained.poll(strategy, count));
+        });
+        runConsumer(messages);
+        retained.assertConsumedOnce();
+        assertThat(requestedOptions)
+                .as("the example asks the server to hold the poll instead of sleeping")
+                .isNotEmpty()
+                .allMatch(DeferredPollOptions.defaults()::equals);
+        if (failFirstProcessing) {
+            assertThat(requestedOffsets)
+                    .as("processing failure must retry the same polling offset")
+                    .startsWith(BigInteger.ZERO, BigInteger.ZERO);
+        }
+    }
+
+    @Test
+    void shouldNotAdvanceOnEmptyDeferredPolls() throws Exception {
+        var retained = new RetainedMessages(0, MESSAGES_COUNT, false);
+        List<BigInteger> requestedOffsets = new ArrayList<>();
+        var emptyPolls = new AtomicInteger();
+        var messages = new DeferredMessages((strategy, count, options) -> {
+            requestedOffsets.add(strategy.value());
+            // Two quiet replies before every batch: an empty deferred reply
+            // advances nothing, so the next poll repeats the same offset.
+            if (emptyPolls.incrementAndGet() % 3 != 0) {
+                return CompletableFuture.completedFuture(
+                        new PolledMessages(0L, BigInteger.ZERO, 0L, List.of(), PartitionContext.EMPTY));
+            }
+            return CompletableFuture.completedFuture(retained.poll(strategy, count));
+        });
+        runConsumer(messages);
+        retained.assertConsumedOnce();
+        assertThat(requestedOffsets).startsWith(BigInteger.ZERO, BigInteger.ZERO, BigInteger.ZERO);
+    }
+
+    @Test
+    void shouldStopAfterTheIdleLimitWithoutMessages() throws Exception {
+        var polls = new AtomicInteger();
+        // A held poll costs real time on a server, so the double spends some
+        // too; otherwise the idle limit would be measured against a hot loop.
+        var messages = new DeferredMessages((strategy, count, options) -> {
+            polls.incrementAndGet();
+            return CompletableFuture.supplyAsync(
+                    () -> new PolledMessages(0L, BigInteger.ZERO, 0L, List.of(), PartitionContext.EMPTY),
+                    CompletableFuture.delayedExecutor(IDLE_POLL_MILLIS, TimeUnit.MILLISECONDS));
+        });
+        runConsumer(messages);
+        assertThat(polls.get()).isGreaterThan(1);
+    }
+
+    private static void runConsumer(DeferredMessages messages) throws Exception {
         var client = new AsyncIggyTcpClient("localhost", 8090) {
             @Override
             public org.apache.iggy.client.async.MessagesClient messages() {
-                return new org.apache.iggy.client.async.MessagesClient() {
-                    @Override
-                    public CompletableFuture<PolledMessages> pollMessages(
-                            StreamId streamId,
-                            TopicId topicId,
-                            Optional<Long> partitionId,
-                            Consumer consumer,
-                            PollingStrategy strategy,
-                            Long count,
-                            boolean autoCommit) {
-                        requestedOffsets.add(strategy.value());
-                        if (failFirstProcessing && requestedOffsets.size() == 1) {
-                            var message = retained.retained.get(0);
-                            var invalid = new Message(message.header(), null, message.userHeaders());
-                            return CompletableFuture.completedFuture(new PolledMessages(
-                                    0L, message.header().offset(), 1L, List.of(invalid), PartitionContext.EMPTY));
-                        }
-                        return CompletableFuture.completedFuture(retained.pollMessages(
-                                streamId, topicId, partitionId, consumer, strategy, count, autoCommit));
-                    }
-
-                    @Override
-                    public CompletableFuture<SendMessagesResponse> sendMessages(
-                            StreamId streamId, TopicId topicId, Partitioning partitioning, List<Message> messages) {
-                        throw new AssertionError("Consumer must not send messages");
-                    }
-                };
+                return messages;
             }
         };
         ExecutorService processingPool = Executors.newSingleThreadExecutor();
@@ -129,13 +169,7 @@
                     "pollMessagesAsync", AsyncIggyTcpClient.class, ExecutorService.class);
             consume.setAccessible(true);
             var completed = (CompletableFuture<?>) consume.invoke(null, client, processingPool);
-            completed.get(10, TimeUnit.SECONDS);
-            retained.assertConsumedOnce();
-            if (failFirstProcessing) {
-                assertThat(requestedOffsets)
-                        .as("processing failure must retry the same polling offset")
-                        .startsWith(BigInteger.ZERO, BigInteger.ZERO);
-            }
+            completed.get(CONSUMER_TIMEOUT_SECONDS, TimeUnit.SECONDS);
         } finally {
             processingPool.shutdownNow();
             assertThat(processingPool.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
@@ -153,6 +187,54 @@
                 .flatMap(consumer -> Stream.of(0, 25).map(firstOffset -> Arguments.of(consumer, firstOffset)));
     }
 
+    /**
+     * An async client that only answers deferred polls. An immediate poll fails
+     * the test, so a consumer that stops asking the server to wait is caught.
+     */
+    private static final class DeferredMessages implements org.apache.iggy.client.async.MessagesClient {
+        private final Reply reply;
+
+        private DeferredMessages(Reply reply) {
+            this.reply = reply;
+        }
+
+        @Override
+        public CompletableFuture<PolledMessages> pollMessagesDeferred(
+                StreamId streamId,
+                TopicId topicId,
+                Optional<Long> partitionId,
+                Consumer consumer,
+                PollingStrategy strategy,
+                Long count,
+                boolean autoCommit,
+                DeferredPollOptions options) {
+            return reply.poll(strategy, count, options);
+        }
+
+        @Override
+        public CompletableFuture<PolledMessages> pollMessages(
+                StreamId streamId,
+                TopicId topicId,
+                Optional<Long> partitionId,
+                Consumer consumer,
+                PollingStrategy strategy,
+                Long count,
+                boolean autoCommit) {
+            throw new AssertionError("Consumer must poll with an explicit readiness wait");
+        }
+
+        @Override
+        public CompletableFuture<SendMessagesResponse> sendMessages(
+                StreamId streamId, TopicId topicId, Partitioning partitioning, List<Message> messages) {
+            throw new AssertionError("Consumer must not send messages");
+        }
+
+        @FunctionalInterface
+        private interface Reply {
+            CompletableFuture<PolledMessages> poll(PollingStrategy strategy, Long count, DeferredPollOptions options);
+        }
+    }
+
     private static final class RetainedMessages implements MessagesClient {
         private final List<Message> retained;
         private final List<BigInteger> delivered = new ArrayList<>();
@@ -187,6 +269,10 @@
                 PollingStrategy strategy,
                 Long count,
                 boolean autoCommit) {
+            return poll(strategy, count);
+        }
+
+        private PolledMessages poll(PollingStrategy strategy, Long count) {
             var batch = retained.stream()
                     .filter(message -> message.header().offset().compareTo(strategy.value()) >= 0)
                     .limit(Math.min(count, MAX_BATCH_SIZE))
diff --git a/examples/node/package-lock.json b/examples/node/package-lock.json
index 19cc4a2..2f51fbe 100644
--- a/examples/node/package-lock.json
+++ b/examples/node/package-lock.json
@@ -23,7 +23,7 @@
     },
     "../../foreign/node": {
       "name": "apache-iggy",
-      "version": "0.10.1-edge.4",
+      "version": "0.10.1-edge.5",
       "license": "Apache-2.0",
       "dependencies": {
         "@node-rs/xxhash": "1.7.8",
diff --git a/examples/python/high-level/consumer.py b/examples/python/high-level/consumer.py
index 67253e2..ee06cfc 100644
--- a/examples/python/high-level/consumer.py
+++ b/examples/python/high-level/consumer.py
@@ -17,7 +17,6 @@
 
 import argparse
 import asyncio
-from datetime import timedelta
 from typing import NamedTuple
 
 from apache_iggy import (
@@ -67,7 +66,6 @@
         polling_strategy=PollingStrategy.First(),
         batch_length=100,
         auto_commit=AutoCommit.After(AutoCommitAfter.ConsumingEachMessage()),
-        poll_interval=timedelta(milliseconds=100),
     )
 
     shutdown_event = asyncio.Event()
diff --git a/examples/python/uv.lock b/examples/python/uv.lock
index 7c474a0..c09aee9 100644
--- a/examples/python/uv.lock
+++ b/examples/python/uv.lock
@@ -8,7 +8,7 @@
 
 [[package]]
 name = "apache-iggy"
-version = "0.9.1.dev4"
+version = "0.9.1.dev5"
 source = { directory = "../../foreign/python" }
 
 [package.metadata]
diff --git a/examples/rust/src/multi-tenant/consumer/main.rs b/examples/rust/src/multi-tenant/consumer/main.rs
index 25c5491..e731f16 100644
--- a/examples/rust/src/multi-tenant/consumer/main.rs
+++ b/examples/rust/src/multi-tenant/consumer/main.rs
@@ -23,7 +23,6 @@
 use std::collections::HashMap;
 use std::env;
 use std::error::Error;
-use std::str::FromStr;
 use tokio::task::JoinHandle;
 use tracing::{error, info};
 use tracing_subscriber::layer::SubscriberExt;
@@ -154,7 +153,6 @@
             &tenant.stream,
             TOPICS,
             args.messages_per_batch,
-            &args.interval,
         )
         .await?;
         tenant.add_consumers(consumers);
@@ -291,7 +289,6 @@
     stream: &str,
     topics: &[&str],
     batch_length: u32,
-    interval: &str,
 ) -> Result<Vec<TenantConsumer>, IggyError> {
     let mut consumers = Vec::new();
     for topic in topics {
@@ -299,7 +296,6 @@
             let mut consumer = client
                 .consumer_group(CONSUMER_GROUP, stream, topic)?
                 .batch_length(batch_length)
-                .poll_interval(IggyDuration::from_str(interval).expect("Invalid duration"))
                 .polling_strategy(PollingStrategy::next())
                 .auto_join_consumer_group()
                 .auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages))
diff --git a/examples/rust/src/new-sdk/consumer/main.rs b/examples/rust/src/new-sdk/consumer/main.rs
index 7edac5b..0d57f73 100644
--- a/examples/rust/src/new-sdk/consumer/main.rs
+++ b/examples/rust/src/new-sdk/consumer/main.rs
@@ -23,7 +23,6 @@
     OrderCreated, OrderRejected,
 };
 use std::error::Error;
-use std::str::FromStr;
 use std::sync::Arc;
 use tracing::{error, info, warn};
 use tracing_subscriber::layer::SubscriberExt;
@@ -60,7 +59,6 @@
     .create_consumer_group_if_not_exists()
     .auto_join_consumer_group()
     .polling_strategy(PollingStrategy::next())
-    .poll_interval(IggyDuration::from_str(&args.interval)?)
     .batch_length(args.messages_per_batch)
     .build();
 
@@ -74,16 +72,11 @@
     args: &Args,
     consumer: &mut IggyConsumer,
 ) -> Result<(), Box<dyn Error>> {
-    let interval = args.get_interval();
     let mut consumed_messages = 0;
 
     info!(
-        "Messages will be polled by consumer: {} from stream: {}, topic: {}, partition: {} with interval {}.",
-        args.consumer_id,
-        args.stream_id,
-        args.topic_id,
-        args.partition_id,
-        interval.map_or("none".to_string(), |i| i.as_human_time_string())
+        "Messages will be polled by consumer: {} from stream: {}, topic: {}, partition: {} with long polling.",
+        args.consumer_id, args.stream_id, args.topic_id, args.partition_id
     );
 
     let total_messages_to_consume = args.message_batches_limit * args.messages_per_batch as u64;
diff --git a/examples/rust/src/stream-builder/stream-basic/main.rs b/examples/rust/src/stream-builder/stream-basic/main.rs
index 08d7704..4c38215 100644
--- a/examples/rust/src/stream-builder/stream-basic/main.rs
+++ b/examples/rust/src/stream-builder/stream-basic/main.rs
@@ -29,7 +29,9 @@
 
     println!("Build iggy producer & consumer");
     // For customization, use the `new` or `from_stream_topic` constructor
-    let stream_config = IggyStreamConfig::default();
+    let consumer_config = IggyConsumerConfig::default()
+        .with_auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages));
+    let stream_config = IggyStreamConfig::new(consumer_config, IggyProducerConfig::default());
     let (producer, mut consumer) = IggyStream::build(&client, &stream_config).await?;
 
     println!("Start message stream");
diff --git a/examples/rust/src/stream-builder/stream-consumer-config/main.rs b/examples/rust/src/stream-builder/stream-consumer-config/main.rs
index 874c197..d7a847e 100644
--- a/examples/rust/src/stream-builder/stream-consumer-config/main.rs
+++ b/examples/rust/src/stream-builder/stream-consumer-config/main.rs
@@ -17,7 +17,6 @@
 
 use iggy::prelude::*;
 use iggy_examples::shared::stream::PrintEventConsumer;
-use std::str::FromStr;
 use tokio::sync::oneshot;
 
 const IGGY_URL: &str = "iggy://iggy:iggy@localhost:8090";
@@ -55,8 +54,8 @@
         .consumer_kind(ConsumerKind::ConsumerGroup)
         // Topic creation count. Ordinary consumers select a partition separately with partition_id.
         .partitions_count(1)
-        // The polling interval for messages.
-        .polling_interval(IggyDuration::from_str("5ms").unwrap())
+        // A poll returns when `min_count` messages are ready, `max_bytes` is reached or `max_wait` expires.
+        .poll_options(DeferredPollOptions::default())
         // `PollingStrategy` specifies from where to start polling messages.
         // It has the following kinds:
         // - `Offset` - start polling from the specified offset.
diff --git a/examples/rust/src/stream-builder/stream-consumer/main.rs b/examples/rust/src/stream-builder/stream-consumer/main.rs
index 6d833a9..dc09a77 100644
--- a/examples/rust/src/stream-builder/stream-consumer/main.rs
+++ b/examples/rust/src/stream-builder/stream-consumer/main.rs
@@ -25,7 +25,9 @@
 async fn main() -> Result<(), IggyError> {
     println!("Build iggy client & consumer");
     //For customization, use the `new` or `from_stream_topic` constructor
-    let config = IggyConsumerConfig::default();
+    // Stores the offset of each polled batch, so a restart continues after it.
+    let config = IggyConsumerConfig::default()
+        .with_auto_commit(AutoCommit::When(AutoCommitWhen::PollingMessages));
     let (client, mut consumer) =
         IggyStreamConsumer::with_client_from_url(IGGY_URL, &config).await?;
 
diff --git a/foreign/cpp/Cargo.toml b/foreign/cpp/Cargo.toml
index 9f85d8c..1cf9f8c 100644
--- a/foreign/cpp/Cargo.toml
+++ b/foreign/cpp/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy-cpp"
-version = "0.1.6"
+version = "0.1.7"
 edition = "2024"
 license = "Apache-2.0"
 
diff --git a/foreign/cpp/MODULE.bazel b/foreign/cpp/MODULE.bazel
index 80c7a4e..e2bd0da 100644
--- a/foreign/cpp/MODULE.bazel
+++ b/foreign/cpp/MODULE.bazel
@@ -17,7 +17,7 @@
 
 module(
     name = "iggy_cpp",
-    version = "0.1.6",
+    version = "0.1.7",
 )
 
 bazel_dep(name = "rules_cc", version = "0.2.26")
diff --git a/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj b/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
index ddb5cf2..6676f5b 100644
--- a/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
+++ b/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
@@ -26,7 +26,7 @@
         <TargetFrameworks>net8.0;net10.0</TargetFrameworks>
         <AssemblyName>Apache.Iggy</AssemblyName>
         <RootNamespace>Apache.Iggy</RootNamespace>
-        <Version>0.9.1-edge.4</Version>
+        <Version>0.9.1-edge.5</Version>
         <GenerateDocumentationFile>true</GenerateDocumentationFile>
     </PropertyGroup>
 
diff --git a/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs b/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs
index 1b46305..db6cc92 100644
--- a/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs
+++ b/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs
@@ -49,6 +49,9 @@
     internal const int SEND_MESSAGES_CODE = 101;
     // 103 and 104 are retired and must never be reused.
     internal const int GET_PARTITION_ROUTE_CODE = 105;
+    internal const int POLL_MESSAGES_DEFERRED_CODE = 106;
+    internal const int WAIT_FOR_MESSAGES_CODE = 107;
+    internal const int CANCEL_DEFERRED_REQUEST_CODE = 108;
     internal const int GET_CONSUMER_OFFSET_CODE = 120;
     internal const int STORE_CONSUMER_OFFSET_CODE = 121;
     internal const int DELETE_CONSUMER_OFFSET_CODE = 122;
diff --git a/foreign/go/contracts/version.go b/foreign/go/contracts/version.go
index 8022e33..c22f3ea 100644
--- a/foreign/go/contracts/version.go
+++ b/foreign/go/contracts/version.go
@@ -17,4 +17,4 @@
 
 package iggcon
 
-const Version = "0.9.1-edge.4"
+const Version = "0.9.1-edge.5"
diff --git a/foreign/go/internal/command/code.go b/foreign/go/internal/command/code.go
index 4ff3aa2..f2bb2fe 100644
--- a/foreign/go/internal/command/code.go
+++ b/foreign/go/internal/command/code.go
@@ -48,28 +48,31 @@
 	PollMessagesCode         Code = 100
 	SendMessagesCode         Code = 101
 	// 35, 103, 104, 123, 124, 204 and 304 are retired and must never be reused.
-	GetPartitionRouteCode    Code = 105
-	GetOffsetCode            Code = 120
-	StoreOffsetCode          Code = 121
-	DeleteConsumerOffsetCode Code = 122
-	GetStreamCode            Code = 200
-	GetStreamsCode           Code = 201
-	CreateStreamCode         Code = 202
-	DeleteStreamCode         Code = 203
-	GetTopicCode             Code = 300
-	GetTopicsCode            Code = 301
-	CreateTopicCode          Code = 302
-	DeleteTopicCode          Code = 303
-	CreatePartitionsCode     Code = 402
-	DeletePartitionsCode     Code = 403
-	DeleteSegmentsCode       Code = 503
-	GetGroupCode             Code = 600
-	GetGroupsCode            Code = 601
-	CreateGroupCode          Code = 602
-	DeleteGroupCode          Code = 603
-	JoinGroupCode            Code = 604
-	LeaveGroupCode           Code = 605
-	SyncGroupCode            Code = 606
+	GetPartitionRouteCode     Code = 105
+	PollMessagesDeferredCode  Code = 106
+	WaitForMessagesCode       Code = 107
+	CancelDeferredRequestCode Code = 108
+	GetOffsetCode             Code = 120
+	StoreOffsetCode           Code = 121
+	DeleteConsumerOffsetCode  Code = 122
+	GetStreamCode             Code = 200
+	GetStreamsCode            Code = 201
+	CreateStreamCode          Code = 202
+	DeleteStreamCode          Code = 203
+	GetTopicCode              Code = 300
+	GetTopicsCode             Code = 301
+	CreateTopicCode           Code = 302
+	DeleteTopicCode           Code = 303
+	CreatePartitionsCode      Code = 402
+	DeletePartitionsCode      Code = 403
+	DeleteSegmentsCode        Code = 503
+	GetGroupCode              Code = 600
+	GetGroupsCode             Code = 601
+	CreateGroupCode           Code = 602
+	DeleteGroupCode           Code = 603
+	JoinGroupCode             Code = 604
+	LeaveGroupCode            Code = 605
+	SyncGroupCode             Code = 606
 )
 
 //    internal const int GET_PERSONAL_ACCESS_TOKENS_CODE = 41;
diff --git a/foreign/java/README.md b/foreign/java/README.md
index 0a96937..f628d39 100644
--- a/foreign/java/README.md
+++ b/foreign/java/README.md
@@ -38,7 +38,7 @@
 
 ### Snapshot Versions
 
-Development builds of the next release carry the version `0.9.4-SNAPSHOT`. Get them from the ASF snapshot repository:
+Development builds of the next release carry the version `0.9.5-SNAPSHOT`. Get them from the ASF snapshot repository:
 
 **Gradle:**
 
@@ -51,7 +51,7 @@
 }
 
 dependencies {
-    implementation 'org.apache.iggy:iggy:0.9.4-SNAPSHOT'
+    implementation 'org.apache.iggy:iggy:0.9.5-SNAPSHOT'
 }
 ```
 
@@ -72,7 +72,7 @@
     <dependency>
         <groupId>org.apache.iggy</groupId>
         <artifactId>iggy</artifactId>
-        <version>0.9.4-SNAPSHOT</version>
+        <version>0.9.5-SNAPSHOT</version>
     </dependency>
 </dependencies>
 ```
@@ -125,7 +125,7 @@
 
 `sendBinaryRequestToReplica(code, payload, replicaId)` sends a raw command to one node of a cluster, named by the replica id that cluster metadata returns.
 
-Each node answers non-replicated commands such as `GetStats` from its own state, so this reads the state of that node. Replicated commands, polls, consumer-offset reads and session-control codes fail with `INVALID_COMMAND` before anything is sent.
+Each node answers non-replicated commands such as `GetStats` from its own state, so this reads the state of that node. Replicated commands, polls, readiness waits, deferred-request cancels, consumer-offset reads and session-control codes fail with `INVALID_COMMAND` before anything is sent.
 
 An id that cluster metadata does not name fails with `INVALID_IDENTIFIER`. An unreachable node is retried until the request timeout, and a `NOT_PRIMARY` reply is returned rather than sent to another node. The HTTP client throws `IggyOperationNotSupportedException`.
 
@@ -414,6 +414,67 @@
 
 A cursor's `toString()` is its `mc1_` text, which `MetadataChangeCursor.parse` reads back, so a mirror can store it. A query the rules refuse fails with an `IggyValidationException` for invalid command before anything is sent, and a record of a kind this SDK does not know arrives as `MetadataChangeBody.Unknown` or `MetadataChangeBody.UnknownJson`.
 
+## Polling
+
+`pollMessages` returns whatever the server already holds. An empty topic answers
+at once, so the caller has to poll again.
+
+`pollMessagesDeferred` lets the server hold the request instead. It waits up to
+`maxWait` for `minCount` messages, caps the encoded response at `maxBytes`, and
+bounds the whole call by `requestTimeout`.
+
+```java
+import org.apache.iggy.message.DeferredPollOptions;
+
+// Defaults: 1 s wait, 1 message, 1 MiB, 11 s budget.
+var options = DeferredPollOptions.defaults()
+    .withMaxWait(Duration.ofSeconds(5))
+    .withMinCount(10);
+
+var polled = client.messages().pollMessagesDeferred(
+    streamId, topicId, Optional.of(0L), Consumer.of(1L),
+    PollingStrategy.next(), 100L, false, options);
+```
+
+If the readiness wait expires, the server still answers, with a partial batch or
+an empty one. If the request timeout expires, the call fails.
+
+The TCP client multiplexes deferred polls with other requests on its shared
+connections and routes them to the partition primary. Up to 64 deferred polls
+can be in flight per client; further polls wait for a slot in arrival order,
+within their original request timeout. A poll that expires before getting a
+slot fails with the retryable `TRANSIENT_NOT_ACCEPTED`. Queued and active
+requests share the client's 4096 pending request limit.
+Each TCP attempt uses the time remaining before the original call deadline,
+including time spent waiting for a slot, routing, or retrying. A server capacity
+refusal (`DEFERRED_POLL_CAPACITY_EXCEEDED`) backs off without discarding the
+cached partition route.
+The HTTP client accepts a plain consumer only,
+because the server refuses a consumer group over HTTP: without a persistent
+connection the client cannot follow the group's partition assignment.
+Deferred HTTP polls use a separate pool of up to 64 connections. A timeout or
+transport failure before the request can be sent reports `IggyServerException`
+with `TRANSIENT_NOT_ACCEPTED` (58). Once sending starts, those failures report
+`TRANSIENT_NOT_COMMITTED` (57), because the server may already have advanced the
+offset. The original failure is available as the cause. The client retries only
+definite non-admission, including `DEFERRED_POLL_CAPACITY_EXCEEDED` (90), within
+the original request timeout.
+
+A parked `Next` or auto-commit poll does not block sends to its partition.
+Polls that advance progress share a separate partition lane with offset reads,
+stores, and deletes. Stores and deletes also take the mutation lane, after their
+progress turn. A retained uncertain store or delete blocks later progress calls
+until it is resolved or abandoned; an active replay keeps its lanes until it
+finishes. Consumers on the same partition share this ordering, including when
+they name a consumer group by different aliases.
+
+Cancelling a TCP deferred poll sends a per-request cancellation on the same
+authenticated connection. This stops a poll still waiting for readiness;
+cancellation can race an offset update that the server has already accepted.
+With auto-commit, a lost or timed-out reply can leave the stored offset advanced
+without delivering its messages to the caller. Use manual offset commits after
+processing when those messages must remain available for another attempt.
+
 ## Exception Handling
 
 The SDK's custom exception types inherit from `IggyException`. When you join a failed future, the cause can come back inside a `CompletionException`. The HTTP client's `close()` method declares `IOException`. Handle these two boundaries as well as the specific SDK errors.
diff --git a/foreign/java/gradle.properties b/foreign/java/gradle.properties
index 7eefd2a..3e28a08 100644
--- a/foreign/java/gradle.properties
+++ b/foreign/java/gradle.properties
@@ -15,5 +15,5 @@
 # specific language governing permissions and limitations
 # under the License.
 
-version=0.9.4-SNAPSHOT
+version=0.9.5-SNAPSHOT
 group=org.apache.iggy
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/MessagesClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/MessagesClient.java
index 82aae3f..184621d 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/MessagesClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/MessagesClient.java
@@ -20,8 +20,10 @@
 package org.apache.iggy.client.async;
 
 import org.apache.iggy.consumergroup.Consumer;
+import org.apache.iggy.exception.IggyOperationNotSupportedException;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
 import org.apache.iggy.message.PolledMessages;
@@ -154,6 +156,83 @@
     }
 
     /**
+     * Polls messages asynchronously, letting the server hold the request until data is ready.
+     *
+     * <p>Unlike {@link #pollMessages}, which reads whatever is resident and returns, this
+     * waits up to {@link DeferredPollOptions#maxWait()} for {@link DeferredPollOptions#minCount()}
+     * messages, caps the encoded response at {@link DeferredPollOptions#maxBytes()}, and bounds
+     * the whole exchange by {@link DeferredPollOptions#requestTimeout()}. An expired readiness
+     * wait can still return partial or empty data; an expired request timeout is an error.
+     *
+     * <p>The server must support command 106. An older one answers
+     * {@link org.apache.iggy.exception.IggyErrorCode#INVALID_COMMAND}, which also covers an
+     * operator wait limit below the requested wait and a request this client encoded wrongly.
+     * Never retry it and never infer the cause from the code alone.
+     *
+     * @param streamId    the stream identifier (numeric or string-based)
+     * @param topicId     the topic identifier (numeric or string-based)
+     * @param partitionId optional partition ID to poll from; when empty and polling with a
+     *                    group consumer, the client selects the partition round-robin from
+     *                    the member's synced group assignment
+     * @param consumer    the consumer identity, either individual or group
+     * @param strategy    the polling strategy controlling where to start reading
+     * @param count       the maximum number of messages to return
+     * @param autoCommit  whether the server should automatically commit the consumer offset
+     * @param options     the readiness, byte and request-time limits
+     * @return a {@link CompletableFuture} that completes with the {@link PolledMessages}
+     */
+    @SuppressWarnings("checkstyle:ParameterNumber")
+    default CompletableFuture<PolledMessages> pollMessagesDeferred(
+            StreamId streamId,
+            TopicId topicId,
+            Optional<Long> partitionId,
+            Consumer consumer,
+            PollingStrategy strategy,
+            Long count,
+            boolean autoCommit,
+            DeferredPollOptions options) {
+        return CompletableFuture.failedFuture(new IggyOperationNotSupportedException(
+                "pollMessagesDeferred", getClass().getSimpleName()));
+    }
+
+    /**
+     * Polls messages asynchronously with a deferred wait using numeric identifiers.
+     *
+     * <p>See {@link #pollMessagesDeferred(StreamId, TopicId, Optional, Consumer, PollingStrategy,
+     * Long, boolean, DeferredPollOptions)} for full documentation.
+     *
+     * @param streamId    the numeric stream ID
+     * @param topicId     the numeric topic ID
+     * @param partitionId optional partition ID
+     * @param consumerId  the numeric consumer ID
+     * @param strategy    the polling strategy
+     * @param count       the maximum number of messages to return
+     * @param autoCommit  whether to auto-commit offsets
+     * @param options     the readiness, byte and request-time limits
+     * @return a {@link CompletableFuture} that completes with the {@link PolledMessages}
+     */
+    @SuppressWarnings("checkstyle:ParameterNumber")
+    default CompletableFuture<PolledMessages> pollMessagesDeferred(
+            Long streamId,
+            Long topicId,
+            Optional<Long> partitionId,
+            Long consumerId,
+            PollingStrategy strategy,
+            Long count,
+            boolean autoCommit,
+            DeferredPollOptions options) {
+        return pollMessagesDeferred(
+                StreamId.of(streamId),
+                TopicId.of(topicId),
+                partitionId,
+                Consumer.of(consumerId),
+                strategy,
+                count,
+                autoCommit,
+                options);
+    }
+
+    /**
      * Sends messages to a topic asynchronously.
      *
      * <p>Messages are routed to partitions according to the specified {@link Partitioning}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClient.java
index caee709..66ffbdb 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClient.java
@@ -414,22 +414,30 @@
      * Sends a command code and payload and returns the raw response payload.
      *
      * <p>Session-control codes complete the returned future with an invalid-command error.
+     * So does CancelDeferredRequest, because a cancel names a pending request by its
+     * id and the server answers no cancel. WaitForMessages completes it with a
+     * feature-unavailable error, because this client does not route readiness waits.
+     * None of them is sent.
      *
-     * <p>SendMessages, PollMessages, StoreConsumerOffset and DeleteConsumerOffset
-     * carry the partition context the server fences them by, which the client
-     * captures before it sends them. A SendMessages payload must name an explicit
-     * partition id: balanced and messages-key partitioning complete the future
-     * with a feature-unavailable error, because only the typed
+     * <p>SendMessages, PollMessages, deferred polls, StoreConsumerOffset and
+     * DeleteConsumerOffset carry the partition context the server fences them by,
+     * which the client captures before it sends them. A SendMessages payload must
+     * name an explicit partition id: balanced and messages-key partitioning
+     * complete the future with a feature-unavailable error, because only the typed
      * {@link MessagesClient#sendMessages} resolves them to a partition. A payload
-     * of these commands that does not follow their binary layout completes the
-     * future with an {@link org.apache.iggy.exception.IggyInvalidArgumentException}.
-     * Neither failure sends anything.
+     * of these commands that does not follow their binary layout, or a deferred
+     * poll with limits that {@link org.apache.iggy.message.DeferredPollOptions}
+     * refuses, completes the future with an
+     * {@link org.apache.iggy.exception.IggyInvalidArgumentException}. Neither
+     * failure sends anything.
      *
-     * <p>PollMessages takes the routed path of a typed poll, and StoreConsumerOffset
-     * and DeleteConsumerOffset that of a typed offset write. Each uses the context
-     * its route reports, so after another client deleted and recreated the
-     * partition, one call can fail with code 87 (5009 for a consumer group). The
-     * failed route is dropped and the next call routes again.
+     * <p>PollMessages and deferred polls take the routed path of a typed poll, and
+     * StoreConsumerOffset and DeleteConsumerOffset that of a typed offset write.
+     * Each uses the context its route reports, so after another client deleted and
+     * recreated the partition, one call can fail with code 87 (5009 for a consumer
+     * group). The failed route is dropped and the next call routes again. As for a
+     * typed deferred poll, the request timeout in a deferred poll's payload sets
+     * its deadline.
      *
      * <p>The future completes on the client event loop, as the callbacks section of this class says.
      *
@@ -459,8 +467,9 @@
      * as GetStats from its own state, so this reads the state of the chosen node.
      *
      * <p>The request carries the metadata watermark of the session, so the replica answers after it applied the
-     * metadata that this client saw. Replicated commands, PollMessages, GetConsumerOffset and session-control codes
-     * complete the returned future with an invalid-command error before anything is sent. An id that cluster metadata does not
+     * metadata that this client saw. Replicated commands, PollMessages, deferred polls, WaitForMessages,
+     * CancelDeferredRequest, GetConsumerOffset and session-control codes complete the returned future with an
+     * invalid-command error before anything is sent. An id that cluster metadata does not
      * name after one read of it completes the future with an invalid-identifier error. A replica that cannot be
      * reached, or that answers with code 58, is asked again until the request timeout, which ends the call with code
      * 58. Any other refusal, NotPrimary (89) included, completes the future unchanged and is never sent to another
@@ -478,6 +487,9 @@
         Objects.requireNonNull(payload, "payload cannot be null");
         if (isSessionControlCode(code)
                 || code == CommandCode.Messages.POLL.getValue()
+                || code == CommandCode.Messages.POLL_DEFERRED.getValue()
+                || code == CommandCode.Messages.WAIT_FOR_MESSAGES.getValue()
+                || code == CommandCode.Messages.CANCEL_DEFERRED_REQUEST.getValue()
                 || code == CommandCode.ConsumerOffset.GET.getValue()
                 || VsrOperation.operationForCode(code) != VsrOperation.NON_REPLICATED) {
             return CompletableFuture.failedFuture(
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncTcpConnection.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncTcpConnection.java
index bfdfb24..04a859f 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncTcpConnection.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/AsyncTcpConnection.java
@@ -807,7 +807,8 @@
     private static boolean isRefusal(IggyServerException serverError) {
         return serverError != null
                 && (serverError.getRawErrorCode() == TRANSIENT_NOT_ACCEPTED
-                        || serverError.getRawErrorCode() == NOT_PRIMARY);
+                        || serverError.getRawErrorCode() == NOT_PRIMARY
+                        || serverError.getRawErrorCode() == IggyErrorCode.DEFERRED_POLL_CAPACITY_EXCEEDED.getCode());
     }
 
     /**
@@ -1063,6 +1064,7 @@
     private static boolean isPartitionCommand(int code) {
         return code == CommandCode.Messages.SEND.getValue()
                 || code == CommandCode.Messages.POLL.getValue()
+                || code == CommandCode.Messages.POLL_DEFERRED.getValue()
                 || code == CommandCode.ConsumerOffset.GET.getValue()
                 || isOffsetWriteCode(code);
     }
@@ -1207,17 +1209,26 @@
     }
 
     /** Replays transient refusals with the same dedup identity; committed lifecycle refusals need a new id. */
-    @SuppressWarnings("checkstyle:NPathComplexity")
-    private void writeVsrFrame(VsrExchange exchange, ByteBuf frame, boolean uncertain, long lifecycleRetryIntervalMs) {
+    @SuppressWarnings({"checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity"})
+    private void writeVsrFrame(
+            VsrExchange exchange, ByteBuf retainedFrame, boolean uncertain, long lifecycleRetryIntervalMs) {
         Channel channel = exchange.channel();
         VsrResponseHandler handler = exchange.handler();
         CompletableFuture<ByteBuf> responseFuture = exchange.responseFuture();
         if (exchange.requestDeadlineNanos() - System.nanoTime() <= 0) {
             IggyTimeoutException timeout = responseTimeout(exchange.commandCode());
-            releaseFrame(exchange, frame);
+            releaseFrame(exchange, retainedFrame);
             responseFuture.completeExceptionally(preserveUncertainOutcome(timeout, uncertain));
             return;
         }
+        ByteBuf frame;
+        try {
+            frame = frameForAttempt(exchange, retainedFrame);
+        } catch (RuntimeException error) {
+            releaseFrame(exchange, retainedFrame);
+            responseFuture.completeExceptionally(preserveUncertainOutcome(error, uncertain));
+            return;
+        }
         CompletableFuture<ByteBuf> attempt = new CompletableFuture<>();
         VsrResponseHandler.PendingRequest pending;
         try {
@@ -1261,6 +1272,27 @@
                 handler.closeChannel(channel, future.cause());
             }
         });
+        pending.sent(channel);
+    }
+
+    private static ByteBuf frameForAttempt(VsrExchange exchange, ByteBuf retainedFrame) {
+        if (exchange.commandCode() != CommandCode.Messages.POLL_DEFERRED.getValue()) {
+            return retainedFrame;
+        }
+        int parameters = retainedFrame.writerIndex() - PollRouter.DEFERRED_PARAMETERS_BYTES;
+        var options = PollRouter.deferredOptions(retainedFrame);
+        var remaining = options.remaining(
+                options.requestTimeout().minusNanos(exchange.requestDeadlineNanos() - System.nanoTime()));
+        if (remaining.isEmpty()) {
+            throw notAccepted();
+        }
+        // A progress ticket and pending writers share the frozen frame. Only this attempt's copy changes.
+        ByteBuf frame = retainedFrame.copy();
+        frame.setLongLE(
+                parameters - retainedFrame.readerIndex(), remaining.get().maxWaitMicros());
+        frame.setLongLE(frame.writerIndex() - Long.BYTES, remaining.get().requestTimeoutMicros());
+        retainedFrame.release();
+        return frame;
     }
 
     private static void releaseFrame(VsrExchange exchange, ByteBuf frame) {
@@ -1558,12 +1590,16 @@
         if (isPollRoutingCode(commandCode)
                 || (routed
                         && (commandCode == CommandCode.Messages.POLL.getValue()
+                                || commandCode == CommandCode.Messages.POLL_DEFERRED.getValue()
                                 || commandCode == CommandCode.ConsumerOffset.GET.getValue()))) {
             return false;
         }
         if (serverError.getRawErrorCode() == TRANSIENT_NOT_COMMITTED) {
             return System.nanoTime() < deadlineNanos;
         }
+        if (serverError.getRawErrorCode() == IggyErrorCode.DEFERRED_POLL_CAPACITY_EXCEEDED.getCode()) {
+            return !routed && System.nanoTime() < deadlineNanos;
+        }
         if (serverError.getRawErrorCode() == TRANSIENT_NOT_ACCEPTED) {
             return !routed && System.nanoTime() < notAcceptedDeadlineNanos;
         }
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/MessagesTcpClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/MessagesTcpClient.java
index 4f5397c..dc63aab 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/MessagesTcpClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/MessagesTcpClient.java
@@ -29,6 +29,7 @@
 import org.apache.iggy.hash.XxHash32;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
 import org.apache.iggy.message.PartitioningKind;
@@ -112,7 +113,36 @@
             PollingStrategy strategy,
             Long count,
             boolean autoCommit) {
-        long deadline = pollRouter.operationDeadline();
+        return pollMessagesImpl(streamId, topicId, partitionId, consumer, strategy, count, autoCommit, null);
+    }
+
+    @Override
+    public CompletableFuture<PolledMessages> pollMessagesDeferred(
+            StreamId streamId,
+            TopicId topicId,
+            Optional<Long> partitionId,
+            Consumer consumer,
+            PollingStrategy strategy,
+            Long count,
+            boolean autoCommit,
+            DeferredPollOptions options) {
+        options.validate(count);
+        return pollMessagesImpl(streamId, topicId, partitionId, consumer, strategy, count, autoCommit, options);
+    }
+
+    @SuppressWarnings("checkstyle:ParameterNumber")
+    private CompletableFuture<PolledMessages> pollMessagesImpl(
+            StreamId streamId,
+            TopicId topicId,
+            Optional<Long> partitionId,
+            Consumer consumer,
+            PollingStrategy strategy,
+            Long count,
+            boolean autoCommit,
+            DeferredPollOptions options) {
+        long deadline = options == null
+                ? pollRouter.operationDeadline()
+                : System.nanoTime() + options.requestTimeout().toNanos();
         var state = pollRouter.startCall();
         if (consumer.kind() == Consumer.Kind.ConsumerGroup && partitionId.isEmpty()) {
             // The VSR broker fences group polls against unowned partitions
@@ -126,6 +156,7 @@
                     strategy,
                     count,
                     autoCommit,
+                    options,
                     GROUP_POLL_MAX_ATTEMPTS,
                     cancellation,
                     deadline,
@@ -137,8 +168,8 @@
             });
             return pollRouter.onClientLoop(result);
         }
-        return pollRouter.onClientLoop(
-                pollPartition(streamId, topicId, partitionId, consumer, strategy, count, autoCommit, deadline, state));
+        return pollRouter.onClientLoop(pollPartition(
+                streamId, topicId, partitionId, consumer, strategy, count, autoCommit, options, deadline, state));
     }
 
     @SuppressWarnings("checkstyle:ParameterNumber")
@@ -150,8 +181,20 @@
             PollingStrategy strategy,
             Long count,
             boolean autoCommit,
+            DeferredPollOptions options,
             long deadline,
             AsyncTcpConnection.TransientFailoverState state) {
+        DeferredPollOptions attempt = null;
+        if (options != null) {
+            // The deadline was set when the call began, so a later attempt carries only what is left of it.
+            Optional<DeferredPollOptions> remaining =
+                    options.remaining(options.requestTimeout().minusNanos(deadline - System.nanoTime()));
+            if (remaining.isEmpty()) {
+                return CompletableFuture.failedFuture(
+                        new IggyServerException(IggyErrorCode.TRANSIENT_NOT_ACCEPTED.getCode()));
+            }
+            attempt = remaining.get();
+        }
 
         var payload = Unpooled.buffer();
 
@@ -164,9 +207,17 @@
         payload.writeIntLE(count.intValue());
 
         payload.writeByte(autoCommit ? 1 : 0);
+        if (attempt != null) {
+            payload.writeLongLE(attempt.maxWaitMicros());
+            payload.writeIntLE((int) attempt.minCount());
+            payload.writeIntLE((int) attempt.maxBytes());
+            payload.writeLongLE(attempt.requestTimeoutMicros());
+        }
 
         CompletableFuture<ByteBuf> sent = pollRouter.partitionRequest(
-                org.apache.iggy.serde.CommandCode.Messages.POLL.getValue(),
+                options == null
+                        ? org.apache.iggy.serde.CommandCode.Messages.POLL.getValue()
+                        : org.apache.iggy.serde.CommandCode.Messages.POLL_DEFERRED.getValue(),
                 payload,
                 strategy.context(),
                 deadline,
@@ -320,6 +371,7 @@
             PollingStrategy strategy,
             Long count,
             boolean autoCommit,
+            DeferredPollOptions options,
             int attemptsLeft,
             CallCancellation cancellation,
             long deadline,
@@ -360,6 +412,7 @@
                             strategy,
                             count,
                             autoCommit,
+                            options,
                             deadline,
                             state);
                     cancellation.track(polledPartition);
@@ -375,6 +428,7 @@
                                             strategy,
                                             count,
                                             autoCommit,
+                                            options,
                                             attemptsLeft - 1,
                                             cancellation,
                                             deadline,
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/PollRouter.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/PollRouter.java
index a56c06e..5f764a3 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/PollRouter.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/PollRouter.java
@@ -41,6 +41,7 @@
 import org.apache.iggy.exception.IggyTimeoutException;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.PollingKind;
 import org.apache.iggy.partition.PartitionContext;
 import org.apache.iggy.partition.PartitionRoute;
@@ -50,11 +51,13 @@
 
 import java.io.IOException;
 import java.time.Duration;
+import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.NavigableMap;
@@ -77,9 +80,12 @@
  */
 final class PollRouter {
     static final int POLL_PARAMETERS_BYTES = 14;
+    static final int DEFERRED_PARAMETERS_BYTES = 2 * Long.BYTES + 2 * Integer.BYTES;
 
     static final Duration POLL_TIMEOUT = Duration.ofSeconds(30);
     static final int MAX_CONCURRENT_DIALS = 4;
+    private static final int MAX_DEFERRED_POLLS = 64;
+
     private static final int MAX_ROUTES = 4096;
     private static final int MAX_CONNECTIONS = 256;
     private static final int MAX_PENDING_POLLS = 4096;
@@ -97,6 +103,8 @@
     private long topologyGeneration;
     private final Map<ConnectionInfo, Slot> connections = new HashMap<>();
     private final Set<Poll> pending = new HashSet<>();
+    private final Set<Poll> deferredWaiting = new LinkedHashSet<>();
+    private int activeDeferredPolls;
     private volatile int rosterSize;
     private final NavigableMap<Integer, ConnectionInfo> replicas = new TreeMap<>();
     // Read without the monitor: a slot opens its link under the slot monitor, and the client reads this there to size
@@ -531,7 +539,9 @@
             if (!explicit) {
                 if (command == CommandCode.ConsumerOffset.STORE.getValue()
                         || command == CommandCode.ConsumerOffset.DELETE.getValue()
-                        || (command == CommandCode.Messages.POLL.getValue() && consumerKind == 2)) {
+                        || ((command == CommandCode.Messages.POLL.getValue()
+                                        || command == CommandCode.Messages.POLL_DEFERRED.getValue())
+                                && consumerKind == 2)) {
                     throw new IggyInvalidArgumentException("A group poll or offset mutation requires a partition id");
                 }
                 partition = 0;
@@ -563,6 +573,16 @@
         }
     }
 
+    /** The limits that end a deferred poll request or frame, checked by the {@link DeferredPollOptions} constructor. */
+    static DeferredPollOptions deferredOptions(ByteBuf request) {
+        int parameters = request.writerIndex() - DEFERRED_PARAMETERS_BYTES;
+        return new DeferredPollOptions(
+                Duration.of(request.getLongLE(parameters), ChronoUnit.MICROS),
+                request.getUnsignedIntLE(parameters + Long.BYTES),
+                request.getUnsignedIntLE(parameters + Long.BYTES + Integer.BYTES),
+                Duration.of(request.getLongLE(parameters + Long.BYTES + 2 * Integer.BYTES), ChronoUnit.MICROS));
+    }
+
     ConsensusSession sessionOwner() {
         return coordinator.get().sessionOwner();
     }
@@ -609,12 +629,7 @@
     }
 
     private CompletableFuture<ByteBuf> send(Poll poll) {
-        poll.result.whenComplete((response, error) -> {
-            poll.finish(error);
-            synchronized (this) {
-                pending.remove(poll);
-            }
-        });
+        poll.result.whenComplete((response, error) -> finish(poll, error));
         synchronized (this) {
             if (pending.size() >= MAX_PENDING_POLLS) {
                 poll.result.completeExceptionally(
@@ -630,6 +645,19 @@
                     .eventLoop()
                     .schedule(poll::endPending, Math.max(0, poll.deadline - System.nanoTime()), TimeUnit.NANOSECONDS);
             poll.result.whenComplete((response, error) -> timeout.cancel(false));
+            synchronized (this) {
+                if (poll.result.isDone()) {
+                    return poll.result;
+                }
+                if (poll.dataCommand == CommandCode.Messages.POLL_DEFERRED.getValue()) {
+                    if (activeDeferredPolls >= MAX_DEFERRED_POLLS) {
+                        deferredWaiting.add(poll);
+                        return poll.result;
+                    }
+                    poll.deferredAdmitted = true;
+                    activeDeferredPolls++;
+                }
+            }
             attempt(poll);
         } catch (RuntimeException error) {
             poll.fail(error);
@@ -702,6 +730,53 @@
         return replicas.get(replicaId);
     }
 
+    private void finish(Poll poll, Throwable error) {
+        poll.finish(error);
+        Poll next = null;
+        synchronized (this) {
+            pending.remove(poll);
+            deferredWaiting.remove(poll);
+            if (poll.deferredAdmitted) {
+                activeDeferredPolls--;
+                var waiting = deferredWaiting.iterator();
+                while (waiting.hasNext()) {
+                    Poll candidate = waiting.next();
+                    waiting.remove();
+                    if (!candidate.result.isDone()) {
+                        candidate.deferredAdmitted = true;
+                        activeDeferredPolls++;
+                        next = candidate;
+                        break;
+                    }
+                }
+            }
+        }
+        if (next != null) {
+            startDeferred(next);
+        }
+    }
+
+    private void startDeferred(Poll poll) {
+        try {
+            // Completion can hold another poll's monitor, so admission continues on the client loop.
+            coordinator.get().eventLoop().execute(() -> {
+                try {
+                    attempt(poll);
+                } catch (RuntimeException error) {
+                    poll.fail(error);
+                }
+            });
+        } catch (RuntimeException error) {
+            List<Poll> waiting;
+            synchronized (this) {
+                waiting = List.copyOf(deferredWaiting);
+                deferredWaiting.clear();
+            }
+            poll.fail(error);
+            waiting.forEach(queued -> queued.fail(error));
+        }
+    }
+
     CompletableFuture<Void> clearSession() {
         return clearSession(null);
     }
@@ -713,6 +788,7 @@
             topologyGeneration++;
             clear();
             ending = Set.copyOf(pending);
+            deferredWaiting.clear();
             closing = List.copyOf(connections.values());
             connections.clear();
         }
@@ -727,6 +803,10 @@
         if (poll.result.isDone()) {
             return;
         }
+        if (poll.deadline - System.nanoTime() <= 0) {
+            poll.endPending();
+            return;
+        }
         if (poll.state.signedOut()) {
             poll.fail(poll.state.lineageEnded(null));
             return;
@@ -737,7 +817,7 @@
                 synchronized (this) {
                     Route known = routes.get(poll.key);
                     if (known != null) {
-                        coordinator.get().sessionOwner().checkRetained(poll.laneFor(known.context));
+                        coordinator.get().sessionOwner().checkRetained(poll.laneFor(known.context), poll.isMutation());
                     }
                 }
             } catch (RuntimeException error) {
@@ -772,7 +852,7 @@
         boolean notPrimary = refusal != null && refusal.getRawErrorCode() == AsyncTcpConnection.NOT_PRIMARY;
         boolean lostLink = connectionFailed(error);
         invalidateRoute(poll, error, notPrimary);
-        if ((!poll.requiresLane || poll.isMutation())
+        if (!poll.advancesProgress()
                 && (lostLink
                         || (refusal != null
                                 && refusal.getRawErrorCode() == AsyncTcpConnection.TRANSIENT_NOT_COMMITTED))) {
@@ -795,7 +875,12 @@
             retryLifecycle(poll, refusal);
             return;
         }
-        if (!poll.terminalRoutingRefusal && (isNotAccepted(error) || notPrimary)) {
+        if (!poll.terminalRoutingRefusal
+                && (isNotAccepted(error)
+                        || notPrimary
+                        || (refusal != null
+                                && refusal.getRawErrorCode()
+                                        == IggyErrorCode.DEFERRED_POLL_CAPACITY_EXCEEDED.getCode()))) {
             retry(poll, notPrimary);
         } else {
             poll.fail(error);
@@ -1380,6 +1465,7 @@
         private final CompletableFuture<ByteBuf> result = new CompletableFuture<>();
         private final long deadline;
         private volatile CompletableFuture<ByteBuf> activeExchange;
+        private boolean deferredAdmitted;
         // The caller's context, or else the first route's, stays fixed within one operation.
         private final AtomicReference<PartitionContext> context;
         private final boolean hasCallerContext;
@@ -1414,10 +1500,14 @@
             this.deadline = deadline;
             this.context = new AtomicReference<>(callerContext.orElse(null));
             this.hasCallerContext = callerContext.isPresent();
+            int pollEnd = payload.writerIndex()
+                    - (dataCommand == CommandCode.Messages.POLL_DEFERRED.getValue() ? DEFERRED_PARAMETERS_BYTES : 0);
             this.requiresLane = isMutation()
-                    || (dataCommand == CommandCode.Messages.POLL.getValue()
-                            && (payload.getUnsignedByte(payload.writerIndex() - 1) != 0
-                                    || payload.getUnsignedByte(payload.writerIndex() - POLL_PARAMETERS_BYTES)
+                    || dataCommand == CommandCode.ConsumerOffset.GET.getValue()
+                    || ((dataCommand == CommandCode.Messages.POLL.getValue()
+                                    || dataCommand == CommandCode.Messages.POLL_DEFERRED.getValue())
+                            && (payload.getUnsignedByte(pollEnd - 1) != 0
+                                    || payload.getUnsignedByte(pollEnd - POLL_PARAMETERS_BYTES)
                                             == PollingKind.Next.asCode()));
         }
 
@@ -1425,13 +1515,18 @@
             return VsrOperation.operationForCode(dataCommand) != VsrOperation.NON_REPLICATED;
         }
 
+        private boolean advancesProgress() {
+            return requiresLane && !isMutation() && dataCommand != CommandCode.ConsumerOffset.GET.getValue();
+        }
+
         private ConsensusSession.LaneKey laneFor(PartitionContext fallback) {
             PartitionContext captured = context.get();
             ByteBuf query = Unpooled.wrappedBuffer(routingBody);
             try {
                 return new ConsensusSession.LaneKey(
                         (captured == null ? fallback : captured).incarnation().longValue(),
-                        query.getUnsignedIntLE(query.writerIndex() - Integer.BYTES));
+                        query.getUnsignedIntLE(query.writerIndex() - Integer.BYTES),
+                        dataCommand != CommandCode.Messages.SEND.getValue());
             } finally {
                 query.release();
             }
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/PoolChannelHandler.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/PoolChannelHandler.java
index 627b300..f2cfae8 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/PoolChannelHandler.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/PoolChannelHandler.java
@@ -87,7 +87,12 @@
         pipeline.addLast(
                 "flushConsolidation",
                 new FlushConsolidationHandler(FlushConsolidationHandler.DEFAULT_EXPLICIT_FLUSH_AFTER_FLUSHES, false));
-        pipeline.addLast("frameDecoder", new VsrFrameDecoder(maxVsrFrameSize));
-        pipeline.addLast("responseHandler", new VsrResponseHandler(consensusSession, onEviction));
+        var responseHandler = new VsrResponseHandler(consensusSession, onEviction);
+        pipeline.addLast(
+                "frameDecoder",
+                new VsrFrameDecoder(
+                        maxVsrFrameSize,
+                        requestId -> responseHandler.maxResponseFrameSize(requestId, maxVsrFrameSize)));
+        pipeline.addLast("responseHandler", responseHandler);
     }
 }
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/RawPartitionRequests.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/RawPartitionRequests.java
index 9e681e1..734d3db 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/RawPartitionRequests.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/RawPartitionRequests.java
@@ -25,6 +25,7 @@
 import org.apache.iggy.exception.IggyErrorCode;
 import org.apache.iggy.exception.IggyInvalidArgumentException;
 import org.apache.iggy.exception.IggyServerException;
+import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.PartitioningKind;
 import org.apache.iggy.serde.CommandCode;
 
@@ -36,14 +37,21 @@
 /**
  * Stamps raw partition commands with the partition context the server fences
  * them by, as Rust's raw API does: a send to an explicit partition shares the
- * typed sends' context cache, and polls and offset writes take the routed path
- * of a typed poll or offset write. Other commands go out as they are.
+ * typed sends' context cache, and polls, deferred polls and offset writes take
+ * the routed path of a typed poll or offset write. Readiness waits, which Rust
+ * routes to the primary of each partition they name, and deferred-request
+ * cancels are refused. Other commands go out as they are.
  */
 final class RawPartitionRequests {
     private static final int SEND_MESSAGES = CommandCode.Messages.SEND.getValue();
     private static final int POLL_MESSAGES = CommandCode.Messages.POLL.getValue();
+    private static final int POLL_MESSAGES_DEFERRED = CommandCode.Messages.POLL_DEFERRED.getValue();
+    private static final int WAIT_FOR_MESSAGES = CommandCode.Messages.WAIT_FOR_MESSAGES.getValue();
+    private static final int CANCEL_DEFERRED_REQUEST = CommandCode.Messages.CANCEL_DEFERRED_REQUEST.getValue();
     private static final int STORE_CONSUMER_OFFSET = CommandCode.ConsumerOffset.STORE.getValue();
     private static final int DELETE_CONSUMER_OFFSET = CommandCode.ConsumerOffset.DELETE.getValue();
+    // A poll's count and auto-commit flag end its polling parameters.
+    private static final int COUNT_AND_AUTO_COMMIT_BYTES = Integer.BYTES + 1;
     private static final int SEND_METADATA_LENGTH_BYTES = Integer.BYTES;
     // An identifier and a partitioning both lead with a kind byte and a length byte.
     private static final int KIND_AND_LENGTH_BYTES = 2;
@@ -55,6 +63,8 @@
     private static final int DELETE_OFFSET_SUFFIX_BYTES = 1;
     private static final String UNPARTITIONED_SEND = "A raw SendMessages request needs an explicit partition id."
             + " Send balanced or messages-key batches with MessagesClient.sendMessages.";
+    private static final String UNROUTED_WAIT = "A raw WaitForMessages request needs the primary of each partition"
+            + " it names, and this client does not route readiness waits.";
 
     private final Supplier<AsyncTcpConnection> connection;
     private final PollRouter pollRouter;
@@ -75,7 +85,9 @@
     /**
      * Fails a partition command whose payload does not follow the layout the
      * typed API writes, and a send without an explicit partition, before
-     * anything is sent: either would otherwise go out with no context.
+     * anything is sent: either would otherwise go out with no context. A
+     * deferred poll with limits that the server's decoder refuses fails the
+     * same way.
      */
     CompletableFuture<ByteBuf> send(int code, byte[] payload) {
         try {
@@ -85,6 +97,18 @@
             if (code == POLL_MESSAGES) {
                 return pollMessages(payload);
             }
+            if (code == POLL_MESSAGES_DEFERRED) {
+                return pollMessagesDeferred(payload);
+            }
+            if (code == WAIT_FOR_MESSAGES) {
+                throw IggyServerException.fromTcpResponse(
+                        IggyErrorCode.FEATURE_UNAVAILABLE.getCode(), UNROUTED_WAIT.getBytes(StandardCharsets.UTF_8));
+            }
+            if (code == CANCEL_DEFERRED_REQUEST) {
+                // A cancel names a pending request by its id, and the server answers no cancel,
+                // so a new raw request could only wait for its deadline.
+                throw IggyServerException.fromTcpResponse(IggyErrorCode.INVALID_COMMAND.getCode(), new byte[0]);
+            }
             if (code == CommandCode.ConsumerOffset.GET.getValue()) {
                 targetEnd(payload, code);
                 return pollRouter.partitionRequest(code, Unpooled.copiedBuffer(payload), Optional.empty());
@@ -126,6 +150,30 @@
         return messages.sendPoll(Unpooled.copiedBuffer(payload), Optional.empty());
     }
 
+    private CompletableFuture<ByteBuf> pollMessagesDeferred(byte[] payload) {
+        int parameters = targetEnd(payload, POLL_MESSAGES_DEFERRED);
+        int limits = parameters + PollRouter.POLL_PARAMETERS_BYTES;
+        if (payload.length - limits != PollRouter.DEFERRED_PARAMETERS_BYTES) {
+            throw malformed(POLL_MESSAGES_DEFERRED, "polling parameters", parameters);
+        }
+        ByteBuf request = Unpooled.copiedBuffer(payload);
+        DeferredPollOptions options;
+        try {
+            options = PollRouter.deferredOptions(request);
+            options.validate(request.getUnsignedIntLE(limits - COUNT_AND_AUTO_COMMIT_BYTES));
+        } catch (IggyInvalidArgumentException refused) {
+            request.release();
+            throw refused;
+        }
+        // As for a typed deferred poll, the request's own budget sets the deadline,
+        // and each attempt sends what is left of that budget.
+        return pollRouter.partitionRequest(
+                POLL_MESSAGES_DEFERRED,
+                request,
+                Optional.empty(),
+                System.nanoTime() + options.requestTimeout().toNanos());
+    }
+
     private CompletableFuture<ByteBuf> writeOffset(int code, byte[] payload) {
         int target = targetEnd(payload, code);
         int suffix = code == STORE_CONSUMER_OFFSET ? STORE_OFFSET_SUFFIX_BYTES : DELETE_OFFSET_SUFFIX_BYTES;
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/ConsensusSession.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/ConsensusSession.java
index 249d6d3..8f6f54a 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/ConsensusSession.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/ConsensusSession.java
@@ -481,6 +481,7 @@
         maxPendingBytes = bytes;
     }
 
+    /** An offset mutation takes its progress turn before joining the partition mutation queue. */
     public CompletableFuture<Ticket> acquire(
             LaneKey lane, boolean mutation, long frameBytes, long deadline, EventExecutor executor) {
         ConsensusSession owner = owner();
@@ -488,6 +489,7 @@
             return owner.acquire(lane, mutation, frameBytes, deadline, executor);
         }
         CompletableFuture<Ticket> result = new CompletableFuture<>();
+        LaneKey mutationLane = lane.progress() && mutation ? new LaneKey(lane.incarnation(), lane.partition()) : null;
         var timer = executor.schedule(
                 () -> result.completeExceptionally(
                         new IggyServerException(IggyErrorCode.TRANSIENT_NOT_ACCEPTED.getCode())),
@@ -500,24 +502,41 @@
             timer.cancel(false);
             boolean wakeNext;
             synchronized (this) {
-                var waiting = laneAdmissions.get(lane);
-                wakeNext = waiting.getFirst() == result && !lanes.containsKey(lane);
-                waiting.remove(result);
-                if (waiting.isEmpty()) {
-                    laneAdmissions.remove(lane);
+                wakeNext = removeAdmission(lane, result);
+                if (mutationLane != null) {
+                    wakeNext |= removeAdmission(mutationLane, result);
                 }
             }
             if (wakeNext) {
                 changed();
             }
         });
-        acquireWhenAvailable(lane, mutation, frameBytes, deadline, result);
+        acquireWhenAvailable(lane, mutationLane, mutation, frameBytes, deadline, result, false);
         return result;
     }
 
-    @SuppressWarnings("checkstyle:CyclomaticComplexity")
+    private boolean removeAdmission(LaneKey lane, CompletableFuture<Ticket> result) {
+        var waiting = laneAdmissions.get(lane);
+        if (waiting == null) {
+            return false;
+        }
+        boolean wakeNext = waiting.getFirst() == result && !lanes.containsKey(lane);
+        waiting.remove(result);
+        if (waiting.isEmpty()) {
+            laneAdmissions.remove(lane);
+        }
+        return wakeNext;
+    }
+
+    @SuppressWarnings({"checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity"})
     private void acquireWhenAvailable(
-            LaneKey lane, boolean mutation, long frameBytes, long deadline, CompletableFuture<Ticket> result) {
+            LaneKey lane,
+            LaneKey mutationLane,
+            boolean mutation,
+            long frameBytes,
+            long deadline,
+            CompletableFuture<Ticket> result,
+            boolean mutationQueued) {
         CompletableFuture<Void> changed = null;
         Ticket admitted = null;
         RuntimeException failure = null;
@@ -526,24 +545,43 @@
                 return;
             }
             Ticket blocking = lanes.get(lane);
+            Ticket mutationBlocking = mutationLane == null ? null : lanes.get(mutationLane);
             if (blocking != null && blocking.retained) {
                 failure = new IggyOperationException(IggyErrorCode.LANE_BLOCKED, blocking.operationId);
+            } else if (mutationBlocking != null && mutationBlocking.retained) {
+                failure = new IggyOperationException(IggyErrorCode.LANE_BLOCKED, mutationBlocking.operationId);
             } else if (mutation && frameBytes > maxPendingBytes) {
                 failure = new IggyInvalidArgumentException("Request exceeds the pending operation byte limit");
             } else if (deadline - System.nanoTime() <= 0) {
                 failure = new IggyServerException(IggyErrorCode.TRANSIENT_NOT_ACCEPTED.getCode());
-            } else if (laneAdmissions.get(lane).getFirst() != result
-                    || blocking != null
-                    || (mutation
-                            && (pendingOperations >= maxPendingOperations
-                                    || frameBytes > maxPendingBytes - pendingBytes))) {
+            } else if (laneAdmissions.get(lane).getFirst() != result || blocking != null) {
                 changed = admissionChanged;
             } else {
-                admitted = new Ticket(this, lane, mutation, frameBytes, generation);
-                lanes.put(lane, admitted);
-                if (mutation) {
-                    pendingOperations++;
-                    pendingBytes += frameBytes;
+                // A store enters the mutation queue only after its progress turn,
+                // so a parked poll cannot hold back producers through that store.
+                if (mutationLane != null && !mutationQueued) {
+                    laneAdmissions
+                            .computeIfAbsent(mutationLane, ignored -> new ArrayDeque<>())
+                            .addLast(result);
+                    mutationQueued = true;
+                }
+                if (mutationBlocking != null
+                        || (mutationLane != null
+                                && laneAdmissions.get(mutationLane).getFirst() != result)
+                        || (mutation
+                                && (pendingOperations >= maxPendingOperations
+                                        || frameBytes > maxPendingBytes - pendingBytes))) {
+                    changed = admissionChanged;
+                } else {
+                    admitted = new Ticket(this, lane, mutationLane, mutation, frameBytes, generation);
+                    lanes.put(lane, admitted);
+                    if (mutationLane != null) {
+                        lanes.put(mutationLane, admitted);
+                    }
+                    if (mutation) {
+                        pendingOperations++;
+                        pendingBytes += frameBytes;
+                    }
                 }
             }
         }
@@ -554,17 +592,29 @@
                 admitted.complete();
             }
         } else {
-            changed.thenRun(() -> acquireWhenAvailable(lane, mutation, frameBytes, deadline, result));
+            boolean queued = mutationQueued;
+            changed.thenRun(
+                    () -> acquireWhenAvailable(lane, mutationLane, mutation, frameBytes, deadline, result, queued));
         }
     }
 
     public void checkRetained(LaneKey lane) {
+        checkRetained(lane, false);
+    }
+
+    public void checkRetained(LaneKey lane, boolean mutation) {
         ConsensusSession owner = owner();
         synchronized (owner) {
             Ticket ticket = owner.lanes.get(lane);
             if (ticket != null && ticket.retained) {
                 throw new IggyOperationException(IggyErrorCode.LANE_BLOCKED, ticket.operationId);
             }
+            if (mutation && lane.progress()) {
+                Ticket blocking = owner.lanes.get(new LaneKey(lane.incarnation(), lane.partition()));
+                if (blocking != null && blocking.retained) {
+                    throw new IggyOperationException(IggyErrorCode.LANE_BLOCKED, blocking.operationId);
+                }
+            }
         }
     }
 
@@ -743,8 +793,12 @@
         } while (clientIdLow == 0 && clientIdHigh == 0);
     }
 
-    public record LaneKey(long incarnation, long partition) {
+    public record LaneKey(long incarnation, long partition, boolean progress) {
         public static final LaneKey METADATA = new LaneKey(0, 0);
+
+        public LaneKey(long incarnation, long partition) {
+            this(incarnation, partition, false);
+        }
     }
 
     /** A copy of the reply body of a proved commit, or no body and the code of a proved committed rejection. */
@@ -754,6 +808,7 @@
     public static final class Ticket {
         private final ConsensusSession owner;
         private final LaneKey lane;
+        private final LaneKey mutationLane;
         private final boolean mutation;
         private final long bytes;
         private long generation;
@@ -771,9 +826,16 @@
         private boolean bytesReleased;
         private boolean restarting;
 
-        private Ticket(ConsensusSession owner, LaneKey lane, boolean mutation, long bytes, long generation) {
+        private Ticket(
+                ConsensusSession owner,
+                LaneKey lane,
+                LaneKey mutationLane,
+                boolean mutation,
+                long bytes,
+                long generation) {
             this.owner = owner;
             this.lane = lane;
+            this.mutationLane = mutationLane;
             this.mutation = mutation;
             this.bytes = bytes;
             this.generation = generation;
@@ -1002,6 +1064,9 @@
                 }
                 completed = true;
                 owner.lanes.remove(lane, this);
+                if (mutationLane != null) {
+                    owner.lanes.remove(mutationLane, this);
+                }
                 owner.retained.remove(operationId, this);
                 if (mutation && !abandoned) {
                     owner.pendingOperations--;
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrFrameDecoder.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrFrameDecoder.java
index 7398f52..94ddc34 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrFrameDecoder.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrFrameDecoder.java
@@ -25,6 +25,7 @@
 import io.netty.handler.codec.DecoderException;
 
 import java.util.List;
+import java.util.function.LongUnaryOperator;
 
 /**
  * Decoder for VSR response frames: a 256-byte consensus header whose total
@@ -37,17 +38,32 @@
     public static final int DEFAULT_MAX_FRAME_SIZE = 64 * 1024 * 1024;
 
     private final int maxFrameSize;
+    private final LongUnaryOperator requestLimit;
 
     public VsrFrameDecoder() {
         this(DEFAULT_MAX_FRAME_SIZE);
     }
 
     public VsrFrameDecoder(int maxFrameSize) {
+        this(maxFrameSize, ignored -> maxFrameSize);
+    }
+
+    /**
+     * A deferred poll declares how many response bytes it will accept, and that
+     * bound has to reach the decoder before it accumulates a body, not after.
+     * The limit is looked up per frame by its request id, so one exchange can
+     * tighten it while the connection's other replies keep the ceiling.
+     *
+     * @param maxFrameSize the connection's frame ceiling, never below the header size
+     * @param requestLimit the frame limit of the request a reply answers
+     */
+    public VsrFrameDecoder(int maxFrameSize, LongUnaryOperator requestLimit) {
         if (maxFrameSize < VsrHeaders.HEADER_SIZE) {
             throw new IllegalArgumentException(
                     "Maximum VSR frame size must be at least " + VsrHeaders.HEADER_SIZE + " bytes");
         }
         this.maxFrameSize = maxFrameSize;
+        this.requestLimit = requestLimit;
     }
 
     @Override
@@ -56,7 +72,8 @@
             return;
         }
         long totalSize = VsrHeaders.readSize(in);
-        if (totalSize < VsrHeaders.HEADER_SIZE || totalSize > maxFrameSize) {
+        if (totalSize < VsrHeaders.HEADER_SIZE
+                || totalSize > Math.min(maxFrameSize, requestLimit.applyAsLong(VsrHeaders.readReplyRequestId(in)))) {
             throw new DecoderException("Invalid VSR frame size " + totalSize + ", connection is desynchronized");
         }
         if (in.readableBytes() < totalSize) {
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrResponseHandler.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrResponseHandler.java
index f10b0bb..bb5fef8 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrResponseHandler.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/async/tcp/vsr/VsrResponseHandler.java
@@ -20,6 +20,7 @@
 package org.apache.iggy.client.async.tcp.vsr;
 
 import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
 import io.netty.channel.Channel;
 import io.netty.channel.ChannelHandlerContext;
 import io.netty.channel.SimpleChannelInboundHandler;
@@ -29,12 +30,16 @@
 import org.apache.iggy.exception.IggyNotConnectedException;
 import org.apache.iggy.exception.IggyServerException;
 import org.apache.iggy.exception.IggyTimeoutException;
+import org.apache.iggy.serde.CommandCode;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.IntConsumer;
@@ -67,6 +72,13 @@
         session.onChannelCreated();
     }
 
+    public long maxResponseFrameSize(long requestId, int defaultLimit) {
+        PendingRequest pending = pendingRequests.get(requestId);
+        return pending == null
+                ? defaultLimit
+                : Math.min(defaultLimit, pending.maxResponseBody + VsrHeaders.HEADER_SIZE);
+    }
+
     public PendingRequest registerRequest(
             Channel channel,
             ByteBuf requestFrame,
@@ -78,7 +90,19 @@
                 VsrHeaders.readRequestOperation(requestFrame),
                 VsrHeaders.readRequestId(requestFrame),
                 RequestIdentity.from(requestFrame));
-        registerRequest(pending);
+        if (commandCode == CommandCode.Messages.POLL_DEFERRED.getValue()) {
+            pending.maxResponseBody =
+                    requestFrame.getUnsignedIntLE(requestFrame.writerIndex() - Long.BYTES - Integer.BYTES);
+            pending.cancellation = new byte[VsrHeaders.HEADER_SIZE];
+            requestFrame.getBytes(requestFrame.readerIndex(), pending.cancellation);
+            ByteBuffer.wrap(pending.cancellation)
+                    .order(ByteOrder.LITTLE_ENDIAN)
+                    .putInt(VsrHeaders.SIZE_OFFSET, VsrHeaders.HEADER_SIZE)
+                    .putInt(
+                            VsrHeaders.REQUEST_RESERVED_CODE_OFFSET,
+                            CommandCode.Messages.CANCEL_DEFERRED_REQUEST.getValue());
+        }
+        registerRequest(pending, channel);
         long timeoutNanos = Math.max(0, deadlineNanos - System.nanoTime());
         ScheduledFuture<?> timeoutFuture;
         try {
@@ -106,7 +130,7 @@
         return pending;
     }
 
-    private void registerRequest(PendingRequest pending) {
+    private void registerRequest(PendingRequest pending, Channel channel) {
         pendingRequests.compute(pending.requestId, (requestId, previous) -> {
             if (previous != null && !previous.future.isDone()) {
                 throw new IllegalStateException(
@@ -115,7 +139,11 @@
             // Timeout and cancellation callbacks can replay before the previous entry's cleanup runs.
             return pending;
         });
-        pending.future.whenComplete((response, error) -> pendingRequests.remove(pending.requestId, pending));
+        pending.future.whenComplete((response, error) -> {
+            if (pendingRequests.remove(pending.requestId, pending)) {
+                pending.abandon(channel);
+            }
+        });
     }
 
     public void closeChannel(Channel channel, Throwable cause) {
@@ -225,7 +253,8 @@
         pending.committed = marked
                 && resultCode != VsrHeaders.ERROR_TRANSIENT_NOT_COMMITTED
                 && resultCode != VsrHeaders.ERROR_TRANSIENT_NOT_ACCEPTED
-                && resultCode != IggyErrorCode.NOT_PRIMARY.getCode();
+                && resultCode != IggyErrorCode.NOT_PRIMARY.getCode()
+                && resultCode != IggyErrorCode.DEFERRED_POLL_CAPACITY_EXCEEDED.getCode();
         recordSessionReply(frame, pending, operation, resultCode, bodyStart, bodyLength);
         if (resultCode != 0) {
             throw IggyServerException.fromTcpResponse(resultCode, new byte[0]);
@@ -334,10 +363,14 @@
         private final int operation;
         private final long requestId;
         private final RequestIdentity identity;
+        private long maxResponseBody = Integer.MAX_VALUE;
         private volatile boolean committed;
         private volatile boolean failedByLink;
         private volatile boolean detachedSession;
         private volatile boolean keepsLineage;
+        private byte[] cancellation;
+        private boolean sent;
+        private boolean abandoned;
 
         private PendingRequest(
                 CompletableFuture<ByteBuf> future, int operation, long requestId, RequestIdentity identity) {
@@ -351,6 +384,33 @@
             return committed;
         }
 
+        /**
+         * Called after the original write is issued. Off the loop that write is a queued task, and the
+         * cancellation is queued behind it, so it cannot overtake the request.
+         */
+        public synchronized void sent(Channel channel) {
+            sent = true;
+            sendCancellation(channel);
+        }
+
+        private synchronized void abandon(Channel channel) {
+            abandoned = true;
+            sendCancellation(channel);
+        }
+
+        private void sendCancellation(Channel channel) {
+            if (sent && abandoned && cancellation != null) {
+                byte[] frame = cancellation;
+                cancellation = null;
+                // On the loop a write runs inline, ahead of the queued original write.
+                try {
+                    channel.eventLoop().execute(() -> channel.writeAndFlush(Unpooled.wrappedBuffer(frame)));
+                } catch (RejectedExecutionException ignored) {
+                    // A loop that rejects tasks is closing the channel, which ends the request as well.
+                }
+            }
+        }
+
         /** Whether the link failed this request, so no reply answered it. */
         public boolean isFailedByLink() {
             return failedByLink;
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/MessagesClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/MessagesClient.java
index 3d025b9..abea7f6 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/MessagesClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/MessagesClient.java
@@ -20,8 +20,10 @@
 package org.apache.iggy.client.blocking;
 
 import org.apache.iggy.consumergroup.Consumer;
+import org.apache.iggy.exception.IggyOperationNotSupportedException;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
 import org.apache.iggy.message.PolledMessages;
@@ -77,6 +79,80 @@
             Long count,
             boolean autoCommit);
 
+    /**
+     * Polls messages with a deferred wait using numeric identifiers.
+     *
+     * <p>See {@link #pollMessagesDeferred(StreamId, TopicId, Optional, Consumer, PollingStrategy,
+     * Long, boolean, DeferredPollOptions)} for full documentation.
+     *
+     * @param streamId    the numeric stream ID
+     * @param topicId     the numeric topic ID
+     * @param partitionId optional partition ID
+     * @param consumerId  the numeric consumer ID
+     * @param strategy    the polling strategy
+     * @param count       the maximum number of messages to return
+     * @param autoCommit  whether to auto-commit offsets
+     * @param options     the readiness, byte and request-time limits
+     * @return the polled messages
+     */
+    @SuppressWarnings("checkstyle:ParameterNumber")
+    default PolledMessages pollMessagesDeferred(
+            Long streamId,
+            Long topicId,
+            Optional<Long> partitionId,
+            Long consumerId,
+            PollingStrategy strategy,
+            Long count,
+            boolean autoCommit,
+            DeferredPollOptions options) {
+        return pollMessagesDeferred(
+                StreamId.of(streamId),
+                TopicId.of(topicId),
+                partitionId,
+                Consumer.of(consumerId),
+                strategy,
+                count,
+                autoCommit,
+                options);
+    }
+
+    /**
+     * Polls messages, letting the server hold the request until data is ready.
+     *
+     * <p>Unlike {@link #pollMessages}, which reads whatever is resident and returns, this
+     * waits up to {@link DeferredPollOptions#maxWait()} for {@link DeferredPollOptions#minCount()}
+     * messages, caps the encoded response at {@link DeferredPollOptions#maxBytes()}, and bounds
+     * the whole exchange by {@link DeferredPollOptions#requestTimeout()}. An expired readiness
+     * wait can still return partial or empty data; an expired request timeout is an error.
+     *
+     * <p>The HTTP client accepts only a plain consumer, because the server refuses a group
+     * consumer over HTTP: without a persistent connection the client cannot follow the group's
+     * partition assignment. A group consumer is rejected before any request is sent.
+     *
+     * @param streamId    the stream identifier (numeric or string-based)
+     * @param topicId     the topic identifier (numeric or string-based)
+     * @param partitionId optional partition ID to poll from
+     * @param consumer    the consumer identity
+     * @param strategy    the polling strategy controlling where to start reading
+     * @param count       the maximum number of messages to return
+     * @param autoCommit  whether the server should automatically commit the consumer offset
+     * @param options     the readiness, byte and request-time limits
+     * @return the polled messages
+     */
+    @SuppressWarnings("checkstyle:ParameterNumber")
+    default PolledMessages pollMessagesDeferred(
+            StreamId streamId,
+            TopicId topicId,
+            Optional<Long> partitionId,
+            Consumer consumer,
+            PollingStrategy strategy,
+            Long count,
+            boolean autoCommit,
+            DeferredPollOptions options) {
+        throw new IggyOperationNotSupportedException(
+                "pollMessagesDeferred", getClass().getSimpleName());
+    }
+
     default SendMessagesResponse sendMessages(
             Long streamId, Long topicId, Partitioning partitioning, List<Message> messages) {
         return sendMessages(StreamId.of(streamId), TopicId.of(topicId), partitioning, messages);
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/InternalHttpClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/InternalHttpClient.java
index 47d650a..d44a33e 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/InternalHttpClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/InternalHttpClient.java
@@ -19,6 +19,7 @@
 
 package org.apache.iggy.client.blocking.http;
 
+import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase;
 import org.apache.hc.client5.http.config.ConnectionConfig;
 import org.apache.hc.client5.http.config.RequestConfig;
 import org.apache.hc.client5.http.impl.DefaultHttpRequestRetryStrategy;
@@ -29,35 +30,59 @@
 import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
 import org.apache.hc.core5.http.ClassicHttpRequest;
 import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.HttpException;
 import org.apache.hc.core5.http.HttpRequest;
 import org.apache.hc.core5.http.HttpResponse;
 import org.apache.hc.core5.http.HttpStatus;
 import org.apache.hc.core5.http.Method;
 import org.apache.hc.core5.http.NameValuePair;
+import org.apache.hc.core5.http.impl.io.HttpRequestExecutor;
+import org.apache.hc.core5.http.io.HttpClientConnection;
 import org.apache.hc.core5.http.io.HttpClientResponseHandler;
+import org.apache.hc.core5.http.io.HttpResponseInformationCallback;
 import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
 import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.io.ModalCloseable;
 import org.apache.hc.core5.ssl.SSLContextBuilder;
+import org.apache.hc.core5.util.TimeValue;
 import org.apache.hc.core5.util.Timeout;
+import org.apache.iggy.exception.IggyClientException;
 import org.apache.iggy.exception.IggyConnectionException;
 import org.apache.iggy.exception.IggyErrorCode;
+import org.apache.iggy.exception.IggyException;
+import org.apache.iggy.exception.IggyInvalidArgumentException;
+import org.apache.iggy.exception.IggyMalformedResponseException;
 import org.apache.iggy.exception.IggyServerException;
+import org.apache.iggy.exception.IggyTimeoutException;
 import org.apache.iggy.exception.IggyTlsException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import tools.jackson.core.JacksonException;
 import tools.jackson.core.type.TypeReference;
 import tools.jackson.databind.JavaType;
+import tools.jackson.databind.JsonNode;
 import tools.jackson.databind.ObjectMapper;
 import tools.jackson.databind.node.ObjectNode;
 
+import java.io.ByteArrayOutputStream;
 import java.io.Closeable;
 import java.io.File;
 import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.net.URISyntaxException;
 import java.security.GeneralSecurityException;
 import java.time.Duration;
 import java.util.EnumSet;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
 
 final class InternalHttpClient implements Closeable {
 
@@ -65,14 +90,31 @@
 
     private static final String AUTHORIZATION = "Authorization";
     private static final String UNCERTAIN_ATTEMPT = "iggy.uncertain-attempt";
+    private static final String DEFERRED_ATTEMPTED = "iggy.deferred-attempted";
     private static final Set<IggyErrorCode> SETTLED_REFUSALS = EnumSet.of(
             IggyErrorCode.HISTORY_UNAVAILABLE,
             IggyErrorCode.LIFECYCLE_BUSY,
             IggyErrorCode.UNAUTHORIZED,
             IggyErrorCode.UNAUTHENTICATED);
+
+    /**
+     * Deferred requests occupy their connection for the whole readiness wait, so
+     * they get their own pool and can never starve control traffic. The bound
+     * matches the binary client's deferred lease count.
+     */
+    private static final int MAX_DEFERRED_CONNECTIONS = 64;
+
+    /** Mirrors the binary client's pending-poll admission cap. */
+    private static final int MAX_PENDING_DEFERRED_REQUESTS = 4096;
+
+    private static final int BODY_CHUNK_BYTES = 8 * 1024;
+
     private final String url;
     private final ObjectMapper objectMapper = ObjectMapperFactory.getInstance();
     private final CloseableHttpClient httpClient;
+    private final CloseableHttpClient deferredHttpClient;
+    private final ScheduledThreadPoolExecutor deadlines = createDeadlineTimer();
+    private final AtomicInteger pendingDeferred = new AtomicInteger();
     private Optional<String> token = Optional.empty();
 
     InternalHttpClient(
@@ -83,6 +125,7 @@
         UrlValidator.validateHttpUrl(url);
         this.url = url;
         this.httpClient = createHttpClient(connectionTimeout, requestTimeout, tlsCertificate);
+        this.deferredHttpClient = createDeferredHttpClient(connectionTimeout, tlsCertificate);
     }
 
     private static CloseableHttpClient createHttpClient(
@@ -93,16 +136,7 @@
         var connectionManagerBuilder = PoolingHttpClientConnectionManagerBuilder.create()
                 .setDefaultConnectionConfig(connectionConfigBuilder.build());
 
-        tlsCertificate.ifPresent(cert -> {
-            try {
-                var sslContext =
-                        SSLContextBuilder.create().loadTrustMaterial(cert, null).build();
-                var tlsStrategy = new DefaultClientTlsStrategy(sslContext);
-                connectionManagerBuilder.setTlsSocketStrategy(tlsStrategy);
-            } catch (GeneralSecurityException | IOException e) {
-                throw new IggyTlsException("Failed to configure TLS certificate", e);
-            }
-        });
+        applyTlsCertificate(connectionManagerBuilder, tlsCertificate);
 
         var requestConfigBuilder = RequestConfig.custom();
         requestTimeout.ifPresent(timeout -> requestConfigBuilder.setResponseTimeout(Timeout.of(timeout)));
@@ -114,9 +148,169 @@
                 .build();
     }
 
+    /**
+     * A deferred poll declares its own total budget. The library's timeouts
+     * restart for every wait, so a read that starts near the deadline would
+     * outlive it. A timer cancels the exchange at the deadline instead, which
+     * ends the connection lease, the connect, the wait for headers and any body
+     * read. Automatic retries are off: the server answers both
+     * {@code TRANSIENT_NOT_ACCEPTED} and the ambiguous
+     * {@code TRANSIENT_NOT_COMMITTED} with HTTP 503, and a proxy can answer it
+     * too, so only a parsed server code may authorize a replay.
+     *
+     * @param request       the prepared deferred request
+     * @param clazz         the success body type
+     * @param deadlineNanos absolute {@link System#nanoTime()} deadline for the whole exchange
+     * @param maxBodyBytes  the largest encoded body this client will accumulate
+     * @param <T>           the success body type
+     * @return the decoded body
+     */
+    <T> T executeDeferred(ClassicHttpRequest request, Class<T> clazz, long deadlineNanos, long maxBodyBytes) {
+        if (pendingDeferred.incrementAndGet() > MAX_PENDING_DEFERRED_REQUESTS) {
+            pendingDeferred.decrementAndGet();
+            throw deferredFailure(false, new IggyClientException("Too many pending deferred HTTP polls"));
+        }
+        ScheduledFuture<?> abort = null;
+        var context = HttpClientContext.create();
+        try {
+            long remainingNanos = deadlineNanos - System.nanoTime();
+            if (remainingNanos <= 0) {
+                throw new IggyTimeoutException("Deferred poll exceeded its request timeout");
+            }
+            HttpUriRequestBase exchange = cancellable(request);
+            abort = deadlines.schedule(exchange::cancel, remainingNanos, TimeUnit.NANOSECONDS);
+            // Rounded up to whole milliseconds, so neither fires before the deadline and reports it as a
+            // connection failure. Their defaults would end a long readiness wait early.
+            Timeout budget = Timeout.ofMilliseconds(TimeUnit.NANOSECONDS.toMillis(remainingNanos) + 1);
+            context.setRequestConfig(RequestConfig.custom()
+                    .setConnectionRequestTimeout(budget)
+                    .setResponseTimeout(budget)
+                    .build());
+            return deferredHttpClient.execute(exchange, context, response -> {
+                byte[] body;
+                try {
+                    body = readBounded(response.getEntity(), deadlineNanos, maxBodyBytes);
+                } catch (IOException | RuntimeException unread) {
+                    // A graceful close reads the rest of the body first, and a slow peer would hold the
+                    // caller past its deadline. An immediate close discards the connection unread.
+                    ((ModalCloseable) response).close(CloseMode.IMMEDIATE);
+                    throw unread;
+                }
+                if (!isSuccessful(response.getCode())) {
+                    throw errorResponse(response.getCode(), body);
+                }
+                return objectMapper.readValue(body, clazz);
+            });
+        } catch (IOException | CancellationException | IggyTimeoutException failure) {
+            throw deferredFailure(Boolean.TRUE.equals(context.getAttribute(DEFERRED_ATTEMPTED)), failure);
+        } finally {
+            if (abort != null) {
+                abort.cancel(false);
+            }
+            pendingDeferred.decrementAndGet();
+        }
+    }
+
+    static IggyServerException deferredFailure(boolean possiblySent, Throwable failure) {
+        var error = new IggyServerException(
+                (possiblySent ? IggyErrorCode.TRANSIENT_NOT_COMMITTED : IggyErrorCode.TRANSIENT_NOT_ACCEPTED)
+                        .getCode());
+        error.initCause(failure);
+        return error;
+    }
+
+    /** The client only lets a request that implements its cancellation contract be aborted mid-exchange. */
+    private static HttpUriRequestBase cancellable(ClassicHttpRequest request) {
+        HttpUriRequestBase cancellable;
+        try {
+            cancellable = new HttpUriRequestBase(request.getMethod(), request.getUri());
+        } catch (URISyntaxException invalid) {
+            throw new IggyInvalidArgumentException("Invalid deferred poll URI " + request.getRequestUri(), invalid);
+        }
+        cancellable.setHeaders(request.getHeaders());
+        return cancellable;
+    }
+
+    /**
+     * Reads the whole body under the caller's deadline. The deadline timer
+     * aborts a read that blocks, and the check between reads refuses a body
+     * that is still arriving after it. On failure the stream stays open, since
+     * closing it would read the rest of the body: the caller discards the
+     * connection instead.
+     */
+    private static byte[] readBounded(HttpEntity entity, long deadlineNanos, long maxBodyBytes) throws IOException {
+        if (entity == null) {
+            return new byte[0];
+        }
+        if (entity.getContentLength() > maxBodyBytes) {
+            throw oversized(entity.getContentLength(), maxBodyBytes);
+        }
+        var body = new ByteArrayOutputStream();
+        byte[] chunk = new byte[BODY_CHUNK_BYTES];
+        InputStream content = entity.getContent();
+        int read;
+        while ((read = content.read(chunk)) >= 0) {
+            if (Thread.interrupted()) {
+                Thread.currentThread().interrupt();
+                throw new InterruptedIOException("Interrupted while reading a deferred poll response");
+            }
+            if (deadlineNanos - System.nanoTime() <= 0) {
+                throw new IggyTimeoutException("Deferred poll exceeded its request timeout while reading");
+            }
+            if (body.size() + read > maxBodyBytes) {
+                throw oversized((long) body.size() + read, maxBodyBytes);
+            }
+            body.write(chunk, 0, read);
+        }
+        return body.toByteArray();
+    }
+
+    private static IggyMalformedResponseException oversized(long size, long limit) {
+        return new IggyMalformedResponseException(
+                "Deferred poll response body of " + size + " bytes exceeds the limit of " + limit);
+    }
+
+    /** A proxy or a failing peer can answer with a body that is no Iggy error, such as an HTML 503 page. */
+    private IggyException errorResponse(int status, byte[] body) {
+        String unreadable = "HTTP " + status + " response carries no Iggy error";
+        JsonNode error;
+        try {
+            error = objectMapper.readTree(body);
+        } catch (JacksonException parseFailure) {
+            return new IggyMalformedResponseException(unreadable, parseFailure);
+        }
+        if (!(error instanceof ObjectNode)) {
+            return new IggyMalformedResponseException(unreadable);
+        }
+        return IggyServerException.fromHttpResponse(
+                textOrNull(error, "id"),
+                textOrNull(error, "code"),
+                textOrNull(error, "reason"),
+                textOrNull(error, "field"));
+    }
+
+    private static String textOrNull(JsonNode node, String field) {
+        return node.has(field) ? node.get(field).asString() : null;
+    }
+
     @Override
     public void close() throws IOException {
+        deadlines.shutdownNow();
         httpClient.close();
+        deferredHttpClient.close();
+    }
+
+    private static void applyTlsCertificate(
+            PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder, Optional<File> tlsCertificate) {
+        tlsCertificate.ifPresent(cert -> {
+            try {
+                var sslContext =
+                        SSLContextBuilder.create().loadTrustMaterial(cert, null).build();
+                connectionManagerBuilder.setTlsSocketStrategy(new DefaultClientTlsStrategy(sslContext));
+            } catch (GeneralSecurityException | IOException e) {
+                throw new IggyTlsException("Failed to configure TLS certificate", e);
+            }
+        });
     }
 
     void setToken(Optional<String> token) {
@@ -162,6 +356,34 @@
         });
     }
 
+    private static CloseableHttpClient createDeferredHttpClient(
+            Optional<Duration> connectionTimeout, Optional<File> tlsCertificate) {
+        var connectionConfigBuilder = ConnectionConfig.custom();
+        connectionTimeout.ifPresent(timeout -> connectionConfigBuilder.setConnectTimeout(Timeout.of(timeout)));
+        var connectionManagerBuilder = PoolingHttpClientConnectionManagerBuilder.create()
+                .setDefaultConnectionConfig(connectionConfigBuilder.build())
+                .setMaxConnTotal(MAX_DEFERRED_CONNECTIONS)
+                .setMaxConnPerRoute(MAX_DEFERRED_CONNECTIONS);
+        applyTlsCertificate(connectionManagerBuilder, tlsCertificate);
+        return HttpClients.custom()
+                .setConnectionManager(connectionManagerBuilder.build())
+                .setRequestExecutor(new DeferredRequestExecutor())
+                .disableAutomaticRetries()
+                .evictIdleConnections(TimeValue.ofSeconds(30))
+                .build();
+    }
+
+    private static ScheduledThreadPoolExecutor createDeadlineTimer() {
+        var timer = new ScheduledThreadPoolExecutor(1, task -> {
+            var thread = new Thread(task, "iggy-http-deferred-deadline");
+            thread.setDaemon(true);
+            return thread;
+        });
+        // Most polls end before their deadline, and a cancelled task would otherwise stay queued until then.
+        timer.setRemoveOnCancelPolicy(true);
+        return timer;
+    }
+
     private <T> T executeRequest(ClassicHttpRequest request, HttpClientResponseHandler<T> responseHandler) {
         var context = HttpClientContext.create();
         try {
@@ -231,12 +453,10 @@
 
     private void handleErrorResponse(ClassicHttpResponse response) throws IOException {
         if (!isSuccessful(response.getCode())) {
-            var errorNode = objectMapper.readValue(response.getEntity().getContent(), ObjectNode.class);
-            String id = errorNode.has("id") ? errorNode.get("id").asString() : null;
-            String code = errorNode.has("code") ? errorNode.get("code").asString() : null;
-            String reason = errorNode.has("reason") ? errorNode.get("reason").asString() : null;
-            String field = errorNode.has("field") ? errorNode.get("field").asString() : null;
-            throw IggyServerException.fromHttpResponse(id, code, reason, field);
+            HttpEntity entity = response.getEntity();
+            throw errorResponse(
+                    response.getCode(),
+                    entity == null ? new byte[0] : entity.getContent().readAllBytes());
         }
     }
 
@@ -248,6 +468,20 @@
         return statusCode >= 200 && statusCode < 300;
     }
 
+    private static final class DeferredRequestExecutor extends HttpRequestExecutor {
+        @Override
+        public ClassicHttpResponse execute(
+                ClassicHttpRequest request,
+                HttpClientConnection connection,
+                HttpResponseInformationCallback callback,
+                HttpContext context)
+                throws IOException, HttpException {
+            // The lease and connection already exist. A failed header write may still reach the server.
+            context.setAttribute(DEFERRED_ATTEMPTED, Boolean.TRUE);
+            return super.execute(request, connection, callback, context);
+        }
+    }
+
     /**
      * Resends what the default strategy resends: a 429 or a 503 answer to any request, and an I/O
      * failure of an idempotent one. Each resend marks the request uncertain unless the server
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/MessagesHttpClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/MessagesHttpClient.java
index d348ad5..875c9f1 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/MessagesHttpClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/http/MessagesHttpClient.java
@@ -20,6 +20,8 @@
 package org.apache.iggy.client.blocking.http;
 
 import com.fasterxml.jackson.annotation.JsonInclude;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.NameValuePair;
 import org.apache.hc.core5.http.message.BasicNameValuePair;
 import org.apache.iggy.client.blocking.MessagesClient;
 import org.apache.iggy.client.blocking.TopicsClient;
@@ -27,9 +29,12 @@
 import org.apache.iggy.consumergroup.Consumer;
 import org.apache.iggy.exception.IggyAuthorizationException;
 import org.apache.iggy.exception.IggyErrorCode;
+import org.apache.iggy.exception.IggyOperationNotSupportedException;
 import org.apache.iggy.exception.IggyServerException;
+import org.apache.iggy.exception.IggyTimeoutException;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
 import org.apache.iggy.message.PartitioningKind;
@@ -41,13 +46,25 @@
 
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
+import java.time.Duration;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 
 class MessagesHttpClient implements MessagesClient {
 
+    /**
+     * The server applies the byte limit to the equivalent binary selection and
+     * then encodes it as JSON, which is larger. Rust bounds the JSON body at the
+     * same multiple.
+     */
+    private static final long JSON_BODY_EXPANSION = 16;
+
+    private static final long INITIAL_RETRY_INTERVAL_MILLIS = 50;
+    private static final long MAX_RETRY_INTERVAL_MILLIS = 1000;
+
     private final InternalHttpClient httpClient;
     private final TopicsClient topicsClient;
     private final SendContexts sendContexts;
@@ -75,16 +92,104 @@
                         .orElse(null),
                 new BasicNameValuePair("kind", strategy.kind().name().toLowerCase()),
                 new BasicNameValuePair("value", strategy.value().toString()),
-                strategy.context()
-                        .map(context -> new BasicNameValuePair(
-                                "context", ObjectMapperFactory.getInstance().writeValueAsString(context)))
-                        .orElse(null),
+                contextParameter(strategy),
                 new BasicNameValuePair("count", count.toString()),
                 new BasicNameValuePair("auto_commit", Boolean.toString(autoCommit)));
         return httpClient.execute(request, PolledMessages.class);
     }
 
     @Override
+    @SuppressWarnings("checkstyle:ParameterNumber")
+    public PolledMessages pollMessagesDeferred(
+            StreamId streamId,
+            TopicId topicId,
+            Optional<Long> partitionId,
+            Consumer consumer,
+            PollingStrategy strategy,
+            Long count,
+            boolean autoCommit,
+            DeferredPollOptions options) {
+        options.validate(count);
+        if (consumer.kind() == Consumer.Kind.ConsumerGroup) {
+            // The server refuses consumer_group on HTTP: without a persistent
+            // connection the client cannot follow the group's partition assignment.
+            throw new IggyOperationNotSupportedException(
+                    "A consumer group cannot poll over HTTP, which cannot follow the group's partition assignment");
+        }
+        long startNanos = System.nanoTime();
+        long requestDeadlineNanos = startNanos + options.requestTimeout().toNanos();
+        long maxBodyBytes = Math.multiplyExact(options.maxBytes(), JSON_BODY_EXPANSION);
+        long retryIntervalMillis = INITIAL_RETRY_INTERVAL_MILLIS;
+        while (true) {
+            // Both budgets carry across retries rather than restarting.
+            DeferredPollOptions remaining = options.remaining(Duration.ofNanos(System.nanoTime() - startNanos))
+                    .orElseThrow(() -> InternalHttpClient.deferredFailure(
+                            false, new IggyTimeoutException("Deferred poll exceeded its request timeout")));
+            ClassicHttpRequest request =
+                    deferredRequest(streamId, topicId, partitionId, consumer, strategy, count, autoCommit, remaining);
+            try {
+                return httpClient.executeDeferred(request, PolledMessages.class, requestDeadlineNanos, maxBodyBytes);
+            } catch (IggyServerException refused) {
+                // Only an explicit non-admission proves nothing was served. An
+                // opaque 503 can be an ambiguous not-committed or a proxy fault.
+                if (refused.getRawErrorCode() != IggyErrorCode.TRANSIENT_NOT_ACCEPTED.getCode()
+                        && refused.getRawErrorCode() != IggyErrorCode.DEFERRED_POLL_CAPACITY_EXCEEDED.getCode()) {
+                    throw refused;
+                }
+                sleepBeforeRetry(retryIntervalMillis, requestDeadlineNanos, refused);
+                retryIntervalMillis = Math.min(retryIntervalMillis * 2, MAX_RETRY_INTERVAL_MILLIS);
+            }
+        }
+    }
+
+    @SuppressWarnings("checkstyle:ParameterNumber")
+    private ClassicHttpRequest deferredRequest(
+            StreamId streamId,
+            TopicId topicId,
+            Optional<Long> partitionId,
+            Consumer consumer,
+            PollingStrategy strategy,
+            Long count,
+            boolean autoCommit,
+            DeferredPollOptions options) {
+        return httpClient.prepareGetRequest(
+                path(streamId, topicId) + "/deferred",
+                new BasicNameValuePair("consumer_id", consumer.id().toString()),
+                partitionId
+                        .map(id -> new BasicNameValuePair("partition_id", id.toString()))
+                        .orElse(null),
+                new BasicNameValuePair("kind", strategy.kind().name().toLowerCase()),
+                new BasicNameValuePair("value", strategy.value().toString()),
+                contextParameter(strategy),
+                new BasicNameValuePair("count", count.toString()),
+                new BasicNameValuePair("auto_commit", Boolean.toString(autoCommit)),
+                new BasicNameValuePair("wait_us", Long.toString(options.maxWaitMicros())),
+                new BasicNameValuePair("min_count", Long.toString(options.minCount())),
+                new BasicNameValuePair("max_bytes", Long.toString(options.maxBytes())),
+                new BasicNameValuePair("request_timeout_us", Long.toString(options.requestTimeoutMicros())));
+    }
+
+    // Without the context the server would read a recreated partition instead of refusing the poll.
+    private static NameValuePair contextParameter(PollingStrategy strategy) {
+        return strategy.context()
+                .map(context -> new BasicNameValuePair(
+                        "context", ObjectMapperFactory.getInstance().writeValueAsString(context)))
+                .orElse(null);
+    }
+
+    private static void sleepBeforeRetry(long intervalMillis, long requestDeadlineNanos, IggyServerException refused) {
+        if (requestDeadlineNanos - System.nanoTime() <= TimeUnit.MILLISECONDS.toNanos(intervalMillis)) {
+            throw refused;
+        }
+        try {
+            Thread.sleep(intervalMillis);
+        } catch (InterruptedException interrupted) {
+            Thread.currentThread().interrupt();
+            throw InternalHttpClient.deferredFailure(false, interrupted);
+        }
+    }
+
+    @Override
     public SendMessagesResponse sendMessages(
             StreamId streamId, TopicId topicId, Partitioning partitioning, List<Message> messages) {
         try {
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/FutureUtil.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/FutureUtil.java
index 3dfa7e2..b7969f6 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/FutureUtil.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/FutureUtil.java
@@ -24,6 +24,7 @@
 import java.util.concurrent.CancellationException;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutionException;
 
 final class FutureUtil {
 
@@ -39,6 +40,30 @@
         }
     }
 
+    /**
+     * Waits for a call that can be held by the server for a long time. Unlike
+     * {@link #resolve}, an interrupt reaches the caller: it cancels the
+     * exchange, restores the interrupt flag and reports it, instead of parking
+     * until the server answers.
+     *
+     * @param future the pending call
+     * @param <T>    the result type
+     * @return the resolved value
+     */
+    static <T> T resolveInterruptibly(CompletableFuture<T> future) {
+        try {
+            return future.get();
+        } catch (InterruptedException interrupted) {
+            future.cancel(false);
+            Thread.currentThread().interrupt();
+            throw new IggyClientException("Interrupted while waiting for a response", interrupted);
+        } catch (ExecutionException e) {
+            throw unwrap(e.getCause());
+        } catch (CancellationException e) {
+            throw new IggyClientException("Operation was cancelled", e);
+        }
+    }
+
     private static RuntimeException unwrap(Throwable cause) {
         if (cause instanceof RuntimeException re) {
             return re;
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClient.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClient.java
index 47e561b..f8baa26 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClient.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClient.java
@@ -23,6 +23,7 @@
 import org.apache.iggy.consumergroup.Consumer;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
 import org.apache.iggy.message.PolledMessages;
@@ -54,6 +55,21 @@
     }
 
     @Override
+    @SuppressWarnings("checkstyle:ParameterNumber")
+    public PolledMessages pollMessagesDeferred(
+            StreamId streamId,
+            TopicId topicId,
+            Optional<Long> partitionId,
+            Consumer consumer,
+            PollingStrategy strategy,
+            Long count,
+            boolean autoCommit,
+            DeferredPollOptions options) {
+        return FutureUtil.resolveInterruptibly(delegate.pollMessagesDeferred(
+                streamId, topicId, partitionId, consumer, strategy, count, autoCommit, options));
+    }
+
+    @Override
     public SendMessagesResponse sendMessages(
             StreamId streamId, TopicId topicId, Partitioning partitioning, List<Message> messages) {
         return FutureUtil.resolve(delegate.sendMessages(streamId, topicId, partitioning, messages));
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/exception/IggyErrorCode.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/exception/IggyErrorCode.java
index aac1115..bf04e6a 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/exception/IggyErrorCode.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/exception/IggyErrorCode.java
@@ -79,6 +79,7 @@
     /** A committed lifecycle operation prevents this mutation. Retry as a new request. */
     LIFECYCLE_BUSY(88),
     NOT_PRIMARY(89),
+    DEFERRED_POLL_CAPACITY_EXCEEDED(90),
 
     // Client errors
     CLIENT_NOT_FOUND(100),
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/DeferredPollOptions.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/DeferredPollOptions.java
new file mode 100644
index 0000000..61fb956
--- /dev/null
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/message/DeferredPollOptions.java
@@ -0,0 +1,216 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iggy.message;
+
+import org.apache.iggy.exception.IggyInvalidArgumentException;
+
+import java.time.Duration;
+import java.time.temporal.ChronoUnit;
+import java.util.Optional;
+
+/**
+ * Readiness and response limits for a deferred (long) poll, independent of the
+ * maximum message count. Mirrors {@code DeferredPollOptions} in
+ * {@code core/common/src/types/message/deferred_poll_options.rs}.
+ *
+ * <p>A deferred poll returns as soon as {@code minCount} messages are readable,
+ * when {@code maxBytes} prevents selecting more, or when {@code maxWait} expires.
+ * Readiness expiry permits a final bounded read, so it can return partial or
+ * empty data. {@code requestTimeout} is the total budget for routing, waiting,
+ * reading and the reply; exhausting it is an error, never an empty success.
+ * After a positive wait ends, the server still needs {@link #REPLY_MARGIN} for
+ * the final read and the reply, so the budget must cover both.
+ *
+ * <p>{@code maxBytes} bounds the encoded response body, including the 40-byte
+ * poll prefix and all batch framing, but excluding the 256-byte transport
+ * header. It is not a payload-only limit, and not a bound on server disk work.
+ * The smallest limit, {@link #MIN_MAX_BYTES}, adds one batch header and one
+ * message header to the prefix, the least room any message needs.
+ *
+ * @param maxWait        readiness wait, zero through 600 s; the server's
+ *                       configured ceiling can be lower, 30 s by default
+ * @param minCount       messages that make the poll ready, at least one and no
+ *                       greater than the poll's {@code count}
+ * @param maxBytes       maximum encoded response body, at least
+ *                       {@link #MIN_MAX_BYTES} (344)
+ * @param requestTimeout total request budget, positive and no more than 630 s;
+ *                       with a positive {@code maxWait}, at least
+ *                       {@code maxWait} plus {@link #REPLY_MARGIN}
+ */
+public record DeferredPollOptions(Duration maxWait, long minCount, long maxBytes, Duration requestTimeout) {
+
+    /** Finite protocol ceiling; a server may admit less. */
+    public static final Duration MAX_WAIT = Duration.ofSeconds(600);
+
+    public static final Duration MAX_REQUEST_TIMEOUT = MAX_WAIT.plusSeconds(30);
+
+    /** Mirrors {@code MIN_DEFERRED_POLL_REPLY_MARGIN_US}, which the server enforces when it decodes a poll. */
+    public static final Duration REPLY_MARGIN = Duration.ofSeconds(1);
+
+    /**
+     * Mirrors {@code MIN_DEFERRED_POLL_MAX_BYTES}, the smallest limit that can return a message. The parts are
+     * qualified because checkstyle wants these private constants declared below.
+     */
+    public static final long MIN_MAX_BYTES = DeferredPollOptions.POLL_RESPONSE_HEADER_SIZE
+            + DeferredPollOptions.BATCH_HEADER_SIZE
+            + DeferredPollOptions.BATCH_MESSAGE_HEADER_SIZE;
+
+    public static final Duration DEFAULT_MAX_WAIT = Duration.ofSeconds(1);
+    public static final long DEFAULT_MIN_COUNT = 1;
+    public static final long DEFAULT_MAX_BYTES = 1024L * 1024L;
+    public static final Duration DEFAULT_REQUEST_TIMEOUT = Duration.ofSeconds(11);
+
+    private static final DeferredPollOptions DEFAULTS =
+            new DeferredPollOptions(DEFAULT_MAX_WAIT, DEFAULT_MIN_COUNT, DEFAULT_MAX_BYTES, DEFAULT_REQUEST_TIMEOUT);
+
+    private static final long MAX_UNSIGNED_INT = 0xFFFF_FFFFL;
+    private static final long NANOS_PER_MICRO = 1000;
+
+    /** The poll response prefix, including the partition context. */
+    private static final long POLL_RESPONSE_HEADER_SIZE = 40;
+
+    private static final long BATCH_HEADER_SIZE = 256;
+    private static final long BATCH_MESSAGE_HEADER_SIZE = 48;
+
+    public DeferredPollOptions {
+        requireMicrosecondDuration(maxWait, "maxWait");
+        requireMicrosecondDuration(requestTimeout, "requestTimeout");
+        if (maxWait.compareTo(MAX_WAIT) > 0) {
+            throw new IggyInvalidArgumentException("Deferred poll maxWait " + maxWait + " exceeds the protocol maximum "
+                    + MAX_WAIT + "; a server can admit less");
+        }
+        if (requestTimeout.isZero() || requestTimeout.compareTo(MAX_REQUEST_TIMEOUT) > 0) {
+            throw new IggyInvalidArgumentException("Deferred poll requestTimeout " + requestTimeout
+                    + " must be positive and no more than " + MAX_REQUEST_TIMEOUT);
+        }
+        if (!maxWait.isZero() && requestTimeout.compareTo(maxWait.plus(REPLY_MARGIN)) < 0) {
+            throw new IggyInvalidArgumentException("Deferred poll requestTimeout " + requestTimeout
+                    + " is shorter than maxWait " + maxWait + " plus the reply margin " + REPLY_MARGIN);
+        }
+        if (minCount < 1 || minCount > MAX_UNSIGNED_INT) {
+            throw new IggyInvalidArgumentException(
+                    "Deferred poll minCount " + minCount + " must be between 1 and " + MAX_UNSIGNED_INT);
+        }
+        if (maxBytes < MIN_MAX_BYTES || maxBytes > MAX_UNSIGNED_INT) {
+            throw new IggyInvalidArgumentException("Deferred poll maxBytes " + maxBytes + " must be between "
+                    + MIN_MAX_BYTES + " and " + MAX_UNSIGNED_INT);
+        }
+    }
+
+    /** Wake on the first readable message within one second, up to 1 MiB. */
+    public static DeferredPollOptions defaults() {
+        return DEFAULTS;
+    }
+
+    /**
+     * Each {@code with} call validates the whole record, so a wait that leaves
+     * less than {@link #REPLY_MARGIN} of the current {@code requestTimeout}
+     * throws. With the defaults that is any wait above 10 s: raise the budget
+     * with {@link #withRequestTimeout} first.
+     */
+    public DeferredPollOptions withMaxWait(Duration maxWait) {
+        return new DeferredPollOptions(maxWait, minCount, maxBytes, requestTimeout);
+    }
+
+    public DeferredPollOptions withMinCount(long minCount) {
+        return new DeferredPollOptions(maxWait, minCount, maxBytes, requestTimeout);
+    }
+
+    public DeferredPollOptions withMaxBytes(long maxBytes) {
+        return new DeferredPollOptions(maxWait, minCount, maxBytes, requestTimeout);
+    }
+
+    public DeferredPollOptions withRequestTimeout(Duration requestTimeout) {
+        return new DeferredPollOptions(maxWait, minCount, maxBytes, requestTimeout);
+    }
+
+    /**
+     * Checks the one rule that depends on the poll itself. Call it before
+     * allocating buffers or acquiring a connection.
+     *
+     * @param count the maximum message count the poll requests
+     * @throws IggyInvalidArgumentException if the readiness target cannot be met
+     */
+    public void validate(long count) {
+        if (count < 1 || count > MAX_UNSIGNED_INT) {
+            throw new IggyInvalidArgumentException(
+                    "Poll count " + count + " must be between 1 and " + MAX_UNSIGNED_INT);
+        }
+        if (minCount > count) {
+            throw new IggyInvalidArgumentException(
+                    "Deferred poll minCount " + minCount + " exceeds the requested count " + count);
+        }
+    }
+
+    /**
+     * What is left of both budgets once {@code elapsed} has passed, so a retry
+     * continues them instead of starting over. Both shrink by the same whole
+     * microseconds, which keeps the reply margin, and the wait stops at zero.
+     *
+     * @param elapsed time spent since the poll began
+     * @return the remaining options, or empty once the request budget is spent
+     */
+    public Optional<DeferredPollOptions> remaining(Duration elapsed) {
+        if (elapsed.compareTo(requestTimeout) >= 0) {
+            return Optional.empty();
+        }
+        // Rounding the spent time up keeps whole microseconds and never overstates a budget.
+        long spentMicros = (Math.max(0, elapsed.toNanos()) + NANOS_PER_MICRO - 1) / NANOS_PER_MICRO;
+        long requestMicros = requestTimeoutMicros() - spentMicros;
+        if (requestMicros <= 0) {
+            return Optional.empty();
+        }
+        return Optional.of(new DeferredPollOptions(
+                Duration.of(Math.max(0, maxWaitMicros() - spentMicros), ChronoUnit.MICROS),
+                minCount,
+                maxBytes,
+                Duration.of(requestMicros, ChronoUnit.MICROS)));
+    }
+
+    public long maxWaitMicros() {
+        return maxWait.toNanos() / NANOS_PER_MICRO;
+    }
+
+    public long requestTimeoutMicros() {
+        return requestTimeout.toNanos() / NANOS_PER_MICRO;
+    }
+
+    /**
+     * Rejects what the wire cannot carry exactly. Silently truncating a
+     * sub-microsecond wait would change a caller's readiness budget, and a
+     * duration too large for nanosecond arithmetic cannot be compared either.
+     */
+    private static void requireMicrosecondDuration(Duration duration, String name) {
+        if (duration.isNegative()) {
+            throw new IggyInvalidArgumentException("Deferred poll " + name + " " + duration + " must not be negative");
+        }
+        long nanos;
+        try {
+            nanos = duration.toNanos();
+        } catch (ArithmeticException overflow) {
+            throw new IggyInvalidArgumentException(
+                    "Deferred poll " + name + " " + duration + " is too large to express", overflow);
+        }
+        if (nanos % NANOS_PER_MICRO != 0) {
+            throw new IggyInvalidArgumentException(
+                    "Deferred poll " + name + " " + duration + " must be a whole number of microseconds");
+        }
+    }
+}
diff --git a/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/CommandCode.java b/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/CommandCode.java
index 36993a0..593b17d 100644
--- a/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/CommandCode.java
+++ b/foreign/java/java-sdk/src/main/java/org/apache/iggy/serde/CommandCode.java
@@ -98,7 +98,10 @@
         POLL(100),
         SEND(101),
         // 103 and 104 are retired and must never be reused.
-        GET_PARTITION_ROUTE(105);
+        GET_PARTITION_ROUTE(105),
+        POLL_DEFERRED(106),
+        WAIT_FOR_MESSAGES(107),
+        CANCEL_DEFERRED_REQUEST(108);
 
         private final int value;
 
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/ProtocolParityTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/ProtocolParityTest.java
index 094d219..6ebe141 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/ProtocolParityTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/ProtocolParityTest.java
@@ -84,6 +84,9 @@
             Map.entry(CommandCode.Messages.POLL, "POLL_MESSAGES_CODE"),
             Map.entry(CommandCode.Messages.SEND, "SEND_MESSAGES_CODE"),
             Map.entry(CommandCode.Messages.GET_PARTITION_ROUTE, "GET_PARTITION_ROUTE_CODE"),
+            Map.entry(CommandCode.Messages.POLL_DEFERRED, "POLL_MESSAGES_DEFERRED_CODE"),
+            Map.entry(CommandCode.Messages.WAIT_FOR_MESSAGES, "WAIT_FOR_MESSAGES_CODE"),
+            Map.entry(CommandCode.Messages.CANCEL_DEFERRED_REQUEST, "CANCEL_DEFERRED_REQUEST_CODE"),
             Map.entry(CommandCode.ConsumerOffset.GET, "GET_CONSUMER_OFFSET_CODE"),
             Map.entry(CommandCode.ConsumerOffset.STORE, "STORE_CONSUMER_OFFSET_CODE"),
             Map.entry(CommandCode.ConsumerOffset.DELETE, "DELETE_CONSUMER_OFFSET_CODE"),
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/DeferredPollCompatibilityTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/DeferredPollCompatibilityTest.java
new file mode 100644
index 0000000..fd759fd
--- /dev/null
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/DeferredPollCompatibilityTest.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iggy.client;
+
+import org.apache.iggy.consumergroup.Consumer;
+import org.apache.iggy.exception.IggyOperationNotSupportedException;
+import org.apache.iggy.identifier.StreamId;
+import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.DeferredPollOptions;
+import org.apache.iggy.message.Message;
+import org.apache.iggy.message.Partitioning;
+import org.apache.iggy.message.PolledMessages;
+import org.apache.iggy.message.PollingStrategy;
+import org.apache.iggy.message.SendMessagesResponse;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigInteger;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * An implementation written before deferred polling existed still compiles and
+ * runs. Its inherited default reports the missing support instead of silently
+ * degrading to an immediate poll, which would drop the readiness, byte and
+ * request-time guarantees the caller asked for.
+ */
+class DeferredPollCompatibilityTest {
+
+    private static final PolledMessages EMPTY =
+            new PolledMessages(0L, BigInteger.ZERO, 0L, List.of(), org.apache.iggy.partition.PartitionContext.EMPTY);
+
+    @Test
+    void shouldReportMissingSupportFromABlockingImplementation() {
+        var client = new LegacyBlockingClient();
+
+        assertThatThrownBy(() -> client.pollMessagesDeferred(
+                        StreamId.of(1L),
+                        TopicId.of(1L),
+                        Optional.of(0L),
+                        Consumer.of(0L),
+                        PollingStrategy.next(),
+                        10L,
+                        false,
+                        DeferredPollOptions.defaults()))
+                .isInstanceOf(IggyOperationNotSupportedException.class);
+        assertThat(client.immediatePolls).isZero();
+    }
+
+    @Test
+    void shouldReportMissingSupportFromAnAsyncImplementation() {
+        var client = new LegacyAsyncClient();
+
+        assertThatThrownBy(() -> client.pollMessagesDeferred(
+                                1L,
+                                1L,
+                                Optional.of(0L),
+                                0L,
+                                PollingStrategy.next(),
+                                10L,
+                                false,
+                                DeferredPollOptions.defaults())
+                        .get())
+                .isInstanceOf(ExecutionException.class)
+                .hasCauseInstanceOf(IggyOperationNotSupportedException.class);
+        assertThat(client.immediatePolls).isZero();
+    }
+
+    @Test
+    void shouldDelegateNumericOverloadsToTheTypedMethod() {
+        var client = new RecordingBlockingClient();
+
+        client.pollMessagesDeferred(
+                7L, 9L, Optional.of(3L), 5L, PollingStrategy.next(), 10L, true, DeferredPollOptions.defaults());
+
+        assertThat(client.streamId).hasToString("7");
+        assertThat(client.topicId).hasToString("9");
+        assertThat(client.consumer.id()).hasToString("5");
+        assertThat(client.consumer.kind()).isEqualTo(Consumer.Kind.Consumer);
+    }
+
+    private static class LegacyBlockingClient implements org.apache.iggy.client.blocking.MessagesClient {
+        private int immediatePolls;
+
+        @Override
+        public PolledMessages pollMessages(
+                StreamId streamId,
+                TopicId topicId,
+                Optional<Long> partitionId,
+                Consumer consumer,
+                PollingStrategy strategy,
+                Long count,
+                boolean autoCommit) {
+            immediatePolls++;
+            return EMPTY;
+        }
+
+        @Override
+        public SendMessagesResponse sendMessages(
+                StreamId streamId, TopicId topicId, Partitioning partitioning, List<Message> messages) {
+            return SendMessagesResponse.empty();
+        }
+    }
+
+    private static final class LegacyAsyncClient implements org.apache.iggy.client.async.MessagesClient {
+        private int immediatePolls;
+
+        @Override
+        public CompletableFuture<PolledMessages> pollMessages(
+                StreamId streamId,
+                TopicId topicId,
+                Optional<Long> partitionId,
+                Consumer consumer,
+                PollingStrategy strategy,
+                Long count,
+                boolean autoCommit) {
+            immediatePolls++;
+            return CompletableFuture.completedFuture(EMPTY);
+        }
+
+        @Override
+        public CompletableFuture<SendMessagesResponse> sendMessages(
+                StreamId streamId, TopicId topicId, Partitioning partitioning, List<Message> messages) {
+            return CompletableFuture.completedFuture(SendMessagesResponse.empty());
+        }
+    }
+
+    private static final class RecordingBlockingClient extends LegacyBlockingClient {
+        private StreamId streamId;
+        private TopicId topicId;
+        private Consumer consumer;
+
+        @Override
+        @SuppressWarnings("checkstyle:ParameterNumber")
+        public PolledMessages pollMessagesDeferred(
+                StreamId streamId,
+                TopicId topicId,
+                Optional<Long> partitionId,
+                Consumer consumer,
+                PollingStrategy strategy,
+                Long count,
+                boolean autoCommit,
+                DeferredPollOptions options) {
+            this.streamId = streamId;
+            this.topicId = topicId;
+            this.consumer = consumer;
+            return EMPTY;
+        }
+    }
+}
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientPartitionContextTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientPartitionContextTest.java
index 84148f7..065957a 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientPartitionContextTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientPartitionContextTest.java
@@ -29,18 +29,24 @@
 import org.apache.iggy.identifier.ConsumerId;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
 import org.apache.iggy.message.PolledMessages;
 import org.apache.iggy.message.PollingStrategy;
 import org.apache.iggy.message.SendMessagesResponse;
 import org.apache.iggy.partition.PartitionContext;
+import org.apache.iggy.serde.CommandCode;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
 
 import java.io.IOException;
 import java.math.BigInteger;
 import java.net.InetAddress;
 import java.net.ServerSocket;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
 import java.time.Duration;
 import java.util.List;
 import java.util.Optional;
@@ -86,6 +92,8 @@
     private static final int LIFECYCLE_BUSY = IggyErrorCode.LIFECYCLE_BUSY.getCode();
     private static final int PARTITION_NOT_OWNED = 5009;
     private static final int ASSIGNED_PARTITION = 7;
+    private static final int DEFERRED_PARAMETERS_BYTES = 2 * Long.BYTES + 2 * Integer.BYTES;
+    private static final int RESYNC_REQUIRED_PARTITION = -1;
     // An offset write status of the fake primary: the write is stored.
     private static final int STORED = 0;
     private static final long ROUTE_INCARNATION = 1;
@@ -537,6 +545,149 @@
         }
     }
 
+    @ParameterizedTest
+    @EnumSource(
+            value = IggyErrorCode.class,
+            names = {"TRANSIENT_NOT_ACCEPTED", "NOT_PRIMARY", "DEFERRED_POLL_CAPACITY_EXCEEDED"})
+    void shouldReduceDeferredRetryBudgetsWithoutChangingIdentity(IggyErrorCode refusal) throws Exception {
+        for (boolean progress : new boolean[] {false, true}) {
+            InetAddress loopback = InetAddress.getLoopbackAddress();
+            try (ServerSocket coordinatorSocket = new ServerSocket(0, 4, loopback)) {
+                AtomicInteger routes = new AtomicInteger();
+                List<Request> polls = new CopyOnWriteArrayList<>();
+                CompletableFuture<Void> coordinator = serve(coordinatorSocket, request -> {
+                    if (request.operation() == OPERATION_REGISTER) {
+                        return Response.success(OPERATION_REGISTER, registerBody(1));
+                    }
+                    if (request.is(GET_CLUSTER_METADATA_CODE, OPERATION_NON_REPLICATED)) {
+                        return Response.success(
+                                OPERATION_NON_REPLICATED, singleNodeMetadata(coordinatorSocket.getLocalPort()));
+                    }
+                    if (request.is(GET_PARTITION_ROUTE_CODE, OPERATION_NON_REPLICATED)) {
+                        routes.incrementAndGet();
+                        return Response.success(
+                                OPERATION_NON_REPLICATED,
+                                pollRoute(request, 1, 1, coordinatorSocket.getLocalPort(), ROUTE_INCARNATION)
+                                        .setIntLE(2 * Long.BYTES, routes.get() - 1));
+                    }
+                    if (request.is(CommandCode.Messages.POLL_DEFERRED.getValue(), OPERATION_NON_REPLICATED)) {
+                        polls.add(request);
+                        return polls.size() == 1
+                                ? Response.error(OPERATION_NON_REPLICATED, refusal.getCode())
+                                : Response.success(OPERATION_NON_REPLICATED, emptyPoll(0));
+                    }
+                    throw new IllegalStateException("Unexpected coordinator request: " + request);
+                });
+                AsyncIggyTcpClient client = client(coordinatorSocket);
+                var options = DeferredPollOptions.defaults();
+                try {
+                    client.connect().get(5, TimeUnit.SECONDS);
+                    client.login().get(5, TimeUnit.SECONDS);
+                    client.messages()
+                            .pollMessagesDeferred(
+                                    StreamId.of(1L),
+                                    TopicId.of(1L),
+                                    Optional.of(0L),
+                                    Consumer.of(7L),
+                                    PollingStrategy.offset(BigInteger.ZERO),
+                                    1L,
+                                    progress,
+                                    options)
+                            .get(5, TimeUnit.SECONDS);
+                    assertThat(polls).hasSize(2);
+                    Request first = polls.get(0);
+                    Request second = polls.get(1);
+                    assertThat(second.requestId()).isEqualTo(first.requestId());
+                    assertThat(second.clientLow()).isEqualTo(first.clientLow());
+                    assertThat(second.clientHigh()).isEqualTo(first.clientHigh());
+                    assertThat(stampedContext(second)).isEqualTo(stampedContext(first));
+                    ByteBuffer firstBody = ByteBuffer.wrap(first.body()).order(ByteOrder.LITTLE_ENDIAN);
+                    ByteBuffer secondBody = ByteBuffer.wrap(second.body()).order(ByteOrder.LITTLE_ENDIAN);
+                    long firstWait = firstBody.getLong(firstBody.limit() - DEFERRED_PARAMETERS_BYTES);
+                    long secondWait = secondBody.getLong(secondBody.limit() - DEFERRED_PARAMETERS_BYTES);
+                    long firstBudget = firstBody.getLong(firstBody.limit() - Long.BYTES);
+                    long secondBudget = secondBody.getLong(secondBody.limit() - Long.BYTES);
+                    assertThat(secondWait).isLessThan(firstWait);
+                    assertThat(secondBudget).isLessThan(firstBudget);
+                    assertThat(secondBudget - secondWait)
+                            .isEqualTo(options.requestTimeoutMicros() - options.maxWaitMicros());
+                    assertThat(routes).hasValue(refusal == IggyErrorCode.DEFERRED_POLL_CAPACITY_EXCEEDED ? 1 : 2);
+                } finally {
+                    client.close().get(5, TimeUnit.SECONDS);
+                }
+                coordinator.get(5, TimeUnit.SECONDS);
+            }
+        }
+    }
+
+    @Test
+    void shouldReduceDeferredPollBudgetsWhenAGroupResyncStartsAnotherAttempt() throws Exception {
+        InetAddress loopback = InetAddress.getLoopbackAddress();
+        try (ServerSocket coordinatorSocket = new ServerSocket(0, 4, loopback)) {
+            AtomicInteger syncs = new AtomicInteger();
+            List<Long> waits = new CopyOnWriteArrayList<>();
+            List<Long> budgets = new CopyOnWriteArrayList<>();
+            CompletableFuture<Void> coordinator = serve(coordinatorSocket, request -> {
+                if (request.operation() == OPERATION_REGISTER) {
+                    return Response.success(OPERATION_REGISTER, registerBody(1));
+                }
+                if (request.is(GET_CLUSTER_METADATA_CODE, OPERATION_NON_REPLICATED)) {
+                    return Response.success(
+                            OPERATION_NON_REPLICATED, singleNodeMetadata(coordinatorSocket.getLocalPort()));
+                }
+                if (request.is(GROUP_SYNC_CODE, OPERATION_NON_REPLICATED)) {
+                    return Response.success(
+                            OPERATION_NON_REPLICATED,
+                            Unpooled.buffer()
+                                    .writeLongLE(syncs.incrementAndGet())
+                                    .writeIntLE(1)
+                                    .writeIntLE(0));
+                }
+                if (request.is(GET_PARTITION_ROUTE_CODE, OPERATION_NON_REPLICATED)) {
+                    return Response.success(
+                            OPERATION_NON_REPLICATED,
+                            pollRoute(request, 1, 1, coordinatorSocket.getLocalPort(), ROUTE_INCARNATION));
+                }
+                if (request.is(CommandCode.Messages.POLL_DEFERRED.getValue(), OPERATION_NON_REPLICATED)) {
+                    ByteBuffer body = ByteBuffer.wrap(request.body()).order(ByteOrder.LITTLE_ENDIAN);
+                    waits.add(body.getLong(body.limit() - DEFERRED_PARAMETERS_BYTES));
+                    budgets.add(body.getLong(body.limit() - Long.BYTES));
+                    return Response.success(
+                            OPERATION_NON_REPLICATED, emptyPoll(waits.size() == 1 ? RESYNC_REQUIRED_PARTITION : 0));
+                }
+                throw new IllegalStateException("Unexpected coordinator request: " + request);
+            });
+            AsyncIggyTcpClient client = client(coordinatorSocket);
+            var options = DeferredPollOptions.defaults();
+            try {
+                client.connect().get(5, TimeUnit.SECONDS);
+                client.login().get(5, TimeUnit.SECONDS);
+                client.messages()
+                        .pollMessagesDeferred(
+                                StreamId.of(1L),
+                                TopicId.of(1L),
+                                Optional.empty(),
+                                Consumer.group(ConsumerId.of(7L)),
+                                PollingStrategy.next(),
+                                1L,
+                                false,
+                                options)
+                        .get(5, TimeUnit.SECONDS);
+                assertThat(syncs).hasValue(2);
+                assertThat(waits).hasSize(2);
+                assertThat(waits.get(0)).isLessThan(options.maxWaitMicros());
+                assertThat(waits.get(1)).isLessThan(waits.get(0));
+                assertThat(budgets.get(1)).isLessThan(budgets.get(0));
+                assertThat(budgets.get(0) - waits.get(0))
+                        .isEqualTo(options.requestTimeoutMicros() - options.maxWaitMicros());
+                assertThat(budgets.get(1) - waits.get(1)).isEqualTo(budgets.get(0) - waits.get(0));
+            } finally {
+                client.close().get(5, TimeUnit.SECONDS);
+            }
+            coordinator.get(5, TimeUnit.SECONDS);
+        }
+    }
+
     @Test
     void shouldSyncAnEmptyGroupAssignmentAgainOnTheNextPoll() throws Exception {
         InetAddress loopback = InetAddress.getLoopbackAddress();
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientRawRequestTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientRawRequestTest.java
index 15f515c..b54f177 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientRawRequestTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientRawRequestTest.java
@@ -29,6 +29,7 @@
 import org.apache.iggy.exception.IggyInvalidArgumentException;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
 import org.apache.iggy.message.PollingStrategy;
@@ -40,6 +41,8 @@
 import java.net.InetAddress;
 import java.net.ServerSocket;
 import java.net.Socket;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
 import java.time.Duration;
 import java.util.Arrays;
 import java.util.List;
@@ -77,6 +80,9 @@
 class AsyncIggyTcpClientRawRequestTest {
     private static final int SEND_CODE = 101;
     private static final int GET_PARTITION_ROUTE_CODE = 105;
+    private static final int POLL_DEFERRED_CODE = 106;
+    private static final int WAIT_FOR_MESSAGES_CODE = 107;
+    private static final int CANCEL_DEFERRED_REQUEST_CODE = 108;
     private static final int STORE_OFFSET_CODE = 121;
     private static final int DELETE_OFFSET_CODE = 122;
     private static final int GET_OFFSET_ROUTING_CODE = 105;
@@ -94,6 +100,7 @@
     private static final long METADATA_OP = 7;
     private static final StreamId STREAM = StreamId.of(1L);
     private static final TopicId TOPIC = TopicId.of(2L);
+    private static final DeferredPollOptions DEFERRED = DeferredPollOptions.defaults();
     // Replicated commands travel under their operation and carry no command code.
     private static final Map<Integer, Integer> REPLICATED_CODES = Map.of(
             OPERATION_SEND_MESSAGES, SEND_CODE,
@@ -367,6 +374,37 @@
     }
 
     @Test
+    void shouldRouteRawDeferredPollsLikeTypedDeferredPolls() throws Exception {
+        assertRawRequests(request -> null, (client, requests) -> {
+            client.sendBinaryRequest(
+                            POLL_DEFERRED_CODE,
+                            deferred(rawPoll(), DEFERRED.minCount(), DEFERRED.requestTimeoutMicros()))
+                    .get(5, TimeUnit.SECONDS);
+
+            assertThat(requests)
+                    .extracting(AsyncIggyTcpClientRawRequestTest::code)
+                    .containsExactly(GET_PARTITION_ROUTE_CODE, POLL_DEFERRED_CODE);
+            assertCapturedContext(List.of(requests.get(1)));
+            // The budget counts down from the request timeout in the payload, so routing time comes off it.
+            ByteBuffer body = ByteBuffer.wrap(requests.get(1).body()).order(ByteOrder.LITTLE_ENDIAN);
+            assertThat(body.getLong(body.limit() - Long.BYTES))
+                    .isLessThan(DEFERRED.requestTimeoutMicros())
+                    .isGreaterThan(DEFERRED.requestTimeoutMicros() / 2);
+        });
+    }
+
+    @Test
+    void shouldRefuseRawReadinessWaitsAndDeferredCancelsWithoutSendingThem() throws Exception {
+        assertRawRequests(request -> null, (client, requests) -> {
+            CompletableFuture<byte[]> wait = client.sendBinaryRequest(WAIT_FOR_MESSAGES_CODE, new byte[0]);
+            assertServerError(wait, FEATURE_UNAVAILABLE);
+            assertThatThrownBy(wait::join).rootCause().hasMessageContaining("readiness waits");
+            assertServerError(client.sendBinaryRequest(CANCEL_DEFERRED_REQUEST_CODE, new byte[0]), INVALID_COMMAND);
+            assertThat(requests).isEmpty();
+        });
+    }
+
+    @Test
     void shouldRouteRawOffsetWritesLikeTypedOffsetWrites() throws Exception {
         assertRawRequests(request -> null, (client, requests) -> {
             client.sendBinaryRequest(STORE_OFFSET_CODE, rawStoreOffset()).get(5, TimeUnit.SECONDS);
@@ -425,6 +463,8 @@
             byte[] delete = rawDeleteOffset();
             delete[bytes(offsetTarget()).length - OPTIONAL_PARTITION_BYTES] = 0;
             assertMalformed(client.sendBinaryRequest(POLL_CODE, groupPoll));
+            assertMalformed(client.sendBinaryRequest(
+                    POLL_DEFERRED_CODE, deferred(groupPoll, DEFERRED.minCount(), DEFERRED.requestTimeoutMicros())));
             assertMalformed(client.sendBinaryRequest(STORE_OFFSET_CODE, store));
             assertMalformed(client.sendBinaryRequest(DELETE_OFFSET_CODE, delete));
             assertThat(requests).isEmpty();
@@ -456,6 +496,11 @@
             badIdentifier[Integer.BYTES + 1] = 5;
             assertMalformed(client.sendBinaryRequest(SEND_CODE, badIdentifier));
             assertMalformed(client.sendBinaryRequest(POLL_CODE, Arrays.copyOf(poll, poll.length - 1)));
+            // No deferred limits, a minimum above the poll's count of one, and no request budget.
+            assertMalformed(client.sendBinaryRequest(POLL_DEFERRED_CODE, poll));
+            assertMalformed(client.sendBinaryRequest(
+                    POLL_DEFERRED_CODE, deferred(poll, DEFERRED.minCount() + 1, DEFERRED.requestTimeoutMicros())));
+            assertMalformed(client.sendBinaryRequest(POLL_DEFERRED_CODE, deferred(poll, DEFERRED.minCount(), 0)));
             assertMalformed(client.sendBinaryRequest(STORE_OFFSET_CODE, bytes(offsetTarget())));
             assertMalformed(client.sendBinaryRequest(DELETE_OFFSET_CODE, Arrays.copyOf(delete, delete.length + 1)));
             assertMalformed(client.sendBinaryRequest(STORE_OFFSET_CODE, Arrays.copyOf(store, 2)));
@@ -589,7 +634,7 @@
                                 .writeByte(0));
             case GET_OFFSET_CODE -> Response.success(OPERATION_NON_REPLICATED, Unpooled.EMPTY_BUFFER);
             case SEND_CODE -> Response.success(OPERATION_SEND_MESSAGES, Unpooled.EMPTY_BUFFER);
-            case POLL_CODE -> Response.success(OPERATION_NON_REPLICATED, emptyPoll(0));
+            case POLL_CODE, POLL_DEFERRED_CODE -> Response.success(OPERATION_NON_REPLICATED, emptyPoll(0));
             case STORE_OFFSET_CODE, DELETE_OFFSET_CODE ->
                 Response.success(request.operation(), Unpooled.buffer().writeIntLE(0));
             default -> Response.error(request.operation(), INVALID_COMMAND);
@@ -636,6 +681,16 @@
         return bytes(payload);
     }
 
+    private static byte[] deferred(byte[] poll, long minCount, long requestTimeoutMicros) {
+        return bytes(
+                Unpooled.wrappedBuffer(poll),
+                Unpooled.buffer()
+                        .writeLongLE(DEFERRED.maxWaitMicros())
+                        .writeIntLE((int) minCount)
+                        .writeIntLE((int) DEFERRED.maxBytes())
+                        .writeLongLE(requestTimeoutMicros));
+    }
+
     static byte[] rawStoreOffset() {
         return bytes(
                 offsetTarget(), toBytesAsU64(BigInteger.TEN), Unpooled.buffer().writeByte(1));
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientReplicaRequestTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientReplicaRequestTest.java
index 4dc9aeb..5c7bc14 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientReplicaRequestTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/AsyncIggyTcpClientReplicaRequestTest.java
@@ -56,6 +56,9 @@
     private static final int GET_STATS_CODE = 10;
     private static final int LOGIN_CODE = 38;
     private static final int LOGOUT_CODE = 39;
+    private static final int POLL_DEFERRED_CODE = 106;
+    private static final int WAIT_FOR_MESSAGES_CODE = 107;
+    private static final int CANCEL_DEFERRED_REQUEST_CODE = 108;
     private static final int GET_OFFSET_CODE = 120;
     private static final int NOT_PRIMARY = 89;
     private static final long METADATA_COMMIT = 11;
@@ -176,7 +179,15 @@
             try {
                 int requests = cluster.leaderRequests.get();
 
-                for (int code : List.of(CREATE_STREAM_CODE, POLL_CODE, GET_OFFSET_CODE, LOGIN_CODE, LOGOUT_CODE)) {
+                for (int code : List.of(
+                        CREATE_STREAM_CODE,
+                        POLL_CODE,
+                        POLL_DEFERRED_CODE,
+                        WAIT_FOR_MESSAGES_CODE,
+                        CANCEL_DEFERRED_REQUEST_CODE,
+                        GET_OFFSET_CODE,
+                        LOGIN_CODE,
+                        LOGOUT_CODE)) {
                     assertServerError(
                             client.sendBinaryRequestToReplica(code, new byte[0], 1),
                             IggyErrorCode.INVALID_COMMAND.getCode());
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/PollRouterTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/PollRouterTest.java
index e125104..85970a6 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/PollRouterTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/PollRouterTest.java
@@ -35,6 +35,7 @@
 import org.apache.iggy.client.async.tcp.vsr.ConsensusSession;
 import org.apache.iggy.client.async.tcp.vsr.VsrFrameDecoder;
 import org.apache.iggy.client.async.tcp.vsr.VsrHeaders;
+import org.apache.iggy.client.async.tcp.vsr.VsrOperation;
 import org.apache.iggy.consumergroup.Consumer;
 import org.apache.iggy.exception.IggyClientException;
 import org.apache.iggy.exception.IggyConnectionException;
@@ -44,6 +45,7 @@
 import org.apache.iggy.identifier.ConsumerId;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
 import org.apache.iggy.message.PolledMessages;
@@ -104,6 +106,7 @@
     private static final int PRIMARY_PORT = 8090;
     private static final int BIND_SECRET_BYTES = 32;
     private static final int PENDING_POLL_CAPACITY = 4096;
+    private static final int DEFERRED_POLL_CAPACITY = 64;
     private static final int REQUEST_ID_OFFSET = 168;
     private static final int REQUEST_INCARNATION_OFFSET = 200;
     private static final int REQUEST_METADATA_OP_OFFSET = 216;
@@ -428,8 +431,9 @@
      * A poll over the pending capacity fails at once, and the router holds no more payloads than that capacity. Each
      * held poll ends with an explicit failure, and every payload goes back.
      */
-    @Test
-    void shouldReleaseEveryPayloadAboveAndWithinThePendingPollCapacity() throws Exception {
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void shouldReleaseEveryPayloadAboveAndWithinThePendingPollCapacity(boolean deferred) throws Exception {
         IoEventLoopGroup group = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory());
         ManualTimers timers = new ManualTimers();
         Coordinator coordinator = new Coordinator(group, timers);
@@ -441,10 +445,10 @@
             for (int index = 0; index < PENDING_POLL_CAPACITY; index++) {
                 ByteBuf payload = tracked(pollPayload());
                 payloads.add(payload);
-                held.add(router.poll(payload, Optional.empty()));
+                held.add(routedPoll(router, payload, deferred));
             }
             ByteBuf rejected = tracked(pollPayload());
-            assertThatThrownBy(() -> router.poll(rejected, Optional.empty()).join())
+            assertThatThrownBy(() -> routedPoll(router, rejected, deferred).join())
                     .hasRootCauseMessage("Too many pending primary polls and offset writes");
             assertThat(rejected.refCnt()).isZero();
             assertThat(payloads)
@@ -460,16 +464,168 @@
         }
     }
 
-    /** A reply that comes after its poll timed out goes back, and so does the payload of that poll. */
     @Test
-    void shouldReleaseALateReplyAndThePayloadOfARoutedPollThatTimedOut() throws Exception {
+    void shouldAdmitDeferredPollsInOrderAsSlotsBecomeAvailable() throws Exception {
         IoEventLoopGroup group = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory());
         ManualTimers timers = new ManualTimers();
         Coordinator coordinator = new Coordinator(group, timers);
         Primary primary = new Primary(group);
         PollRouter router = new PollRouter(() -> coordinator, endpoint -> primary);
         try {
-            router.poll(pollPayload(), Optional.empty())
+            List<CompletableFuture<ByteBuf>> replies = new ArrayList<>();
+            for (int index = 0; index < DEFERRED_POLL_CAPACITY + 2; index++) {
+                var reply = new CompletableFuture<ByteBuf>();
+                primary.replies.add(reply);
+                replies.add(reply);
+            }
+            List<CompletableFuture<ByteBuf>> polls = new ArrayList<>();
+            for (int index = 0; index < DEFERRED_POLL_CAPACITY + 2; index++) {
+                polls.add(routedPoll(router, tracked(pollPayload()), true));
+            }
+            assertThat(primary.sentContexts).hasSize(DEFERRED_POLL_CAPACITY);
+            assertThat(polls).allSatisfy(poll -> assertThat(poll).isNotDone());
+
+            replies.get(0).complete(emptyPoll(0));
+            polls.get(0).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS).release();
+            await(() -> primary.sentContexts.size() == DEFERRED_POLL_CAPACITY + 1, "first queued poll", TIMEOUT);
+            assertThat(polls.get(DEFERRED_POLL_CAPACITY + 1)).isNotDone();
+
+            replies.get(DEFERRED_POLL_CAPACITY).complete(emptyPoll(0));
+            polls.get(DEFERRED_POLL_CAPACITY)
+                    .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)
+                    .release();
+            await(() -> primary.sentContexts.size() == DEFERRED_POLL_CAPACITY + 2, "second queued poll", TIMEOUT);
+            replies.get(DEFERRED_POLL_CAPACITY + 1).complete(emptyPoll(0));
+            polls.get(DEFERRED_POLL_CAPACITY + 1)
+                    .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)
+                    .release();
+        } finally {
+            router.clearSession().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            timers.shutdownGracefully(0, 1, TimeUnit.SECONDS).get(5, TimeUnit.SECONDS);
+            group.shutdownGracefully(0, 1, TimeUnit.SECONDS).get(5, TimeUnit.SECONDS);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void shouldRemoveADeferredPollThatEndsWhileWaitingForASlot(boolean cancelled) throws Exception {
+        IoEventLoopGroup group = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory());
+        ManualTimers timers = new ManualTimers();
+        Coordinator coordinator = new Coordinator(group, timers);
+        Primary primary = new Primary(group);
+        PollRouter router = new PollRouter(() -> coordinator, endpoint -> primary);
+        try {
+            List<CompletableFuture<ByteBuf>> replies = new ArrayList<>();
+            List<CompletableFuture<ByteBuf>> polls = new ArrayList<>();
+            for (int index = 0; index < DEFERRED_POLL_CAPACITY; index++) {
+                var reply = new CompletableFuture<ByteBuf>();
+                replies.add(reply);
+                primary.replies.add(reply);
+                polls.add(routedPoll(router, tracked(pollPayload()), true));
+            }
+            ByteBuf expiredPayload = tracked(pollPayload());
+            var expired = routedPoll(router, expiredPayload, true);
+            assertThat(expired).isNotDone();
+            if (cancelled) {
+                expired.cancel(false);
+            } else {
+                timers.runLast();
+                assertServerError(expired, AsyncTcpConnection.TRANSIENT_NOT_ACCEPTED);
+            }
+            assertThat(expiredPayload.refCnt()).isZero();
+            var nextReply = new CompletableFuture<ByteBuf>();
+            primary.replies.add(nextReply);
+            var next = routedPoll(router, tracked(pollPayload()), true);
+
+            replies.get(0).complete(emptyPoll(0));
+            polls.get(0).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS).release();
+            await(() -> primary.sentContexts.size() == DEFERRED_POLL_CAPACITY + 1, "next live queued poll", TIMEOUT);
+            nextReply.complete(emptyPoll(0));
+            next.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS).release();
+        } finally {
+            router.clearSession().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            timers.shutdownGracefully(0, 1, TimeUnit.SECONDS).get(5, TimeUnit.SECONDS);
+            group.shutdownGracefully(0, 1, TimeUnit.SECONDS).get(5, TimeUnit.SECONDS);
+        }
+    }
+
+    @Test
+    void shouldNotSendAQueuedDeferredPollAfterItsDeadline() throws Exception {
+        IoEventLoopGroup group = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory());
+        ManualTimers timers = new ManualTimers();
+        Coordinator coordinator = new Coordinator(group, timers);
+        Primary primary = new Primary(group);
+        PollRouter router = new PollRouter(() -> coordinator, endpoint -> primary);
+        try {
+            List<CompletableFuture<ByteBuf>> polls = new ArrayList<>();
+            for (int index = 0; index < DEFERRED_POLL_CAPACITY; index++) {
+                primary.replies.add(new CompletableFuture<>());
+                polls.add(routedPoll(router, tracked(pollPayload()), true));
+            }
+            var options = DeferredPollOptions.defaults();
+            ByteBuf payload = tracked(pollPayload());
+            payload.writeLongLE(options.maxWaitMicros())
+                    .writeIntLE((int) options.minCount())
+                    .writeIntLE((int) options.maxBytes())
+                    .writeLongLE(options.requestTimeoutMicros());
+            var queued = router.partitionRequest(
+                    CommandCode.Messages.POLL_DEFERRED.getValue(), payload, Optional.empty(), System.nanoTime());
+            assertThat(queued).isNotDone();
+
+            polls.get(0).cancel(false);
+
+            assertServerError(queued, AsyncTcpConnection.TRANSIENT_NOT_ACCEPTED);
+            assertThat(primary.sentContexts).hasSize(DEFERRED_POLL_CAPACITY);
+            assertThat(payload.refCnt()).isZero();
+        } finally {
+            router.clearSession().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            timers.shutdownGracefully(0, 1, TimeUnit.SECONDS).get(5, TimeUnit.SECONDS);
+            group.shutdownGracefully(0, 1, TimeUnit.SECONDS).get(5, TimeUnit.SECONDS);
+        }
+    }
+
+    @Test
+    void shouldEndQueuedDeferredPollsOnSessionResetAndReleaseTheirSlots() throws Exception {
+        IoEventLoopGroup group = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory());
+        ManualTimers timers = new ManualTimers();
+        Coordinator coordinator = new Coordinator(group, timers);
+        Primary primary = new Primary(group);
+        PollRouter router = new PollRouter(() -> coordinator, endpoint -> primary);
+        try {
+            coordinator.routeReady = new CompletableFuture<>();
+            List<CompletableFuture<ByteBuf>> polls = new ArrayList<>();
+            for (int index = 0; index <= DEFERRED_POLL_CAPACITY; index++) {
+                polls.add(routedPoll(router, tracked(pollPayload()), true));
+            }
+
+            router.clearSession().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            assertThat(polls).allSatisfy(poll -> assertServerError(poll, AsyncTcpConnection.TRANSIENT_NOT_ACCEPTED));
+            coordinator.routeReady.complete(null);
+            List<CompletableFuture<ByteBuf>> fresh = new ArrayList<>();
+            for (int index = 0; index < DEFERRED_POLL_CAPACITY; index++) {
+                primary.replies.add(new CompletableFuture<>());
+                fresh.add(routedPoll(router, tracked(pollPayload()), true));
+            }
+            assertThat(primary.sentContexts).hasSize(DEFERRED_POLL_CAPACITY);
+            assertThat(fresh).allSatisfy(poll -> assertThat(poll).isNotDone());
+        } finally {
+            router.clearSession().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            timers.shutdownGracefully(0, 1, TimeUnit.SECONDS).get(5, TimeUnit.SECONDS);
+            group.shutdownGracefully(0, 1, TimeUnit.SECONDS).get(5, TimeUnit.SECONDS);
+        }
+    }
+
+    /** A reply that comes after its poll timed out goes back, and so does the payload of that poll. */
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void shouldReleaseALateReplyAndThePayloadOfARoutedPollThatTimedOut(boolean deferred) throws Exception {
+        IoEventLoopGroup group = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory());
+        ManualTimers timers = new ManualTimers();
+        Coordinator coordinator = new Coordinator(group, timers);
+        Primary primary = new Primary(group);
+        PollRouter router = new PollRouter(() -> coordinator, endpoint -> primary);
+        try {
+            routedPoll(router, pollPayload(), deferred)
                     .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)
                     .release();
             int pollTimeout = timers.scheduled.size();
@@ -478,7 +634,7 @@
             primary.beforeSend = () -> timers.scheduled.get(pollTimeout).run();
             ByteBuf payload = tracked(pollPayload());
 
-            var polled = router.poll(payload, Optional.empty());
+            var polled = routedPoll(router, payload, deferred);
 
             assertServerError(polled, AsyncTcpConnection.TRANSIENT_NOT_COMMITTED);
             assertThat(late.refCnt()).isZero();
@@ -490,21 +646,22 @@
         }
     }
 
-    @Test
-    void shouldReleaseThePayloadOfACancelledRoutedPollAndCancelItsExchange() throws Exception {
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void shouldReleaseThePayloadOfACancelledRoutedPollAndCancelItsExchange(boolean deferred) throws Exception {
         IoEventLoopGroup group = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory());
         ManualTimers timers = new ManualTimers();
         Coordinator coordinator = new Coordinator(group, timers);
         Primary primary = new Primary(group);
         PollRouter router = new PollRouter(() -> coordinator, endpoint -> primary);
         try {
-            router.poll(pollPayload(), Optional.empty())
+            routedPoll(router, pollPayload(), deferred)
                     .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)
                     .release();
             CompletableFuture<ByteBuf> exchange = new CompletableFuture<>();
             primary.replies.add(exchange);
             ByteBuf payload = tracked(pollPayload());
-            var polled = router.poll(payload, Optional.empty());
+            var polled = routedPoll(router, payload, deferred);
             assertThat(polled).isNotDone();
 
             assertThat(polled.cancel(false)).isTrue();
@@ -557,15 +714,16 @@
         }
     }
 
-    @Test
-    void shouldReleaseThePayloadOfARoutedPollRetriedAfterARefusal() throws Exception {
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void shouldReleaseThePayloadOfARoutedPollRetriedAfterARefusal(boolean deferred) throws Exception {
         IoEventLoopGroup group = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory());
         ManualTimers timers = new ManualTimers();
         Coordinator coordinator = new Coordinator(group, timers);
         Primary primary = new Primary(group);
         PollRouter router = new PollRouter(() -> coordinator, endpoint -> primary);
         try {
-            router.poll(pollPayload(), Optional.empty())
+            routedPoll(router, pollPayload(), deferred)
                     .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)
                     .release();
             ByteBuf reply = tracked(emptyPoll(0));
@@ -573,7 +731,7 @@
                     IggyServerException.fromTcpResponse(AsyncTcpConnection.TRANSIENT_NOT_ACCEPTED, new byte[0])));
             primary.replies.add(CompletableFuture.completedFuture(reply));
             ByteBuf payload = tracked(pollPayload());
-            var polled = router.poll(payload, Optional.empty());
+            var polled = routedPoll(router, payload, deferred);
             assertThat(polled).isNotDone();
 
             timers.runLast();
@@ -732,6 +890,108 @@
     }
 
     @Test
+    @SuppressWarnings({"checkstyle:CyclomaticComplexity", "checkstyle:NPathComplexity"})
+    void shouldSendWhileADeferredProgressPollKeepsOffsetOperationsWaiting() throws Exception {
+        InetAddress loopback = InetAddress.getLoopbackAddress();
+        try (ServerSocket coordinatorSocket = new ServerSocket(0, 1, loopback);
+                ServerSocket primarySocket = new ServerSocket(0, 1, loopback)) {
+            CompletableFuture<Void> parked = new CompletableFuture<>();
+            AtomicInteger offsetRequests = new AtomicInteger();
+            var coordinatorServing = serve(coordinatorSocket, request -> {
+                if (request.operation() == OPERATION_REGISTER) {
+                    return Response.success(OPERATION_REGISTER, registerBody(SESSION));
+                }
+                if (request.is(GET_CLUSTER_METADATA_CODE, OPERATION_NON_REPLICATED)) {
+                    return Response.success(
+                            OPERATION_NON_REPLICATED,
+                            AsyncIggyTcpClientTransientFailoverTest.clusterMetadata(
+                                    coordinatorSocket.getLocalPort(),
+                                    primarySocket.getLocalPort(),
+                                    coordinatorSocket.getLocalPort()));
+                }
+                if (request.is(GET_PARTITION_ROUTE_CODE, OPERATION_NON_REPLICATED)) {
+                    return Response.success(
+                            OPERATION_NON_REPLICATED,
+                            pollRoute(request, SESSION, ROUTE_WATERMARK, primarySocket.getLocalPort(), INCARNATION));
+                }
+                return pong(request);
+            });
+            var primaryServing = serve(primarySocket, request -> {
+                if (request.is(BIND_SESSION_CODE, OPERATION_NON_REPLICATED)) {
+                    return Response.success(OPERATION_NON_REPLICATED, bindBody(request));
+                }
+                if (request.is(CommandCode.Messages.POLL_DEFERRED.getValue(), OPERATION_NON_REPLICATED)) {
+                    parked.complete(null);
+                    return Response.noReply();
+                }
+                if (request.is(CommandCode.Messages.CANCEL_DEFERRED_REQUEST.getValue(), OPERATION_NON_REPLICATED)) {
+                    return Response.noReply();
+                }
+                if (request.operation() == VsrOperation.SEND_MESSAGES) {
+                    return Response.success(request.operation(), Unpooled.EMPTY_BUFFER);
+                }
+                if (request.is(CommandCode.ConsumerOffset.GET.getValue(), OPERATION_NON_REPLICATED)
+                        || request.operation() == VsrOperation.STORE_CONSUMER_OFFSET
+                        || request.operation() == VsrOperation.DELETE_CONSUMER_OFFSET) {
+                    offsetRequests.incrementAndGet();
+                    return Response.success(
+                            request.operation(),
+                            request.operation() == OPERATION_NON_REPLICATED
+                                    ? Unpooled.EMPTY_BUFFER
+                                    : Unpooled.buffer(Integer.BYTES).writeIntLE(0));
+                }
+                return pong(request);
+            });
+            AsyncTcpConnection parent = connection(coordinatorSocket);
+            PollRouter router = new PollRouter(() -> parent, endpoint -> connection(primarySocket));
+            router.coordinatorEndpoint(
+                    () -> new ConnectionInfo(loopback.getHostAddress(), coordinatorSocket.getLocalPort()));
+            try {
+                parent.connect().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                new UsersTcpClient(() -> parent)
+                        .login("user", "password")
+                        .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                ByteBuf payload = pollPayload();
+                payload.setByte(payload.writerIndex() - 1, 1);
+                var poll = routedPoll(router, payload, true);
+                parked.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                List<CompletableFuture<ByteBuf>> offsets = new ArrayList<>();
+                for (var command : List.of(
+                        CommandCode.ConsumerOffset.STORE,
+                        CommandCode.ConsumerOffset.GET,
+                        CommandCode.ConsumerOffset.DELETE)) {
+                    ByteBuf offset = offsetPayload();
+                    if (command != CommandCode.ConsumerOffset.STORE) {
+                        offset.writerIndex(offset.writerIndex() - Long.BYTES);
+                    }
+                    offsets.add(router.partitionRequest(command.getValue(), offset, Optional.empty()));
+                }
+                new MessagesTcpClient(() -> parent, new ClientRoutingState(), router)
+                        .sendMessages(
+                                StreamId.of(1L),
+                                TopicId.of(1L),
+                                Partitioning.partitionId(0L),
+                                List.of(Message.of("message")))
+                        .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                assertThat(poll).isNotDone();
+                assertThat(offsetRequests.get()).isZero();
+                assertThat(offsets).allSatisfy(offset -> assertThat(offset).isNotDone());
+                assertThat(poll.cancel(false)).isTrue();
+                for (var offset : offsets) {
+                    offset.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS).release();
+                }
+                assertThat(offsetRequests.get()).isEqualTo(offsets.size());
+            } finally {
+                router.clearSession().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                parent.close().get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+                parent.sessionOwner().reset();
+            }
+            coordinatorServing.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+            primaryServing.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
+        }
+    }
+
+    @Test
     void shouldNotDeadlockWhenTheTimeoutFiresWhileAnInlineSendHoldsTheRouter() throws Exception {
         IoEventLoopGroup group = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory());
         ManualTimers timers = new ManualTimers();
@@ -1204,7 +1464,7 @@
         var owner = connection.sessionOwner();
         owner.configurePendingOperations(1, frameBytes);
         var ticket = owner.acquire(
-                        new ConsensusSession.LaneKey(INCARNATION, 0),
+                        new ConsensusSession.LaneKey(INCARNATION, 0, true),
                         true,
                         frameBytes,
                         System.nanoTime() + NEVER.toNanos(),
@@ -1436,6 +1696,18 @@
         }
     }
 
+    private static CompletableFuture<ByteBuf> routedPoll(PollRouter router, ByteBuf payload, boolean deferred) {
+        if (!deferred) {
+            return router.poll(payload, Optional.empty());
+        }
+        var options = DeferredPollOptions.defaults();
+        payload.writeLongLE(options.maxWaitMicros())
+                .writeIntLE((int) options.minCount())
+                .writeIntLE((int) options.maxBytes())
+                .writeLongLE(options.requestTimeoutMicros());
+        return router.partitionRequest(CommandCode.Messages.POLL_DEFERRED.getValue(), payload, Optional.empty());
+    }
+
     /** Answers the first poll and leaves every later one in flight until it is closed. */
     private static final class Primary extends OfflineConnection {
         private final List<PartitionContext> sentContexts = new CopyOnWriteArrayList<>();
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/ConsensusSessionTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/ConsensusSessionTest.java
index 133388a..32e76ce 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/ConsensusSessionTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/ConsensusSessionTest.java
@@ -260,6 +260,138 @@
     }
 
     @Test
+    void shouldAdmitProducersWhileAnOffsetStoreWaitsForProgressAndRemoveBothCancelledAdmissions() {
+        ConsensusSession session = new ConsensusSession();
+        session.configurePendingOperations(1, VsrHeaders.HEADER_SIZE);
+        session.bind(42);
+        EmbeddedChannel channel = new EmbeddedChannel();
+        var mutation = new ConsensusSession.LaneKey(7, 1);
+        var progress = new ConsensusSession.LaneKey(7, 1, true);
+        long deadline = System.nanoTime() + DEADLINE;
+        try {
+            var poll = session.acquire(progress, false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+                    .join();
+            var store = session.acquire(progress, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            assertThat(store).isNotDone();
+            var producer = session.acquire(mutation, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            assertThat(producer).isDone();
+
+            poll.complete();
+            var laterProducer = session.acquire(mutation, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            var offsetRead = session.acquire(progress, false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            assertThat(store).isNotDone();
+            assertThat(laterProducer).isNotDone();
+            assertThat(offsetRead).isNotDone();
+            producer.join().complete();
+            assertThat(store).isDone();
+            assertThat(laterProducer).isNotDone();
+            assertThat(offsetRead).isNotDone();
+            store.join().complete();
+            laterProducer.join().complete();
+            offsetRead.join().complete();
+
+            var blockedMutation = session.acquire(mutation, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+                    .join();
+            var cancelledStore = session.acquire(progress, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            var queuedRead = session.acquire(progress, false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            var queuedProducer = session.acquire(mutation, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            assertThat(queuedRead).isNotDone();
+            assertThat(queuedProducer).isNotDone();
+            cancelledStore.cancel(false);
+            assertThat(queuedRead).isDone();
+            queuedRead.join().complete();
+            blockedMutation.complete();
+            queuedProducer.join().complete();
+        } finally {
+            session.reset();
+            channel.finishAndReleaseAll();
+        }
+    }
+
+    @Test
+    void shouldKeepBothOffsetLanesUntilAnAbandonedReplayEnds() {
+        ConsensusSession session = new ConsensusSession();
+        session.configurePendingOperations(1, 2L * VsrHeaders.HEADER_SIZE);
+        session.bind(42);
+        EmbeddedChannel channel = new EmbeddedChannel();
+        var mutation = new ConsensusSession.LaneKey(7, 1);
+        var progress = new ConsensusSession.LaneKey(7, 1, true);
+        long deadline = System.nanoTime() + DEADLINE;
+        try {
+            var store = session.acquire(progress, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+                    .join();
+            freezeAndSend(store, 9);
+            store.retain();
+            for (var lane : new ConsensusSession.LaneKey[] {mutation, progress}) {
+                assertOperationError(
+                        session.acquire(lane, lane == mutation, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop()),
+                        IggyErrorCode.LANE_BLOCKED);
+            }
+            assertThat(session.resume(9, deadline, channel.eventLoop()).join()).isSameAs(store);
+            ByteBuf alias = store.frame();
+            session.abandon(9);
+            var poll = session.acquire(progress, false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            var send = session.acquire(mutation, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            assertThat(poll).isNotDone();
+            assertThat(send).isNotDone();
+            var independent = session.acquire(
+                    new ConsensusSession.LaneKey(8, 1), true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            assertThat(independent).isDone();
+            independent.join().complete();
+            store.complete();
+            poll.join().complete();
+            send.join().complete();
+            assertThat(alias.refCnt()).isPositive();
+            alias.release();
+            store.finishWrite();
+            session.acquire(mutation, true, 2L * VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+                    .join()
+                    .complete();
+        } finally {
+            session.reset();
+            channel.finishAndReleaseAll();
+        }
+    }
+
+    @Test
+    void shouldAllowProgressPastARetainedSendAndReleaseBothOffsetLanesOnReset() {
+        ConsensusSession session = new ConsensusSession();
+        session.bind(42);
+        EmbeddedChannel channel = new EmbeddedChannel();
+        var mutation = new ConsensusSession.LaneKey(7, 1);
+        var progress = new ConsensusSession.LaneKey(7, 1, true);
+        long deadline = System.nanoTime() + DEADLINE;
+        try {
+            var send = session.acquire(mutation, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+                    .join();
+            freezeAndSend(send, 9);
+            send.retain();
+            var poll = session.acquire(progress, false, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop());
+            assertThat(poll).isDone();
+            poll.join().complete();
+            assertOperationError(
+                    session.acquire(progress, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop()),
+                    IggyErrorCode.LANE_BLOCKED);
+            session.abandon(9);
+            var store = session.acquire(progress, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+                    .join();
+            freezeAndSend(store, 10);
+            store.retain();
+            session.reset();
+            session.bind(43);
+            session.acquire(progress, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+                    .join()
+                    .complete();
+            session.acquire(mutation, true, VsrHeaders.HEADER_SIZE, deadline, channel.eventLoop())
+                    .join()
+                    .complete();
+        } finally {
+            session.reset();
+            channel.finishAndReleaseAll();
+        }
+    }
+
+    @Test
     void shouldPreserveAllUnsignedRequestBitsAndRefuseCounterReuseAcrossReset() throws Exception {
         ConsensusSession session = new ConsensusSession();
         session.bind(42);
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrFrameDecoderTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrFrameDecoderTest.java
index fe916cd..4b98390 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrFrameDecoderTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrFrameDecoderTest.java
@@ -26,6 +26,8 @@
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
 
+import java.util.concurrent.atomic.AtomicInteger;
+
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
@@ -115,4 +117,78 @@
             }
         }
     }
+
+    @Test
+    void shouldApplyEachRequestsLimitIndependentlyOnASharedConnection() {
+        long limitedRequest = 1L;
+        long ordinaryRequest = 2L;
+        int limit = VsrHeaders.HEADER_SIZE + 40;
+        EmbeddedChannel shared = new EmbeddedChannel(new VsrFrameDecoder(
+                VsrFrameDecoder.DEFAULT_MAX_FRAME_SIZE,
+                request -> request == limitedRequest ? limit : VsrFrameDecoder.DEFAULT_MAX_FRAME_SIZE));
+        try {
+            ByteBuf ordinary = Unpooled.buffer(limit + 1);
+            ordinary.writeZero(limit + 1);
+            ordinary.setIntLE(VsrHeaders.SIZE_OFFSET, limit + 1);
+            ordinary.setLongLE(VsrHeaders.REPLY_REQUEST_OFFSET, ordinaryRequest);
+            assertThat(shared.writeInbound(ordinary)).isTrue();
+            ByteBuf decoded = shared.readInbound();
+            assertThat(decoded.readableBytes()).isEqualTo(limit + 1);
+            decoded.release();
+
+            ByteBuf header = Unpooled.buffer(VsrHeaders.HEADER_SIZE);
+            header.writeZero(VsrHeaders.HEADER_SIZE);
+            header.setIntLE(VsrHeaders.SIZE_OFFSET, limit + 1);
+            header.setLongLE(VsrHeaders.REPLY_REQUEST_OFFSET, limitedRequest);
+            assertThatThrownBy(() -> shared.writeInbound(header)).isInstanceOf(DecoderException.class);
+        } finally {
+            try {
+                shared.finishAndReleaseAll();
+            } catch (DecoderException ignored) {
+                // Closing a failed decoder can replay its exception.
+            }
+        }
+    }
+
+    @Test
+    void shouldFollowALimitTightenedForOneExchange() {
+        int tightened = VsrHeaders.HEADER_SIZE + 16;
+        AtomicInteger limit = new AtomicInteger(VsrFrameDecoder.DEFAULT_MAX_FRAME_SIZE);
+        EmbeddedChannel tightenedChannel = new EmbeddedChannel(
+                new VsrFrameDecoder(VsrFrameDecoder.DEFAULT_MAX_FRAME_SIZE, request -> limit.get()));
+        try {
+            limit.set(tightened);
+            ByteBuf oversized = Unpooled.buffer(VsrHeaders.HEADER_SIZE);
+            oversized.writeZero(VsrHeaders.HEADER_SIZE);
+            oversized.setIntLE(VsrHeaders.SIZE_OFFSET, tightened + 1);
+
+            assertThatThrownBy(() -> tightenedChannel.writeInbound(oversized)).isInstanceOf(DecoderException.class);
+        } finally {
+            try {
+                tightenedChannel.finishAndReleaseAll();
+            } catch (DecoderException ignored) {
+                // Closing a failed decoder can replay its exception.
+            }
+        }
+    }
+
+    @Test
+    void shouldAcceptAFrameInsideTheTightenedLimitBeforeAccumulatingIt() {
+        int tightened = VsrHeaders.HEADER_SIZE + 16;
+        AtomicInteger limit = new AtomicInteger(tightened);
+        EmbeddedChannel tightenedChannel = new EmbeddedChannel(
+                new VsrFrameDecoder(VsrFrameDecoder.DEFAULT_MAX_FRAME_SIZE, request -> limit.get()));
+        try {
+            ByteBuf frame = Unpooled.buffer(tightened);
+            frame.writeZero(tightened);
+            frame.setIntLE(VsrHeaders.SIZE_OFFSET, tightened);
+
+            assertThat(tightenedChannel.writeInbound(frame)).isTrue();
+            ByteBuf decoded = tightenedChannel.readInbound();
+            assertThat(decoded.readableBytes()).isEqualTo(tightened);
+            decoded.release();
+        } finally {
+            tightenedChannel.finishAndReleaseAll();
+        }
+    }
 }
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrResponseHandlerTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrResponseHandlerTest.java
index de6374f..88ea188 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrResponseHandlerTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/async/tcp/vsr/VsrResponseHandlerTest.java
@@ -24,11 +24,14 @@
 import io.netty.channel.embedded.EmbeddedChannel;
 import org.apache.iggy.exception.IggyAuthenticationException;
 import org.apache.iggy.exception.IggyConnectionException;
+import org.apache.iggy.exception.IggyErrorCode;
 import org.apache.iggy.exception.IggyServerException;
 import org.apache.iggy.exception.IggyTimeoutException;
+import org.apache.iggy.serde.CommandCode;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import java.nio.charset.StandardCharsets;
@@ -61,6 +64,83 @@
         channel.finishAndReleaseAll();
     }
 
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void shouldCancelOnlyAfterTheDeferredFrameHasBeenSent(boolean cancelBeforeWrite) {
+        long requestId = 7;
+        long sessionId = 9;
+        CompletableFuture<ByteBuf> future = new CompletableFuture<>();
+        ByteBuf request = requestFrame(VsrOperation.NON_REPLICATED, requestId);
+        request.setLongLE(VsrHeaders.REQUEST_SESSION_OFFSET, sessionId);
+        request.setIntLE(VsrHeaders.REQUEST_RESERVED_CODE_OFFSET, CommandCode.Messages.POLL_DEFERRED.getValue());
+        var pending = handler.registerRequest(
+                channel,
+                request,
+                future,
+                System.nanoTime() + TimeUnit.MINUTES.toNanos(1),
+                CommandCode.Messages.POLL_DEFERRED.getValue());
+        try {
+            if (cancelBeforeWrite) {
+                future.cancel(false);
+                assertThat((Object) channel.readOutbound()).isNull();
+            }
+            channel.writeOutbound(request.retainedDuplicate());
+            pending.sent(channel);
+            if (!cancelBeforeWrite) {
+                future.cancel(false);
+            }
+            channel.runPendingTasks();
+            ByteBuf original = channel.readOutbound();
+            assertThat(original.getIntLE(VsrHeaders.REQUEST_RESERVED_CODE_OFFSET))
+                    .isEqualTo(CommandCode.Messages.POLL_DEFERRED.getValue());
+            original.release();
+            ByteBuf cancellation = channel.readOutbound();
+            assertThat(cancellation.readableBytes()).isEqualTo(VsrHeaders.HEADER_SIZE);
+            assertThat(cancellation.getIntLE(VsrHeaders.REQUEST_RESERVED_CODE_OFFSET))
+                    .isEqualTo(CommandCode.Messages.CANCEL_DEFERRED_REQUEST.getValue());
+            assertThat(VsrHeaders.readRequestId(cancellation)).isEqualTo(requestId);
+            assertThat(cancellation.getLongLE(VsrHeaders.REQUEST_SESSION_OFFSET))
+                    .isEqualTo(sessionId);
+            cancellation.release();
+            assertThat((Object) channel.readOutbound()).isNull();
+            assertThat(channel.isActive()).isTrue();
+        } finally {
+            request.release();
+        }
+    }
+
+    @Test
+    void shouldQueueTheCancellationBehindAnOriginalWriteWaitingForTheLoop() {
+        CompletableFuture<ByteBuf> future = new CompletableFuture<>();
+        ByteBuf request = requestFrame(VsrOperation.NON_REPLICATED, 7);
+        request.setIntLE(VsrHeaders.REQUEST_RESERVED_CODE_OFFSET, CommandCode.Messages.POLL_DEFERRED.getValue());
+        var pending = handler.registerRequest(
+                channel,
+                request,
+                future,
+                System.nanoTime() + TimeUnit.MINUTES.toNanos(1),
+                CommandCode.Messages.POLL_DEFERRED.getValue());
+        try {
+            // A write issued off the loop waits in its queue, and a deadline task can run before it.
+            channel.eventLoop().execute(() -> channel.writeAndFlush(request.retainedDuplicate()));
+            pending.sent(channel);
+            future.cancel(false);
+            channel.runPendingTasks();
+
+            ByteBuf first = channel.readOutbound();
+            assertThat(first.getIntLE(VsrHeaders.REQUEST_RESERVED_CODE_OFFSET))
+                    .as("a cancellation that overtakes its request cancels nothing")
+                    .isEqualTo(CommandCode.Messages.POLL_DEFERRED.getValue());
+            first.release();
+            ByteBuf second = channel.readOutbound();
+            assertThat(second.getIntLE(VsrHeaders.REQUEST_RESERVED_CODE_OFFSET))
+                    .isEqualTo(CommandCode.Messages.CANCEL_DEFERRED_REQUEST.getValue());
+            second.release();
+        } finally {
+            request.release();
+        }
+    }
+
     @Test
     void shouldFailWithStatusCodeOnDeniedReply() {
         CompletableFuture<ByteBuf> future = enqueue(VsrOperation.NON_REPLICATED, 7);
@@ -524,6 +604,36 @@
         assertThatThrownBy(other::join).hasCauseInstanceOf(IggyConnectionException.class);
     }
 
+    @ParameterizedTest
+    @EnumSource(
+            value = IggyErrorCode.class,
+            names = {
+                "TRANSIENT_NOT_COMMITTED",
+                "TRANSIENT_NOT_ACCEPTED",
+                "NOT_PRIMARY",
+                "DEFERRED_POLL_CAPACITY_EXCEEDED"
+            })
+    void shouldNotTreatATransientDeferredRefusalAsACommittedOutcome(IggyErrorCode refusal) {
+        long requestId = 7;
+        CompletableFuture<ByteBuf> result = new CompletableFuture<>();
+        ByteBuf request = requestFrame(VsrOperation.NON_REPLICATED, requestId);
+        VsrResponseHandler.PendingRequest pending = handler.registerRequest(
+                channel,
+                request,
+                result,
+                System.nanoTime() + TimeUnit.MINUTES.toNanos(1),
+                CommandCode.Messages.POLL_DEFERRED.getValue());
+        request.release();
+        ByteBuf reply = replyFrame(VsrOperation.NON_REPLICATED, requestId, Unpooled.EMPTY_BUFFER);
+        reply.setIntLE(VsrHeaders.REPLY_STATUS_OFFSET, refusal.getCode());
+
+        channel.writeInbound(reply);
+
+        assertThat(rawErrorCode(result)).isEqualTo(refusal.getCode());
+        assertThat(pending.isCommitted()).isFalse();
+        assertThat(channel.isActive()).isTrue();
+    }
+
     @Test
     void shouldProveCommittedBusinessRejectionOnlyForTheExactRequest() {
         int[] mismatchedOffsets = {
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/MessagesClientBaseTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/MessagesClientBaseTest.java
index fd4034f..d5bf017 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/MessagesClientBaseTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/MessagesClientBaseTest.java
@@ -20,16 +20,20 @@
 package org.apache.iggy.client.blocking;
 
 import org.apache.iggy.consumergroup.Consumer;
+import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
+import org.apache.iggy.message.PolledMessages;
 import org.apache.iggy.message.PollingKind;
 import org.apache.iggy.message.PollingStrategy;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 import java.math.BigInteger;
+import java.time.Duration;
 import java.util.List;
 import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
 
 import static java.util.Optional.empty;
 import static org.apache.iggy.TestConstants.STREAM_NAME;
@@ -38,6 +42,11 @@
 
 public abstract class MessagesClientBaseTest extends IntegrationTest {
 
+    private static final Duration READINESS_WAIT = Duration.ofSeconds(4);
+    private static final Duration SHORT_READINESS_WAIT = Duration.ofMillis(400);
+    private static final Duration SEND_DELAY = Duration.ofMillis(200);
+    private static final int BYTE_BOUND_PAYLOAD_SIZE = 400;
+
     protected MessagesClient messagesClient;
 
     @BeforeEach
@@ -213,4 +222,125 @@
         assertThat(polledMessages.messages()).hasSize(1);
         assertThat(new String(polledMessages.messages().get(0).payload())).isEqualTo(content);
     }
+
+    @Test
+    void shouldWakeADeferredPollWhenAMessageArrives() {
+        // given
+        setUpStreamAndTopic();
+        var options = DeferredPollOptions.defaults().withMaxWait(READINESS_WAIT);
+        CompletableFuture<Void> sender = CompletableFuture.runAsync(() -> {
+            sleep(SEND_DELAY);
+            messagesClient.sendMessages(
+                    STREAM_NAME, TOPIC_NAME, Partitioning.partitionId(0L), List.of(Message.of("late arrival")));
+        });
+
+        // when
+        long startedNanos = System.nanoTime();
+        PolledMessages polled = messagesClient.pollMessagesDeferred(
+                STREAM_NAME,
+                TOPIC_NAME,
+                Optional.of(0L),
+                Consumer.of(0L),
+                PollingStrategy.first(),
+                10L,
+                false,
+                options);
+        Duration elapsed = Duration.ofNanos(System.nanoTime() - startedNanos);
+        sender.join();
+
+        // then
+        assertThat(polled.messages()).hasSize(1);
+        assertThat(new String(polled.messages().get(0).payload())).isEqualTo("late arrival");
+        assertThat(elapsed)
+                .as("the poll woke on the message, not on the readiness deadline")
+                .isLessThan(READINESS_WAIT);
+    }
+
+    @Test
+    void shouldReturnEmptyWhenTheReadinessWaitExpires() {
+        // given
+        setUpStreamAndTopic();
+        var options = DeferredPollOptions.defaults().withMaxWait(SHORT_READINESS_WAIT);
+
+        // when
+        long startedNanos = System.nanoTime();
+        PolledMessages polled = messagesClient.pollMessagesDeferred(
+                STREAM_NAME,
+                TOPIC_NAME,
+                Optional.of(0L),
+                Consumer.of(0L),
+                PollingStrategy.first(),
+                10L,
+                false,
+                options);
+        Duration elapsed = Duration.ofNanos(System.nanoTime() - startedNanos);
+
+        // then
+        assertThat(polled.messages()).isEmpty();
+        assertThat(elapsed).isGreaterThanOrEqualTo(SHORT_READINESS_WAIT.dividedBy(2));
+    }
+
+    @Test
+    void shouldReturnAPartialBatchWhenTheReadinessTargetIsNotMet() {
+        // given
+        setUpStreamAndTopic();
+        messagesClient.sendMessages(
+                STREAM_NAME, TOPIC_NAME, Partitioning.partitionId(0L), List.of(Message.of("only one")));
+        var options =
+                DeferredPollOptions.defaults().withMaxWait(SHORT_READINESS_WAIT).withMinCount(3);
+
+        // when
+        PolledMessages polled = messagesClient.pollMessagesDeferred(
+                STREAM_NAME,
+                TOPIC_NAME,
+                Optional.of(0L),
+                Consumer.of(0L),
+                PollingStrategy.first(),
+                10L,
+                false,
+                options);
+
+        // then
+        assertThat(polled.messages()).hasSize(1);
+    }
+
+    @Test
+    void shouldReturnADeferredBatchBoundedByBytes() {
+        // given
+        setUpStreamAndTopic();
+        String payload = "x".repeat(BYTE_BOUND_PAYLOAD_SIZE);
+        messagesClient.sendMessages(
+                STREAM_NAME,
+                TOPIC_NAME,
+                Partitioning.partitionId(0L),
+                List.of(Message.of(payload), Message.of(payload), Message.of(payload)));
+        // Room for a message and its framing, far short of all three.
+        var options = DeferredPollOptions.defaults()
+                .withMaxWait(SHORT_READINESS_WAIT)
+                .withMaxBytes(2L * BYTE_BOUND_PAYLOAD_SIZE);
+
+        // when
+        PolledMessages polled = messagesClient.pollMessagesDeferred(
+                STREAM_NAME,
+                TOPIC_NAME,
+                Optional.of(0L),
+                Consumer.of(0L),
+                PollingStrategy.first(),
+                10L,
+                false,
+                options);
+
+        // then
+        assertThat(polled.messages()).hasSizeBetween(1, 2);
+        assertThat(new String(polled.messages().get(0).payload())).isEqualTo(payload);
+    }
+
+    private static void sleep(Duration duration) {
+        try {
+            Thread.sleep(duration.toMillis());
+        } catch (InterruptedException interrupted) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException(interrupted);
+        }
+    }
 }
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/http/MessagesHttpDeferredTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/http/MessagesHttpDeferredTest.java
new file mode 100644
index 0000000..a63cc90
--- /dev/null
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/http/MessagesHttpDeferredTest.java
@@ -0,0 +1,552 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iggy.client.blocking.http;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.iggy.consumergroup.Consumer;
+import org.apache.iggy.exception.IggyErrorCode;
+import org.apache.iggy.exception.IggyException;
+import org.apache.iggy.exception.IggyMalformedResponseException;
+import org.apache.iggy.exception.IggyOperationNotSupportedException;
+import org.apache.iggy.exception.IggyServerException;
+import org.apache.iggy.exception.IggyTimeoutException;
+import org.apache.iggy.identifier.StreamId;
+import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.DeferredPollOptions;
+import org.apache.iggy.message.PolledMessages;
+import org.apache.iggy.message.PollingStrategy;
+import org.apache.iggy.partition.PartitionContext;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.math.BigInteger;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * The HTTP deferred poll against a peer that can misbehave: the route and its
+ * query, the local refusal a stateless transport forces, the retry rule, and
+ * the body bound a well-behaved server never exercises.
+ */
+class MessagesHttpDeferredTest {
+
+    private static final String EMPTY_POLL = "{\"partition_id\":0,\"current_offset\":0,\"count\":0,"
+            + "\"context\":{\"incarnation\":1,\"owner_generation\":1,\"metadata_op\":1},\"messages\":[]}";
+    private static final String NOT_ACCEPTED =
+            "{\"id\":58,\"code\":\"transient_not_accepted\",\"reason\":\"no admission\"}";
+    private static final String CAPACITY_EXCEEDED =
+            "{\"id\":90,\"code\":\"deferred_poll_capacity_exceeded\",\"reason\":\"at capacity\"}";
+    private static final String NOT_COMMITTED =
+            "{\"id\":57,\"code\":\"transient_not_committed\",\"reason\":\"unknown outcome\"}";
+    private static final int SERVICE_UNAVAILABLE = 503;
+    private static final int TRANSIENT_NOT_COMMITTED = 57;
+    private static final int DEFERRED_CONNECTION_LIMIT = 64;
+    private static final Duration STALL = Duration.ofMinutes(1);
+    private static final Duration DEADLINE_SLACK = Duration.ofMillis(500);
+
+    @Test
+    void shouldSendTheDeclaredLimitsToTheDeferredRoute() throws Exception {
+        var peer = new FakePeer(exchange -> respond(exchange, 200, EMPTY_POLL));
+        try (var server = peer.start();
+                var client = client(peer)) {
+            var options = DeferredPollOptions.defaults()
+                    .withMaxWait(Duration.ofSeconds(2))
+                    .withMinCount(4)
+                    .withMaxBytes(2048)
+                    .withRequestTimeout(Duration.ofSeconds(9));
+
+            PolledMessages polled = poll(client, Consumer.of(3L), options);
+
+            assertThat(polled.messages()).isEmpty();
+            assertThat(peer.paths).containsExactly("/streams/1/topics/1/messages/deferred");
+            Map<String, String> query = peer.queries.get(0);
+            assertThat(Long.parseLong(query.get("wait_us")))
+                    .as("both budgets are reduced by the time already spent")
+                    .isPositive()
+                    .isLessThanOrEqualTo(options.maxWaitMicros());
+            assertThat(query.get("min_count")).isEqualTo("4");
+            assertThat(query.get("max_bytes")).isEqualTo("2048");
+            assertThat(Long.parseLong(query.get("request_timeout_us")))
+                    .isPositive()
+                    .isLessThanOrEqualTo(options.requestTimeoutMicros());
+            assertThat(query.get("consumer_id")).isEqualTo("3");
+            assertThat(query.get("partition_id")).isEqualTo("0");
+            assertThat(query.get("count")).isEqualTo("10");
+            assertThat(query.get("auto_commit")).isEqualTo("false");
+        }
+    }
+
+    @Test
+    void shouldRefuseAGroupConsumerWithoutReachingTheServer() throws Exception {
+        var peer = new FakePeer(exchange -> respond(exchange, 200, EMPTY_POLL));
+        try (var server = peer.start();
+                var client = client(peer)) {
+
+            assertThatThrownBy(() -> poll(client, Consumer.group(3L), DeferredPollOptions.defaults()))
+                    .isInstanceOf(IggyOperationNotSupportedException.class);
+
+            assertThat(peer.paths)
+                    .as("the server refuses a group consumer over HTTP, so the client refuses it first")
+                    .isEmpty();
+        }
+    }
+
+    @Test
+    void shouldSendThePollContextSoTheServerCanRefuseARecreatedPartition() throws Exception {
+        var peer = new FakePeer(exchange -> respond(exchange, 200, EMPTY_POLL));
+        try (var server = peer.start();
+                var client = client(peer)) {
+            var context = new PartitionContext(BigInteger.valueOf(7), BigInteger.valueOf(3), BigInteger.valueOf(11));
+
+            client.messages()
+                    .pollMessagesDeferred(
+                            StreamId.of(1L),
+                            TopicId.of(1L),
+                            Optional.of(0L),
+                            Consumer.of(3L),
+                            PollingStrategy.offset(BigInteger.TEN).withContext(context),
+                            10L,
+                            false,
+                            DeferredPollOptions.defaults());
+
+            Map<String, String> query = peer.queries.get(0);
+            assertThat(query).containsKey("context");
+            String sent = URLDecoder.decode(query.get("context"), StandardCharsets.UTF_8);
+            assertThat(ObjectMapperFactory.getInstance().readValue(sent, PartitionContext.class))
+                    .isEqualTo(context);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"<html>Service Unavailable</html>", ""})
+    void shouldReportAnErrorBodyThatIsNoIggyError(String body) throws Exception {
+        var peer = new FakePeer(exchange -> respond(exchange, SERVICE_UNAVAILABLE, body));
+        try (var server = peer.start();
+                var client = client(peer)) {
+
+            assertThatThrownBy(() -> poll(client, Consumer.of(3L), DeferredPollOptions.defaults()))
+                    .isInstanceOf(IggyMalformedResponseException.class)
+                    .hasMessageContaining(String.valueOf(SERVICE_UNAVAILABLE));
+        }
+    }
+
+    @Test
+    void shouldDiscardARefusedBodyInsteadOfWaitingForTheRest() throws Exception {
+        var release = new CountDownLatch(1);
+        var peer = new FakePeer(exchange -> {
+            exchange.sendResponseHeaders(200, 1024 * 1024);
+            OutputStream body = exchange.getResponseBody();
+            body.write(new byte[8 * 1024]);
+            body.flush();
+            hold(release, STALL);
+        });
+        try (var server = peer.start();
+                var client = client(peer)) {
+            try {
+                var options = DeferredPollOptions.defaults().withMaxBytes(1024);
+                long startNanos = System.nanoTime();
+
+                assertThatThrownBy(() -> poll(client, Consumer.of(3L), options))
+                        .isInstanceOf(IggyException.class)
+                        .hasMessageContaining("bytes");
+
+                assertThat(Duration.ofNanos(System.nanoTime() - startNanos))
+                        .as("reading the rest would wait for a peer that never sends it")
+                        .isLessThan(options.requestTimeout().dividedBy(2));
+            } finally {
+                release.countDown();
+            }
+        }
+    }
+
+    // Headers at once, near the deadline, and after it: each wait restarts the library's own timeout.
+    @ParameterizedTest
+    @ValueSource(longs = {0, 700, 1500})
+    void shouldAbortTheExchangeAtTheDeadline(long headerDelayMillis) throws Exception {
+        var release = new CountDownLatch(1);
+        var peer = new FakePeer(exchange -> {
+            hold(release, Duration.ofMillis(headerDelayMillis));
+            exchange.sendResponseHeaders(200, 1024);
+            OutputStream body = exchange.getResponseBody();
+            body.write(new byte[16]);
+            body.flush();
+            hold(release, STALL);
+        });
+        try (var server = peer.start();
+                var client = client(peer)) {
+            try {
+                var options = DeferredPollOptions.defaults()
+                        .withMaxWait(Duration.ZERO)
+                        .withRequestTimeout(Duration.ofSeconds(1));
+                long startNanos = System.nanoTime();
+
+                assertThatThrownBy(() -> poll(client, Consumer.of(3L), options))
+                        .isInstanceOf(IggyServerException.class)
+                        .extracting(error -> ((IggyServerException) error).getErrorCode())
+                        .isEqualTo(IggyErrorCode.TRANSIENT_NOT_COMMITTED);
+
+                assertThat(Duration.ofNanos(System.nanoTime() - startNanos))
+                        .as("a wait that starts near the deadline must not outlive it")
+                        .isLessThan(options.requestTimeout().plus(DEADLINE_SLACK));
+            } finally {
+                release.countDown();
+            }
+        }
+    }
+
+    @Test
+    void shouldAbortADeferredPollWaitingForAConnectionLease() throws Exception {
+        var parked = new CountDownLatch(DEFERRED_CONNECTION_LIMIT);
+        var release = new CountDownLatch(1);
+        var executor = Executors.newFixedThreadPool(DEFERRED_CONNECTION_LIMIT * 2);
+        var peer = new FakePeer(exchange -> {
+            parked.countDown();
+            hold(release, STALL);
+            respond(exchange, 200, EMPTY_POLL);
+        });
+        try (var server = peer.start(executor);
+                var client = client(peer)) {
+            try {
+                var longPoll = DeferredPollOptions.defaults().withRequestTimeout(STALL);
+                List<Future<PolledMessages>> holders = new ArrayList<>();
+                for (int connection = 0; connection < DEFERRED_CONNECTION_LIMIT; connection++) {
+                    holders.add(executor.submit(() -> poll(client, Consumer.of(1L), longPoll)));
+                }
+                assertThat(parked.await(5, TimeUnit.SECONDS))
+                        .as("every deferred connection must be leased before the next poll starts")
+                        .isTrue();
+                var shortPoll = DeferredPollOptions.defaults()
+                        .withMaxWait(Duration.ZERO)
+                        .withRequestTimeout(Duration.ofSeconds(1));
+
+                assertThatThrownBy(() -> poll(client, Consumer.of(2L), shortPoll))
+                        .isInstanceOf(IggyServerException.class)
+                        .hasCauseInstanceOf(CancellationException.class)
+                        .extracting(error -> ((IggyServerException) error).getErrorCode())
+                        .isEqualTo(IggyErrorCode.TRANSIENT_NOT_ACCEPTED);
+
+                assertThat(peer.queries).hasSize(DEFERRED_CONNECTION_LIMIT);
+                release.countDown();
+                for (Future<PolledMessages> holder : holders) {
+                    assertThat(holder.get(5, TimeUnit.SECONDS).messages()).isEmpty();
+                }
+                assertThat(poll(client, Consumer.of(3L), shortPoll).messages()).isEmpty();
+                assertThat(peer.queries)
+                        .extracting(query -> query.get("consumer_id"))
+                        .containsOnly("1", "3")
+                        .hasSize(DEFERRED_CONNECTION_LIMIT + 1);
+            } finally {
+                release.countDown();
+            }
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    void shouldRefuseAnExpiredBudgetWithoutReachingTheServer() throws Exception {
+        var peer = new FakePeer(exchange -> respond(exchange, 200, EMPTY_POLL));
+        try (var server = peer.start();
+                var client = new InternalHttpClient(
+                        "http://" + InetAddress.getLoopbackAddress().getHostAddress() + ":" + peer.port,
+                        Optional.empty(),
+                        Optional.empty(),
+                        Optional.empty())) {
+            var request = client.prepareGetRequest("/streams/1/topics/1/messages/deferred");
+
+            assertThatThrownBy(() -> client.executeDeferred(
+                            request,
+                            PolledMessages.class,
+                            System.nanoTime(),
+                            DeferredPollOptions.defaults().maxBytes()))
+                    .isInstanceOf(IggyServerException.class)
+                    .hasCauseInstanceOf(IggyTimeoutException.class)
+                    .extracting(error -> ((IggyServerException) error).getErrorCode())
+                    .isEqualTo(IggyErrorCode.TRANSIENT_NOT_ACCEPTED);
+            assertThat(peer.queries).isEmpty();
+        }
+    }
+
+    @Test
+    void shouldReportAConnectionRefusalAsNotAccepted() throws Exception {
+        int port;
+        try (var socket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
+            port = socket.getLocalPort();
+        }
+        try (var client = new InternalHttpClient(
+                "http://" + InetAddress.getLoopbackAddress().getHostAddress() + ":" + port,
+                Optional.empty(),
+                Optional.empty(),
+                Optional.empty())) {
+            var options = DeferredPollOptions.defaults();
+            var request = client.prepareGetRequest("/streams/1/topics/1/messages/deferred");
+
+            assertThatThrownBy(() -> client.executeDeferred(
+                            request,
+                            PolledMessages.class,
+                            System.nanoTime() + options.requestTimeout().toNanos(),
+                            options.maxBytes()))
+                    .isInstanceOf(IggyServerException.class)
+                    .hasCauseInstanceOf(IOException.class)
+                    .extracting(error -> ((IggyServerException) error).getErrorCode())
+                    .isEqualTo(IggyErrorCode.TRANSIENT_NOT_ACCEPTED);
+        }
+    }
+
+    @Test
+    void shouldReportALostAutoCommitReplyAsNotCommittedWithoutRetrying() throws Exception {
+        var peer = new FakePeer(HttpExchange::close);
+        try (var server = peer.start();
+                var client = client(peer)) {
+            assertThatThrownBy(() -> client.messages()
+                            .pollMessagesDeferred(
+                                    StreamId.of(1L),
+                                    TopicId.of(1L),
+                                    Optional.of(0L),
+                                    Consumer.of(3L),
+                                    PollingStrategy.first(),
+                                    10L,
+                                    true,
+                                    DeferredPollOptions.defaults()))
+                    .isInstanceOf(IggyServerException.class)
+                    .hasCauseInstanceOf(IOException.class)
+                    .extracting(error -> ((IggyServerException) error).getErrorCode())
+                    .isEqualTo(IggyErrorCode.TRANSIENT_NOT_COMMITTED);
+            assertThat(peer.queries).hasSize(1);
+            assertThat(peer.queries.get(0).get("auto_commit")).isEqualTo("true");
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {NOT_ACCEPTED, CAPACITY_EXCEEDED})
+    void shouldPreserveNonAdmissionWhenRetryBackoffIsInterrupted(String refusal) throws Exception {
+        Thread caller = Thread.currentThread();
+        var peer = new FakePeer(exchange -> {
+            respond(exchange, SERVICE_UNAVAILABLE, refusal);
+            long deadline = System.nanoTime() + DEADLINE_SLACK.toNanos();
+            // Interrupting the socket read before the refusal is decoded would leave an unknown outcome.
+            while (System.nanoTime() < deadline) {
+                for (StackTraceElement frame : caller.getStackTrace()) {
+                    if (frame.getClassName().equals(MessagesHttpClient.class.getName())
+                            && frame.getMethodName().equals("sleepBeforeRetry")) {
+                        caller.interrupt();
+                        return;
+                    }
+                }
+                Thread.yield();
+            }
+        });
+        try (var server = peer.start();
+                var client = client(peer)) {
+            try {
+                var options = DeferredPollOptions.defaults()
+                        .withMaxWait(Duration.ZERO)
+                        .withRequestTimeout(Duration.ofSeconds(1));
+
+                assertThatThrownBy(() -> poll(client, Consumer.of(3L), options))
+                        .isInstanceOf(IggyServerException.class)
+                        .hasCauseInstanceOf(InterruptedException.class)
+                        .extracting(error -> ((IggyServerException) error).getErrorCode())
+                        .isEqualTo(IggyErrorCode.TRANSIENT_NOT_ACCEPTED);
+                assertThat(caller.isInterrupted()).isTrue();
+                assertThat(peer.queries).hasSize(1);
+            } finally {
+                Thread.interrupted();
+            }
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {NOT_ACCEPTED, CAPACITY_EXCEEDED})
+    void shouldRetryAnExplicitRefusalWithWhatIsLeftOfTheBudget(String refusal) throws Exception {
+        var refusals = new AtomicInteger(2);
+        var peer = new FakePeer(exchange -> {
+            if (refusals.getAndDecrement() > 0) {
+                respond(exchange, SERVICE_UNAVAILABLE, refusal);
+            } else {
+                respond(exchange, 200, EMPTY_POLL);
+            }
+        });
+        try (var server = peer.start();
+                var client = client(peer)) {
+
+            poll(client, Consumer.of(3L), DeferredPollOptions.defaults());
+
+            assertThat(peer.queries).hasSize(3);
+            long firstWait = Long.parseLong(peer.queries.get(0).get("wait_us"));
+            long lastWait = Long.parseLong(peer.queries.get(2).get("wait_us"));
+            assertThat(lastWait).isLessThan(firstWait);
+        }
+    }
+
+    @Test
+    void shouldNotReplayAnAmbiguousRefusal() throws Exception {
+        var peer = new FakePeer(exchange -> respond(exchange, SERVICE_UNAVAILABLE, NOT_COMMITTED));
+        try (var server = peer.start();
+                var client = client(peer)) {
+
+            assertThatThrownBy(() -> poll(client, Consumer.of(3L), DeferredPollOptions.defaults()))
+                    .isInstanceOf(IggyServerException.class)
+                    .extracting(error -> ((IggyServerException) error).getRawErrorCode())
+                    .isEqualTo(TRANSIENT_NOT_COMMITTED);
+            assertThat(peer.queries)
+                    .as("an auto-commit poll that may have been served is never replayed")
+                    .hasSize(1);
+        }
+    }
+
+    @Test
+    void shouldRefuseABodyLargerThanTheDeclaredBound() throws Exception {
+        var peer = new FakePeer(exchange -> {
+            // Chunked, so the bound has to hold while the body is read.
+            exchange.sendResponseHeaders(200, 0);
+            try (OutputStream body = exchange.getResponseBody()) {
+                byte[] chunk = new byte[8 * 1024];
+                for (int written = 0; written < 1024 * 1024; written += chunk.length) {
+                    body.write(chunk);
+                }
+            }
+        });
+        try (var server = peer.start();
+                var client = client(peer)) {
+            var options = DeferredPollOptions.defaults().withMaxBytes(1024);
+
+            assertThatThrownBy(() -> poll(client, Consumer.of(3L), options))
+                    .isInstanceOf(IggyException.class)
+                    .hasMessageContaining("bytes");
+        }
+    }
+
+    private static IggyHttpClient client(FakePeer peer) {
+        return new IggyHttpClient("http://" + InetAddress.getLoopbackAddress().getHostAddress() + ":" + peer.port);
+    }
+
+    private static PolledMessages poll(IggyHttpClient client, Consumer consumer, DeferredPollOptions options) {
+        return client.messages()
+                .pollMessagesDeferred(
+                        StreamId.of(1L),
+                        TopicId.of(1L),
+                        Optional.of(0L),
+                        consumer,
+                        PollingStrategy.first(),
+                        10L,
+                        false,
+                        options);
+    }
+
+    /** Holds the server's only exchange thread, which stopping the server joins, so a test releases it first. */
+    private static void hold(CountDownLatch release, Duration limit) {
+        try {
+            release.await(limit.toMillis(), TimeUnit.MILLISECONDS);
+        } catch (InterruptedException interrupted) {
+            Thread.currentThread().interrupt();
+        }
+    }
+
+    private static void respond(HttpExchange exchange, int status, String body) throws IOException {
+        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+        exchange.getResponseHeaders().add("Content-Type", "application/json");
+        exchange.sendResponseHeaders(status, bytes.length);
+        try (OutputStream out = exchange.getResponseBody()) {
+            out.write(bytes);
+        }
+    }
+
+    private static final class FakePeer implements HttpHandler {
+        private final List<String> paths = new CopyOnWriteArrayList<>();
+        private final List<Map<String, String>> queries = new CopyOnWriteArrayList<>();
+        private final HttpHandler handler;
+        private int port;
+
+        private FakePeer(HttpHandler handler) {
+            this.handler = handler;
+        }
+
+        private ServerHandle start() throws IOException {
+            return start(null);
+        }
+
+        private ServerHandle start(Executor executor) throws IOException {
+            HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
+            server.createContext("/", this);
+            server.setExecutor(executor);
+            server.start();
+            port = server.getAddress().getPort();
+            return new ServerHandle(server);
+        }
+
+        @Override
+        public void handle(HttpExchange exchange) throws IOException {
+            paths.add(exchange.getRequestURI().getPath());
+            queries.add(parseQuery(exchange.getRequestURI().getRawQuery()));
+            try {
+                handler.handle(exchange);
+            } finally {
+                exchange.close();
+            }
+        }
+
+        private static Map<String, String> parseQuery(String rawQuery) {
+            Map<String, String> parsed = new HashMap<>();
+            if (rawQuery == null) {
+                return parsed;
+            }
+            for (String pair : rawQuery.split("&")) {
+                int separator = pair.indexOf('=');
+                if (separator > 0) {
+                    parsed.put(pair.substring(0, separator), pair.substring(separator + 1));
+                }
+            }
+            return parsed;
+        }
+    }
+
+    private record ServerHandle(HttpServer server) implements AutoCloseable {
+        @Override
+        public void close() {
+            server.stop(0);
+        }
+    }
+}
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClientTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClientTest.java
index dbb96d2..d9a1525 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClientTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/MessagesTcpClientTest.java
@@ -19,17 +19,20 @@
 
 package org.apache.iggy.client.blocking.tcp;
 
+import org.apache.iggy.client.async.tcp.AsyncIggyTcpClient;
 import org.apache.iggy.client.blocking.IggyBaseClient;
 import org.apache.iggy.client.blocking.MessagesClientBaseTest;
 import org.apache.iggy.consumergroup.Consumer;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
 import org.apache.iggy.message.PollingStrategy;
 import org.junit.jupiter.api.Test;
 
 import java.nio.charset.StandardCharsets;
+import java.time.Duration;
 import java.util.List;
 import java.util.Optional;
 
@@ -53,6 +56,48 @@
      */
 
     @Test
+    void shouldCancelAWaitingDeferredPollWithoutCommittingItsPartialBatch() throws InterruptedException {
+        setUpStreamAndTopic();
+        messagesClient.sendMessages(
+                STREAM_NAME, TOPIC_NAME, Partitioning.partitionId(0L), List.of(Message.of("unseen")));
+        var asyncClient = AsyncIggyTcpClient.builder()
+                .host(serverHost())
+                .port(serverTcpPort())
+                .build();
+        try {
+            asyncClient.connect().join();
+            asyncClient.users().login("iggy", "iggy").join();
+            var messages = asyncClient.messages();
+            var consumer = Consumer.of(0L);
+            messages.pollMessages(
+                            STREAM_NAME, TOPIC_NAME, Optional.of(0L), consumer, PollingStrategy.first(), 1L, false)
+                    .join();
+            Duration wait = Duration.ofMillis(500);
+            var pending = messages.pollMessagesDeferred(
+                    STREAM_NAME,
+                    TOPIC_NAME,
+                    Optional.of(0L),
+                    consumer,
+                    PollingStrategy.next(),
+                    2L,
+                    true,
+                    DeferredPollOptions.defaults().withMaxWait(wait).withMinCount(2));
+            Thread.sleep(wait.dividedBy(5).toMillis());
+            assertThat(pending.cancel(false)).isTrue();
+            Thread.sleep(wait.plusMillis(100).toMillis());
+
+            var polled = messages.pollMessages(
+                            STREAM_NAME, TOPIC_NAME, Optional.of(0L), consumer, PollingStrategy.next(), 1L, false)
+                    .join();
+            assertThat(polled.messages()).hasSize(1);
+            assertThat(new String(polled.messages().get(0).payload(), StandardCharsets.UTF_8))
+                    .isEqualTo("unseen");
+        } finally {
+            asyncClient.close().join();
+        }
+    }
+
+    @Test
     void shouldRouteSameMessageKeyToSamePartition() {
         setUpStreamAndTopic();
 
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/TlsConnectionTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/TlsConnectionTest.java
index 1338153..6cc2bd8 100644
--- a/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/TlsConnectionTest.java
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/client/blocking/tcp/TlsConnectionTest.java
@@ -22,6 +22,7 @@
 import org.apache.iggy.client.BaseIntegrationTest;
 import org.apache.iggy.identifier.StreamId;
 import org.apache.iggy.identifier.TopicId;
+import org.apache.iggy.message.DeferredPollOptions;
 import org.apache.iggy.message.Message;
 import org.apache.iggy.message.Partitioning;
 import org.apache.iggy.message.PolledMessages;
@@ -195,6 +196,52 @@
         }
     }
 
+    @Test
+    void deferredPollOverTlsShouldWork() {
+        var client = IggyTcpClient.builder()
+                .host(TLS_HOST)
+                .port(serverTcpPort())
+                .enableTls()
+                .tlsCertificate(CERTS_DIR.resolve("iggy_ca_cert.pem").toFile())
+                .credentials("iggy", "iggy")
+                .buildAndLogin();
+
+        String streamName = "tls-deferred-stream";
+        StreamId streamId = StreamId.of(streamName);
+        String topicName = "tls-deferred-topic";
+        TopicId topicId = TopicId.of(topicName);
+
+        try {
+            client.streams().createStream(streamName);
+            client.topics().createTopic(streamId, topicName, 1L);
+            client.messages()
+                    .sendMessages(
+                            streamId,
+                            topicId,
+                            Partitioning.partitionId(0L),
+                            List.of(Message.of("tls-deferred-1"), Message.of("tls-deferred-2")));
+
+            PolledMessages polled = client.messages()
+                    .pollMessagesDeferred(
+                            streamId,
+                            topicId,
+                            Optional.of(0L),
+                            org.apache.iggy.consumergroup.Consumer.of(0L),
+                            PollingStrategy.first(),
+                            10L,
+                            false,
+                            DeferredPollOptions.defaults().withMinCount(2));
+
+            assertThat(polled.messages()).hasSize(2);
+        } finally {
+            try {
+                client.streams().deleteStream(streamId);
+            } catch (RuntimeException e) {
+                log.debug("Cleanup failed: {}", e.getMessage());
+            }
+        }
+    }
+
     private static Path findCertsDir() {
         File dir = new File(System.getProperty("user.dir"));
         while (dir != null) {
diff --git a/foreign/java/java-sdk/src/test/java/org/apache/iggy/message/DeferredPollOptionsTest.java b/foreign/java/java-sdk/src/test/java/org/apache/iggy/message/DeferredPollOptionsTest.java
new file mode 100644
index 0000000..77be7fa
--- /dev/null
+++ b/foreign/java/java-sdk/src/test/java/org/apache/iggy/message/DeferredPollOptionsTest.java
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iggy.message;
+
+import org.apache.iggy.exception.IggyInvalidArgumentException;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.time.temporal.ChronoUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class DeferredPollOptionsTest {
+
+    @Test
+    void shouldMatchTheServerDefaults() {
+        var defaults = DeferredPollOptions.defaults();
+
+        assertThat(defaults.maxWait()).isEqualTo(Duration.ofSeconds(1));
+        assertThat(defaults.minCount()).isEqualTo(1);
+        assertThat(defaults.maxBytes()).isEqualTo(1024 * 1024);
+        assertThat(defaults.requestTimeout()).isEqualTo(Duration.ofSeconds(11));
+        assertThat(defaults.maxWaitMicros()).isEqualTo(1_000_000);
+        assertThat(defaults.requestTimeoutMicros()).isEqualTo(11_000_000);
+    }
+
+    @Test
+    void shouldAcceptTheProtocolBoundaries() {
+        var atLimit = new DeferredPollOptions(
+                DeferredPollOptions.MAX_WAIT,
+                0xFFFF_FFFFL,
+                DeferredPollOptions.MIN_MAX_BYTES,
+                DeferredPollOptions.MAX_REQUEST_TIMEOUT);
+
+        assertThat(atLimit.maxWaitMicros()).isEqualTo(600_000_000L);
+        assertThat(atLimit.requestTimeoutMicros()).isEqualTo(630_000_000L);
+    }
+
+    @Test
+    void shouldAcceptAZeroWaitAsAnImmediateBoundedRead() {
+        var immediate = DeferredPollOptions.defaults().withMaxWait(Duration.ZERO);
+
+        assertThat(immediate.maxWaitMicros()).isZero();
+    }
+
+    @Test
+    void shouldRejectLimitsTheServerWouldRefuse() {
+        var defaults = DeferredPollOptions.defaults();
+
+        assertThatThrownBy(() -> defaults.withMaxWait(DeferredPollOptions.MAX_WAIT.plusMillis(1)))
+                .isInstanceOf(IggyInvalidArgumentException.class);
+        assertThatThrownBy(() -> defaults.withMaxWait(Duration.ofSeconds(-1)))
+                .isInstanceOf(IggyInvalidArgumentException.class);
+        assertThatThrownBy(() -> defaults.withRequestTimeout(Duration.ZERO))
+                .isInstanceOf(IggyInvalidArgumentException.class);
+        assertThatThrownBy(() -> defaults.withRequestTimeout(DeferredPollOptions.MAX_REQUEST_TIMEOUT.plusMillis(1)))
+                .isInstanceOf(IggyInvalidArgumentException.class);
+        assertThatThrownBy(() -> defaults.withMinCount(0)).isInstanceOf(IggyInvalidArgumentException.class);
+        assertThatThrownBy(() -> defaults.withMinCount(0x1_0000_0000L))
+                .isInstanceOf(IggyInvalidArgumentException.class);
+        assertThatThrownBy(() -> defaults.withMaxBytes(DeferredPollOptions.MIN_MAX_BYTES - 1))
+                .isInstanceOf(IggyInvalidArgumentException.class);
+        assertThatThrownBy(() -> defaults.withMaxBytes(0x1_0000_0000L))
+                .isInstanceOf(IggyInvalidArgumentException.class);
+    }
+
+    @Test
+    void shouldNeedRoomForOneMessageInTheByteLimit() {
+        // The server refuses a limit below its poll prefix, one batch header and one message header.
+        assertThat(DeferredPollOptions.defaults().withMaxBytes(344).maxBytes()).isEqualTo(344);
+        assertThatThrownBy(() -> DeferredPollOptions.defaults().withMaxBytes(343))
+                .isInstanceOf(IggyInvalidArgumentException.class)
+                .hasMessageContaining("must be between 344");
+    }
+
+    @Test
+    void shouldAcceptABudgetThatCoversTheWaitAndExactlyTheReplyMargin() {
+        var exact = new DeferredPollOptions(Duration.ofSeconds(10), 1, 1024, Duration.ofSeconds(11));
+
+        assertThat(exact.requestTimeout().minus(exact.maxWait())).isEqualTo(DeferredPollOptions.REPLY_MARGIN);
+    }
+
+    @Test
+    void shouldRejectABudgetOneMicrosecondShortOfTheReplyMargin() {
+        Duration oneMicroShort = Duration.ofSeconds(11).minus(1, ChronoUnit.MICROS);
+
+        assertThatThrownBy(() -> new DeferredPollOptions(Duration.ofSeconds(10), 1, 1024, oneMicroShort))
+                .isInstanceOf(IggyInvalidArgumentException.class)
+                .hasMessageContaining("reply margin");
+        assertThatThrownBy(() -> new DeferredPollOptions(Duration.ofSeconds(11), 1, 1024, Duration.ofSeconds(11)))
+                .isInstanceOf(IggyInvalidArgumentException.class)
+                .hasMessageContaining("reply margin");
+    }
+
+    @Test
+    void shouldNeedNoReplyMarginForAZeroWait() {
+        var immediate = new DeferredPollOptions(Duration.ZERO, 1, 1024, Duration.of(1, ChronoUnit.MICROS));
+
+        assertThat(immediate.requestTimeoutMicros()).isEqualTo(1);
+    }
+
+    @Test
+    void shouldNeedTheRequestTimeoutRaisedBeforeAWaitAboveTheDefaultMargin() {
+        assertThatThrownBy(() -> DeferredPollOptions.defaults().withMaxWait(Duration.ofSeconds(20)))
+                .isInstanceOf(IggyInvalidArgumentException.class);
+
+        var raised = DeferredPollOptions.defaults()
+                .withRequestTimeout(Duration.ofSeconds(21))
+                .withMaxWait(Duration.ofSeconds(20));
+
+        assertThat(raised.maxWait()).isEqualTo(Duration.ofSeconds(20));
+    }
+
+    @Test
+    void shouldShrinkBothBudgetsByTheTimeAlreadySpent() {
+        var options = DeferredPollOptions.defaults().withMinCount(3).withMaxBytes(2048);
+
+        var remaining = options.remaining(Duration.ofMillis(400)).orElseThrow();
+
+        assertThat(remaining.maxWait()).isEqualTo(Duration.ofMillis(600));
+        assertThat(remaining.requestTimeout()).isEqualTo(Duration.ofMillis(10_600));
+        assertThat(remaining.minCount()).isEqualTo(3);
+        assertThat(remaining.maxBytes()).isEqualTo(2048);
+    }
+
+    @Test
+    void shouldStopTheWaitAtZeroAndCountAPartialMicrosecondAsSpent() {
+        var remaining = DeferredPollOptions.defaults()
+                .remaining(Duration.ofSeconds(2).plusNanos(1))
+                .orElseThrow();
+
+        assertThat(remaining.maxWait()).isZero();
+        assertThat(remaining.requestTimeoutMicros()).isEqualTo(8_999_999);
+    }
+
+    @Test
+    void shouldReportASpentRequestBudget() {
+        var options = DeferredPollOptions.defaults();
+
+        assertThat(options.remaining(options.requestTimeout())).isEmpty();
+        assertThat(options.remaining(options.requestTimeout().minusNanos(1)))
+                .as("the last partial microsecond counts as spent")
+                .isEmpty();
+    }
+
+    @Test
+    void shouldRejectFractionalMicrosecondsInsteadOfTruncatingThem() {
+        assertThatThrownBy(() -> DeferredPollOptions.defaults().withMaxWait(Duration.ofNanos(1500)))
+                .isInstanceOf(IggyInvalidArgumentException.class)
+                .hasMessageContaining("whole number of microseconds");
+        assertThatThrownBy(() -> DeferredPollOptions.defaults().withRequestTimeout(Duration.ofNanos(11_000_000_001L)))
+                .isInstanceOf(IggyInvalidArgumentException.class)
+                .hasMessageContaining("whole number of microseconds");
+    }
+
+    @Test
+    void shouldRejectAReadinessTargetTheCountCannotMeet() {
+        var options = DeferredPollOptions.defaults().withMinCount(10);
+
+        options.validate(10);
+
+        assertThatThrownBy(() -> options.validate(9))
+                .isInstanceOf(IggyInvalidArgumentException.class)
+                .hasMessageContaining("exceeds the requested count");
+        assertThatThrownBy(() -> options.validate(0)).isInstanceOf(IggyInvalidArgumentException.class);
+        assertThatThrownBy(() -> options.validate(0x1_0000_0000L)).isInstanceOf(IggyInvalidArgumentException.class);
+    }
+}
diff --git a/foreign/node/package-lock.json b/foreign/node/package-lock.json
index cde42ac..7568b35 100644
--- a/foreign/node/package-lock.json
+++ b/foreign/node/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "apache-iggy",
-  "version": "0.10.1-edge.4",
+  "version": "0.10.1-edge.5",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "apache-iggy",
-      "version": "0.10.1-edge.4",
+      "version": "0.10.1-edge.5",
       "license": "Apache-2.0",
       "dependencies": {
         "@node-rs/xxhash": "1.7.8",
diff --git a/foreign/node/package.json b/foreign/node/package.json
index 97e12d3..4f0a4cc 100644
--- a/foreign/node/package.json
+++ b/foreign/node/package.json
@@ -1,7 +1,7 @@
 {
   "name": "apache-iggy",
   "type": "module",
-  "version": "0.10.1-edge.4",
+  "version": "0.10.1-edge.5",
   "description": "Official Apache Iggy NodeJS SDK",
   "keywords": [
     "iggy",
diff --git a/foreign/node/src/wire/command.code.ts b/foreign/node/src/wire/command.code.ts
index 0df76a6..b0468c2 100644
--- a/foreign/node/src/wire/command.code.ts
+++ b/foreign/node/src/wire/command.code.ts
@@ -48,6 +48,9 @@
   SendMessages: 101,
   // 103 and 104 are retired and must never be reused.
   GetPartitionRoute: 105,
+  PollMessagesDeferred: 106,
+  WaitForMessages: 107,
+  CancelDeferredRequest: 108,
   GetOffset: 120,
   StoreOffset: 121,
   DeleteConsumerOffset: 122,
diff --git a/foreign/php/Cargo.toml b/foreign/php/Cargo.toml
index 0355998..bf84f7d 100644
--- a/foreign/php/Cargo.toml
+++ b/foreign/php/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "iggy-php"
-version = "0.1.6"
+version = "0.1.7"
 edition = "2024"
 authors = ["Iggy Committers <dev@iggy.apache.org>"]
 license = "Apache-2.0"
diff --git a/foreign/php/README.md b/foreign/php/README.md
index 9b2cb53..c53dec9 100644
--- a/foreign/php/README.md
+++ b/foreign/php/README.md
@@ -153,7 +153,7 @@
     \Iggy\AutoCommit::disabled(),
     true,
     true,
-    1_000_000,
+    null,
     null,
     null,
     null,
@@ -357,6 +357,9 @@
   by default.
 - `iterMessages()` returns a PHP `Iterator` and can be used with `foreach`.
   Break out of the loop when the caller's processing limit or shutdown signal is reached.
+- Without an `AutoCommit` argument, the consumer stores no offset, as with
+  `AutoCommit::disabled()`. Store positions after processing, or each restart
+  reads the same messages again.
 - `AutoCommit::when()` may queue an offset commit before the PHP callback runs.
   If callback success must control commits, use `AutoCommit::disabled()` and call
   `storePosition($message->position())` after the callback work succeeds.
diff --git a/foreign/php/iggy-php.stubs.php b/foreign/php/iggy-php.stubs.php
index 3a3f551..ea1e7fc 100644
--- a/foreign/php/iggy-php.stubs.php
+++ b/foreign/php/iggy-php.stubs.php
@@ -104,7 +104,8 @@
          * Creates and initializes a consumer group consumer.
          *
          * `$partition_id` is ignored for a consumer group: the member reads the partitions
-         * the server assigns to it.
+         * the server assigns to it. `$poll_interval_micros` must be null: the consumer waits
+         * on the server for new messages.
          *
          * @param string $name
          * @param string $stream
diff --git a/foreign/php/src/client.rs b/foreign/php/src/client.rs
index 3dbf5ee..9d65add 100644
--- a/foreign/php/src/client.rs
+++ b/foreign/php/src/client.rs
@@ -27,8 +27,8 @@
 use ext_php_rs::{binary::Binary, exception::PhpResult, php_class, php_impl, types::Zval};
 use iggy::prelude::{
     CompressionAlgorithm, Consumer as RustConsumer, IggyClient as RustIggyClient,
-    IggyClientBuilder, IggyDuration, IggyExpiry, IggyMessage as RustMessage, MaxTopicSize,
-    Partitioning, PollingStrategy as RustPollingStrategy, *,
+    IggyClientBuilder, IggyExpiry, IggyMessage as RustMessage, MaxTopicSize, Partitioning,
+    PollingStrategy as RustPollingStrategy, *,
 };
 use tokio::sync::Mutex;
 
@@ -589,7 +589,8 @@
     /// Creates and initializes a consumer group consumer.
     ///
     /// `$partition_id` is ignored for a consumer group: the member reads the partitions
-    /// the server assigns to it.
+    /// the server assigns to it. `$poll_interval_micros` must be null: the consumer waits
+    /// on the server for new messages.
     #[allow(clippy::too_many_arguments)]
     #[php(defaults(
         create_consumer_group_if_not_exists = true,
@@ -613,10 +614,15 @@
         init_retry_interval_micros: Option<&Zval>,
         allow_replay: bool,
     ) -> PhpResult<IggyConsumer> {
+        // The removed setting keeps its slot, so a positional call written for it fails
+        // instead of setting `polling_retry_interval_micros`.
+        if poll_interval_micros.is_some_and(|value| !value.is_null()) {
+            return Err(to_php_exception(
+                "'poll_interval_micros' is no longer supported and must be null: the consumer waits on the server for new messages",
+            ));
+        }
         let partition_id = optional_unsigned::<u32>(partition_id, "partition_id")?;
         let batch_length = optional_unsigned::<u32>(batch_length, "batch_length")?;
-        let poll_interval_micros =
-            optional_unsigned::<u64>(poll_interval_micros, "poll_interval_micros")?;
         let polling_retry_interval_micros = optional_unsigned::<u64>(
             polling_retry_interval_micros,
             "polling_retry_interval_micros",
@@ -650,10 +656,6 @@
         if let Some(auto_commit) = auto_commit {
             builder = builder.auto_commit(auto_commit.into());
         }
-        builder = match poll_interval_micros {
-            Some(micros) => builder.poll_interval(IggyDuration::from(micros)),
-            None => builder.without_poll_interval(),
-        };
         if let Some(micros) = polling_retry_interval_micros {
             builder = builder.polling_retry_interval(non_zero_duration_micros(
                 "polling_retry_interval_micros",
diff --git a/foreign/php/tests/ErrorTest.php b/foreign/php/tests/ErrorTest.php
index 6179df7..0934e03 100644
--- a/foreign/php/tests/ErrorTest.php
+++ b/foreign/php/tests/ErrorTest.php
@@ -116,7 +116,7 @@
                 AutoCommit::disabled(),
                 true,
                 true,
-                micros(1),
+                null,
                 null,
                 null,
                 null,
diff --git a/foreign/php/tests/IggySdkTest.php b/foreign/php/tests/IggySdkTest.php
index b2b7f92..5f99911 100644
--- a/foreign/php/tests/IggySdkTest.php
+++ b/foreign/php/tests/IggySdkTest.php
@@ -496,7 +496,7 @@
                 AutoCommit::interval(micros(5)),
                 true,
                 true,
-                micros(1),
+                null,
                 null,
                 null,
                 null,
@@ -514,39 +514,6 @@
         }
     }
 
-    #[TestDox('Consumer group accepts a zero polling interval')]
-    public function testConsumerGroupAcceptsZeroPollInterval(): void
-    {
-        $client = new_client();
-        $consumerName = unique_name('consumer-group-consumer');
-        $streamName = unique_name('consumer-group-stream');
-        $topicName = unique_name('consumer-group-topic');
-
-        try {
-            create_stream_and_topic($client, $streamName, $topicName);
-            $consumer = $client->consumerGroup(
-                $consumerName,
-                $streamName,
-                $topicName,
-                0,
-                PollingStrategy::next(),
-                10,
-                AutoCommit::disabled(),
-                true,
-                true,
-                0,
-                null,
-                null,
-                null,
-                false,
-            );
-
-            assert_same($consumerName, $consumer->name());
-        } finally {
-            cleanup_stream_with_topics($client, $streamName, [$topicName]);
-        }
-    }
-
     #[TestDox('Consumer group rejects zero duration options before initialization')]
     public function testConsumerGroupRejectsZeroDurationOptions(): void
     {
@@ -579,7 +546,7 @@
                     AutoCommit::disabled(),
                     true,
                     true,
-                    micros(1),
+                    null,
                     0,
                     null,
                     null,
@@ -599,7 +566,7 @@
                     AutoCommit::disabled(),
                     true,
                     true,
-                    micros(1),
+                    null,
                     null,
                     0,
                     0,
@@ -632,7 +599,7 @@
                 AutoCommit::disabled(),
                 true,
                 true,
-                micros(1),
+                null,
                 null,
                 null,
                 null,
@@ -678,7 +645,7 @@
                 AutoCommit::interval(micros(5)),
                 true,
                 true,
-                micros(1),
+                null,
                 null,
                 null,
                 null,
@@ -731,7 +698,7 @@
                 AutoCommit::interval(micros(5)),
                 true,
                 true,
-                micros(1),
+                null,
                 null,
                 null,
                 null,
@@ -788,8 +755,8 @@
                 AutoCommit::disabled(),
                 true,
                 true,
-                micros(1),
                 null,
+                micros(1),
                 null,
                 null,
                 false,
@@ -833,7 +800,7 @@
                 AutoCommit::disabled(),
                 true,
                 true,
-                micros(1),
+                null,
                 null,
                 null,
                 null,
diff --git a/foreign/php/tests/RawCommandTest.php b/foreign/php/tests/RawCommandTest.php
index b0626f0..7d6ae38 100644
--- a/foreign/php/tests/RawCommandTest.php
+++ b/foreign/php/tests/RawCommandTest.php
@@ -238,6 +238,8 @@
             $arguments = array_replace($methods['consumerGroup'][0], [$field => 4294967296]);
             assert_throws(static fn () => $client->consumerGroup(...$arguments), $field);
         }
+        $arguments = array_replace($methods['consumerGroup'][0], ['poll_interval_micros' => 1_000]);
+        assert_throws(static fn () => $client->consumerGroup(...$arguments), 'poll_interval_micros');
         $arguments = array_replace($methods['createTopic'][0], ['messages_required_to_save' => 4294967296]);
         assert_throws(static fn () => $client->createTopic(...$arguments), 'messages_required_to_save');
     }
diff --git a/foreign/python/Cargo.toml b/foreign/python/Cargo.toml
index 2ff1f96..eab18ec 100644
--- a/foreign/python/Cargo.toml
+++ b/foreign/python/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "apache-iggy"
-version = "0.9.1-dev4"
+version = "0.9.1-dev5"
 edition = "2024"
 authors = ["Iggy Committers <dev@iggy.apache.org>"]
 license = "Apache-2.0"
@@ -37,7 +37,7 @@
 [dependencies]
 bytes = "1.12.1"
 futures = "0.3.34"
-iggy = { path = "../../core/sdk", version = "0.11.1-edge.4" }
+iggy = { path = "../../core/sdk", version = "0.11.1-edge.5" }
 paste = "1"
 pyo3 = "0.29.3"
 pyo3-async-runtimes = { version = "0.29.0", features = [
diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi
index cfaef53..d7ef8f8 100644
--- a/foreign/python/apache_iggy.pyi
+++ b/foreign/python/apache_iggy.pyi
@@ -2098,7 +2098,7 @@
         auto_commit: AutoCommit | None = None,
         create_consumer_group_if_not_exists: builtins.bool = True,
         auto_join_consumer_group: builtins.bool = True,
-        poll_interval: datetime.timedelta | None = None,
+        *,
         polling_retry_interval: datetime.timedelta | None = None,
         init_retries: builtins.int | None = None,
         init_retry_interval: datetime.timedelta | None = None,
@@ -2108,10 +2108,11 @@
         Creates a new consumer group consumer.
         `partition_id` is ignored for a consumer group: the member reads the partitions
         the server assigns to it.
+        `auto_commit` defaults to `AutoCommit.Disabled()`: the consumer stores no offset,
+        so store positions after processing, or each restart reads the same messages again.
         Returns the consumer or a RuntimeError on failure. Raises `ValueError` if
-        `poll_interval`, `polling_retry_interval`, `init_retry_interval` or an
-        `AutoCommit` interval is negative, or if any of those except `poll_interval`
-        is zero.
+        `polling_retry_interval`, `init_retry_interval` or an `AutoCommit` interval
+        is negative or zero.
 
         Consumer groups are not available over HTTP. With `auto_join_consumer_group`
         left on, this call fails at the join with `Feature is unavailable`.
diff --git a/foreign/python/pyproject.toml b/foreign/python/pyproject.toml
index 3b87649..b67e2a5 100644
--- a/foreign/python/pyproject.toml
+++ b/foreign/python/pyproject.toml
@@ -22,7 +22,7 @@
 [project]
 name = "apache-iggy"
 requires-python = ">=3.11"
-version = "0.9.1.dev4"
+version = "0.9.1.dev5"
 description = "Apache Iggy is the persistent message streaming platform written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of processing millions of messages per second."
 readme = "README.md"
 license = { file = "LICENSE" }
diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs
index 34a0dfb..0b07dd1 100644
--- a/foreign/python/src/client.rs
+++ b/foreign/python/src/client.rs
@@ -1773,10 +1773,11 @@
     /// Creates a new consumer group consumer.
     /// `partition_id` is ignored for a consumer group: the member reads the partitions
     /// the server assigns to it.
+    /// `auto_commit` defaults to `AutoCommit.Disabled()`: the consumer stores no offset,
+    /// so store positions after processing, or each restart reads the same messages again.
     /// Returns the consumer or a RuntimeError on failure. Raises `ValueError` if
-    /// `poll_interval`, `polling_retry_interval`, `init_retry_interval` or an
-    /// `AutoCommit` interval is negative, or if any of those except `poll_interval`
-    /// is zero.
+    /// `polling_retry_interval`, `init_retry_interval` or an `AutoCommit` interval
+    /// is negative or zero.
     ///
     /// Consumer groups are not available over HTTP. With `auto_join_consumer_group`
     /// left on, this call fails at the join with `Feature is unavailable`.
@@ -1797,7 +1798,7 @@
         auto_commit=None,
         create_consumer_group_if_not_exists=true,
         auto_join_consumer_group=true,
-        poll_interval=None,
+        *,
         polling_retry_interval=None,
         init_retries=None,
         init_retry_interval=None,
@@ -1821,8 +1822,6 @@
         create_consumer_group_if_not_exists: bool,
         auto_join_consumer_group: bool,
         #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
-        poll_interval: Option<Py<PyDelta>>,
-        #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
         polling_retry_interval: Option<Py<PyDelta>>,
         #[gen_stub(override_type(type_repr = "builtins.int | None"))] init_retries: Option<u32>,
         #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))]
@@ -1855,11 +1854,6 @@
         if let Some(auto_commit) = auto_commit {
             builder = builder.auto_commit(RustAutoCommit::try_from(auto_commit)?)
         };
-        if let Some(poll_interval) = poll_interval {
-            builder = builder.poll_interval(py_delta_to_iggy_duration(&poll_interval)?)
-        } else {
-            builder = builder.without_poll_interval()
-        };
         if let Some(polling_retry_interval) = polling_retry_interval {
             builder = builder.polling_retry_interval(reject_zero(
                 py_delta_to_iggy_duration(&polling_retry_interval)?,
diff --git a/foreign/python/tests/test_client_config.py b/foreign/python/tests/test_client_config.py
index 32c9f1d..e04794d 100644
--- a/foreign/python/tests/test_client_config.py
+++ b/foreign/python/tests/test_client_config.py
@@ -371,22 +371,27 @@
                 auto_commit=auto_commit,
             )
 
-    def test_zero_poll_interval_is_allowed(self):
-        """Test that a zero poll interval passes validation.
+    def test_arguments_after_auto_join_are_keyword_only(self):
+        """Test that a positional argument after auto_join_consumer_group fails.
 
-        Zero there means "do not wait before polling" and is short-circuited
-        before the sleep, unlike the retry intervals. Reaching the awaitable is
-        what proves it: building one without a running loop is the next failure,
-        and a rejected value would have raised ValueError first.
+        The removed poll_interval was the tenth argument. A positional call
+        written for it would otherwise set polling_retry_interval.
         """
         client = IggyClient()
 
-        with pytest.raises(RuntimeError):
+        with pytest.raises(TypeError, match="positional arguments"):
             client.consumer_group(
-                name="group",
-                stream="stream",
-                topic="topic",
-                poll_interval=timedelta(0),
+                "group",
+                "stream",
+                "topic",
+                None,
+                None,
+                None,
+                None,
+                True,
+                True,
+                # pyrefly: ignore  # bad-argument-count
+                timedelta(seconds=1),
             )
 
 
diff --git a/foreign/python/tests/test_consumer_group.py b/foreign/python/tests/test_consumer_group.py
index f8c4472..3ab2e33 100644
--- a/foreign/python/tests/test_consumer_group.py
+++ b/foreign/python/tests/test_consumer_group.py
@@ -789,7 +789,6 @@
             PollingStrategy.Next(),
             10,
             auto_commit=AutoCommit.Interval(timedelta(seconds=5)),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         assert consumer.name() == consumer_name
@@ -824,7 +823,6 @@
             PollingStrategy.First(),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         async def take(received: ReceiveMessage) -> None:
@@ -881,7 +879,6 @@
             PollingStrategy.First(),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         async def take(received: ReceiveMessage) -> None:
@@ -935,7 +932,6 @@
             PollingStrategy.First(),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         assert consumer.get_last_consumed_offset(partition_id) is None
@@ -1007,7 +1003,6 @@
             PollingStrategy.First(),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         assert consumer.get_last_stored_offset(partition_id) is None
@@ -1074,7 +1069,6 @@
             PollingStrategy.First(),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
         await wait_for_consumer_group_assignment(
             iggy_client, stream_name, topic_name, consumer_name, members_count=1
@@ -1130,7 +1124,6 @@
             PollingStrategy.Next(),
             10,
             auto_commit=AutoCommit.Interval(timedelta(seconds=5)),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         async def take(message: ReceiveMessage) -> None:
@@ -1177,7 +1170,6 @@
             PollingStrategy.Next(),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         async def take(message: ReceiveMessage) -> None:
@@ -1219,7 +1211,6 @@
             PollingStrategy.Next(),
             10,
             auto_commit=AutoCommit.Interval(timedelta(seconds=5)),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         await iggy_client.send_messages(
@@ -1261,7 +1252,6 @@
             PollingStrategy.Next(),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         await iggy_client.send_messages(
@@ -1310,7 +1300,6 @@
             PollingStrategy.First(),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         iterator = consumer.iter_messages()
@@ -1350,7 +1339,6 @@
             PollingStrategy.Last(),
             1,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         iterator = consumer.iter_messages()
@@ -1395,7 +1383,6 @@
             PollingStrategy.Last(),
             2,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         iterator = consumer.iter_messages()
@@ -1436,7 +1423,6 @@
             PollingStrategy.Last(),
             1,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         async def take(message: ReceiveMessage) -> None:
@@ -1483,7 +1469,6 @@
             PollingStrategy.First(),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         iterator = first_consumer.iter_messages()
@@ -1501,7 +1486,6 @@
             PollingStrategy.Offset(value=start_offset),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         iterator = offset_consumer.iter_messages()
@@ -1549,7 +1533,6 @@
             PollingStrategy.First(),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         iterator = first_consumer.iter_messages()
@@ -1567,7 +1550,6 @@
             PollingStrategy.Offset(value=start_offset),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         async def take(message: ReceiveMessage) -> None:
@@ -1620,7 +1602,6 @@
             PollingStrategy.First(),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         iterator = first_consumer.iter_messages()
@@ -1638,7 +1619,6 @@
             PollingStrategy.Timestamp(value=start_timestamp),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         iterator = timestamp_consumer.iter_messages()
@@ -1690,7 +1670,6 @@
             PollingStrategy.First(),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         iterator = first_consumer.iter_messages()
@@ -1708,7 +1687,6 @@
             PollingStrategy.Timestamp(value=start_timestamp),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         async def take(message: ReceiveMessage) -> None:
@@ -1750,7 +1728,6 @@
             PollingStrategy.First(),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         assert consumer.get_last_consumed_offset(partition_id) is None
@@ -1811,7 +1788,6 @@
             PollingStrategy.First(),
             2,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         iterator = consumer.iter_messages()
@@ -1849,7 +1825,6 @@
             PollingStrategy.First(),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
             allow_replay=allow_replay,
         )
 
@@ -1882,7 +1857,6 @@
             PollingStrategy.Next(),
             1,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
             allow_replay=allow_replay,
         )
 
@@ -1915,7 +1889,6 @@
                 PollingStrategy.Next(),
                 10,
                 auto_commit=AutoCommit.Disabled(),
-                poll_interval=timedelta(milliseconds=25),
                 init_retries=10,
                 init_retry_interval=timedelta(milliseconds=50),
             )
@@ -1954,7 +1927,6 @@
             10,
             auto_commit=AutoCommit.Disabled(),
             auto_join_consumer_group=False,
-            poll_interval=timedelta(milliseconds=25),
         )
 
         assert consumer.name() == consumer_name
@@ -1982,7 +1954,6 @@
             PollingStrategy.Next(),
             10,
             auto_commit=AutoCommit.Disabled(),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         consumer = await iggy_client.consumer_group(
@@ -1994,7 +1965,6 @@
             10,
             auto_commit=AutoCommit.Disabled(),
             create_consumer_group_if_not_exists=False,
-            poll_interval=timedelta(milliseconds=25),
         )
 
         assert consumer.name() == consumer_name
@@ -2031,7 +2001,6 @@
                 PollingStrategy.Next(),
                 10,
                 auto_commit=AutoCommit.Disabled(),
-                poll_interval=timedelta(milliseconds=25),
             )
 
     @pytest.mark.asyncio
@@ -2055,7 +2024,6 @@
                 10,
                 auto_commit=AutoCommit.Disabled(),
                 create_consumer_group_if_not_exists=False,
-                poll_interval=timedelta(milliseconds=25),
             )
 
     @pytest.mark.asyncio
@@ -2091,7 +2059,6 @@
                 PollingStrategy.Next(),
                 10,
                 auto_commit=AutoCommit.Disabled(),
-                poll_interval=timedelta(milliseconds=25),
                 **kwargs,
             )
 
@@ -2110,7 +2077,6 @@
                 PollingStrategy.Next(),
                 10,
                 auto_commit=AutoCommit.Disabled(),
-                poll_interval=timedelta(milliseconds=25),
             )
 
     @pytest.mark.asyncio
@@ -2132,7 +2098,6 @@
                 PollingStrategy.Next(),
                 10,
                 auto_commit=AutoCommit.Disabled(),
-                poll_interval=timedelta(milliseconds=25),
             )
 
     @pytest.mark.asyncio
@@ -2155,7 +2120,6 @@
             PollingStrategy.Next(),
             10,
             auto_commit=AutoCommit.Interval(timedelta(seconds=5)),
-            poll_interval=timedelta(milliseconds=25),
         )
 
         async def take(_: ReceiveMessage) -> None:
diff --git a/foreign/python/uv.lock b/foreign/python/uv.lock
index 1f94aae..351aed5 100644
--- a/foreign/python/uv.lock
+++ b/foreign/python/uv.lock
@@ -11,7 +11,7 @@
 
 [[package]]
 name = "apache-iggy"
-version = "0.9.1.dev4"
+version = "0.9.1.dev5"
 source = { editable = "." }
 
 [package.optional-dependencies]
diff --git a/foreign/swift/Sources/Iggy/Errors/IggyErrorCode.swift b/foreign/swift/Sources/Iggy/Errors/IggyErrorCode.swift
index 8747bde..5e9d1f2 100644
--- a/foreign/swift/Sources/Iggy/Errors/IggyErrorCode.swift
+++ b/foreign/swift/Sources/Iggy/Errors/IggyErrorCode.swift
@@ -96,6 +96,7 @@
     case historyUnavailable = 87
     case lifecycleBusy = 88
     case notPrimary = 89
+    case deferredPollCapacityExceeded = 90
     case clientNotFound = 100
     case invalidClientId = 101
     case connectionClosed = 206
@@ -361,6 +362,7 @@
         case .historyUnavailable: "history_unavailable"
         case .lifecycleBusy: "lifecycle_busy"
         case .notPrimary: "not_primary"
+        case .deferredPollCapacityExceeded: "deferred_poll_capacity_exceeded"
         case .clientNotFound: "client_not_found"
         case .invalidClientId: "invalid_client_id"
         case .connectionClosed: "connection_closed"
diff --git a/foreign/swift/Sources/Iggy/Iggy.swift b/foreign/swift/Sources/Iggy/Iggy.swift
index 9e8307f..a0dc490 100644
--- a/foreign/swift/Sources/Iggy/Iggy.swift
+++ b/foreign/swift/Sources/Iggy/Iggy.swift
@@ -21,5 +21,5 @@
     public static let sdkName = "swift-sdk"
     /// Version of this SDK as reported to the server. Provisional until the
     /// release wiring lands; the release tooling reads this constant.
-    public static let sdkVersion = "0.1.3"
+    public static let sdkVersion = "0.1.4"
 }
diff --git a/foreign/swift/Tests/IggyTests/Fixtures/golden.json b/foreign/swift/Tests/IggyTests/Fixtures/golden.json
index 6ae26e6..2622e33 100644
--- a/foreign/swift/Tests/IggyTests/Fixtures/golden.json
+++ b/foreign/swift/Tests/IggyTests/Fixtures/golden.json
@@ -716,6 +716,7 @@
     "87": "history_unavailable",
     "88": "lifecycle_busy",
     "89": "not_primary",
+    "90": "deferred_poll_capacity_exceeded",
     "100": "client_not_found",
     "101": "invalid_client_id",
     "206": "connection_closed",
@@ -927,7 +928,7 @@
     "polling.next": "050000000000000000",
     "polling.offset.100": "016400000000000000",
     "polling.timestamp.1700000000000": "020068e5cf8b010000",
-    "request.bind_session": "003000000973776966742d73646b05302e312e3301000000000000000000000000000000640000000000000078000000000000005a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a",
+    "request.bind_session": "003000000973776966742d73646b05302e312e3401000000000000000000000000000000640000000000000078000000000000005a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a",
     "request.change_password": "010401000000086f6c642d706173730c6e65772d706173732d313233",
     "request.create_consumer_group": "01040100000001040200000003677270",
     "request.create_partitions": "01040100000001040200000005000000",
@@ -967,8 +968,8 @@
     "request.get_users": "",
     "request.join_consumer_group": "010401000000010402000000010403000000",
     "request.leave_consumer_group": "010401000000010402000000020167",
-    "request.login_register": "003000000973776966742d73646b05302e312e335a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a0469676779046967677900000000",
-    "request.login_register_with_pat": "003000000973776966742d73646b05302e312e335a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a107061742d61626331323364656634353600000000",
+    "request.login_register": "003000000973776966742d73646b05302e312e345a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a0469676779046967677900000000",
+    "request.login_register_with_pat": "003000000973776966742d73646b05302e312e345a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a107061742d61626331323364656634353600000000",
     "request.logout_user": "",
     "request.ping": "",
     "request.poll_messages": "0101040100000001040a00000001041400000001050000000164000000000000003200000001",
@@ -1019,7 +1020,7 @@
     "response.user_details.no_permissions": "0100000000e090663c130600010561646d696e000000000000000000000000",
     "stream_key.fingerprint": "8441e38538cbfa57",
     "user_headers.sample": "0207000000617474656d70740b0400000003000000020800000074726163652d69640203000000616263",
-    "version_info": "003000000973776966742d73646b05302e312e33",
+    "version_info": "003000000973776966742d73646b05302e312e34",
     "vsr.eviction.incompatible_protocol": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab89674523010030000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e",
     "vsr.eviction.stale_client": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab89674523010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d",
     "vsr.reply.committed": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0100000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001032547698badcfeefcdab896745230100000000000000000c0000000000000000000000000000000700000000000000800000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000",
diff --git a/foreign/swift/Tools/golden-vectors/src/main.rs b/foreign/swift/Tools/golden-vectors/src/main.rs
index 2c009d1..efa884d 100644
--- a/foreign/swift/Tools/golden-vectors/src/main.rs
+++ b/foreign/swift/Tools/golden-vectors/src/main.rs
@@ -208,7 +208,7 @@
     ClientVersionInfo {
         protocol_version: IGGY_PROTOCOL_VERSION,
         sdk_name: name("swift-sdk"),
-        sdk_version: name("0.1.3"),
+        sdk_version: name("0.1.4"),
     }
 }