diff --git a/contrib/test_decoding/expected/stream.out b/contrib/test_decoding/expected/stream.out
index 0f21dcb8e0..0b6288fb14 100644
--- a/contrib/test_decoding/expected/stream.out
+++ b/contrib/test_decoding/expected/stream.out
@@ -106,6 +106,29 @@ SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL,NULL, 'incl
  committing streamed transaction
 (17 rows)
 
+-- bug #19839
+--
+-- A subtransaction without decoded changes must not be marked as serialized
+-- when its parent transaction spills to disk during a partial TOAST change.
+-- Otherwise, truncating the transaction after streaming fails on the
+-- subtransaction's invalid final_lsn.
+BEGIN;
+SAVEPOINT s;
+SELECT 1 FROM stream_test LIMIT 1 FOR UPDATE;
+ ?column? 
+----------
+        1
+(1 row)
+
+RELEASE SAVEPOINT s;
+INSERT INTO stream_test VALUES (repeat('a', 100000));
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1', 'stream-changes', '1');
+ count 
+-------
+     4
+(1 row)
+
 DROP TABLE stream_test;
 SELECT pg_drop_replication_slot('regression_slot');
  pg_drop_replication_slot 
diff --git a/contrib/test_decoding/sql/stream.sql b/contrib/test_decoding/sql/stream.sql
index 4feec62972..1541e5dca3 100644
--- a/contrib/test_decoding/sql/stream.sql
+++ b/contrib/test_decoding/sql/stream.sql
@@ -44,5 +44,19 @@ toasted-123456789012345678901234567890123456789012345678901234567890123456789012
 
 SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL,NULL, 'include-xids', '0', 'skip-empty-xacts', '1', 'stream-changes', '1');
 
+-- bug #19839
+--
+-- A subtransaction without decoded changes must not be marked as serialized
+-- when its parent transaction spills to disk during a partial TOAST change.
+-- Otherwise, truncating the transaction after streaming fails on the
+-- subtransaction's invalid final_lsn.
+BEGIN;
+SAVEPOINT s;
+SELECT 1 FROM stream_test LIMIT 1 FOR UPDATE;
+RELEASE SAVEPOINT s;
+INSERT INTO stream_test VALUES (repeat('a', 100000));
+COMMIT;
+SELECT count(*) FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1', 'stream-changes', '1');
+
 DROP TABLE stream_test;
 SELECT pg_drop_replication_slot('regression_slot');
diff --git a/src/backend/replication/logical/reorderbuffer.c b/src/backend/replication/logical/reorderbuffer.c
index 48a19ed4f6..71bf1e8e3d 100644
--- a/src/backend/replication/logical/reorderbuffer.c
+++ b/src/backend/replication/logical/reorderbuffer.c
@@ -3896,12 +3896,21 @@ ReorderBufferSerializeTXN(ReorderBuffer *rb, ReorderBufferTXN *txn)
 
 		/* update the decoding stats */
 		UpdateDecodingStats((LogicalDecodingContext *) rb->private_data);
+
+		/*
+		 * Mark the transaction as serialized only if something was written,
+		 * and only after the check above.  final_lsn is advanced for each
+		 * change written, so a transaction that wrote nothing, such as a
+		 * subtransaction without decoded changes, would be marked with an
+		 * invalid final_lsn, and ReorderBufferRestoreCleanup() would later
+		 * fail on it.
+		 */
+		txn->txn_flags |= RBTXN_IS_SERIALIZED;
 	}
 
 	Assert(spilled == txn->nentries_mem);
 	Assert(dlist_is_empty(&txn->changes));
 	txn->nentries_mem = 0;
-	txn->txn_flags |= RBTXN_IS_SERIALIZED;
 
 	if (fd != -1)
 		CloseTransientFile(fd);
