diff --git a/cumulus/client/consensus/aura/src/collator.rs b/cumulus/client/consensus/aura/src/collator.rs
index b00c3952e2bc90e057dc464ac4b9179927cd67da..83f82aff96bd639983dbb8ade143dbc17322868c 100644
--- a/cumulus/client/consensus/aura/src/collator.rs
+++ b/cumulus/client/consensus/aura/src/collator.rs
@@ -172,12 +172,14 @@ where
 		inherent_data: (ParachainInherentData, InherentData),
 		proposal_duration: Duration,
 		max_pov_size: usize,
-	) -> Result<(Collation, ParachainBlockData<Block>, Block::Hash), Box<dyn Error + Send + 'static>>
-	{
+	) -> Result<
+		Option<(Collation, ParachainBlockData<Block>, Block::Hash)>,
+		Box<dyn Error + Send + 'static>,
+	> {
 		let mut digest = additional_pre_digest.into().unwrap_or_default();
 		digest.push(slot_claim.pre_digest.clone());
 
-		let proposal = self
+		let maybe_proposal = self
 			.proposer
 			.propose(
 				&parent_header,
@@ -190,6 +192,11 @@ where
 			.await
 			.map_err(|e| Box::new(e) as Box<dyn Error + Send>)?;
 
+		let proposal = match maybe_proposal {
+			None => return Ok(None),
+			Some(p) => p,
+		};
+
 		let sealed_importable = seal::<_, P>(
 			proposal.block,
 			proposal.storage_changes,
@@ -234,7 +241,7 @@ where
 				);
 			}
 
-			Ok((collation, block_data, post_hash))
+			Ok(Some((collation, block_data, post_hash)))
 		} else {
 			Err(Box::<dyn Error + Send + Sync>::from("Unable to produce collation")
 				as Box<dyn Error + Send>)
diff --git a/cumulus/client/consensus/aura/src/collators/basic.rs b/cumulus/client/consensus/aura/src/collators/basic.rs
index dc0078b0d6a9838534b5599c8838b261c470b7b3..78f6b726aff0cb63cd08259c327bfbda71c05b8b 100644
--- a/cumulus/client/consensus/aura/src/collators/basic.rs
+++ b/cumulus/client/consensus/aura/src/collators/basic.rs
@@ -203,7 +203,7 @@ where
 					.await
 			);
 
-			let (collation, _, post_hash) = try_request!(
+			let maybe_collation = try_request!(
 				collator
 					.collate(
 						&parent_header,
@@ -220,8 +220,14 @@ where
 					.await
 			);
 
-			let result_sender = Some(collator.collator_service().announce_with_barrier(post_hash));
-			request.complete(Some(CollationResult { collation, result_sender }));
+			if let Some((collation, _, post_hash)) = maybe_collation {
+				let result_sender =
+					Some(collator.collator_service().announce_with_barrier(post_hash));
+				request.complete(Some(CollationResult { collation, result_sender }));
+			} else {
+				request.complete(None);
+				tracing::debug!(target: crate::LOG_TARGET, "No block proposal");
+			}
 		}
 	}
 }
diff --git a/cumulus/client/consensus/aura/src/collators/lookahead.rs b/cumulus/client/consensus/aura/src/collators/lookahead.rs
index 57cd646fbcdef58fca265454eeeb677c35beee7b..5d62094e4aa1746b4301a3d98614d2b322d1d05e 100644
--- a/cumulus/client/consensus/aura/src/collators/lookahead.rs
+++ b/cumulus/client/consensus/aura/src/collators/lookahead.rs
@@ -359,7 +359,7 @@ where
 					)
 					.await
 				{
-					Ok((collation, block_data, new_block_hash)) => {
+					Ok(Some((collation, block_data, new_block_hash))) => {
 						// Here we are assuming that the import logic protects against equivocations
 						// and provides sybil-resistance, as it should.
 						collator.collator_service().announce_block(new_block_hash, None);
@@ -387,6 +387,10 @@ where
 						parent_hash = new_block_hash;
 						parent_header = block_data.into_header();
 					},
+					Ok(None) => {
+						tracing::debug!(target: crate::LOG_TARGET, "No block proposal");
+						break
+					},
 					Err(err) => {
 						tracing::error!(target: crate::LOG_TARGET, ?err);
 						break
diff --git a/cumulus/client/consensus/proposer/src/lib.rs b/cumulus/client/consensus/proposer/src/lib.rs
index 7404651bcd96cc40eeecc3c465b2fafffc25c567..7eb90a5ac02bbbea708406980b9a8c2e06d1708e 100644
--- a/cumulus/client/consensus/proposer/src/lib.rs
+++ b/cumulus/client/consensus/proposer/src/lib.rs
@@ -76,7 +76,7 @@ pub trait ProposerInterface<Block: BlockT> {
 		inherent_digests: Digest,
 		max_duration: Duration,
 		block_size_limit: Option<usize>,
-	) -> Result<Proposal<Block, StorageProof>, Error>;
+	) -> Result<Option<Proposal<Block, StorageProof>>, Error>;
 }
 
 /// A simple wrapper around a Substrate proposer for creating collations.
@@ -109,7 +109,7 @@ where
 		inherent_digests: Digest,
 		max_duration: Duration,
 		block_size_limit: Option<usize>,
-	) -> Result<Proposal<B, StorageProof>, Error> {
+	) -> Result<Option<Proposal<B, StorageProof>>, Error> {
 		let proposer = self
 			.inner
 			.init(parent_header)
@@ -127,6 +127,7 @@ where
 		proposer
 			.propose(inherent_data, inherent_digests, max_duration, block_size_limit)
 			.await
+			.map(Some)
 			.map_err(|e| Error::proposing(anyhow::Error::new(e)).into())
 	}
 }
diff --git a/prdoc/pr_2834.prdoc b/prdoc/pr_2834.prdoc
new file mode 100644
index 0000000000000000000000000000000000000000..3a5881659de657dcf55254792bf6db61451ff09d
--- /dev/null
+++ b/prdoc/pr_2834.prdoc
@@ -0,0 +1,13 @@
+title: "proposer: return optional block"
+
+doc:
+  - audience: Node Dev
+    description: |
+      The `ProposerInterface` trait now returns an optional `Proposal`, allowing
+      for no block to be created. This is a breaking change that only impacts custom
+      `ProposerInterface` implementations. The change allows more flexibility in choosing
+      when to create blocks.
+
+crates:
+  - name: "cumulus-client-consensus-aura"
+  - name: "cumulus-client-consensus-proposer"