diff --git a/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php
index b5fc2aaf25a685b87fdd1f423ed6f2f7bfcb9e1f..8dc9bac392eac15ca673acab3b507e6851e8814f 100644
--- a/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php
+++ b/core/lib/Drupal/Core/EventSubscriber/FinishResponseSubscriber.php
@@ -12,6 +12,7 @@
 use Drupal\Core\Site\Settings;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpFoundation\StreamedResponse;
 use Symfony\Component\HttpKernel\Event\ResponseEvent;
 use Symfony\Component\HttpKernel\KernelEvents;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
@@ -298,6 +299,21 @@ protected function setExpiresNoCache(Response $response) {
     $response->setExpires(\DateTime::createFromFormat('j-M-Y H:i:s T', '19-Nov-1978 05:00:00 UTC'));
   }
 
+  /**
+   * Sets the Content-Length header on the response.
+   *
+   * @param \Symfony\Component\HttpKernel\Event\ResponseEvent $event
+   *   The event to process.
+   */
+  public function setContentLengthHeader(ResponseEvent $event): void {
+    $response = $event->getResponse();
+    if ($response instanceof StreamedResponse) {
+      return;
+    }
+
+    $response->headers->set('Content-Length', strlen($response->getContent()), TRUE);
+  }
+
   /**
    * Registers the methods in this class that should be listeners.
    *
@@ -309,6 +325,10 @@ public static function getSubscribedEvents(): array {
     // There is no specific reason for choosing 16 beside it should be executed
     // before ::onRespond().
     $events[KernelEvents::RESPONSE][] = ['onAllResponds', 16];
+    // Run very late, after all other response subscribers have run. However,
+    // any response subscribers that convert a response to a streamed response
+    // must run after this and undo what this does.
+    $events[KernelEvents::RESPONSE][] = ['setContentLengthHeader', -1024];
     return $events;
   }
 
diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt
index 1719f779143c706f18437b45574ff7132a9198f6..0be92d6ffde0db46c314eb2fea418b767b247457 100644
--- a/core/misc/cspell/dictionary.txt
+++ b/core/misc/cspell/dictionary.txt
@@ -625,6 +625,7 @@ linkset
 linktext
 lisu
 litererally
+litespeed
 llamaids
 llamasarelame
 llamma
diff --git a/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php b/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php
index e818522a69ffae32c87434d154a9c1f0e44bbf50..03e1d4261cd955addefbc202b17f740545a7217c 100644
--- a/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php
+++ b/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php
@@ -82,6 +82,8 @@ public function onRespond(ResponseEvent $event) {
       $content = $response->getContent();
       $content = str_replace('<drupal-big-pipe-scripts-bottom-marker>', '', $content);
       $response->setContent($content);
+      // FinishResponseSubscriber::setContentLengthHeader() already ran.
+      $response->headers->set('Content-Length', strlen($content), TRUE);
     }
 
     // If there are neither BigPipe placeholders nor no-JS BigPipe placeholders,
@@ -93,6 +95,10 @@ public function onRespond(ResponseEvent $event) {
 
     $big_pipe_response = new BigPipeResponse($response);
     $big_pipe_response->setBigPipeService($this->getBigPipeService($event));
+
+    // A BigPipe response's length is impossible to predict.
+    $big_pipe_response->headers->remove('Content-Length');
+
     $event->setResponse($big_pipe_response);
   }
 
diff --git a/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php
index f88fa41416d0e5d0842a6edc8ff7e5f8a593df9e..b3cc1c0df83d1b26b8dbbc501febd5584ba10492 100644
--- a/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php
+++ b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php
@@ -1300,10 +1300,13 @@ public function testNonCacheableMethods() {
     $methods = [
       'HEAD',
       'GET',
-      'PATCH',
-      'DELETE',
     ];
-    $non_post_request_options = $base_request_options + [
+    foreach ($methods as $method) {
+      $response = $this->request($method, Url::fromUri('internal:/jsonapi/node/article/' . $node->uuid()), $base_request_options);
+      $this->assertSame(200, $response->getStatusCode());
+    }
+
+    $patch_request_options = $base_request_options + [
       RequestOptions::JSON => [
         'data' => [
           'type' => 'node--article',
@@ -1311,10 +1314,11 @@ public function testNonCacheableMethods() {
         ],
       ],
     ];
-    foreach ($methods as $method) {
-      $response = $this->request($method, Url::fromUri('internal:/jsonapi/node/article/' . $node->uuid()), $non_post_request_options);
-      $this->assertSame($method === 'DELETE' ? 204 : 200, $response->getStatusCode());
-    }
+    $response = $this->request('PATCH', Url::fromUri('internal:/jsonapi/node/article/' . $node->uuid()), $patch_request_options);
+    $this->assertSame(200, $response->getStatusCode());
+
+    $response = $this->request('DELETE', Url::fromUri('internal:/jsonapi/node/article/' . $node->uuid()), $base_request_options);
+    $this->assertSame(204, $response->getStatusCode());
 
     $post_request_options = $base_request_options + [
       RequestOptions::JSON => [
diff --git a/core/modules/jsonapi/tests/src/Functional/NodeTest.php b/core/modules/jsonapi/tests/src/Functional/NodeTest.php
index 9c9f5b16c6072587c361e82b94fce5b2d263e6da..d0893201e1a875a4edcab376c89de20c2be6f05d 100644
--- a/core/modules/jsonapi/tests/src/Functional/NodeTest.php
+++ b/core/modules/jsonapi/tests/src/Functional/NodeTest.php
@@ -393,11 +393,15 @@ protected function assertCacheableNormalizations(): void {
     $request_options = $this->getAuthenticationRequestOptions();
     $request_options[RequestOptions::QUERY] = ['fields' => ['node--camelids' => 'title']];
     $this->request('GET', $url, $request_options);
+    // Cacheable normalizations are written after the response is flushed to
+    // the client; give the server a chance to complete this work.
+    sleep(1);
     // Ensure the normalization cache is being incrementally built. After
     // requesting the title, only the title is in the cache.
     $this->assertNormalizedFieldsAreCached(['title']);
     $request_options[RequestOptions::QUERY] = ['fields' => ['node--camelids' => 'field_rest_test']];
     $this->request('GET', $url, $request_options);
+    sleep(1);
     // After requesting an additional field, then that field is in the cache and
     // the old one is still there.
     $this->assertNormalizedFieldsAreCached(['title', 'field_rest_test']);
diff --git a/core/modules/language/tests/src/Functional/ConfigurableLanguageManagerTest.php b/core/modules/language/tests/src/Functional/ConfigurableLanguageManagerTest.php
index d6b71e3a36a5b5f7029d9e3dd833fbfa9c7173a2..2bd7360721c79d03336838280975fa3d96c6a0d6 100644
--- a/core/modules/language/tests/src/Functional/ConfigurableLanguageManagerTest.php
+++ b/core/modules/language/tests/src/Functional/ConfigurableLanguageManagerTest.php
@@ -266,4 +266,16 @@ public function testUserProfileTranslationWithPreferredAdminLanguage() {
     $assert_session->pageTextNotContains($field_label_es);
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  protected function drupalGet($path, array $options = [], array $headers = []) {
+    $response = parent::drupalGet($path, $options, $headers);
+    // The \Drupal\locale\LocaleTranslation service clears caches after the
+    // response is flushed to the client; wait for Drupal to perform its
+    // termination work before continuing.
+    sleep(1);
+    return $response;
+  }
+
 }
diff --git a/core/modules/locale/tests/src/Functional/LocaleLocaleLookupTest.php b/core/modules/locale/tests/src/Functional/LocaleLocaleLookupTest.php
index 9ba855d1f158f2fad261a856e673c085655591c3..5b43ad50bf1ab7666825b85cc705682280434ebd 100644
--- a/core/modules/locale/tests/src/Functional/LocaleLocaleLookupTest.php
+++ b/core/modules/locale/tests/src/Functional/LocaleLocaleLookupTest.php
@@ -68,6 +68,11 @@ public function testLanguageFallbackDefaults() {
    * @dataProvider providerTestFixOldPluralStyle
    */
   public function testFixOldPluralStyle($translation_value, $expected) {
+    // The \Drupal\locale\LocaleTranslation service stores localization cache
+    // data after the response is flushed to the client. We do not want to race
+    // with any string translations that may be saving from the login in
+    // ::setUp().
+    sleep(1);
     $string_storage = \Drupal::service('locale.storage');
     $string = $string_storage->findString(['source' => 'Member for', 'context' => '']);
     $lid = $string->getId();
diff --git a/core/modules/path/tests/src/Functional/PathAliasTest.php b/core/modules/path/tests/src/Functional/PathAliasTest.php
index 1c8873344bee8645cc897af706197d1a47115c8f..f6ebeb9be328cdc98a55db60470b88ab0377bbe7 100644
--- a/core/modules/path/tests/src/Functional/PathAliasTest.php
+++ b/core/modules/path/tests/src/Functional/PathAliasTest.php
@@ -66,6 +66,9 @@ public function testPathCache() {
     \Drupal::cache('data')->deleteAll();
     // Make sure the path is not converted to the alias.
     $this->drupalGet(trim($edit['path[0][value]'], '/'), ['alias' => TRUE]);
+    // The \Drupal\path_alias\AliasWhitelist service performs cache clears after
+    // Drupal has flushed the response to the client; wait for this to finish.
+    sleep(1);
     $this->assertNotEmpty(\Drupal::cache('data')->get('preload-paths:' . $edit['path[0][value]']), 'Cache entry was created.');
 
     // Visit the alias for the node and confirm a cache entry is created.
@@ -73,6 +76,7 @@ public function testPathCache() {
     // @todo Remove this once https://www.drupal.org/node/2480077 lands.
     Cache::invalidateTags(['rendered']);
     $this->drupalGet(trim($edit['alias[0][value]'], '/'));
+    sleep(1);
     $this->assertNotEmpty(\Drupal::cache('data')->get('preload-paths:' . $edit['path[0][value]']), 'Cache entry was created.');
   }
 
diff --git a/core/modules/system/system.install b/core/modules/system/system.install
index cbab5df838eb107f738039d82eb0e8145f636102..c5425e95cf488f4a467fce3d2f59e75f6f3a5da2 100644
--- a/core/modules/system/system.install
+++ b/core/modules/system/system.install
@@ -501,6 +501,17 @@ function system_requirements($phase) {
     }
   }
 
+  if ($phase === 'runtime') {
+    if (!function_exists('fastcgi_finish_request') && !function_exists('litespeed_finish_request') && !ob_get_status()) {
+      $requirements['output_buffering'] = [
+        'title' => t('Output Buffering'),
+        'error_value' => t('Not enabled'),
+        'severity' => REQUIREMENT_WARNING,
+        'description' => t('<a href="https://www.php.net/manual/en/function.ob-start.php">Output buffering</a> is not enabled. This may degrade Drupal\'s performance. You can enable output buffering by default <a href="https://www.php.net/manual/en/outcontrol.configuration.php#ini.output-buffering">in your PHP settings</a>.'),
+      ];
+    }
+  }
+
   if ($phase == 'install' || $phase == 'update') {
     // Test for PDO (database).
     $requirements['database_extensions'] = [
diff --git a/core/modules/system/tests/modules/destructable_test/destructable_test.info.yml b/core/modules/system/tests/modules/destructable_test/destructable_test.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ed91a24398748d56f886c784611ecf4fe2777737
--- /dev/null
+++ b/core/modules/system/tests/modules/destructable_test/destructable_test.info.yml
@@ -0,0 +1,5 @@
+name: 'Tests for DestructableInterface'
+type: module
+description: 'Provides test services which implement DestructableInterface.'
+package: Testing
+version: VERSION
diff --git a/core/modules/system/tests/modules/destructable_test/destructable_test.routing.yml b/core/modules/system/tests/modules/destructable_test/destructable_test.routing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..51eaae2d4b7de5bf618d8fdec691c063a2a9a360
--- /dev/null
+++ b/core/modules/system/tests/modules/destructable_test/destructable_test.routing.yml
@@ -0,0 +1,7 @@
+destructable:
+  path: '/destructable'
+  defaults:
+    _controller: '\Drupal\destructable_test\Controller\CallsDestructableServiceController::render'
+    _title: 'Calls destructable service'
+  requirements:
+    _access: 'TRUE'
diff --git a/core/modules/system/tests/modules/destructable_test/destructable_test.services.yml b/core/modules/system/tests/modules/destructable_test/destructable_test.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..316ce275d04d7e4e4e850ea388377e20d70c6511
--- /dev/null
+++ b/core/modules/system/tests/modules/destructable_test/destructable_test.services.yml
@@ -0,0 +1,4 @@
+services:
+  Drupal\destructable_test\Destructable:
+    tags:
+      - { name: needs_destruction }
diff --git a/core/modules/system/tests/modules/destructable_test/src/Controller/CallsDestructableServiceController.php b/core/modules/system/tests/modules/destructable_test/src/Controller/CallsDestructableServiceController.php
new file mode 100644
index 0000000000000000000000000000000000000000..42b1e25def8a1ffc38f00463b55fd8f07d20d15d
--- /dev/null
+++ b/core/modules/system/tests/modules/destructable_test/src/Controller/CallsDestructableServiceController.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\destructable_test\Controller;
+
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\destructable_test\Destructable;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Controller to instantiate the destructable service.
+ */
+final class CallsDestructableServiceController extends ControllerBase {
+
+  /**
+   * Destructable service.
+   *
+   * @var \Drupal\destructable_test\Destructable
+   */
+  protected $destructable;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static($container->get(Destructable::class));
+  }
+
+  public function __construct(Destructable $destructable) {
+    $this->destructable = $destructable;
+  }
+
+  /**
+   * Render callback.
+   *
+   * @return \Symfony\Component\HttpFoundation\Response
+   *   Response.
+   */
+  public function render(Request $request): Response {
+    $this->destructable->setSemaphore($request->query->get('semaphore'));
+    return new Response('This is a longer-ish string of content to send to the client, to invoke any trivial transfer buffers both on the server and client side.');
+  }
+
+}
diff --git a/core/modules/system/tests/modules/destructable_test/src/Destructable.php b/core/modules/system/tests/modules/destructable_test/src/Destructable.php
new file mode 100644
index 0000000000000000000000000000000000000000..210adc70aca0a47e2e2a1900a01c235814f7426c
--- /dev/null
+++ b/core/modules/system/tests/modules/destructable_test/src/Destructable.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\destructable_test;
+
+use Drupal\Core\DestructableInterface;
+
+final class Destructable implements DestructableInterface {
+
+  /**
+   * Semaphore filename.
+   *
+   * @var string
+   */
+  protected string $semaphore;
+
+  /**
+   * Set the destination for the semaphore file.
+   *
+   * @param string $semaphore
+   *   Temporary file to set a semaphore flag.
+   */
+  public function setSemaphore(string $semaphore): void {
+    $this->semaphore = $semaphore;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function destruct() {
+    sleep(3);
+    file_put_contents($this->semaphore, 'ran');
+  }
+
+}
diff --git a/core/modules/system/tests/modules/system_test/src/Controller/SystemTestController.php b/core/modules/system/tests/modules/system_test/src/Controller/SystemTestController.php
index 03f44fec4ba4c5bfbe4aebfc5b4a7e0ec05d618e..b806e582d6a45f3231b95d4294fd13584d35f28a 100644
--- a/core/modules/system/tests/modules/system_test/src/Controller/SystemTestController.php
+++ b/core/modules/system/tests/modules/system_test/src/Controller/SystemTestController.php
@@ -332,8 +332,8 @@ public function shutdownFunctions($arg1, $arg2) {
     // the exception message can not be tested.
     // @see _drupal_shutdown_function()
     // @see \Drupal\system\Tests\System\ShutdownFunctionsTest
-    if (function_exists('fastcgi_finish_request')) {
-      return ['#markup' => 'The function fastcgi_finish_request exists when serving the request.'];
+    if (function_exists('fastcgi_finish_request') || ob_get_status()) {
+      return ['#markup' => 'The response will flush before shutdown functions are called.'];
     }
     return [];
   }
diff --git a/core/modules/system/tests/src/Functional/System/ShutdownFunctionsTest.php b/core/modules/system/tests/src/Functional/System/ShutdownFunctionsTest.php
index 6d7798721c71940bbf0f8c7872a3dda98cd3d3c1..7c36abd3816010f1579485f5422eaabfa6989af7 100644
--- a/core/modules/system/tests/src/Functional/System/ShutdownFunctionsTest.php
+++ b/core/modules/system/tests/src/Functional/System/ShutdownFunctionsTest.php
@@ -42,18 +42,18 @@ public function testShutdownFunctions() {
     $arg2 = $this->randomMachineName();
     $this->drupalGet('system-test/shutdown-functions/' . $arg1 . '/' . $arg2);
 
-    // If using PHP-FPM then fastcgi_finish_request() will have been fired
-    // returning the response before shutdown functions have fired.
+    // If using PHP-FPM or output buffering, the response will be flushed to
+    // the client before shutdown functions have fired.
     // @see \Drupal\system_test\Controller\SystemTestController::shutdownFunctions()
-    $server_using_fastcgi = strpos($this->getSession()->getPage()->getContent(), 'The function fastcgi_finish_request exists when serving the request.');
-    if ($server_using_fastcgi) {
+    $response_will_flush = strpos($this->getSession()->getPage()->getContent(), 'The response will flush before shutdown functions are called.');
+    if ($response_will_flush) {
       // We need to wait to ensure that the shutdown functions have fired.
       sleep(1);
     }
     $this->assertEquals([$arg1, $arg2], \Drupal::state()->get('_system_test_first_shutdown_function'));
     $this->assertEquals([$arg1, $arg2], \Drupal::state()->get('_system_test_second_shutdown_function'));
 
-    if (!$server_using_fastcgi) {
+    if (!$response_will_flush) {
       // Make sure exceptions displayed through
       // \Drupal\Core\Utility\Error::renderExceptionSafe() are correctly
       // escaped.
diff --git a/core/tests/Drupal/FunctionalTests/HttpKernel/DestructableServiceTest.php b/core/tests/Drupal/FunctionalTests/HttpKernel/DestructableServiceTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..38cad0a6385425042b1e4e408b6fa81da98322c9
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/HttpKernel/DestructableServiceTest.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Drupal\FunctionalTests\HttpKernel;
+
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\Url;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests invocation of services performing deferred tasks after response flush.
+ *
+ * @see \Drupal\Core\DestructableInterface
+ *
+ * @group Http
+ */
+class DestructableServiceTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['system', 'destructable_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  public function testDestructableServiceExecutionOrder(): void {
+    $file_system = $this->container->get('file_system');
+    assert($file_system instanceof FileSystemInterface);
+    $semaphore = $file_system
+      ->tempnam($file_system->getTempDirectory(), 'destructable_semaphore');
+    $this->drupalGet(Url::fromRoute('destructable', [], ['query' => ['semaphore' => $semaphore]]));
+    // This should be false as the response should flush before running the
+    // test service.
+    $this->assertEmpty(file_get_contents($semaphore), 'Destructable service did not run when response flushed to client.');
+    // The destructable service will sleep for 3 seconds, then run.
+    // To ensure no race conditions on slow test runners, wait another 3s.
+    sleep(6);
+    $this->assertTrue(file_get_contents($semaphore) === 'ran', 'Destructable service did run.');
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/ApiRequestTrait.php b/core/tests/Drupal/Tests/ApiRequestTrait.php
index 9a5431a2c7e7e1b54c6d427ba0b70afe69caffb5..83888277415bd51312514a4be16fd6ba9ece5285 100644
--- a/core/tests/Drupal/Tests/ApiRequestTrait.php
+++ b/core/tests/Drupal/Tests/ApiRequestTrait.php
@@ -37,6 +37,12 @@ trait ApiRequestTrait {
    * @see \GuzzleHttp\ClientInterface::request()
    */
   protected function makeApiRequest($method, Url $url, array $request_options) {
+    // HEAD requests do not have bodies. If one is specified, Guzzle will not
+    // ignore it and the request will be treated as GET with an overridden
+    // method string, and libcurl will expect to read a response body.
+    if ($method === 'HEAD' && array_key_exists('body', $request_options)) {
+      unset($request_options['body']);
+    }
     $this->refreshVariables();
     $request_options[RequestOptions::HTTP_ERRORS] = FALSE;
     $request_options[RequestOptions::ALLOW_REDIRECTS] = FALSE;