diff --git a/src/wp-admin/includes/class-wp-plugins-list-table.php b/src/wp-admin/includes/class-wp-plugins-list-table.php
index c4866076f9..4fdcdfb535 100644
--- a/src/wp-admin/includes/class-wp-plugins-list-table.php
+++ b/src/wp-admin/includes/class-wp-plugins-list-table.php
@@ -24,6 +24,17 @@ class WP_Plugins_List_Table extends WP_List_Table {
 	 */
 	protected $show_autoupdates = true;
 
+	/**
+	 * Map of plugin file to the number of individual sites it is active on.
+	 *
+	 * Null until first accessed; populated lazily by get_subsite_active_plugin_counts().
+	 *
+	 * @since 6.8.0
+	 *
+	 * @var array<string,int>|null
+	 */
+	protected $subsite_active_counts = null;
+
 	/**
 	 * Constructor.
 	 *
@@ -729,6 +740,58 @@ class WP_Plugins_List_Table extends WP_List_Table {
 		}
 	}
 
+	/**
+	 * Returns a map of plugin file paths to the number of individual (non-network) sites
+	 * they are active on. Computed once per page load and cached in $subsite_active_counts.
+	 *
+	 * @since 6.8.0
+	 *
+	 * @return array<string,int> Plugin file => site count. Empty array on non-multisite.
+	 */
+	protected function get_subsite_active_plugin_counts() {
+		if ( null !== $this->subsite_active_counts ) {
+			return $this->subsite_active_counts;
+		}
+
+		$this->subsite_active_counts = array();
+
+		if ( ! is_multisite() ) {
+			return $this->subsite_active_counts;
+		}
+
+		/**
+		 * Filters the maximum number of sites queried when checking whether a plugin
+		 * is active on any individual site before showing the delete action in the
+		 * Network Admin plugins list. Defaults to 100 to avoid unbounded queries on
+		 * very large networks. Increase the limit (or set to 0 for no limit) when
+		 * accuracy across all sites is required.
+		 *
+		 * @since 6.8.0
+		 *
+		 * @param int $sites_query_limit Maximum number of sites to query. Default 100.
+		 */
+		$sites_query_limit = apply_filters( 'delete_plugin_subsite_check_limit', 100 );
+
+		$sites = get_sites(
+			array(
+				'fields' => 'ids',
+				'number' => $sites_query_limit,
+			)
+		);
+
+		foreach ( $sites as $site_id ) {
+			$site_active_plugins = (array) get_blog_option( $site_id, 'active_plugins', array() );
+			foreach ( $site_active_plugins as $active_plugin_file ) {
+				if ( ! isset( $this->subsite_active_counts[ $active_plugin_file ] ) ) {
+					$this->subsite_active_counts[ $active_plugin_file ] = 0;
+				}
+				++$this->subsite_active_counts[ $active_plugin_file ];
+			}
+		}
+
+		return $this->subsite_active_counts;
+	}
+
 	/**
 	 * @global string $status
 	 * @global int $page
@@ -878,27 +941,60 @@ class WP_Plugins_List_Table extends WP_List_Table {
 						}
 					}
 
-					if ( current_user_can( 'delete_plugins' ) && ! is_plugin_active( $plugin_file ) ) {
-						if ( $has_dependents && ! $has_circular_dependency ) {
-							$actions['delete'] = __( 'Delete' ) .
-								'<span class="screen-reader-text">' .
-								__( 'You cannot delete this plugin as other plugins require it.' ) .
-								'</span>';
-						} else {
-							$delete_url = 'plugins.php?action=delete-selected' .
-								'&amp;checked[]=' . urlencode( $plugin_file ) .
-								'&amp;plugin_status=' . $context .
-								'&amp;paged=' . $page .
-								'&amp;s=' . $s;
-
+					if ( current_user_can( 'delete_plugins' ) ) {
+						$subsite_counts    = $this->get_subsite_active_plugin_counts();
+						$active_site_count = $subsite_counts[ $plugin_file ] ?? 0;
+
+						if ( $active_site_count > 0 ) {
+							/*
+							 * Show a non-linked warning whenever the plugin is active on
+							 * any individual site — including the main site. This check is
+							 * intentionally placed before the is_plugin_active() guard so
+							 * that a plugin active on the main site *and* subsites still
+							 * shows the warning rather than a live delete link.
+							 */
 							$actions['delete'] = sprintf(
-								'<a href="%s" id="delete-%s" class="delete" aria-label="%s">%s</a>',
-								wp_nonce_url( $delete_url, 'bulk-plugins' ),
-								esc_attr( $plugin_id_attr ),
-								/* translators: %s: Plugin name. */
-								esc_attr( sprintf( _x( 'Delete %s', 'plugin' ), $plugin_data['Name'] ) ),
-								__( 'Delete' )
+								'<span aria-label="%1$s">%2$s</span>',
+								esc_attr(
+									sprintf(
+										/* translators: 1: Plugin name. 2: Number of sites the plugin is active on. */
+										_n(
+											'Cannot delete %1$s. It is active on %2$d site.',
+											'Cannot delete %1$s. It is active on %2$d sites.',
+											$active_site_count
+										),
+										$plugin_data['Name'],
+										$active_site_count
+									)
+								),
+								sprintf(
+									/* translators: %d: Number of sites the plugin is active on. */
+									_n( 'Active on %d site', 'Active on %d sites', $active_site_count ),
+									$active_site_count
+								)
 							);
+						} elseif ( ! is_plugin_active( $plugin_file ) ) {
+							if ( $has_dependents && ! $has_circular_dependency ) {
+								$actions['delete'] = __( 'Delete' ) .
+									'<span class="screen-reader-text">' .
+									__( 'You cannot delete this plugin as other plugins require it.' ) .
+									'</span>';
+							} else {
+								$delete_url = 'plugins.php?action=delete-selected' .
+									'&amp;checked[]=' . urlencode( $plugin_file ) .
+									'&amp;plugin_status=' . $context .
+									'&amp;paged=' . $page .
+									'&amp;s=' . $s;
+
+								$actions['delete'] = sprintf(
+									'<a href="%s" id="delete-%s" class="delete" aria-label="%s">%s</a>',
+									wp_nonce_url( $delete_url, 'bulk-plugins' ),
+									esc_attr( $plugin_id_attr ),
+									/* translators: %s: Plugin name. */
+									esc_attr( sprintf( _x( 'Delete %s', 'plugin' ), $plugin_data['Name'] ) ),
+									__( 'Delete' )
+								);
+							}
 						}
 					}
 				}
@@ -1279,8 +1375,17 @@ class WP_Plugins_List_Table extends WP_List_Table {
 
 					echo '</div>';
 
-					if ( $has_dependents ) {
+					$row_active_site_count = $screen->in_admin( 'network' )
+						? ( $this->get_subsite_active_plugin_counts()[ $plugin_file ] ?? 0 )
+						: 0;
+
+					if ( $has_dependents && $row_active_site_count > 0 ) {
+						// Both: dependents note with the subsite sentence appended.
+						$this->add_dependents_to_dependency_plugin_row( $plugin_file, $row_active_site_count );
+					} elseif ( $has_dependents ) {
 						$this->add_dependents_to_dependency_plugin_row( $plugin_file );
+					} elseif ( $row_active_site_count > 0 ) {
+						$this->add_subsite_active_note_to_plugin_row( $row_active_site_count );
 					}
 
 					if ( WP_Plugin_Dependencies::has_dependencies( $plugin_file ) ) {
@@ -1547,7 +1652,16 @@ class WP_Plugins_List_Table extends WP_List_Table {
 	 *
 	 * @param string $dependency The dependency's filepath, relative to the plugins directory.
 	 */
-	protected function add_dependents_to_dependency_plugin_row( $dependency ) {
+	/**
+	 * @since 6.5.0
+	 * @since 6.8.0 Added the `$active_site_count` parameter to optionally append a
+	 *              subsite-active sentence to the existing note.
+	 *
+	 * @param string $dependency       The dependency plugin's filepath, relative to the plugins directory.
+	 * @param int    $active_site_count Number of individual sites the plugin is active on. When > 0,
+	 *                                 a sentence is appended to the note. Default 0.
+	 */
+	protected function add_dependents_to_dependency_plugin_row( $dependency, $active_site_count = 0 ) {
 		$dependent_names = WP_Plugin_Dependencies::get_dependent_names( $dependency );
 
 		if ( empty( $dependent_names ) ) {
@@ -1556,6 +1670,18 @@ class WP_Plugins_List_Table extends WP_List_Table {
 
 		$dependency_note = __( 'Note: This plugin cannot be deactivated or deleted until the plugins that require it are deactivated or deleted.' );
 
+		if ( $active_site_count > 0 ) {
+			$dependency_note .= ' ' . sprintf(
+				/* translators: %d: Number of sites the plugin is active on. */
+				_n(
+					'It is also active on %d site.',
+					'It is also active on %d sites.',
+					$active_site_count
+				),
+				$active_site_count
+			);
+		}
+
 		$comma       = wp_get_list_item_separator();
 		$required_by = sprintf(
 			/* translators: %s: List of dependencies. */
@@ -1570,6 +1696,28 @@ class WP_Plugins_List_Table extends WP_List_Table {
 		);
 	}
 
+	/**
+	 * Prints a note that the plugin cannot be deleted because it is active on individual sites.
+	 * Uses the same HTML structure as {@see add_dependents_to_dependency_plugin_row()}.
+	 *
+	 * @since 6.8.0
+	 *
+	 * @param int $active_site_count Number of individual sites the plugin is active on.
+	 */
+	protected function add_subsite_active_note_to_plugin_row( $active_site_count ) {
+		$note = sprintf(
+			/* translators: %d: Number of sites the plugin is active on. */
+			_n(
+				'Note: This plugin cannot be deleted because it is active on %d site.',
+				'Note: This plugin cannot be deleted because it is active on %d sites.',
+				$active_site_count
+			),
+			$active_site_count
+		);
+
+		printf( '<div class="required-by"><p>%s</p></div>', $note );
+	}
+
 	/**
 	 * Prints a list of other plugins that the plugin depends on.
 	 *
diff --git a/src/wp-admin/includes/plugin.php b/src/wp-admin/includes/plugin.php
index 94d4a585a8..94f80cbf2e 100644
--- a/src/wp-admin/includes/plugin.php
+++ b/src/wp-admin/includes/plugin.php
@@ -908,6 +908,41 @@ function delete_plugins( $plugins, $deprecated = '' ) {
 		return false;
 	}
 
+	/*
+	 * In Multisite, do not delete a plugin that is still active on any individual
+	 * site. The check was dropped in 4.6 (changeset 37714 / Shiny Updates) when
+	 * the filesystem workflow was restructured, but the protection is still needed.
+	 *
+	 * @see https://core.trac.wordpress.org/ticket/53255
+	 */
+	if ( is_multisite() ) {
+		$active_on_sites = array();
+
+		$sites = get_sites(
+			array(
+				'fields' => 'ids',
+				'number' => 0,
+			)
+		);
+
+		foreach ( $sites as $site_id ) {
+			$site_active_plugins = (array) get_blog_option( $site_id, 'active_plugins', array() );
+			foreach ( $plugins as $plugin_file ) {
+				if ( in_array( $plugin_file, $site_active_plugins, true ) ) {
+					$active_on_sites[ $plugin_file ][] = $site_id;
+				}
+			}
+		}
+
+		if ( ! empty( $active_on_sites ) ) {
+			return new WP_Error(
+				'active_plugin',
+				__( 'Cannot delete a plugin while it is active on a site.' ),
+				$active_on_sites
+			);
+		}
+	}
+
 	$checked = array();
 	foreach ( $plugins as $plugin ) {
 		$checked[] = 'checked[]=' . $plugin;
diff --git a/src/wp-admin/plugins.php b/src/wp-admin/plugins.php
index 6a359c822f..068209fd8f 100644
--- a/src/wp-admin/plugins.php
+++ b/src/wp-admin/plugins.php
@@ -397,6 +397,9 @@ if ( $action ) {
 
 				?>
 				</p>
+				<?php if ( is_network_admin() ) : ?>
+				<p><?php _e( 'This plugin may be active on individual sites in the network.', 'default' ); ?></p>
+				<?php endif; ?>
 				<form method="post" action="<?php echo esc_url( $_SERVER['REQUEST_URI'] ); ?>" style="display:inline;">
 					<input type="hidden" name="verify-delete" value="1" />
 					<input type="hidden" name="action" value="delete-selected" />
diff --git a/tests/phpunit/tests/admin/includesPlugin.php b/tests/phpunit/tests/admin/includesPlugin.php
index 4d47fb7db0..466c223ede 100644
--- a/tests/phpunit/tests/admin/includesPlugin.php
+++ b/tests/phpunit/tests/admin/includesPlugin.php
@@ -739,4 +739,96 @@ class Tests_Admin_IncludesPlugin extends WP_UnitTestCase {
 			rmdir( $di_bu_dir );
 		}
 	}
+
+	/**
+	 * Tests that delete_plugins() returns a WP_Error when the plugin is active on a subsite.
+	 *
+	 * @ticket 53255
+	 * @group ms-required
+	 *
+	 * @covers ::delete_plugins
+	 */
+	public function test_delete_plugins_returns_wp_error_when_plugin_active_on_subsite() {
+		$plugin  = $this->_create_plugin();
+		$site_id = self::factory()->blog->create();
+
+		update_blog_option( $site_id, 'active_plugins', array( $plugin[0] ) );
+
+		$result = delete_plugins( array( $plugin[0] ) );
+
+		// Clean up.
+		update_blog_option( $site_id, 'active_plugins', array() );
+		if ( file_exists( $plugin[1] ) ) {
+			unlink( $plugin[1] );
+		}
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'active_plugin', $result->get_error_code() );
+		$this->assertArrayHasKey( $plugin[0], $result->get_error_data() );
+		$this->assertContains( $site_id, $result->get_error_data()[ $plugin[0] ] );
+	}
+
+	/**
+	 * Tests that delete_plugins() does not block deletion when the plugin is not active on any subsite.
+	 *
+	 * @ticket 53255
+	 * @group ms-required
+	 *
+	 * @covers ::delete_plugins
+	 */
+	public function test_delete_plugins_does_not_block_when_plugin_not_active_on_any_subsite() {
+		$plugin = $this->_create_plugin();
+
+		$result = delete_plugins( array( $plugin[0] ) );
+
+		// Clean up if the file was not removed (e.g. no filesystem credentials in test env).
+		if ( file_exists( $plugin[1] ) ) {
+			unlink( $plugin[1] );
+		}
+
+		// The function must not return the active_plugin error.
+		if ( is_wp_error( $result ) ) {
+			$this->assertNotSame(
+				'active_plugin',
+				$result->get_error_code(),
+				'delete_plugins() should not block deletion of an inactive plugin.'
+			);
+		} else {
+			// null (no credentials) or true (deleted) are both acceptable.
+			$this->assertNotFalse( $result );
+		}
+	}
+
+	/**
+	 * Tests that delete_plugins() returns a WP_Error listing all affected plugins and sites.
+	 *
+	 * @ticket 53255
+	 * @group ms-required
+	 *
+	 * @covers ::delete_plugins
+	 */
+	public function test_delete_plugins_error_data_contains_all_active_plugins() {
+		$plugin_a = $this->_create_plugin( "<?php\n/*\nPlugin Name: Test A\n*/" );
+		$plugin_b = $this->_create_plugin( "<?php\n/*\nPlugin Name: Test B\n*/" );
+		$site_id  = self::factory()->blog->create();
+
+		update_blog_option( $site_id, 'active_plugins', array( $plugin_a[0], $plugin_b[0] ) );
+
+		$result = delete_plugins( array( $plugin_a[0], $plugin_b[0] ) );
+
+		// Clean up.
+		update_blog_option( $site_id, 'active_plugins', array() );
+		foreach ( array( $plugin_a[1], $plugin_b[1] ) as $file ) {
+			if ( file_exists( $file ) ) {
+				unlink( $file );
+			}
+		}
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'active_plugin', $result->get_error_code() );
+
+		$data = $result->get_error_data();
+		$this->assertArrayHasKey( $plugin_a[0], $data, 'Error data should include plugin A.' );
+		$this->assertArrayHasKey( $plugin_b[0], $data, 'Error data should include plugin B.' );
+	}
 }
