diff --git a/core/MAINTAINERS.txt b/core/MAINTAINERS.txt
index 63748c27bd717b4a3215bf8ef13fff7fba53095d..69802ff981fe19b88e3ec027635d1ddc7b37e7d1 100644
--- a/core/MAINTAINERS.txt
+++ b/core/MAINTAINERS.txt
@@ -261,6 +261,10 @@ Basic Auth module
 - Klaus Purer 'klausi' https://www.drupal.org/u/klausi
 - Juampy Novillo Requena 'juampy' https://www.drupal.org/u/juampy
 
+BigPipe module
+- Wim Leers 'Wim Leers' https://www.drupal.org/u/wim-leers
+- Fabian Franz 'Fabianx' https://www.drupal.org/u/fabianx
+
 Block module
 - Tim Plunkett 'tim.plunkett' https://www.drupal.org/u/tim.plunkett
 - Ben Dougherty 'benjy' https://www.drupal.org/u/benjy
diff --git a/core/composer.json b/core/composer.json
index e8120c604d08bc258ba984104ed2bbeadcfb4032..dbe1db9238b7975b1cba3457a3f0d407c758649c 100644
--- a/core/composer.json
+++ b/core/composer.json
@@ -48,6 +48,7 @@
         "drupal/bartik": "self.version",
         "drupal/ban": "self.version",
         "drupal/basic_auth": "self.version",
+        "drupal/big_pipe": "self.version",
         "drupal/block": "self.version",
         "drupal/block_content": "self.version",
         "drupal/book": "self.version",
diff --git a/core/modules/big_pipe/big_pipe.info.yml b/core/modules/big_pipe/big_pipe.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0108e1f690b0f6b47f16b8a9da70485109ce2bd8
--- /dev/null
+++ b/core/modules/big_pipe/big_pipe.info.yml
@@ -0,0 +1,6 @@
+name: BigPipe
+type: module
+description: 'Sends pages in a way that allows browsers to show them much faster. Uses the BigPipe technique.'
+package: Core (Experimental)
+version: VERSION
+core: 8.x
diff --git a/core/modules/big_pipe/big_pipe.libraries.yml b/core/modules/big_pipe/big_pipe.libraries.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6da49b78e0529d0e70f35dc92817996226c16209
--- /dev/null
+++ b/core/modules/big_pipe/big_pipe.libraries.yml
@@ -0,0 +1,11 @@
+big_pipe:
+  version: VERSION
+  js:
+    js/big_pipe.js: {}
+  drupalSettings:
+    bigPipePlaceholderIds: []
+  dependencies:
+    - core/jquery
+    - core/jquery.once
+    - core/drupal.ajax
+    - core/drupalSettings
diff --git a/core/modules/big_pipe/big_pipe.module b/core/modules/big_pipe/big_pipe.module
new file mode 100644
index 0000000000000000000000000000000000000000..1970c27d988c8a752990542f330740ee3075b4b1
--- /dev/null
+++ b/core/modules/big_pipe/big_pipe.module
@@ -0,0 +1,66 @@
+<?php
+
+/**
+ * @file
+ * Adds BigPipe no-JS detection.
+ */
+
+use Drupal\big_pipe\Render\Placeholder\BigPipeStrategy;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Url;
+
+/**
+ * Implements hook_help().
+ */
+function big_pipe_help($route_name, RouteMatchInterface $route_match) {
+  switch ($route_name) {
+    case 'help.page.big_pipe':
+      $output = '<h3>' . t('About') . '</h3>';
+      $output .= '<p>' . t('The BigPipe module sends pages with dynamic content in a way that allows browsers to show them much faster. For more information, see the <a href=":big_pipe-documentation">online documentation for the BigPipe module</a>.', [':big_pipe-documentation' => 'https://www.drupal.org/documentation/modules/big_pipe']) . '</p>';
+      $output .= '<h3>' . t('Uses') . '</h3>';
+      $output .= '<dl>';
+      $output .= '<dt>' . t('Speeding up your site') . '</dt>';
+      $output .= '<dd>' . t('The module requires no configuration. Every part of the page contains metadata that allows BigPipe to figure this out on its own.') . '</dd>';
+      $output .= '</dl>';
+
+      return $output;
+  }
+}
+
+/**
+ * Implements hook_page_attachments().
+ *
+ * @see \Drupal\big_pipe\Controller\BigPipeController::setNoJsCookie()
+ */
+function big_pipe_page_attachments(array &$page) {
+  // Routes that don't use BigPipe also don't need no-JS detection.
+  if (\Drupal::routeMatch()->getRouteObject()->getOption('_no_big_pipe')) {
+    return;
+  }
+
+  $request = \Drupal::request();
+  // BigPipe is only used when there is an actual session, so only add the no-JS
+  // detection when there actually is a session.
+  // @see \Drupal\big_pipe\Render\Placeholder\BigPipeStrategy.
+  $session_exists = \Drupal::service('session_configuration')->hasSession($request);
+  $page['#cache']['contexts'][] = 'session.exists';
+  // Only do the no-JS detection while we don't know if there's no JS support:
+  // avoid endless redirect loops.
+  $has_big_pipe_nojs_cookie = $request->cookies->has(BigPipeStrategy::NOJS_COOKIE);
+  $page['#cache']['contexts'][] = 'cookies:' . BigPipeStrategy::NOJS_COOKIE;
+  if ($session_exists && !$has_big_pipe_nojs_cookie) {
+    $page['#attached']['html_head'][] = [
+      [
+        // Redirect through a 'Refresh' meta tag if JavaScript is disabled.
+        '#tag' => 'meta',
+        '#noscript' => TRUE,
+        '#attributes' => [
+          'http-equiv' => 'Refresh',
+          // @todo: Switch to Url::fromRoute() once https://www.drupal.org/node/2589967 is resolved.
+          'content' => '0; URL=' . Url::fromUri('internal:/big_pipe/no-js', ['query' => \Drupal::service('redirect.destination')->getAsArray()])->toString(),
+        ],
+      ],
+      'big_pipe_detect_nojs',
+    ];
+  }
+}
diff --git a/core/modules/big_pipe/big_pipe.routing.yml b/core/modules/big_pipe/big_pipe.routing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c7981dc8a5d00ec3fa0104298f71e363dc82aac3
--- /dev/null
+++ b/core/modules/big_pipe/big_pipe.routing.yml
@@ -0,0 +1,9 @@
+big_pipe.nojs:
+  path: '/big_pipe/no-js'
+  defaults:
+    _controller: '\Drupal\big_pipe\Controller\BigPipeController:setNoJsCookie'
+    _title: 'BigPipe no-JS check'
+  options:
+    no_cache: TRUE
+  requirements:
+    _access: 'TRUE'
diff --git a/core/modules/big_pipe/big_pipe.services.yml b/core/modules/big_pipe/big_pipe.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b06255a70a63a0487966a41f60448a66c21bae1f
--- /dev/null
+++ b/core/modules/big_pipe/big_pipe.services.yml
@@ -0,0 +1,25 @@
+services:
+  html_response.big_pipe_subscriber:
+    class: Drupal\big_pipe\EventSubscriber\HtmlResponseBigPipeSubscriber
+    tags:
+      - { name: event_subscriber }
+    arguments: ['@big_pipe']
+  placeholder_strategy.big_pipe:
+    class: Drupal\big_pipe\Render\Placeholder\BigPipeStrategy
+    arguments: ['@session_configuration', '@request_stack', '@current_route_match']
+    tags:
+      - { name: placeholder_strategy, priority: 0 }
+  big_pipe:
+    class: Drupal\big_pipe\Render\BigPipe
+    arguments: ['@renderer', '@session', '@request_stack', '@http_kernel', '@event_dispatcher']
+  html_response.attachments_processor.big_pipe:
+    public: false
+    class: \Drupal\big_pipe\Render\BigPipeResponseAttachmentsProcessor
+    decorates: html_response.attachments_processor
+    decoration_inner_name: html_response.attachments_processor.original
+    arguments: ['@html_response.attachments_processor.original', '@asset.resolver', '@config.factory', '@asset.css.collection_renderer', '@asset.js.collection_renderer', '@request_stack', '@renderer', '@module_handler']
+
+  route_subscriber.no_big_pipe:
+    class: Drupal\big_pipe\EventSubscriber\NoBigPipeRouteAlterSubscriber
+    tags:
+      - { name: event_subscriber }
diff --git a/core/modules/big_pipe/js/big_pipe.js b/core/modules/big_pipe/js/big_pipe.js
new file mode 100644
index 0000000000000000000000000000000000000000..2deefec3437d14611bcc2181adfc710399e13a6e
--- /dev/null
+++ b/core/modules/big_pipe/js/big_pipe.js
@@ -0,0 +1,107 @@
+/**
+ * @file
+ * Renders BigPipe placeholders using Drupal's Ajax system.
+ */
+
+(function ($, Drupal, drupalSettings) {
+
+  'use strict';
+
+  /**
+   * Executes Ajax commands in <script type="application/json"> tag.
+   *
+   * These Ajax commands replace placeholders with HTML and load missing CSS/JS.
+   *
+   * @param {number} index
+   *   Current index.
+   * @param {HTMLScriptElement} placeholderReplacement
+   *   Script tag created by BigPipe.
+   */
+  function bigPipeProcessPlaceholderReplacement(index, placeholderReplacement) {
+    var placeholderId = placeholderReplacement.getAttribute('data-big-pipe-replacement-for-placeholder-with-id');
+    var content = this.textContent.trim();
+    // Ignore any placeholders that are not in the known placeholder list. Used
+    // to avoid someone trying to XSS the site via the placeholdering mechanism.
+    if (typeof drupalSettings.bigPipePlaceholderIds[placeholderId] !== 'undefined') {
+      // If we try to parse the content too early (when the JSON containing Ajax
+      // commands is still arriving), textContent will be empty which will cause
+      // JSON.parse() to fail. Remove once so that it can be processed again
+      // later.
+      // @see bigPipeProcessDocument()
+      if (content === '') {
+        $(this).removeOnce('big-pipe');
+      }
+      else {
+        var response = JSON.parse(content);
+        // Create a Drupal.Ajax object without associating an element, a
+        // progress indicator or a URL.
+        var ajaxObject = Drupal.ajax({
+          url: '',
+          base: false,
+          element: false,
+          progress: false
+        });
+        // Then, simulate an AJAX response having arrived, and let the Ajax
+        // system handle it.
+        ajaxObject.success(response, 'success');
+      }
+    }
+  }
+
+  /**
+   * Processes a streamed HTML document receiving placeholder replacements.
+   *
+   * @param {HTMLDocument} context
+   *   The HTML document containing <script type="application/json"> tags
+   *   generated by BigPipe.
+   *
+   * @return {bool}
+   *   Returns true when processing has been finished and a stop signal has been
+   *   found.
+   */
+  function bigPipeProcessDocument(context) {
+    // Make sure we have BigPipe-related scripts before processing further.
+    if (!context.querySelector('script[data-big-pipe-event="start"]')) {
+      return false;
+    }
+
+    $(context).find('script[data-big-pipe-replacement-for-placeholder-with-id]')
+      .once('big-pipe')
+      .each(bigPipeProcessPlaceholderReplacement);
+
+    // If we see the stop signal, clear the timeout: all placeholder
+    // replacements are guaranteed to be received and processed.
+    if (context.querySelector('script[data-big-pipe-event="stop"]')) {
+      if (timeoutID) {
+        clearTimeout(timeoutID);
+      }
+      return true;
+    }
+
+    return false;
+  }
+
+  function bigPipeProcess() {
+    timeoutID = setTimeout(function () {
+      if (!bigPipeProcessDocument(document)) {
+        bigPipeProcess();
+      }
+    }, interval);
+  }
+
+  var interval = 200;
+  // The internal ID to contain the watcher service.
+  var timeoutID;
+
+  bigPipeProcess();
+
+  // If something goes wrong, make sure everything is cleaned up and has had a
+  // chance to be processed with everything loaded.
+  $(window).on('load', function () {
+    if (timeoutID) {
+      clearTimeout(timeoutID);
+    }
+    bigPipeProcessDocument(document);
+  });
+
+})(jQuery, Drupal, drupalSettings);
diff --git a/core/modules/big_pipe/src/Controller/BigPipeController.php b/core/modules/big_pipe/src/Controller/BigPipeController.php
new file mode 100644
index 0000000000000000000000000000000000000000..282a634e623addaecef3cd0dc99a09566d650e8b
--- /dev/null
+++ b/core/modules/big_pipe/src/Controller/BigPipeController.php
@@ -0,0 +1,64 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\big_pipe\Controller\BigPipeController.
+ */
+
+namespace Drupal\big_pipe\Controller;
+
+use Drupal\big_pipe\Render\Placeholder\BigPipeStrategy;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Routing\LocalRedirectResponse;
+use Symfony\Component\HttpFoundation\Cookie;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+
+/**
+ * Returns responses for BigPipe module routes.
+ */
+class BigPipeController {
+
+  /**
+   * Sets a BigPipe no-JS cookie, redirects back to the original location.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The current request.
+   *
+   * @return \Drupal\Core\Routing\LocalRedirectResponse
+   *   A response that sets the no-JS cookie and redirects back to the original
+   *   location.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
+   *   Thrown when the no-JS cookie is already set or when there is no session.
+   * @throws \Symfony\Component\HttpKernel\Exception\HttpException
+   *   Thrown when the original location is missing, i.e. when no 'destination'
+   *   query argument is set.
+   *
+   * @see \Drupal\big_pipe\Render\Placeholder\BigPipeStrategy
+   */
+  public function setNoJsCookie(Request $request) {
+    // This controller may only be accessed when the browser does not support
+    // JavaScript. It is accessed automatically when that's the case thanks to
+    // big_pipe_page_attachments(). When this controller is executed, deny
+    // access when either:
+    // - the no-JS cookie is already set: this indicates a redirect loop, since
+    //   the cookie was already set, yet the user is executing this controller;
+    // - there is no session, in which case BigPipe is not enabled anyway, so it
+    //   is pointless to set this cookie.
+    if ($request->cookies->has(BigPipeStrategy::NOJS_COOKIE) || $request->getSession() === NULL) {
+      throw new AccessDeniedHttpException();
+    }
+
+    if (!$request->query->has('destination')) {
+      throw new HttpException(400, 'The original location is missing.');
+    }
+
+    $response = new LocalRedirectResponse($request->query->get('destination'));
+    $response->headers->setCookie(new Cookie(BigPipeStrategy::NOJS_COOKIE, TRUE));
+    $response->addCacheableDependency((new CacheableMetadata())->addCacheContexts(['cookies:' . BigPipeStrategy::NOJS_COOKIE, 'session.exists']));
+    return $response;
+  }
+
+}
diff --git a/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php b/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php
new file mode 100644
index 0000000000000000000000000000000000000000..31d1278d74c238eb5d43f8da81e28eb17859a00e
--- /dev/null
+++ b/core/modules/big_pipe/src/EventSubscriber/HtmlResponseBigPipeSubscriber.php
@@ -0,0 +1,161 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\big_pipe\EventSubscriber\HtmlResponseBigPipeSubscriber.
+ */
+
+namespace Drupal\big_pipe\EventSubscriber;
+
+use Drupal\Core\Render\HtmlResponse;
+use Drupal\big_pipe\Render\BigPipeInterface;
+use Drupal\big_pipe\Render\BigPipeResponse;
+use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
+use Symfony\Component\HttpKernel\KernelEvents;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Response subscriber to replace the HtmlResponse with a BigPipeResponse.
+ *
+ * @see \Drupal\big_pipe\Render\BigPipeInterface
+ *
+ * @todo Refactor once https://www.drupal.org/node/2577631 lands.
+ */
+class HtmlResponseBigPipeSubscriber implements EventSubscriberInterface {
+
+  /**
+   * Attribute name of the BigPipe response eligibility test result.
+   *
+   * @see onRespondEarly()
+   * @see onRespond()
+   */
+  const ATTRIBUTE_ELIGIBLE = '_big_pipe_eligible';
+
+  /**
+   * The BigPipe service.
+   *
+   * @var \Drupal\big_pipe\Render\BigPipeInterface
+   */
+  protected $bigPipe;
+
+  /**
+   * Constructs a HtmlResponseBigPipeSubscriber object.
+   *
+   * @param \Drupal\big_pipe\Render\BigPipeInterface $big_pipe
+   *   The BigPipe service.
+   */
+  public function __construct(BigPipeInterface $big_pipe) {
+    $this->bigPipe = $big_pipe;
+  }
+
+  /**
+   * Adds markers to the response necessary for the BigPipe render strategy.
+   *
+   * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event
+   *   The event to process.
+   */
+  public function onRespondEarly(FilterResponseEvent $event) {
+    // It does not make sense to have BigPipe responses for subrequests. BigPipe
+    // is never useful internally in Drupal, only externally towards end users.
+    $response = $event->getResponse();
+    $is_eligible = $event->isMasterRequest() && $response instanceof HtmlResponse;
+    $event->getRequest()->attributes->set(self::ATTRIBUTE_ELIGIBLE, $is_eligible);
+    if (!$is_eligible) {
+      return;
+    }
+
+    // Wrap the scripts_bottom placeholder with a marker before and after,
+    // because \Drupal\big_pipe\Render\BigPipe needs to be able to extract that
+    // markup if there are no-JS BigPipe placeholders.
+    // @see \Drupal\big_pipe\Render\BigPipe::sendPreBody()
+    $attachments = $response->getAttachments();
+    if (isset($attachments['html_response_attachment_placeholders']['scripts_bottom'])) {
+      $scripts_bottom_placeholder = $attachments['html_response_attachment_placeholders']['scripts_bottom'];
+      $content = $response->getContent();
+      $content = str_replace($scripts_bottom_placeholder, '<drupal-big-pipe-scripts-bottom-marker>' . $scripts_bottom_placeholder . '<drupal-big-pipe-scripts-bottom-marker>', $content);
+      $response->setContent($content);
+    }
+  }
+
+  /**
+   * Transforms a HtmlResponse to a BigPipeResponse.
+   *
+   * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event
+   *   The event to process.
+   */
+  public function onRespond(FilterResponseEvent $event) {
+    // Early return if this response was already found to not be eligible.
+    // @see onRespondEarly()
+    if (!$event->getRequest()->attributes->get(self::ATTRIBUTE_ELIGIBLE)) {
+      return;
+    }
+
+    $response = $event->getResponse();
+    $attachments = $response->getAttachments();
+
+    // If there are no no-JS BigPipe placeholders, unwrap the scripts_bottom
+    // markup.
+    // @see onRespondEarly()
+    // @see \Drupal\big_pipe\Render\BigPipe::sendPreBody()
+    if (empty($attachments['big_pipe_nojs_placeholders'])) {
+      $content = $response->getContent();
+      $content = str_replace('<drupal-big-pipe-scripts-bottom-marker>', '', $content);
+      $response->setContent($content);
+    }
+
+    // If there are neither BigPipe placeholders nor no-JS BigPipe placeholders,
+    // there isn't anything dynamic in this response, and we can return early:
+    // there is no point in sending this response using BigPipe.
+    if (empty($attachments['big_pipe_placeholders']) && empty($attachments['big_pipe_nojs_placeholders'])) {
+      return;
+    }
+
+    $big_pipe_response = new BigPipeResponse();
+    $big_pipe_response->setBigPipeService($this->bigPipe);
+
+    // Clone the HtmlResponse's data into the new BigPipeResponse.
+    $big_pipe_response->headers = clone $response->headers;
+    $big_pipe_response
+      ->setStatusCode($response->getStatusCode())
+      ->setContent($response->getContent())
+      ->setAttachments($attachments)
+      ->addCacheableDependency($response->getCacheableMetadata());
+
+    // A BigPipe response can never be cached, because it is intended for a
+    // single user.
+    // @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.1
+    $big_pipe_response->setPrivate();
+
+    // Inform surrogates how they should handle BigPipe responses:
+    // - "no-store" specifies that the response should not be stored in cache;
+    //   it is only to be used for the original request
+    // - "content" identifies what processing surrogates should perform on the
+    //   response before forwarding it. We send, "BigPipe/1.0", which surrogates
+    //   should not process at all, and in fact, they should not even buffer it
+    //   at all.
+    // @see http://www.w3.org/TR/edge-arch/
+    $big_pipe_response->headers->set('Surrogate-Control', 'no-store, content="BigPipe/1.0"');
+
+    // Add header to support streaming on NGINX + php-fpm (nginx >= 1.5.6).
+    $big_pipe_response->headers->set('X-Accel-Buffering', 'no');
+
+    $event->setResponse($big_pipe_response);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    // Run after HtmlResponsePlaceholderStrategySubscriber (priority 5), i.e.
+    // after BigPipeStrategy has been applied, but before normal (priority 0)
+    // response subscribers have been applied, because by then it'll be too late
+    // to transform it into a BigPipeResponse.
+    $events[KernelEvents::RESPONSE][] = ['onRespondEarly', 3];
+
+    // Run as the last possible subscriber.
+    $events[KernelEvents::RESPONSE][] = ['onRespond', -10000];
+
+    return $events;
+  }
+
+}
diff --git a/core/modules/big_pipe/src/EventSubscriber/NoBigPipeRouteAlterSubscriber.php b/core/modules/big_pipe/src/EventSubscriber/NoBigPipeRouteAlterSubscriber.php
new file mode 100644
index 0000000000000000000000000000000000000000..973c2c53fba17c69120a0f6ec2919a8d5f48e9bf
--- /dev/null
+++ b/core/modules/big_pipe/src/EventSubscriber/NoBigPipeRouteAlterSubscriber.php
@@ -0,0 +1,53 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\big_pipe\EventSubscriber\NoBigPipeRouteAlterSubscriber.
+ */
+
+namespace Drupal\big_pipe\EventSubscriber;
+
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Drupal\Core\Routing\RoutingEvents;
+use Drupal\Core\Routing\RouteBuildEvent;
+
+/**
+ * Sets the '_no_big_pipe' option on select routes.
+ */
+class NoBigPipeRouteAlterSubscriber implements EventSubscriberInterface {
+
+  /**
+   * Alters select routes to have the '_no_big_pipe' option.
+   *
+   * @param \Drupal\Core\Routing\RouteBuildEvent $event
+   *   The event to process.
+   */
+  public function onRoutingRouteAlterSetNoBigPipe(RouteBuildEvent $event) {
+    $no_big_pipe_routes = [
+      // The batch system uses a <meta> refresh to work without JavaScript.
+      'system.batch_page.html',
+      // When a user would install the BigPipe module using a browser and with
+      // JavaScript disabled, the first response contains the status messages
+      // for installing a module, but then the BigPipe no-JS redirect occurs,
+      // which then causes the user to not see those status messages.
+      // @see https://www.drupal.org/node/2469431#comment-10901944
+      'system.modules_list',
+    ];
+
+    $route_collection = $event->getRouteCollection();
+    foreach ($no_big_pipe_routes as $excluded_route) {
+      if ($route = $route_collection->get($excluded_route)) {
+        $route->setOption('_no_big_pipe', TRUE);
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  static function getSubscribedEvents() {
+    $events[RoutingEvents::ALTER][] = ['onRoutingRouteAlterSetNoBigPipe'];
+    return $events;
+  }
+
+}
diff --git a/core/modules/big_pipe/src/Render/BigPipe.php b/core/modules/big_pipe/src/Render/BigPipe.php
new file mode 100644
index 0000000000000000000000000000000000000000..cb30d2a2dde05595a1885a25ec4d4fb9cbc372fd
--- /dev/null
+++ b/core/modules/big_pipe/src/Render/BigPipe.php
@@ -0,0 +1,446 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\big_pipe\Render\BigPipe.
+ */
+
+namespace Drupal\big_pipe\Render;
+
+use Drupal\Component\Utility\Crypt;
+use Drupal\Component\Utility\Html;
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Ajax\ReplaceCommand;
+use Drupal\Core\Asset\AttachedAssets;
+use Drupal\Core\Asset\AttachedAssetsInterface;
+use Drupal\Core\Render\HtmlResponse;
+use Drupal\Core\Render\RendererInterface;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpFoundation\Session\SessionInterface;
+use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
+use Symfony\Component\HttpKernel\HttpKernelInterface;
+use Symfony\Component\HttpKernel\KernelEvents;
+
+/**
+ * The default BigPipe service.
+ */
+class BigPipe implements BigPipeInterface {
+
+  /**
+   * The renderer.
+   *
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
+  /**
+   * The session.
+   *
+   * @var \Symfony\Component\HttpFoundation\Session\SessionInterface
+   */
+  protected $session;
+
+  /**
+   * The request stack.
+   *
+   * @var \Symfony\Component\HttpFoundation\RequestStack
+   */
+  protected $requestStack;
+
+  /**
+   * The HTTP kernel.
+   *
+   * @var \Symfony\Component\HttpKernel\HttpKernelInterface
+   */
+  protected $httpKernel;
+
+  /**
+   * The event dispatcher.
+   *
+   * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
+   */
+  protected $eventDispatcher;
+
+  /**
+   * Constructs a new BigPipe class.
+   *
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer.
+   * @param \Symfony\Component\HttpFoundation\Session\SessionInterface $session
+   *   The session.
+   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
+   *   The request stack.
+   * @param \Symfony\Component\HttpKernel\HttpKernelInterface $http_kernel
+   *   The HTTP kernel.
+   * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher
+   *   The event dispatcher.
+   */
+  public function __construct(RendererInterface $renderer, SessionInterface $session, RequestStack $request_stack, HttpKernelInterface $http_kernel, EventDispatcherInterface $event_dispatcher) {
+    $this->renderer = $renderer;
+    $this->session = $session;
+    $this->requestStack = $request_stack;
+    $this->httpKernel = $http_kernel;
+    $this->eventDispatcher = $event_dispatcher;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function sendContent($content, array $attachments) {
+    // First, gather the BigPipe placeholders that must be replaced.
+    $placeholders = isset($attachments['big_pipe_placeholders']) ? $attachments['big_pipe_placeholders'] : [];
+    $nojs_placeholders = isset($attachments['big_pipe_nojs_placeholders']) ? $attachments['big_pipe_nojs_placeholders'] : [];
+
+    // BigPipe sends responses using "Transfer-Encoding: chunked". To avoid
+    // sending already-sent assets, it is necessary to track cumulative assets
+    // from all previously rendered/sent chunks.
+    // @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.41
+    $cumulative_assets = AttachedAssets::createFromRenderArray(['#attached' => $attachments]);
+    $cumulative_assets->setAlreadyLoadedLibraries($attachments['library']);
+
+    // The content in the placeholders may depend on the session, and by the
+    // time the response is sent (see index.php), the session is already closed.
+    // Reopen it for the duration that we are rendering placeholders.
+    $this->session->start();
+
+    list($pre_body, $post_body) = explode('</body>', $content, 2);
+    $this->sendPreBody($pre_body, $nojs_placeholders, $cumulative_assets);
+    $this->sendPlaceholders($placeholders, $this->getPlaceholderOrder($pre_body), $cumulative_assets);
+    $this->sendPostBody($post_body);
+
+    // Close the session again.
+    $this->session->save();
+
+    return $this;
+  }
+
+  /**
+   * Sends everything until just before </body>.
+   *
+   * @param string $pre_body
+   *   The HTML response's content until the closing </body> tag.
+   * @param array $no_js_placeholders
+   *   The no-JS BigPipe placeholders.
+   * @param \Drupal\Core\Asset\AttachedAssetsInterface $cumulative_assets
+   *   The cumulative assets sent so far; to be updated while rendering no-JS
+   *   BigPipe placeholders.
+   */
+  protected function sendPreBody($pre_body, array $no_js_placeholders, AttachedAssetsInterface $cumulative_assets) {
+    // If there are no no-JS BigPipe placeholders, we can send the pre-</body>
+    // part of the page immediately.
+    if (empty($no_js_placeholders)) {
+      print $pre_body;
+      flush();
+      return;
+    }
+
+    // Extract the scripts_bottom markup: the no-JS BigPipe placeholders that we
+    // will render may attach additional asset libraries, and if so, it will be
+    // necessary to re-render scripts_bottom.
+    list($pre_scripts_bottom, $scripts_bottom, $post_scripts_bottom) = explode('<drupal-big-pipe-scripts-bottom-marker>', $pre_body, 3);
+    $cumulative_assets_initial = clone $cumulative_assets;
+
+    $this->sendNoJsPlaceholders($pre_scripts_bottom . $post_scripts_bottom, $no_js_placeholders, $cumulative_assets);
+
+    // If additional asset libraries or drupalSettings were attached by any of
+    // the placeholders, then we need to re-render scripts_bottom.
+    if ($cumulative_assets_initial != $cumulative_assets) {
+      // Create a new HtmlResponse. Ensure the CSS and (non-bottom) JS is sent
+      // before the HTML they're associated with.
+      // @see \Drupal\Core\Render\HtmlResponseSubscriber
+      // @see template_preprocess_html()
+      $js_bottom_placeholder = '<nojs-bigpipe-placeholder-scripts-bottom-placeholder token="' . Crypt::randomBytesBase64(55) . '">';
+
+      $html_response = new HtmlResponse();
+      $html_response->setContent([
+        '#markup' => BigPipeMarkup::create($js_bottom_placeholder),
+        '#attached' => [
+          'drupalSettings' => $cumulative_assets->getSettings(),
+          'library' => $cumulative_assets->getAlreadyLoadedLibraries(),
+          'html_response_attachment_placeholders' => [
+            'scripts_bottom' => $js_bottom_placeholder,
+          ],
+        ],
+      ]);
+      $html_response->getCacheableMetadata()->setCacheMaxAge(0);
+
+      // Push a fake request with the asset libraries loaded so far and dispatch
+      // KernelEvents::RESPONSE event. This results in the attachments for the
+      // HTML response being processed by HtmlResponseAttachmentsProcessor and
+      // hence the HTML to load the bottom JavaScript can be rendered.
+      $fake_request = $this->requestStack->getMasterRequest()->duplicate();
+      $html_response = $this->filterEmbeddedResponse($fake_request, $html_response);
+      $scripts_bottom = $html_response->getContent();
+    }
+
+    print $scripts_bottom;
+    flush();
+  }
+
+  /**
+   * Sends no-JS BigPipe placeholders' replacements as embedded HTML responses.
+   *
+   * @param string $html
+   *   HTML markup.
+   * @param array $no_js_placeholders
+   *   Associative array; the no-JS BigPipe placeholders. Keys are the BigPipe
+   *   selectors.
+   * @param \Drupal\Core\Asset\AttachedAssetsInterface $cumulative_assets
+   *   The cumulative assets sent so far; to be updated while rendering no-JS
+   *   BigPipe placeholders.
+   */
+  protected function sendNoJsPlaceholders($html, $no_js_placeholders, AttachedAssetsInterface $cumulative_assets) {
+    // Split the HTML on every no-JS placeholder string.
+    $prepare_for_preg_split = function ($placeholder_string) {
+      return '(' . preg_quote($placeholder_string, '/') . ')';
+    };
+    $preg_placeholder_strings = array_map($prepare_for_preg_split, array_keys($no_js_placeholders));
+    $fragments = preg_split('/' . implode('|', $preg_placeholder_strings) . '/', $html, NULL, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
+
+    foreach ($fragments as $fragment) {
+      // If the fragment isn't one of the no-JS placeholders, it is the HTML in
+      // between placeholders and it must be printed & flushed immediately. The
+      // rest of the logic in the loop handles the placeholders.
+      if (!isset($no_js_placeholders[$fragment])) {
+        print $fragment;
+        flush();
+        continue;
+      }
+
+      $placeholder = $fragment;
+      assert('isset($no_js_placeholders[$placeholder])');
+      $token = Crypt::randomBytesBase64(55);
+
+      // Render the placeholder, but include the cumulative settings assets, so
+      // we can calculate the overall settings for the entire page.
+      $placeholder_plus_cumulative_settings = [
+        'placeholder' => $no_js_placeholders[$placeholder],
+        'cumulative_settings_' . $token => [
+          '#attached' => [
+            'drupalSettings' => $cumulative_assets->getSettings(),
+          ],
+        ],
+      ];
+      $elements = $this->renderPlaceholder($placeholder, $placeholder_plus_cumulative_settings);
+
+      // Create a new HtmlResponse. Ensure the CSS and (non-bottom) JS is sent
+      // before the HTML they're associated with. In other words: ensure the
+      // critical assets for this placeholder's markup are loaded first.
+      // @see \Drupal\Core\Render\HtmlResponseSubscriber
+      // @see template_preprocess_html()
+      $css_placeholder = '<nojs-bigpipe-placeholder-styles-placeholder token="' . $token . '">';
+      $js_placeholder = '<nojs-bigpipe-placeholder-scripts-placeholder token="' . $token . '">';
+      $elements['#markup'] = BigPipeMarkup::create($css_placeholder . $js_placeholder . (string) $elements['#markup']);
+      $elements['#attached']['html_response_attachment_placeholders']['styles'] = $css_placeholder;
+      $elements['#attached']['html_response_attachment_placeholders']['scripts'] = $js_placeholder;
+
+      $html_response = new HtmlResponse();
+      $html_response->setContent($elements);
+      $html_response->getCacheableMetadata()->setCacheMaxAge(0);
+
+      // Push a fake request with the asset libraries loaded so far and dispatch
+      // KernelEvents::RESPONSE event. This results in the attachments for the
+      // HTML response being processed by HtmlResponseAttachmentsProcessor and
+      // hence:
+      // - the HTML to load the CSS can be rendered.
+      // - the HTML to load the JS (at the top) can be rendered.
+      $fake_request = $this->requestStack->getMasterRequest()->duplicate();
+      $fake_request->request->set('ajax_page_state', ['libraries' => implode(',', $cumulative_assets->getAlreadyLoadedLibraries())]);
+      $html_response = $this->filterEmbeddedResponse($fake_request, $html_response);
+
+      // Send this embedded HTML response.
+      print $html_response->getContent();
+      flush();
+
+      // Another placeholder was rendered and sent, track the set of asset
+      // libraries sent so far. Any new settings also need to be tracked, so
+      // they can be sent in ::sendPreBody().
+      $cumulative_assets->setAlreadyLoadedLibraries(array_merge($cumulative_assets->getAlreadyLoadedLibraries(), $html_response->getAttachments()['library']));
+      $cumulative_assets->setSettings($html_response->getAttachments()['drupalSettings']);
+    }
+  }
+
+  /**
+   * Sends BigPipe placeholders' replacements as embedded AJAX responses.
+   *
+   * @param array $placeholders
+   *   Associative array; the BigPipe placeholders. Keys are the BigPipe
+   *   placeholder IDs.
+   * @param array $placeholder_order
+   *   Indexed array; the order in which the BigPipe placeholders must be sent.
+   *   Values are the BigPipe placeholder IDs. (These values correspond to keys
+   *   in $placeholders.)
+   * @param \Drupal\Core\Asset\AttachedAssetsInterface $cumulative_assets
+   *   The cumulative assets sent so far; to be updated while rendering BigPipe
+   *   placeholders.
+   */
+  protected function sendPlaceholders(array $placeholders, array $placeholder_order, AttachedAssetsInterface $cumulative_assets) {
+    // Return early if there are no BigPipe placeholders to send.
+    if (empty($placeholders)) {
+      return;
+    }
+
+    // Send the start signal.
+    print "\n";
+    print '<script type="application/json" data-big-pipe-event="start"></script>' . "\n";
+    flush();
+
+    // A BigPipe response consists of a HTML response plus multiple embedded
+    // AJAX responses. To process the attachments of those AJAX responses, we
+    // need a fake request that is identical to the master request, but with
+    // one change: it must have the right Accept header, otherwise the work-
+    // around for a bug in IE9 will cause not JSON, but <textarea>-wrapped JSON
+    // to be returned.
+    // @see \Drupal\Core\EventSubscriber\AjaxResponseSubscriber::onResponse()
+    $fake_request = $this->requestStack->getMasterRequest()->duplicate();
+    $fake_request->headers->set('Accept', 'application/json');
+
+    foreach ($placeholder_order as $placeholder_id) {
+      if (!isset($placeholders[$placeholder_id])) {
+        continue;
+      }
+
+      // Render the placeholder.
+      $placeholder_render_array = $placeholders[$placeholder_id];
+      $elements = $this->renderPlaceholder($placeholder_id, $placeholder_render_array);
+
+      // Create a new AjaxResponse.
+      $ajax_response = new AjaxResponse();
+      // JavaScript's querySelector automatically decodes HTML entities in
+      // attributes, so we must decode the entities of the current BigPipe
+      // placeholder ID (which has HTML entities encoded since we use it to find
+      // the placeholders).
+      $big_pipe_js_placeholder_id = Html::decodeEntities($placeholder_id);
+      $ajax_response->addCommand(new ReplaceCommand(sprintf('[data-big-pipe-placeholder-id="%s"]', $big_pipe_js_placeholder_id), $elements['#markup']));
+      $ajax_response->setAttachments($elements['#attached']);
+
+      // Push a fake request with the asset libraries loaded so far and dispatch
+      // KernelEvents::RESPONSE event. This results in the attachments for the
+      // AJAX response being processed by AjaxResponseAttachmentsProcessor and
+      // hence:
+      // - the necessary AJAX commands to load the necessary missing asset
+      //   libraries and updated AJAX page state are added to the AJAX response
+      // - the attachments associated with the response are finalized, which
+      //   allows us to track the total set of asset libraries sent in the
+      //   initial HTML response plus all embedded AJAX responses sent so far.
+      $fake_request->request->set('ajax_page_state', ['libraries' => implode(',', $cumulative_assets->getAlreadyLoadedLibraries())] + $cumulative_assets->getSettings()['ajaxPageState']);
+      $ajax_response = $this->filterEmbeddedResponse($fake_request, $ajax_response);
+
+      // Send this embedded AJAX response.
+      $json = $ajax_response->getContent();
+      $output = <<<EOF
+    <script type="application/json" data-big-pipe-replacement-for-placeholder-with-id="$placeholder_id">
+    $json
+    </script>
+EOF;
+      print $output;
+      flush();
+
+      // Another placeholder was rendered and sent, track the set of asset
+      // libraries sent so far. Any new settings are already sent; we don't need
+      // to track those.
+      if (isset($ajax_response->getAttachments()['drupalSettings']['ajaxPageState']['libraries'])) {
+        $cumulative_assets->setAlreadyLoadedLibraries(explode(',', $ajax_response->getAttachments()['drupalSettings']['ajaxPageState']['libraries']));
+      }
+    }
+
+    // Send the stop signal.
+    print '<script type="application/json" data-big-pipe-event="stop"></script>' . "\n";
+    flush();
+  }
+
+  /**
+   * Filters the given embedded response, using the cumulative AJAX page state.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $fake_request
+   *   A fake subrequest that contains the cumulative AJAX page state of the
+   *   HTML document and all preceding Embedded HTML or AJAX responses.
+   * @param \Symfony\Component\HttpFoundation\Response|\Drupal\Core\Render\HtmlResponse|\Drupal\Core\Ajax\AjaxResponse $embedded_response
+   *   Either a HTML response or an AJAX response that will be embedded in the
+   *   overall HTML response.
+   *
+   * @return \Symfony\Component\HttpFoundation\Response
+   *   The filtered response, which will load only the assets that $fake_request
+   *   did not indicate to already have been loaded, plus the updated cumulative
+   *   AJAX page state.
+   */
+  protected function filterEmbeddedResponse(Request $fake_request, Response $embedded_response) {
+    assert('$embedded_response instanceof \Drupal\Core\Render\HtmlResponse || $embedded_response instanceof \Drupal\Core\Ajax\AjaxResponse');
+    $this->requestStack->push($fake_request);
+    $event = new FilterResponseEvent($this->httpKernel, $fake_request, HttpKernelInterface::SUB_REQUEST, $embedded_response);
+    $this->eventDispatcher->dispatch(KernelEvents::RESPONSE, $event);
+    $filtered_response = $event->getResponse();
+    $this->requestStack->pop();
+    return $filtered_response;
+  }
+
+  /**
+   * Sends </body> and everything after it.
+   *
+   * @param string $post_body
+   *   The HTML response's content after the closing </body> tag.
+   */
+  protected function sendPostBody($post_body) {
+    print '</body>';
+    print $post_body;
+    flush();
+  }
+
+  /**
+   * Renders a placeholder, and just that placeholder.
+   *
+   * BigPipe renders placeholders independently of the rest of the content, so
+   * it needs to be able to render placeholders by themselves.
+   *
+   * @param string $placeholder
+   *   The placeholder to render.
+   * @param array $placeholder_render_array
+   *   The render array associated with that placeholder.
+   *
+   * @return array
+   *   The render array representing the rendered placeholder.
+   *
+   * @see \Drupal\Core\Render\RendererInterface::renderPlaceholder()
+   */
+  protected function renderPlaceholder($placeholder, array $placeholder_render_array) {
+    $elements = [
+      '#markup' => $placeholder,
+      '#attached' => [
+        'placeholders' => [
+          $placeholder => $placeholder_render_array,
+        ],
+      ],
+    ];
+    return $this->renderer->renderPlaceholder($placeholder, $elements);
+  }
+
+  /**
+   * Gets the BigPipe placeholder order.
+   *
+   * Determines the order in which BigPipe placeholders must be replaced.
+   *
+   * @param string $html
+   *   HTML markup.
+   *
+   * @return array
+   *   Indexed array; the order in which the BigPipe placeholders must be sent.
+   *   Values are the BigPipe placeholder IDs.
+   */
+  protected function getPlaceholderOrder($html) {
+    $fragments = explode('<div data-big-pipe-placeholder-id="', $html);
+    array_shift($fragments);
+    $order = [];
+
+    foreach ($fragments as $fragment) {
+      $t = explode('"></div>', $fragment, 2);
+      $placeholder = $t[0];
+      $order[] = $placeholder;
+    }
+
+    return $order;
+  }
+
+}
diff --git a/core/modules/big_pipe/src/Render/BigPipeInterface.php b/core/modules/big_pipe/src/Render/BigPipeInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..55a5a3d2e71963c6ef650410031a7904961fa186
--- /dev/null
+++ b/core/modules/big_pipe/src/Render/BigPipeInterface.php
@@ -0,0 +1,149 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\big_pipe\Render\BigPipeInterface.
+ */
+
+namespace Drupal\big_pipe\Render;
+
+/**
+ * Interface for sending an HTML response in chunks (to get faster page loads).
+ *
+ * At a high level, BigPipe sends a HTML response in chunks:
+ * 1. one chunk: everything until just before </body> — this contains BigPipe
+ *    placeholders for the personalized parts of the page. Hence this sends the
+ *    non-personalized parts of the page. Let's call it The Skeleton.
+ * 2. N chunks: a <script> tag per BigPipe placeholder in The Skeleton.
+ * 3. one chunk: </body> and everything after it.
+ *
+ * This is conceptually identical to Facebook's BigPipe (hence the name).
+ *
+ * @see https://www.facebook.com/notes/facebook-engineering/bigpipe-pipelining-web-pages-for-high-performance/389414033919
+ *
+ * The major way in which Drupal differs from Facebook's implementation (and
+ * others) is in its ability to automatically figure out which parts of the page
+ * can benefit from BigPipe-style delivery. Drupal's render system has the
+ * concept of "auto-placeholdering": content that is too dynamic is replaced
+ * with a placeholder that can then be rendered at a later time. On top of that,
+ * it also has the concept of "placeholder strategies": by default, placeholders
+ * are replaced on the server side and the response is blocked on all of them
+ * being replaced. But it's possible to add additional placeholder strategies.
+ * BigPipe is just another placeholder strategy. Others could be ESI, AJAX …
+ *
+ * @see https://www.drupal.org/developing/api/8/render/arrays/cacheability/auto-placeholdering
+ * @see \Drupal\Core\Render\PlaceholderGeneratorInterface::shouldAutomaticallyPlaceholder()
+ * @see \Drupal\Core\Render\Placeholder\PlaceholderStrategyInterface
+ * @see \Drupal\Core\Render\Placeholder\SingleFlushStrategy
+ * @see \Drupal\big_pipe\Render\Placeholder\BigPipeStrategy
+ *
+ * There is also one noteworthy technical addition that Drupal makes. BigPipe as
+ * described above, and as implemented by Facebook, can only work if JavaScript
+ * is enabled. The BigPipe module also makes it possible to replace placeholders
+ * using BigPipe in-situ, without JavaScript. This is not technically BigPipe at
+ * all; it's just the use of multiple flushes. Since it is able to reuse much of
+ * the logic though, we choose to call this "no-JS BigPipe".
+ *
+ * However, there is also a tangible benefit: some dynamic/expensive content is
+ * not HTML, but for example a HTML attribute value (or part thereof). It's not
+ * possible to efficiently replace such content using JavaScript, so "classic"
+ * BigPipe is out of the question. For example: CSRF tokens in URLs.
+ *
+ * This allows us to use both no-JS BigPipe and "classic" BigPipe in the same
+ * response to maximize the amount of content we can send as early as possible.
+ *
+ * Finally, a closer look at the implementation, and how it supports and reuses
+ * existing Drupal concepts:
+ * 1. BigPipe placeholders: 1 HtmlResponse + N embedded AjaxResponses.
+ *   - Before a BigPipe response is sent, it is just a HTML response that
+ *     contains BigPipe placeholders. Those placeholders look like
+ *     <div data-big-pipe-placeholder-id="…"></div>. JavaScript is used to
+ *     replace those placeholders.
+ *     Therefore these placeholders are actually sent to the client.
+ *   - The Skeleton of course has attachments, including most notably asset
+ *     libraries. And those we track in drupalSettings.ajaxPageState.libraries —
+ *     so that when we load new content through AJAX, we don't load the same
+ *     asset libraries again. A HTML page can have multiple AJAX responses, each
+ *     of which should take into account the combined AJAX page state of the
+ *     HTML document and all preceding AJAX responses.
+ *   - BigPipe does not make use of multiple AJAX requests/responses. It uses a
+ *     single HTML response. But it is a more long-lived one: The Skeleton is
+ *     sent first, the closing </body> tag is not yet sent, and the connection
+ *     is kept open. Whenever another BigPipe Placeholder is rendered, Drupal
+ *     sends (and so actually appends to the already-sent HTML) something like
+ *     <script type="application/json">[{"command":"settings","settings":{…}}, {"command":…}.
+ *   - So, for every BigPipe placeholder, we send such a <script
+ *     type="application/json"> tag. And the contents of that tag is exactly
+ *     like an AJAX response. The BigPipe module has JavaScript that listens for
+ *     these and applies them. Let's call it an Embedded AJAX Response (since it
+ *     is embedded in the HTML response). Now for the interesting bit: each of
+ *     those Embedded AJAX Responses must also take into account the cumulative
+ *     AJAX page state of the HTML document and all preceding Embedded AJAX
+ *     responses.
+ * 2. No-JS BigPipe placeholders: 1 HtmlResponse + N embedded HtmlResponses.
+ *   - Before a BigPipe response is sent, it is just a HTML response that
+ *     contains no-JS BigPipe placeholders. Those placeholders can take two
+ *     different forms:
+ *     1. <div data-big-pipe-nojs-placeholder-id="…"></div> if it's a
+ *        placeholder that will be replaced by HTML
+ *     2. big_pipe_nojs_placeholder_attribute_safe:… if it's a placeholder
+ *        inside a HTML attribute, in which 1. would be invalid (angle brackets
+ *        are not allowed inside HTML attributes)
+ *     No-JS BigPipe placeholders are not replaced using JavaScript, they must
+ *     be replaced upon sending the BigPipe response. So, while the response is
+ *     being sent, upon encountering these placeholders, their corresponding
+ *     placeholder replacements are sent instead.
+ *     Therefore these placeholders are never actually sent to the client.
+ *   - See second bullet of point 1.
+ *   - No-JS BigPipe does not use multiple AJAX requests/responses. It uses a
+ *     single HTML response. But it is a more long-lived one: The Skeleton is
+ *     split into multiple parts, the separators are where the no-JS BigPipe
+ *     placeholders used to be. Whenever another no-JS BigPipe placeholder is
+ *     rendered, Drupal sends (and so actually appends to the already-sent HTML)
+ *     something like
+ *     <link rel="stylesheet" …><script …><content>.
+ *   - So, for every no-JS BigPipe placeholder, we send its associated CSS and
+ *     header JS that has not already been sent (the bottom JS is not yet sent,
+ *     so we can accumulate all of it and send it together at the end). This
+ *     ensures that the markup is rendered as it was originally intended: its
+ *     CSS and JS used to be blocking, and it still is. Let's call it an
+ *     Embedded HTML response. Each of those Embedded HTML Responses must also
+ *     take into account the cumulative AJAX page state of the HTML document and
+ *     all preceding Embedded HTML responses.
+ *   - Finally: any non-critical JavaScript associated with all Embedded HTML
+ *     Responses, i.e. any footer/bottom/non-header JavaScript, is loaded after
+ *     The Skeleton.
+ *
+ * Combining all of the above, when using both BigPipe placeholders and no-JS
+ * BigPipe placeholders, we therefore send: 1 HtmlResponse + M Embedded HTML
+ * Responses + N Embedded AJAX Responses. Schematically, we send these chunks:
+ *  1. Byte zero until 1st no-JS placeholder: headers + <html><head /><div>…</div>
+ *  2. 1st no-JS placeholder replacement: <link rel="stylesheet" …><script …><content>
+ *  3. Content until 2nd no-JS placeholder: <div>…</div>
+ *  4. 2nd no-JS placeholder replacement: <link rel="stylesheet" …><script …><content>
+ *  5. Content until 3rd no-JS placeholder: <div>…</div>
+ *  6. [… repeat until all no-JS placeholder replacements are sent …]
+ *  7. Send content after last no-JS placeholder.
+ *  8. Send script_bottom (markup to load bottom i.e. non-critical JS).
+ *  9. 1st placeholder replacement: <script type="application/json">[{"command":"settings","settings":{…}}, {"command":…}
+ * 10. 2nd placeholder replacement: <script type="application/json">[{"command":"settings","settings":{…}}, {"command":…}
+ * 11. [… repeat until all placeholder replacements are sent …]
+ * 12. Send </body> and everything after it.
+ * 13. Terminate request/response cycle.
+ *
+ * @see \Drupal\big_pipe\EventSubscriber\HtmlResponseBigPipeSubscriber
+ * @see \Drupal\big_pipe\Render\Placeholder\BigPipeStrategy
+ */
+interface BigPipeInterface {
+
+  /**
+   * Sends an HTML response in chunks using the BigPipe technique.
+   *
+   * @param string $content
+   *   The HTML response content to send.
+   * @param array $attachments
+   *   The HTML response's attachments.
+   */
+  public function sendContent($content, array $attachments);
+
+}
diff --git a/core/modules/big_pipe/src/Render/BigPipeMarkup.php b/core/modules/big_pipe/src/Render/BigPipeMarkup.php
new file mode 100644
index 0000000000000000000000000000000000000000..d16d9097bf09a3e9b7e34067cff46b0baea2bc0a
--- /dev/null
+++ b/core/modules/big_pipe/src/Render/BigPipeMarkup.php
@@ -0,0 +1,28 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\big_pipe\Render\BigPipeMarkup.
+ */
+
+namespace Drupal\big_pipe\Render;
+
+use Drupal\Component\Render\MarkupInterface;
+use Drupal\Component\Render\MarkupTrait;
+
+/**
+ * Defines an object that passes safe strings through BigPipe's render pipeline.
+ *
+ * This object should only be constructed with a known safe string. If there is
+ * any risk that the string contains user-entered data that has not been
+ * filtered first, it must not be used.
+ *
+ * @internal
+ *   This object is marked as internal because it should only be used in the
+ *   BigPipe render pipeline.
+ *
+ * @see \Drupal\Core\Render\Markup
+ */
+final class BigPipeMarkup implements MarkupInterface, \Countable {
+  use MarkupTrait;
+}
diff --git a/core/modules/big_pipe/src/Render/BigPipeResponse.php b/core/modules/big_pipe/src/Render/BigPipeResponse.php
new file mode 100644
index 0000000000000000000000000000000000000000..235a15423ceb942b842ba02568c28d1b51e97c8f
--- /dev/null
+++ b/core/modules/big_pipe/src/Render/BigPipeResponse.php
@@ -0,0 +1,51 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\big_pipe\Render\BigPipeResponse.
+ */
+
+namespace Drupal\big_pipe\Render;
+
+use Drupal\Core\Render\HtmlResponse;
+
+/**
+ * A response that is sent in chunks by the BigPipe service.
+ *
+ * Note we cannot use \Symfony\Component\HttpFoundation\StreamedResponse because
+ * it makes the content inaccessible (hidden behind a callback), which means no
+ * middlewares are able to modify the content anymore.
+ *
+ * @see \Drupal\big_pipe\Render\BigPipeInterface
+ *
+ * @todo Will become obsolete with https://www.drupal.org/node/2577631
+ */
+class BigPipeResponse extends HtmlResponse {
+
+  /**
+   * The BigPipe service.
+   *
+   * @var \Drupal\big_pipe\Render\BigPipeInterface
+   */
+  protected $bigPipe;
+
+  /**
+   * Sets the BigPipe service to use.
+   *
+   * @param \Drupal\big_pipe\Render\BigPipeInterface $big_pipe
+   *   The BigPipe service.
+   */
+  public function setBigPipeService(BigPipeInterface $big_pipe) {
+    $this->bigPipe = $big_pipe;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function sendContent() {
+    $this->bigPipe->sendContent($this->content, $this->getAttachments());
+
+    return $this;
+  }
+
+}
diff --git a/core/modules/big_pipe/src/Render/BigPipeResponseAttachmentsProcessor.php b/core/modules/big_pipe/src/Render/BigPipeResponseAttachmentsProcessor.php
new file mode 100644
index 0000000000000000000000000000000000000000..37cde3dd23fbd5664a009058beebd6ffd1fe2210
--- /dev/null
+++ b/core/modules/big_pipe/src/Render/BigPipeResponseAttachmentsProcessor.php
@@ -0,0 +1,113 @@
+<?php
+/**
+ * @file
+ * Contains \Drupal\big_pipe\Render\BigPipeResponseAttachmentsProcessor.
+ */
+
+namespace Drupal\big_pipe\Render;
+
+use Drupal\Core\Asset\AssetCollectionRendererInterface;
+use Drupal\Core\Asset\AssetResolverInterface;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Form\EnforcedResponseException;
+use Drupal\Core\Render\AttachmentsInterface;
+use Drupal\Core\Render\AttachmentsResponseProcessorInterface;
+use Drupal\Core\Render\HtmlResponseAttachmentsProcessor;
+use Drupal\Core\Render\RendererInterface;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * Processes attachments of HTML responses with BigPipe enabled.
+ *
+ * @see \Drupal\Core\Render\HtmlResponseAttachmentsProcessor
+ * @see \Drupal\big_pipe\Render\BigPipeInterface
+ */
+class BigPipeResponseAttachmentsProcessor extends HtmlResponseAttachmentsProcessor {
+
+  /**
+   * The HTML response attachments processor service.
+   *
+   * @var \Drupal\Core\Render\AttachmentsResponseProcessorInterface
+   */
+  protected $htmlResponseAttachmentsProcessor;
+
+  /**
+   * Constructs a BigPipeResponseAttachmentsProcessor object.
+   *
+   * @param \Drupal\Core\Render\AttachmentsResponseProcessorInterface $html_response_attachments_processor
+   *   The HTML response attachments processor service.
+   * @param \Drupal\Core\Asset\AssetResolverInterface $asset_resolver
+   *   An asset resolver.
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   A config factory for retrieving required config objects.
+   * @param \Drupal\Core\Asset\AssetCollectionRendererInterface $css_collection_renderer
+   *   The CSS asset collection renderer.
+   * @param \Drupal\Core\Asset\AssetCollectionRendererInterface $js_collection_renderer
+   *   The JS asset collection renderer.
+   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
+   *   The request stack.
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler service.
+   */
+  public function __construct(AttachmentsResponseProcessorInterface $html_response_attachments_processor, AssetResolverInterface $asset_resolver, ConfigFactoryInterface $config_factory, AssetCollectionRendererInterface $css_collection_renderer, AssetCollectionRendererInterface $js_collection_renderer, RequestStack $request_stack, RendererInterface $renderer, ModuleHandlerInterface $module_handler) {
+    $this->htmlResponseAttachmentsProcessor = $html_response_attachments_processor;
+    parent::__construct($asset_resolver, $config_factory, $css_collection_renderer, $js_collection_renderer, $request_stack, $renderer, $module_handler);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function processAttachments(AttachmentsInterface $response) {
+    assert('$response instanceof \Drupal\Core\Render\HtmlResponse');
+
+    // First, render the actual placeholders; this will cause the BigPipe
+    // placeholder strategy to generate BigPipe placeholders. We need those to
+    // exist already so that we can extract BigPipe placeholders. This is hence
+    // a bit of unfortunate but necessary duplication.
+    // @see \Drupal\big_pipe\Render\Placeholder\BigPipeStrategy
+    // (Note this is copied verbatim from
+    // \Drupal\Core\Render\HtmlResponseAttachmentsProcessor::processAttachments)
+    try {
+      $response = $this->renderPlaceholders($response);
+    }
+    catch (EnforcedResponseException $e) {
+      return $e->getResponse();
+    }
+
+    // Extract BigPipe placeholders; HtmlResponseAttachmentsProcessor does not
+    // know (nor need to know) how to process those.
+    $attachments = $response->getAttachments();
+    $big_pipe_placeholders = [];
+    $big_pipe_nojs_placeholders = [];
+    if (isset($attachments['big_pipe_placeholders'])) {
+      $big_pipe_placeholders = $attachments['big_pipe_placeholders'];
+      unset($attachments['big_pipe_placeholders']);
+    }
+    if (isset($attachments['big_pipe_nojs_placeholders'])) {
+      $big_pipe_nojs_placeholders = $attachments['big_pipe_nojs_placeholders'];
+      unset($attachments['big_pipe_nojs_placeholders']);
+    }
+    $html_response = clone $response;
+    $html_response->setAttachments($attachments);
+
+    // Call HtmlResponseAttachmentsProcessor to process all other attachments.
+    $processed_html_response = $this->htmlResponseAttachmentsProcessor->processAttachments($html_response);
+
+    // Restore BigPipe placeholders.
+    $attachments = $processed_html_response->getAttachments();
+    $big_pipe_response = clone $processed_html_response;
+    if (count($big_pipe_placeholders)) {
+      $attachments['big_pipe_placeholders'] = $big_pipe_placeholders;
+    }
+    if (count($big_pipe_nojs_placeholders)) {
+      $attachments['big_pipe_nojs_placeholders'] = $big_pipe_nojs_placeholders;
+    }
+    $big_pipe_response->setAttachments($attachments);
+
+    return $big_pipe_response;
+  }
+
+}
diff --git a/core/modules/big_pipe/src/Render/Placeholder/BigPipeStrategy.php b/core/modules/big_pipe/src/Render/Placeholder/BigPipeStrategy.php
new file mode 100644
index 0000000000000000000000000000000000000000..e24c9adaaf5c2b71b4535a45e1c68e758346b7bd
--- /dev/null
+++ b/core/modules/big_pipe/src/Render/Placeholder/BigPipeStrategy.php
@@ -0,0 +1,273 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\big_pipe\Render\Placeholder\BigPipeStrategy
+ */
+
+namespace Drupal\big_pipe\Render\Placeholder;
+
+use Drupal\Component\Utility\Html;
+use Drupal\Component\Utility\UrlHelper;
+use Drupal\Core\Render\Placeholder\PlaceholderStrategyInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Session\SessionConfigurationInterface;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * Defines the BigPipe placeholder strategy, to send HTML in chunks.
+ *
+ * First: the BigPipe placeholder strategy only activates if the current request
+ * is associated with a session. Without a session, it is assumed this response
+ * is not actually dynamic: if none of the placeholders show session-dependent
+ * information, then none of the placeholders are uncacheable or poorly
+ * cacheable, which means the Page Cache (for anonymous users) can deal with it.
+ * In other words: BigPipe works for all authenticated users and for anonymous
+ * users that have a session (typical example: a shopping cart).
+ *
+ * (This is the default, and other modules can subclass this placeholder
+ * strategy to have different rules for enabling BigPipe.)
+ *
+ * The BigPipe placeholder strategy actually consists of two substrategies,
+ * depending on whether the current session is in a browser with JavaScript
+ * enabled or not:
+ * 1. with JavaScript enabled: #attached[big_pipe_js_placeholders]. Their
+ *    replacements are streamed at the end of the page: chunk 1 is the entire
+ *    page until the closing </body> tag, chunks 2 to (N-1) are replacement
+ *    values for the placeholders, chunk N is </body> and everything after it.
+ * 2. with JavaScript disabled: #attached[big_pipe_nojs_placeholders]. Their
+ *    replacements are streamed in situ: chunk 1 is the entire page until the
+ *    first no-JS BigPipe placeholder, chunk 2 is the replacement for that
+ *    placeholder, chunk 3 is the chunk from after that placeholder until the
+ *    next no-JS BigPipe placeholder, et cetera.
+ *
+ * JS BigPipe placeholders are preferred because they result in better perceived
+ * performance: the entire page can be sent, minus the placeholders. But it
+ * requires JavaScript.
+ *
+ * No-JS BigPipe placeholders result in more visible blocking: only the part of
+ * the page can be sent until the first placeholder, after it is rendered until
+ * the second, et cetera. (In essence: multiple flushes.)
+ *
+ * Finally, both of those substrategies can also be combined: some placeholders
+ * live in places that cannot be efficiently replaced by JavaScript, for example
+ * CSRF tokens in URLs. Using no-JS BigPipe placeholders in those cases allows
+ * the first part of the page (until the first no-JS BigPipe placeholder) to be
+ * sent sooner than when they would be replaced using SingleFlushStrategy, which
+ * would prevent anything from being sent until all those non-HTML placeholders
+ * would have been replaced.
+ *
+ * See \Drupal\big_pipe\Render\BigPipe for detailed documentation on how those
+ * different placeholders are actually replaced.
+ *
+ * @see \Drupal\big_pipe\Render\BigPipeInterface
+ */
+class BigPipeStrategy implements PlaceholderStrategyInterface {
+
+  /**
+   * BigPipe no-JS cookie name.
+   */
+  const NOJS_COOKIE = 'big_pipe_nojs';
+
+  /**
+   * The session configuration.
+   *
+   * @var \Drupal\Core\Session\SessionConfigurationInterface
+   */
+  protected $sessionConfiguration;
+
+  /**
+   * The request stack.
+   *
+   * @var \Symfony\Component\HttpFoundation\RequestStack
+   */
+  protected $requestStack;
+
+  /**
+   * The current route match.
+   *
+   * @var \Drupal\Core\Routing\RouteMatchInterface
+   */
+  protected $routeMatch;
+
+  /**
+   * Constructs a new BigPipeStrategy class.
+   *
+   * @param \Drupal\Core\Session\SessionConfigurationInterface $session_configuration
+   *   The session configuration.
+   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
+   *   The request stack.
+   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
+   *   The current route match.
+   */
+  public function __construct(SessionConfigurationInterface $session_configuration, RequestStack $request_stack, RouteMatchInterface $route_match) {
+    $this->sessionConfiguration = $session_configuration;
+    $this->requestStack = $request_stack;
+    $this->routeMatch = $route_match;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function processPlaceholders(array $placeholders) {
+    // Routes can opt out from using the BigPipe HTML delivery technique.
+    if ($this->routeMatch->getRouteObject()->getOption('_no_big_pipe')) {
+      return [];
+    }
+
+    if (!$this->sessionConfiguration->hasSession($this->requestStack->getCurrentRequest())) {
+      return [];
+    }
+
+    return $this->doProcessPlaceholders($placeholders);
+  }
+
+  /**
+   * Transforms placeholders to BigPipe placeholders, either no-JS or JS.
+   *
+   * @param array $placeholders
+   *   The placeholders to process.
+   *
+   * @return array
+   *   The BigPipe placeholders.
+   */
+  protected function doProcessPlaceholders(array $placeholders) {
+    $overridden_placeholders = [];
+    foreach ($placeholders as $placeholder => $placeholder_elements) {
+      // BigPipe uses JavaScript and the DOM to find the placeholder to replace.
+      // This means finding the placeholder to replace must be efficient. Most
+      // placeholders are HTML, which we can find efficiently thanks to the
+      // querySelector API. But some placeholders are HTML attribute values or
+      // parts thereof, and potentially even plain text in DOM text nodes. For
+      // BigPipe's JavaScript to find those placeholders, it would need to
+      // iterate over all DOM text nodes. This is highly inefficient. Therefore,
+      // the BigPipe placeholder strategy only converts HTML placeholders into
+      // BigPipe placeholders. The other placeholders need to be replaced on the
+      // server, not via BigPipe.
+      // @see \Drupal\Core\Access\RouteProcessorCsrf::renderPlaceholderCsrfToken()
+      // @see \Drupal\Core\Form\FormBuilder::renderFormTokenPlaceholder()
+      // @see \Drupal\Core\Form\FormBuilder::renderPlaceholderFormAction()
+      if ($placeholder[0] !== '<' || $placeholder !== Html::normalize($placeholder)) {
+        $overridden_placeholders[$placeholder] = static::createBigPipeNoJsPlaceholder($placeholder, $placeholder_elements, TRUE);
+      }
+      else {
+        // If the current request/session doesn't have JavaScript, fall back to
+        // no-JS BigPipe.
+        if ($this->requestStack->getCurrentRequest()->cookies->has(static::NOJS_COOKIE)) {
+          $overridden_placeholders[$placeholder] = static::createBigPipeNoJsPlaceholder($placeholder, $placeholder_elements, FALSE);
+        }
+        else {
+          $overridden_placeholders[$placeholder] = static::createBigPipeJsPlaceholder($placeholder, $placeholder_elements);
+        }
+        $overridden_placeholders[$placeholder]['#cache']['contexts'][] = 'cookies:' . static::NOJS_COOKIE;
+      }
+    }
+
+    return $overridden_placeholders;
+  }
+
+  /**
+   * Creates a BigPipe JS placeholder.
+   *
+   * @param string $original_placeholder
+   *   The original placeholder.
+   * @param array $placeholder_render_array
+   *   The render array for a placeholder.
+   *
+   * @return array
+   *   The resulting BigPipe JS placeholder render array.
+   */
+  protected static function createBigPipeJsPlaceholder($original_placeholder, array $placeholder_render_array) {
+    $big_pipe_placeholder_id = static::generateBigPipePlaceholderId($original_placeholder, $placeholder_render_array);
+
+    return [
+      '#markup' => '<div data-big-pipe-placeholder-id="' . Html::escape($big_pipe_placeholder_id) . '"></div>',
+      '#cache' => [
+        'max-age' => 0,
+        'contexts' => [
+          'session.exists',
+        ],
+      ],
+      '#attached' => [
+        'library' => [
+          'big_pipe/big_pipe',
+        ],
+        // Inform BigPipe' JavaScript known BigPipe placeholder IDs (a whitelist).
+        'drupalSettings' => [
+          'bigPipePlaceholderIds' => [$big_pipe_placeholder_id => TRUE],
+        ],
+        'big_pipe_placeholders' => [
+          Html::escape($big_pipe_placeholder_id) => $placeholder_render_array,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Creates a BigPipe no-JS placeholder.
+   *
+   * @param string $original_placeholder
+   *   The original placeholder.
+   * @param array $placeholder_render_array
+   *   The render array for a placeholder.
+   * @param bool $placeholder_must_be_attribute_safe
+   *   Whether the placeholder must be safe for use in a HTML attribute (in case
+   *   it's a placeholder for a HTML attribute value or a subset of it).
+   *
+   * @return array
+   *   The resulting BigPipe no-JS placeholder render array.
+   */
+  protected static function createBigPipeNoJsPlaceholder($original_placeholder, array $placeholder_render_array, $placeholder_must_be_attribute_safe = FALSE) {
+    if (!$placeholder_must_be_attribute_safe) {
+      $big_pipe_placeholder = '<div data-big-pipe-nojs-placeholder-id="' . Html::escape(static::generateBigPipePlaceholderId($original_placeholder, $placeholder_render_array))  . '"></div>';
+    }
+    else {
+      $big_pipe_placeholder = 'big_pipe_nojs_placeholder_attribute_safe:' . Html::escape($original_placeholder);
+    }
+
+    return [
+      '#markup' => $big_pipe_placeholder,
+      '#cache' => [
+        'max-age' => 0,
+        'contexts' => [
+          'session.exists',
+        ],
+      ],
+      '#attached' => [
+        'big_pipe_nojs_placeholders' => [
+          $big_pipe_placeholder => $placeholder_render_array,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Generates a BigPipe placeholder ID.
+   *
+   * @param string $original_placeholder
+   *   The original placeholder.
+   * @param array $placeholder_render_array
+   *   The render array for a placeholder.
+   *
+   * @return string
+   *   The generated BigPipe placeholder ID.
+   */
+  protected static function generateBigPipePlaceholderId($original_placeholder, array $placeholder_render_array) {
+    // Generate a BigPipe placeholder ID (to be used by BigPipe's JavaScript).
+    // @see \Drupal\Core\Render\PlaceholderGenerator::createPlaceholder()
+    if (isset($placeholder_render_array['#lazy_builder'])) {
+      $callback = $placeholder_render_array['#lazy_builder'][0];
+      $arguments = $placeholder_render_array['#lazy_builder'][1];
+      $token = hash('crc32b', serialize($placeholder_render_array));
+      return UrlHelper::buildQuery(['callback' => $callback, 'args' => $arguments, 'token' => $token]);
+    }
+    // When the placeholder's render array is not using a #lazy_builder,
+    // anything could be in there: only #lazy_builder has a strict contract that
+    // allows us to create a more sane selector. Therefore, simply the original
+    // placeholder into a usable placeholder ID, at the cost of it being obtuse.
+    else {
+      return Html::getId($original_placeholder);
+    }
+  }
+
+}
diff --git a/core/modules/big_pipe/src/Tests/BigPipePlaceholderTestCases.php b/core/modules/big_pipe/src/Tests/BigPipePlaceholderTestCases.php
new file mode 100644
index 0000000000000000000000000000000000000000..5514eb70fb8bbdad686ae39186f0cab591f5a4b2
--- /dev/null
+++ b/core/modules/big_pipe/src/Tests/BigPipePlaceholderTestCases.php
@@ -0,0 +1,350 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Tests\big_pipe\Unit\Render\Placeholder\BigPipePlaceholderTestCases.
+ */
+
+namespace Drupal\big_pipe\Tests;
+
+use Drupal\big_pipe\Render\BigPipeMarkup;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Url;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * BigPipe placeholder test cases for use in both unit and integration tests.
+ *
+ * - Unit test:
+ *   \Drupal\Tests\big_pipe\Unit\Render\Placeholder\BigPipeStrategyTest
+ * - Integration test for BigPipe with JS on:
+ *   \Drupal\big_pipe\Tests\BigPipeTest::testBigPipe()
+ * - Integration test for BigPipe with JS off:
+ *   \Drupal\big_pipe\Tests\BigPipeTest::testBigPipeNoJs()
+ */
+class BigPipePlaceholderTestCases {
+
+  /**
+   * Gets all BigPipe placeholder test cases.
+   *
+   * @param \Symfony\Component\DependencyInjection\ContainerInterface|null $container
+   *   Optional. Necessary to get the embedded AJAX/HTML responses.
+   * @param \Drupal\Core\Session\AccountInterface|null $user
+   *   Optional. Necessary to get the embedded AJAX/HTML responses.
+   *
+   * @return \Drupal\big_pipe\Tests\BigPipePlaceholderTestCase[]
+   */
+  public static function cases(ContainerInterface $container = NULL, AccountInterface $user = NULL) {
+    // Define the two types of cacheability that we expect to see. These will be
+    // used in the expectations.
+    $cacheability_depends_on_session_only = [
+      'max-age' => 0,
+      'contexts' => ['session.exists'],
+    ];
+    $cacheability_depends_on_session_and_nojs_cookie = [
+      'max-age' => 0,
+      'contexts' => ['session.exists', 'cookies:big_pipe_nojs'],
+    ];
+
+
+    // 1. Real-world example of HTML placeholder.
+    $status_messages = new BigPipePlaceholderTestCase(
+      [], //['#type' => 'status_messages'],
+      '<drupal-render-placeholder callback="Drupal\Core\Render\Element\StatusMessages::renderMessages" arguments="0" token="a8c34b5e"></drupal-render-placeholder>',
+      [
+        '#lazy_builder' => [
+          'Drupal\Core\Render\Element\StatusMessages::renderMessages',
+          [NULL]
+        ],
+      ]
+    );
+    $status_messages->bigPipePlaceholderId = 'callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&amp;args[0]&amp;token=a8c34b5e';
+    $status_messages->bigPipePlaceholderRenderArray = [
+      '#markup' => '<div data-big-pipe-placeholder-id="callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&amp;args[0]&amp;token=a8c34b5e"></div>',
+      '#cache' => $cacheability_depends_on_session_and_nojs_cookie,
+      '#attached' => [
+        'library' => ['big_pipe/big_pipe'],
+        'drupalSettings' => [
+          'bigPipePlaceholderIds' => [
+            'callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&args[0]&token=a8c34b5e' => TRUE,
+          ],
+        ],
+        'big_pipe_placeholders' => [
+          'callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&amp;args[0]&amp;token=a8c34b5e' => $status_messages->placeholderRenderArray,
+        ],
+      ],
+    ];
+    $status_messages->bigPipeNoJsPlaceholder = '<div data-big-pipe-nojs-placeholder-id="callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&amp;args[0]&amp;token=a8c34b5e"></div>';
+    $status_messages->bigPipeNoJsPlaceholderRenderArray = [
+      '#markup' => '<div data-big-pipe-nojs-placeholder-id="callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&amp;args[0]&amp;token=a8c34b5e"></div>',
+      '#cache' => $cacheability_depends_on_session_and_nojs_cookie,
+      '#attached' => [
+        'big_pipe_nojs_placeholders' => [
+          '<div data-big-pipe-nojs-placeholder-id="callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&amp;args[0]&amp;token=a8c34b5e"></div>' => $status_messages->placeholderRenderArray,
+        ],
+      ],
+    ];
+    if ($container && $user) {
+      $status_messages->embeddedAjaxResponseCommands = [
+        [
+          'command' => 'settings',
+          'settings' => [
+            'ajaxPageState' => [
+              'theme' => 'classy',
+              'libraries' => 'big_pipe/big_pipe,classy/base,classy/messages,core/drupal.active-link,core/html5shiv,core/normalize,system/base',
+            ],
+            'pluralDelimiter' => \Drupal\Core\StringTranslation\PluralTranslatableMarkup::DELIMITER,
+            'user' => [
+              'uid' => '1',
+              'permissionsHash' => $container->get('user_permissions_hash_generator')->generate($user),
+            ],
+          ],
+          'merge' => TRUE,
+        ],
+        [
+          'command' => 'add_css',
+          'data' => '<link rel="stylesheet" href="' . base_path() . 'core/themes/classy/css/components/messages.css?' . $container->get('state')->get('system.css_js_query_string') . '" media="all" />' . "\n"
+        ],
+        [
+          'command' => 'insert',
+          'method' => 'replaceWith',
+          'selector' => '[data-big-pipe-placeholder-id="callback=Drupal%5CCore%5CRender%5CElement%5CStatusMessages%3A%3ArenderMessages&args[0]&token=a8c34b5e"]',
+          'data' => "\n" . '    <div role="contentinfo" aria-label="Status message" class="messages messages--status">' . "\n" . '                  <h2 class="visually-hidden">Status message</h2>' . "\n" . '                    Hello from BigPipe!' . "\n" . '            </div>' . "\n    \n",
+          'settings' => NULL,
+        ],
+      ];
+      $status_messages->embeddedHtmlResponse = '<link rel="stylesheet" href="' . base_path() . 'core/themes/classy/css/components/messages.css?' . $container->get('state')->get('system.css_js_query_string') . '" media="all" />' . "\n" . "\n" . '    <div role="contentinfo" aria-label="Status message" class="messages messages--status">' . "\n" . '                  <h2 class="visually-hidden">Status message</h2>' . "\n" . '                    Hello from BigPipe!' . "\n" . '            </div>' . "\n    \n";
+    }
+
+
+    // 2. Real-world example of HTML attribute value placeholder: form action.
+    $form_action = new BigPipePlaceholderTestCase(
+      $container ? $container->get('form_builder')->getForm('Drupal\big_pipe_test\Form\BigPipeTestForm') : [],
+      'form_action_cc611e1d',
+      [
+        '#lazy_builder' => ['form_builder:renderPlaceholderFormAction', []],
+      ]
+    );
+    $form_action->bigPipeNoJsPlaceholder = 'big_pipe_nojs_placeholder_attribute_safe:form_action_cc611e1d';
+    $form_action->bigPipeNoJsPlaceholderRenderArray = [
+      '#markup' => 'big_pipe_nojs_placeholder_attribute_safe:form_action_cc611e1d',
+      '#cache' => $cacheability_depends_on_session_only,
+      '#attached' => [
+        'big_pipe_nojs_placeholders' => [
+          'big_pipe_nojs_placeholder_attribute_safe:form_action_cc611e1d' => $form_action->placeholderRenderArray,
+        ],
+      ],
+    ];
+    if ($container) {
+      $form_action->embeddedHtmlResponse = '<form class="big-pipe-test-form" data-drupal-selector="big-pipe-test-form" action="' . base_path() . 'big_pipe_test"';
+    }
+
+
+    // 3. Real-world example of HTML attribute value subset placeholder: CSRF
+    // token in link.
+    $csrf_token = new BigPipePlaceholderTestCase(
+      [
+        '#title' => 'Link with CSRF token',
+        '#type' => 'link',
+        '#url' => Url::fromRoute('system.theme_set_default'),
+      ],
+      'e88b559cce72c80b687d56b0e2a3a5ae4b66bc0e',
+      [
+        '#lazy_builder' => [
+          'route_processor_csrf:renderPlaceholderCsrfToken',
+          ['admin/config/user-interface/shortcut/manage/default/add-link-inline']
+        ],
+      ]
+    );
+    $csrf_token->bigPipeNoJsPlaceholder = 'big_pipe_nojs_placeholder_attribute_safe:e88b559cce72c80b687d56b0e2a3a5ae4b66bc0e';
+    $csrf_token->bigPipeNoJsPlaceholderRenderArray = [
+      '#markup' => 'big_pipe_nojs_placeholder_attribute_safe:e88b559cce72c80b687d56b0e2a3a5ae4b66bc0e',
+      '#cache' => $cacheability_depends_on_session_only,
+      '#attached' => [
+        'big_pipe_nojs_placeholders' => [
+          'big_pipe_nojs_placeholder_attribute_safe:e88b559cce72c80b687d56b0e2a3a5ae4b66bc0e' => $csrf_token->placeholderRenderArray,
+        ],
+      ],
+    ];
+    if ($container) {
+      $csrf_token->embeddedHtmlResponse = $container->get('csrf_token')->get('admin/appearance/default');
+    }
+
+
+    // 4. Edge case: custom string to be considered as a placeholder that
+    // happens to not be valid HTML.
+    $hello = new BigPipePlaceholderTestCase(
+      [
+        '#markup' => BigPipeMarkup::create('<hello'),
+        '#attached' => [
+          'placeholders' => [
+            '<hello' => ['#lazy_builder' => ['\Drupal\big_pipe_test\BigPipeTestController::helloOrYarhar', []]],
+          ]
+        ],
+      ],
+      '<hello',
+      [
+        '#lazy_builder' => [
+          'hello_or_yarhar',
+          []
+        ],
+      ]
+    );
+    $hello->bigPipeNoJsPlaceholder = 'big_pipe_nojs_placeholder_attribute_safe:&lt;hello';
+    $hello->bigPipeNoJsPlaceholderRenderArray = [
+      '#markup' => 'big_pipe_nojs_placeholder_attribute_safe:&lt;hello',
+      '#cache' => $cacheability_depends_on_session_only,
+      '#attached' => [
+        'big_pipe_nojs_placeholders' => [
+          'big_pipe_nojs_placeholder_attribute_safe:&lt;hello' => $hello->placeholderRenderArray,
+        ],
+      ],
+    ];
+    $hello->embeddedHtmlResponse = '<marquee>Yarhar llamas forever!</marquee>';
+
+
+    // 5. Edge case: non-#lazy_builder placeholder.
+    $current_time = new BigPipePlaceholderTestCase(
+      [
+        '#markup' => BigPipeMarkup::create('<time>CURRENT TIME</time>'),
+        '#attached' => [
+          'placeholders' => [
+            '<time>CURRENT TIME</time>' => [
+              '#pre_render' => [
+                '\Drupal\big_pipe_test\BigPipeTestController::currentTime',
+              ],
+            ]
+          ]
+        ]
+      ],
+      '<time>CURRENT TIME</time>',
+      [
+        '#pre_render' => ['current_time'],
+      ]
+    );
+    $current_time->bigPipePlaceholderId = 'timecurrent-timetime';
+    $current_time->bigPipePlaceholderRenderArray = [
+      '#markup' => '<div data-big-pipe-placeholder-id="timecurrent-timetime"></div>',
+      '#cache' => $cacheability_depends_on_session_and_nojs_cookie,
+      '#attached' => [
+        'library' => ['big_pipe/big_pipe'],
+        'drupalSettings' => [
+          'bigPipePlaceholderIds' => [
+            'timecurrent-timetime' => TRUE,
+          ],
+        ],
+        'big_pipe_placeholders' => [
+          'timecurrent-timetime' => $current_time->placeholderRenderArray,
+        ],
+      ],
+    ];
+    $current_time->embeddedAjaxResponseCommands = [
+      [
+        'command' => 'insert',
+        'method' => 'replaceWith',
+        'selector' => '[data-big-pipe-placeholder-id="timecurrent-timetime"]',
+        'data' => '<time datetime=1991-03-14"></time>',
+        'settings' => NULL,
+      ],
+    ];
+    $current_time->bigPipeNoJsPlaceholder = '<div data-big-pipe-nojs-placeholder-id="timecurrent-timetime"></div>';
+    $current_time->bigPipeNoJsPlaceholderRenderArray = [
+      '#markup' => '<div data-big-pipe-nojs-placeholder-id="timecurrent-timetime"></div>',
+      '#cache' => $cacheability_depends_on_session_and_nojs_cookie,
+      '#attached' => [
+        'big_pipe_nojs_placeholders' => [
+          '<div data-big-pipe-nojs-placeholder-id="timecurrent-timetime"></div>' => $current_time->placeholderRenderArray,
+        ],
+      ],
+    ];
+    $current_time->embeddedHtmlResponse = '<time datetime=1991-03-14"></time>';
+
+
+    return [
+      'html' => $status_messages,
+      'html_attribute_value' => $form_action,
+      'html_attribute_value_subset' => $csrf_token,
+      'edge_case__invalid_html' => $hello,
+      'edge_case__html_non_lazy_builder' => $current_time,
+    ];
+  }
+
+}
+
+class BigPipePlaceholderTestCase {
+
+  /**
+   * The original render array.
+   *
+   * @var array
+   */
+  public $renderArray;
+
+  /**
+   * The expected corresponding placeholder string.
+   *
+   * @var string
+   */
+  public $placeholder;
+
+  /**
+   * The expected corresponding placeholder render array.
+   *
+   * @var array
+   */
+  public $placeholderRenderArray;
+
+  /**
+   * The expected BigPipe placeholder ID.
+   *
+   * (Only possible for HTML placeholders.)
+   *
+   * @var null|string
+   */
+  public $bigPipePlaceholderId = NULL;
+
+  /**
+   * The corresponding expected BigPipe placeholder render array.
+   *
+   * @var null|array
+   */
+  public $bigPipePlaceholderRenderArray = NULL;
+
+  /**
+   * The corresponding expected embedded AJAX response.
+   *
+   * @var null|array
+   */
+  public $embeddedAjaxResponseCommands = NULL;
+
+
+  /**
+   * The expected BigPipe no-JS placeholder.
+   *
+   * (Possible for all placeholders, HTML or non-HTML.)
+   *
+   * @var string
+   */
+  public $bigPipeNoJsPlaceholder;
+
+  /**
+   * The corresponding expected BigPipe no-JS placeholder render array.
+   *
+   * @var array
+   */
+  public $bigPipeNoJsPlaceholderRenderArray;
+
+  /**
+   * The corresponding expected embedded HTML response.
+   *
+   * @var string
+   */
+  public $embeddedHtmlResponse;
+
+  public function __construct(array $render_array, $placeholder, array $placeholder_render_array) {
+    $this->renderArray = $render_array;
+    $this->placeholder = $placeholder;
+    $this->placeholderRenderArray = $placeholder_render_array;
+  }
+
+}
diff --git a/core/modules/big_pipe/src/Tests/BigPipeTest.php b/core/modules/big_pipe/src/Tests/BigPipeTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..b30d27b8807b208ea58f73e5f476d7d69c1ef980
--- /dev/null
+++ b/core/modules/big_pipe/src/Tests/BigPipeTest.php
@@ -0,0 +1,356 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\big_pipe\Tests\BigPipeTest.
+ */
+
+namespace Drupal\big_pipe\Tests;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\Html;
+use Drupal\Core\Url;
+use Drupal\simpletest\WebTestBase;
+
+/**
+ * Tests BigPipe's no-JS detection & response delivery (with and without JS).
+ *
+ * Covers:
+ * - big_pipe_page_attachments()
+ * - \Drupal\big_pipe\Controller\BigPipeController
+ * - \Drupal\big_pipe\EventSubscriber\HtmlResponseBigPipeSubscriber
+ * - \Drupal\big_pipe\Render\BigPipe
+ *
+ * @group big_pipe
+ */
+class BigPipeTest extends WebTestBase {
+
+  const START_SIGNAL= '<script type="application/json" data-big-pipe-event="start"></script>';
+  const STOP_SIGNAL= '<script type="application/json" data-big-pipe-event="stop"></script>';
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = ['big_pipe', 'big_pipe_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $dumpHeaders = TRUE;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    // Ignore the <meta> refresh that big_pipe.module sets. It causes a redirect
+    // to a page that sets another cookie, which causes WebTestBase to lose the
+    // session cookie. To avoid this problem, tests should first call
+    // drupalGet() and then call checkForMetaRefresh() manually, and then reset
+    // $this->maximumMetaRefreshCount and $this->metaRefreshCount.
+    // @see doMetaRefresh()
+    $this->maximumMetaRefreshCount = 0;
+  }
+
+  /**
+   * Performs a single <meta> refresh explicitly.
+   *
+   * This test disables the automatic <meta> refresh checking, each time it is
+   * desired that this runs, a test case must explicitly call this.
+   *
+   * @see setUp()
+   */
+  protected function performMetaRefresh() {
+    $this->maximumMetaRefreshCount = 1;
+    $this->checkForMetaRefresh();
+    $this->maximumMetaRefreshCount = 0;
+    $this->metaRefreshCount = 0;
+  }
+
+  /**
+   * Tests BigPipe's no-JS detection.
+   *
+   * Covers:
+   * - big_pipe_page_attachments()
+   * - \Drupal\big_pipe\Controller\BigPipeController
+   */
+  public function testNoJsDetection() {
+    // 1. No session (anonymous).
+    $this->drupalGet(Url::fromRoute('<front>'));
+    $this->assertSessionCookieExists(FALSE);
+    $this->assertBigPipeNoJsCookieExists(FALSE);
+    $this->assertNoRaw('<noscript><meta http-equiv="Refresh" content="0; URL=');
+
+    // 2. Session (authenticated).
+    $this->drupalLogin($this->rootUser);
+    $this->assertSessionCookieExists(TRUE);
+    $this->assertBigPipeNoJsCookieExists(FALSE);
+    $this->assertRaw('<noscript><meta http-equiv="Refresh" content="0; URL=' . base_path() . 'big_pipe/no-js?destination=' . base_path() . 'user/1" />' . "\n" . '</noscript>');
+    $this->assertBigPipeNoJsMetaRefreshRedirect();
+    $this->assertBigPipeNoJsCookieExists(TRUE);
+    $this->assertNoRaw('<noscript><meta http-equiv="Refresh" content="0; URL=');
+    $this->drupalLogout();
+
+    // Close the prior connection and remove the collected state.
+    $this->curlClose();
+    $this->curlCookies = [];
+    $this->cookies = [];
+
+    // 3. Session (anonymous).
+    $this->drupalGet(Url::fromRoute('user.login', [], ['query' => ['trigger_session' => 1]]));
+    $this->drupalGet(Url::fromRoute('user.login'));
+    $this->assertSessionCookieExists(TRUE);
+    $this->assertBigPipeNoJsCookieExists(FALSE);
+    $this->assertRaw('<noscript><meta http-equiv="Refresh" content="0; URL=' . base_path() . 'big_pipe/no-js?destination=' . base_path() . 'user/login" />' . "\n" . '</noscript>');
+    $this->assertBigPipeNoJsMetaRefreshRedirect();
+    $this->assertBigPipeNoJsCookieExists(TRUE);
+    $this->assertNoRaw('<noscript><meta http-equiv="Refresh" content="0; URL=');
+
+    // Close the prior connection and remove the collected state.
+    $this->curlClose();
+    $this->curlCookies = [];
+    $this->cookies = [];
+
+    // Edge case: route with '_no_big_pipe' option.
+    $this->drupalGet(Url::fromRoute('big_pipe_test.no_big_pipe'));
+    $this->assertSessionCookieExists(FALSE);
+    $this->assertBigPipeNoJsCookieExists(FALSE);
+    $this->assertNoRaw('<noscript><meta http-equiv="Refresh" content="0; URL=');
+    $this->drupalLogin($this->rootUser);
+    $this->drupalGet(Url::fromRoute('big_pipe_test.no_big_pipe'));
+    $this->assertSessionCookieExists(TRUE);
+    $this->assertBigPipeNoJsCookieExists(FALSE);
+    $this->assertNoRaw('<noscript><meta http-equiv="Refresh" content="0; URL=');
+  }
+
+  /**
+   * Tests BigPipe-delivered HTML responses when JavaScript is enabled.
+   *
+   * Covers:
+   * - \Drupal\big_pipe\EventSubscriber\HtmlResponseBigPipeSubscriber
+   * - \Drupal\big_pipe\Render\BigPipe
+   * - \Drupal\big_pipe\Render\BigPipe::sendPlaceholders()
+   *
+   * @see \Drupal\big_pipe\Tests\BigPipePlaceholderTestCases
+   */
+  public function testBigPipe() {
+    $this->drupalLogin($this->rootUser);
+    $this->assertSessionCookieExists(TRUE);
+    $this->assertBigPipeNoJsCookieExists(FALSE);
+
+    // By not calling performMetaRefresh() here, we simulate JavaScript being
+    // enabled, because as far as the BigPipe module is concerned, JavaScript is
+    // enabled in the browser as long as the BigPipe no-JS cookie is *not* set.
+    // @see setUp()
+    // @see performMetaRefresh()
+
+    $this->drupalGet(Url::fromRoute('big_pipe_test'));
+    $this->assertBigPipeResponseHeadersPresent();
+
+    $cases = $this->getTestCases();
+    $this->assertBigPipeNoJsPlaceholders([
+      $cases['edge_case__invalid_html']->bigPipeNoJsPlaceholder     => $cases['edge_case__invalid_html']->embeddedHtmlResponse,
+      $cases['html_attribute_value']->bigPipeNoJsPlaceholder        => $cases['html_attribute_value']->embeddedHtmlResponse,
+      $cases['html_attribute_value_subset']->bigPipeNoJsPlaceholder => $cases['html_attribute_value_subset']->embeddedHtmlResponse,
+    ]);
+    $this->assertBigPipePlaceholders([
+      $cases['html']->bigPipePlaceholderId                             => Json::encode($cases['html']->embeddedAjaxResponseCommands),
+      $cases['edge_case__html_non_lazy_builder']->bigPipePlaceholderId => Json::encode($cases['edge_case__html_non_lazy_builder']->embeddedAjaxResponseCommands),
+    ]);
+
+    $this->pass('Verifying BigPipe assets are present…', 'Debug');
+    $this->assertFalse(empty($this->getDrupalSettings()), 'drupalSettings present.');
+    $this->assertTrue(in_array('big_pipe/big_pipe', explode(',', $this->getDrupalSettings()['ajaxPageState']['libraries'])), 'BigPipe asset library is present.');
+  }
+
+  /**
+   * Tests BigPipe-delivered HTML responses when JavaScript is disabled.
+   *
+   * Covers:
+   * - \Drupal\big_pipe\EventSubscriber\HtmlResponseBigPipeSubscriber
+   * - \Drupal\big_pipe\Render\BigPipe
+   * - \Drupal\big_pipe\Render\BigPipe::sendNoJsPlaceholders()
+   *
+   * @see \Drupal\big_pipe\Tests\BigPipePlaceholderTestCases
+   */
+  public function testBigPipeNoJs() {
+    $this->drupalLogin($this->rootUser);
+    $this->assertSessionCookieExists(TRUE);
+    $this->assertBigPipeNoJsCookieExists(FALSE);
+
+    // By calling performMetaRefresh() here, we simulate JavaScript being
+    // disabled, because as far as the BigPipe module is concerned, it is
+    // enabled in the browser when the BigPipe no-JS cookie is set.
+    // @see setUp()
+    // @see performMetaRefresh()
+    $this->performMetaRefresh();
+    $this->assertBigPipeNoJsCookieExists(TRUE);
+
+    $this->drupalGet(Url::fromRoute('big_pipe_test'));
+    $this->assertBigPipeResponseHeadersPresent();
+
+    $cases = $this->getTestCases();
+    $this->assertBigPipeNoJsPlaceholders([
+      $cases['edge_case__invalid_html']->bigPipeNoJsPlaceholder           => $cases['edge_case__invalid_html']->embeddedHtmlResponse,
+      $cases['html_attribute_value']->bigPipeNoJsPlaceholder              => $cases['html_attribute_value']->embeddedHtmlResponse,
+      $cases['html_attribute_value_subset']->bigPipeNoJsPlaceholder       => $cases['html_attribute_value_subset']->embeddedHtmlResponse,
+      $cases['html']->bigPipeNoJsPlaceholder                              => $cases['html']->embeddedHtmlResponse,
+      $cases['edge_case__html_non_lazy_builder']->bigPipeNoJsPlaceholder  => $cases['edge_case__html_non_lazy_builder']->embeddedHtmlResponse,
+    ]);
+
+    $this->pass('Verifying there are no BigPipe placeholders & replacements…', 'Debug');
+    $this->assertEqual('<none>', $this->drupalGetHeader('BigPipe-Test-Placeholders'));
+    $this->pass('Verifying BigPipe start/stop signals are absent…', 'Debug');
+    $this->assertNoRaw(static::START_SIGNAL, 'BigPipe start signal absent.');
+    $this->assertNoRaw(static::STOP_SIGNAL, 'BigPipe stop signal absent.');
+
+    $this->pass('Verifying BigPipe assets are absent…', 'Debug');
+    $this->assertFalse(empty($this->getDrupalSettings()), 'drupalSettings and BigPipe asset library absent.');
+  }
+
+  protected function assertBigPipeResponseHeadersPresent() {
+    $this->pass('Verifying BigPipe response headers…', 'Debug');
+    $this->assertTrue(FALSE !== strpos($this->drupalGetHeader('Cache-Control'), 'private'), 'Cache-Control header set to "private".');
+    $this->assertEqual('no-store, content="BigPipe/1.0"', $this->drupalGetHeader('Surrogate-Control'));
+    $this->assertEqual('no', $this->drupalGetHeader('X-Accel-Buffering'));
+  }
+
+  /**
+   * Asserts expected BigPipe no-JS placeholders are present and replaced.
+   *
+   * @param array $expected_big_pipe_nojs_placeholders
+   *   Keys: BigPipe no-JS placeholder markup. Values: expected replacement
+   *   markup.
+   */
+  protected function assertBigPipeNoJsPlaceholders(array $expected_big_pipe_nojs_placeholders) {
+    $this->pass('Verifying BigPipe no-JS placeholders & replacements…', 'Debug');
+    $this->assertSetsEqual(array_keys($expected_big_pipe_nojs_placeholders), array_map('rawurldecode', explode(' ', $this->drupalGetHeader('BigPipe-Test-No-Js-Placeholders'))));
+    foreach ($expected_big_pipe_nojs_placeholders as $big_pipe_nojs_placeholder => $expected_replacement) {
+      $this->pass('Checking whether the replacement for the BigPipe no-JS placeholder "' . $big_pipe_nojs_placeholder . '" is present:');
+      $this->assertRaw($expected_replacement);
+    }
+  }
+
+  /**
+   * Asserts expected BigPipe placeholders are present and replaced.
+   *
+   * @param array $expected_big_pipe_placeholders
+   *   Keys: BigPipe placeholder IDs. Values: expected AJAX response.
+   */
+  protected function assertBigPipePlaceholders(array $expected_big_pipe_placeholders) {
+    $this->pass('Verifying BigPipe placeholders & replacements…', 'Debug');
+    $this->assertSetsEqual(array_keys($expected_big_pipe_placeholders), explode(' ', $this->drupalGetHeader('BigPipe-Test-Placeholders')));
+    $placeholder_positions = [];
+    $placeholder_replacement_positions = [];
+    foreach ($expected_big_pipe_placeholders as $big_pipe_placeholder_id => $expected_ajax_response) {
+      $this->pass('BigPipe placeholder: ' . $big_pipe_placeholder_id, 'Debug');
+      // Verify expected placeholder.
+      $expected_placeholder_html = '<div data-big-pipe-placeholder-id="' . $big_pipe_placeholder_id . '"></div>';
+      $this->assertRaw($expected_placeholder_html, 'BigPipe placeholder for placeholder ID "' . $big_pipe_placeholder_id . '" found.');
+      $pos = strpos($this->getRawContent(), $expected_placeholder_html);
+      $placeholder_positions[$pos] = $big_pipe_placeholder_id;
+      // Verify expected placeholder replacement.
+      $result = $this->xpath('//script[@data-big-pipe-replacement-for-placeholder-with-id=:id]', [':id' => Html::decodeEntities($big_pipe_placeholder_id)]);
+      $this->assertEqual($expected_ajax_response, trim((string) $result[0]));
+      $expected_placeholder_replacement = '<script type="application/json" data-big-pipe-replacement-for-placeholder-with-id="' . $big_pipe_placeholder_id . '">';
+      $this->assertRaw($expected_placeholder_replacement);
+      $pos = strpos($this->getRawContent(), $expected_placeholder_replacement);
+      $placeholder_replacement_positions[$pos] = $big_pipe_placeholder_id;
+    }
+    ksort($placeholder_positions, SORT_NUMERIC);
+    $this->assertEqual(array_keys($expected_big_pipe_placeholders), array_values($placeholder_positions));
+    $this->assertEqual(count($expected_big_pipe_placeholders), preg_match_all('/' . preg_quote('<div data-big-pipe-placeholder-id="', '/') . '/', $this->getRawContent()));
+    $this->assertEqual(array_keys($expected_big_pipe_placeholders), array_values($placeholder_replacement_positions));
+    $this->assertEqual(count($expected_big_pipe_placeholders), preg_match_all('/' . preg_quote('<script type="application/json" data-big-pipe-replacement-for-placeholder-with-id="', '/') . '/', $this->getRawContent()));
+
+    $this->pass('Verifying BigPipe start/stop signals…', 'Debug');
+    $this->assertRaw(static::START_SIGNAL, 'BigPipe start signal present.');
+    $this->assertRaw(static::STOP_SIGNAL, 'BigPipe stop signal present.');
+    $start_signal_position = strpos($this->getRawContent(), static::START_SIGNAL);
+    $stop_signal_position = strpos($this->getRawContent(), static::STOP_SIGNAL);
+    $this->assertTrue($start_signal_position < $stop_signal_position, 'BigPipe start signal appears before stop signal.');
+
+    $this->pass('Verifying BigPipe placeholder replacements and start/stop signals were streamed in the correct order…', 'Debug');
+    $expected_stream_order = array_keys($expected_big_pipe_placeholders);
+    array_unshift($expected_stream_order, static::START_SIGNAL);
+    array_push($expected_stream_order, static::STOP_SIGNAL);
+    $actual_stream_order = $placeholder_replacement_positions + [
+        $start_signal_position => static::START_SIGNAL,
+        $stop_signal_position => static::STOP_SIGNAL,
+      ];
+    ksort($actual_stream_order, SORT_NUMERIC);
+    $this->assertEqual($expected_stream_order, array_values($actual_stream_order));
+  }
+
+  /**
+   * @return \Drupal\big_pipe\Tests\BigPipePlaceholderTestCase[]
+   */
+  protected function getTestCases() {
+    // Ensure we can generate CSRF tokens for the current user's session.
+    $session_data = $this->container->get('session_handler.write_safe')->read($this->cookies[$this->getSessionName()]['value']);
+    $csrf_token_seed = unserialize(explode('_sf2_meta|', $session_data)[1])['s'];
+    $this->container->get('session_manager.metadata_bag')->setCsrfTokenSeed($csrf_token_seed);
+
+    return \Drupal\big_pipe\Tests\BigPipePlaceholderTestCases::cases($this->container, $this->rootUser);
+  }
+
+  /**
+   * Asserts whether arrays A and B are equal, when treated as sets.
+   */
+  protected function assertSetsEqual(array $a, array $b) {
+    return count($a) == count($b) && !array_diff_assoc($a, $b);
+  }
+
+  /**
+   * Asserts whether a BigPipe no-JS cookie exists or not.
+   */
+  protected function assertBigPipeNoJsCookieExists($expected) {
+    $this->assertCookieExists('big_pipe_nojs', $expected, 'BigPipe no-JS');
+  }
+
+  /**
+   * Asserts whether a session cookie exists or not.
+   */
+  protected function assertSessionCookieExists($expected) {
+    $this->assertCookieExists($this->getSessionName(), $expected, 'Session');
+  }
+
+  /**
+   * Asserts whether a cookie exists on the client or not.
+   */
+  protected function assertCookieExists($cookie_name, $expected, $cookie_label) {
+    $non_deleted_cookies = array_filter($this->cookies, function ($item) { return $item['value'] !== 'deleted'; });
+    $this->assertEqual($expected, isset($non_deleted_cookies[$cookie_name]), $expected ? "$cookie_label cookie exists." : "$cookie_label cookie does not exist.");
+  }
+
+  /**
+   * Calls ::performMetaRefresh() and asserts the responses.
+   */
+  protected function assertBigPipeNoJsMetaRefreshRedirect() {
+    $original_url = $this->url;
+    $this->performMetaRefresh();
+
+    $this->assertEqual($original_url, $this->url, 'Redirected back to the original location.');
+
+    $headers = $this->drupalGetHeaders(TRUE);
+    $this->assertEqual(2, count($headers), 'Two requests were made upon following the <meta> refresh, there are headers for two responses.');
+
+    // First response: redirect.
+    $this->assertEqual('HTTP/1.1 302 Found', $headers[0][':status'], 'The first response was a 302 (redirect).');
+    $this->assertIdentical(0, strpos($headers[0]['set-cookie'], 'big_pipe_nojs=1'), 'The first response sets the big_pipe_nojs cookie.');
+    $this->assertEqual($original_url, $headers[0]['location'], 'The first response redirected back to the original page.');
+    $this->assertTrue(empty(array_diff(['cookies:big_pipe_nojs', 'session.exists'], explode(' ', $headers[0]['x-drupal-cache-contexts']))), 'The first response varies by the "cookies:big_pipe_nojs" and "session.exists" cache contexts.');
+    $this->assertFalse(isset($headers[0]['surrogate-control']), 'The first response has no "Surrogate-Control" header.');
+
+    // Second response: redirect followed.
+    $this->assertEqual('HTTP/1.1 200 OK', $headers[1][':status'], 'The second response was a 200.');
+    $this->assertTrue(empty(array_diff(['cookies:big_pipe_nojs', 'session.exists'], explode(' ', $headers[0]['x-drupal-cache-contexts']))), 'The first response varies by the "cookies:big_pipe_nojs" and "session.exists" cache contexts.');
+    $this->assertEqual('no-store, content="BigPipe/1.0"', $headers[1]['surrogate-control'], 'The second response has a "Surrogate-Control" header.');
+
+    $this->assertNoRaw('<noscript><meta http-equiv="Refresh" content="0; URL=', 'Once the BigPipe no-JS cookie is set, the <meta> refresh is absent: only one redirect ever happens.');
+  }
+
+}
diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.info.yml b/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..46ed28d7bb63722bf6555a63d041ae01706d3967
--- /dev/null
+++ b/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.info.yml
@@ -0,0 +1,6 @@
+name: 'BigPipe test'
+type: module
+description: 'Support module for BigPipe testing.'
+package: Testing
+version: VERSION
+core: 8.x
diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.module b/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.module
new file mode 100644
index 0000000000000000000000000000000000000000..c808ce90e9c46b387f3ba8e30293e72fb2f18640
--- /dev/null
+++ b/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.module
@@ -0,0 +1,13 @@
+<?php
+
+/**
+ * Implements hook_page_top().
+ */
+function big_pipe_test_page_top(array &$page_top) {
+  // Ensure this hook is invoked on every page load.
+  $page_top['#cache']['max-age'] = 0;
+
+  if (\Drupal::request()->query->get('trigger_session')) {
+    $_SESSION['big_pipe_test'] = TRUE;
+  }
+}
diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.routing.yml b/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.routing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e573913bbcb1a5bd660502cae83c99c29b3162c4
--- /dev/null
+++ b/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.routing.yml
@@ -0,0 +1,17 @@
+big_pipe_test:
+  path: '/big_pipe_test'
+  defaults:
+    _controller: '\Drupal\big_pipe_test\BigPipeTestController::test'
+    _title: 'BigPipe test'
+  requirements:
+    _access: 'TRUE'
+
+big_pipe_test.no_big_pipe:
+  path: '/no_big_pipe'
+  defaults:
+    _controller: '\Drupal\big_pipe_test\BigPipeTestController::test'
+    _title: 'BigPipe test'
+  options:
+    _no_big_pipe: TRUE
+  requirements:
+    _access: 'TRUE'
diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.services.yml b/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5ec4895114452e2ab224c9aab917d0e3537f7791
--- /dev/null
+++ b/core/modules/big_pipe/tests/modules/big_pipe_test/big_pipe_test.services.yml
@@ -0,0 +1,5 @@
+services:
+  big_pipe_test_subscriber:
+    class: Drupal\big_pipe_test\EventSubscriber\BigPipeTestSubscriber
+    tags:
+      - { name: event_subscriber }
diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipeTestController.php b/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipeTestController.php
new file mode 100644
index 0000000000000000000000000000000000000000..3a663fcf23d39cf47a8d1bb9ec155bc8e888f3da
--- /dev/null
+++ b/core/modules/big_pipe/tests/modules/big_pipe_test/src/BigPipeTestController.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Drupal\big_pipe_test;
+
+use Drupal\big_pipe\Render\BigPipeMarkup;
+
+class BigPipeTestController {
+
+  /**
+   * Returns a all BigPipe placeholder test case render arrays.
+   *
+   * @return array
+   */
+  public function test() {
+    $build = [];
+
+    $cases = \Drupal\big_pipe\Tests\BigPipePlaceholderTestCases::cases(\Drupal::getContainer());
+
+    // 1. HTML placeholder: status messages. Drupal renders those automatically,
+    // so all that we need to do in this controller is set a message.
+    drupal_set_message('Hello from BigPipe!');
+    $build['html'] = $cases['html']->renderArray;
+
+    // 2. HTML attribute value placeholder: form action.
+    $build['html_attribute_value'] = $cases['html_attribute_value']->renderArray;
+
+    // 3. HTML attribute value subset placeholder: CSRF token in link.
+    $build['html_attribute_value_subset'] = $cases['html_attribute_value_subset']->renderArray;
+
+    // 4. Edge case: custom string to be considered as a placeholder that
+    // happens to not be valid HTML.
+    $build['edge_case__invalid_html'] = $cases['edge_case__invalid_html']->renderArray;
+
+    // 5. Edge case: non-#lazy_builder placeholder.
+    $build['edge_case__html_non_lazy_builder'] = $cases['edge_case__html_non_lazy_builder']->renderArray;
+
+    return $build;
+  }
+
+  /**
+   * #lazy_builder callback; builds <time> markup with current time.
+   *
+   * Note: does not actually use current time, that would complicate testing.
+   *
+   * @return array
+   */
+  public static function currentTime() {
+    return [
+      '#markup' => '<time datetime=' . date('Y-m-d', 668948400) . '"></time>',
+      '#cache' => ['max-age' => 0]
+    ];
+  }
+
+  /**
+   * #lazy_builder callback; says "hello" or "yarhar".
+   *
+   * @return array
+   */
+  public static function helloOrYarhar() {
+    return [
+      '#markup' => BigPipeMarkup::create('<marquee>Yarhar llamas forever!</marquee>'),
+      '#cache' => ['max-age' => 0],
+    ];
+  }
+
+}
diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/src/EventSubscriber/BigPipeTestSubscriber.php b/core/modules/big_pipe/tests/modules/big_pipe_test/src/EventSubscriber/BigPipeTestSubscriber.php
new file mode 100644
index 0000000000000000000000000000000000000000..6af1028e495bc037cdf7aca05a94b41e45b62344
--- /dev/null
+++ b/core/modules/big_pipe/tests/modules/big_pipe_test/src/EventSubscriber/BigPipeTestSubscriber.php
@@ -0,0 +1,53 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\big_pipe_test\EventSubscriber\BigPipeTestSubscriber.
+ */
+
+namespace Drupal\big_pipe_test\EventSubscriber;
+
+use Drupal\Core\Render\HtmlResponse;
+use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
+use Symfony\Component\HttpKernel\KernelEvents;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+class BigPipeTestSubscriber implements EventSubscriberInterface {
+
+  /**
+   * Exposes all BigPipe placeholders (JS and no-JS) via headers for testing.
+   *
+   * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event
+   *   The event to process.
+   */
+  public function onRespond(FilterResponseEvent $event) {
+    $response = $event->getResponse();
+    if (!$response instanceof HtmlResponse) {
+      return;
+    }
+
+    $attachments = $response->getAttachments();
+
+    $response->headers->set('BigPipe-Test-Placeholders', '<none>');
+    $response->headers->set('BigPipe-Test-No-Js-Placeholders', '<none>');
+
+    if (!empty($attachments['big_pipe_placeholders'])) {
+      $response->headers->set('BigPipe-Test-Placeholders', implode(' ', array_keys($attachments['big_pipe_placeholders'])));
+    }
+
+    if (!empty($attachments['big_pipe_nojs_placeholders'])) {
+      $response->headers->set('BigPipe-Test-No-Js-Placeholders', implode(' ', array_map('rawurlencode', array_keys($attachments['big_pipe_nojs_placeholders']))));
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    // Run *just* before \Drupal\big_pipe\EventSubscriber\HtmlResponseBigPipeSubscriber::onRespond().
+    $events[KernelEvents::RESPONSE][] = ['onRespond', -99999];
+
+    return $events;
+  }
+
+}
diff --git a/core/modules/big_pipe/tests/modules/big_pipe_test/src/Form/BigPipeTestForm.php b/core/modules/big_pipe/tests/modules/big_pipe_test/src/Form/BigPipeTestForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..21b6462d0b0a7229266234a6008fbc053f123267
--- /dev/null
+++ b/core/modules/big_pipe/tests/modules/big_pipe_test/src/Form/BigPipeTestForm.php
@@ -0,0 +1,45 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\big_pipe_test\Form\BigPipeTestForm.
+ */
+
+namespace Drupal\big_pipe_test\Form;
+
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+
+class BigPipeTestForm extends FormBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'big_pipe_test_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $form['#token'] = FALSE;
+
+    $form['big_pipe'] = array(
+      '#type' => 'checkboxes',
+      '#title' => $this->t('BigPipe works…'),
+      '#options' => [
+        'js' => $this->t('… with JavaScript'),
+        'nojs' => $this->t('… without JavaScript'),
+      ],
+    );
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) { }
+
+}
diff --git a/core/modules/big_pipe/tests/src/Unit/Render/BigPipeResponseAttachmentsProcessorTest.php b/core/modules/big_pipe/tests/src/Unit/Render/BigPipeResponseAttachmentsProcessorTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..b45129c432792ab50cefc59e1ca162a4e7f8092e
--- /dev/null
+++ b/core/modules/big_pipe/tests/src/Unit/Render/BigPipeResponseAttachmentsProcessorTest.php
@@ -0,0 +1,148 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Tests\big_pipe\Unit\Render\BigPipeResponseAttachmentsProcessorTest.
+ */
+
+namespace Drupal\Tests\big_pipe\Unit\Render;
+
+use Drupal\big_pipe\Render\BigPipeResponse;
+use Drupal\big_pipe\Render\BigPipeResponseAttachmentsProcessor;
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Asset\AssetCollectionRendererInterface;
+use Drupal\Core\Asset\AssetResolverInterface;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Render\AttachmentsInterface;
+use Drupal\Core\Render\AttachmentsResponseProcessorInterface;
+use Drupal\Core\Render\HtmlResponse;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+use Prophecy\Prophecy\ObjectProphecy;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * @coversDefaultClass \Drupal\big_pipe\Render\BigPipeResponseAttachmentsProcessor
+ * @group big_pipe
+ */
+class BigPipeResponseAttachmentsProcessorTest extends UnitTestCase {
+
+  /**
+   * @covers ::processAttachments
+   *
+   * @dataProvider nonHtmlResponseProvider
+   *
+   * @expectedException \AssertionError
+   */
+  public function testNonHtmlResponse($response_class) {
+    $big_pipe_response_attachments_processor = $this->createBigPipeResponseAttachmentsProcessor($this->prophesize(AttachmentsResponseProcessorInterface::class));
+
+    $non_html_response = new $response_class();
+    $big_pipe_response_attachments_processor->processAttachments($non_html_response);
+  }
+
+  function nonHtmlResponseProvider() {
+    return [
+      'AjaxResponse, which implements AttachmentsInterface' => [AjaxResponse::class],
+      'A dummy that implements AttachmentsInterface' => [get_class($this->prophesize(AttachmentsInterface::class)->reveal())],
+    ];
+  }
+
+  /**
+   * @covers ::processAttachments
+   *
+   * @dataProvider attachmentsProvider
+   */
+  public function testHtmlResponse(array $attachments) {
+    $big_pipe_response = new BigPipeResponse('original');
+    $big_pipe_response->setAttachments($attachments);
+
+    // This mock is the main expectation of this test: verify that the decorated
+    // service (that is this mock) never receives BigPipe placeholder
+    // attachments, because it doesn't know (nor should it) how to handle them.
+    $html_response_attachments_processor = $this->prophesize(AttachmentsResponseProcessorInterface::class);
+    $html_response_attachments_processor->processAttachments(Argument::that(function ($response) {
+      return $response instanceof HtmlResponse && empty(array_intersect(['big_pipe_placeholders', 'big_pipe_nojs_placeholders'], array_keys($response->getAttachments())));
+    }))
+      ->will(function ($args) {
+        /** @var \Symfony\Component\HttpFoundation\Response|\Drupal\Core\Render\AttachmentsInterface $response */
+        $response = $args[0];
+        // Simulate its actual behavior.
+        $attachments = array_diff_key($response->getAttachments(), ['html_response_attachment_placeholders' => TRUE]);
+        $response->setContent('processed');
+        $response->setAttachments($attachments);
+        return $response;
+      })
+      ->shouldBeCalled();
+
+    $big_pipe_response_attachments_processor = $this->createBigPipeResponseAttachmentsProcessor($html_response_attachments_processor);
+    $processed_big_pipe_response = $big_pipe_response_attachments_processor->processAttachments($big_pipe_response);
+
+    // The secondary expectation of this test: the original (passed in) response
+    // object remains unchanged, the processed (returned) response object has
+    // the expected values.
+    $this->assertSame($attachments, $big_pipe_response->getAttachments(), 'Attachments of original response object MUST NOT be changed.');
+    $this->assertEquals('original', $big_pipe_response->getContent(), 'Content of original response object MUST NOT be changed.');
+    $this->assertEquals(array_diff_key($attachments, ['html_response_attachment_placeholders' => TRUE]), $processed_big_pipe_response->getAttachments(), 'Attachments of returned (processed) response object MUST be changed.');
+    $this->assertEquals('processed', $processed_big_pipe_response->getContent(), 'Content of returned (processed) response object MUST be changed.');
+  }
+
+  public function attachmentsProvider() {
+    $typical_cases = [
+      'no attachments' => [[]],
+      'libraries' => [['library' => ['core/drupal']]],
+      'libraries + drupalSettings' => [['library' => ['core/drupal'], 'drupalSettings' => ['foo' => 'bar']]],
+    ];
+
+    $official_attachment_types = ['html_head', 'feed', 'html_head_link', 'http_header', 'library', 'placeholders', 'drupalSettings', 'html_response_attachment_placeholders'];
+    $official_attachments_with_random_values = [];
+    foreach ($official_attachment_types as $type) {
+      $official_attachments_with_random_values[$type] = $this->randomMachineName();
+    }
+    $random_attachments = ['random' . $this->randomMachineName() => $this->randomMachineName()];
+    $edge_cases = [
+      'all official attachment types, with random assigned values, even if technically not valid, to prove BigPipeResponseAttachmentsProcessor is a perfect decorator' => [$official_attachments_with_random_values],
+      'random attachment type (unofficial), with random assigned value, to prove BigPipeResponseAttachmentsProcessor is a perfect decorator' => [$random_attachments],
+    ];
+
+    $big_pipe_placeholder_attachments = ['big_pipe_placeholders' => $this->randomMachineName()];
+    $big_pipe_nojs_placeholder_attachments = ['big_pipe_nojs_placeholders' => $this->randomMachineName()];
+    $big_pipe_cases = [
+      'only big_pipe_placeholders' => [$big_pipe_placeholder_attachments],
+      'only big_pipe_nojs_placeholders' => [$big_pipe_nojs_placeholder_attachments],
+      'big_pipe_placeholders + big_pipe_nojs_placeholders' => [$big_pipe_placeholder_attachments + $big_pipe_nojs_placeholder_attachments],
+    ];
+
+    $combined_cases = [
+      'all official attachment types + big_pipe_placeholders + big_pipe_nojs_placeholders' => [$official_attachments_with_random_values + $big_pipe_placeholder_attachments + $big_pipe_nojs_placeholder_attachments],
+      'random attachment types + big_pipe_placeholders + big_pipe_nojs_placeholders' => [$random_attachments + $big_pipe_placeholder_attachments + $big_pipe_nojs_placeholder_attachments],
+    ];
+
+    return $typical_cases + $edge_cases + $big_pipe_cases + $combined_cases;
+  }
+
+  /**
+   * Creates a BigPipeResponseAttachmentsProcessor with mostly dummies.
+   *
+   * @param \Prophecy\Prophecy\ObjectProphecy $decorated_html_response_attachments_processor
+   *   An object prophecy implementing AttachmentsResponseProcessorInterface.
+   *
+   * @return \Drupal\big_pipe\Render\BigPipeResponseAttachmentsProcessor
+   *   The BigPipeResponseAttachmentsProcessor to test.
+   */
+  protected function createBigPipeResponseAttachmentsProcessor(ObjectProphecy $decorated_html_response_attachments_processor) {
+    return new BigPipeResponseAttachmentsProcessor(
+      $decorated_html_response_attachments_processor->reveal(),
+      $this->prophesize(AssetResolverInterface::class)->reveal(),
+      $this->prophesize(ConfigFactoryInterface::class)->reveal(),
+      $this->prophesize(AssetCollectionRendererInterface::class)->reveal(),
+      $this->prophesize(AssetCollectionRendererInterface::class)->reveal(),
+      $this->prophesize(RequestStack::class)->reveal(),
+      $this->prophesize(RendererInterface::class)->reveal(),
+      $this->prophesize(ModuleHandlerInterface::class)->reveal()
+    );
+  }
+
+}
diff --git a/core/modules/big_pipe/tests/src/Unit/Render/Placeholder/BigPipeStrategyTest.php b/core/modules/big_pipe/tests/src/Unit/Render/Placeholder/BigPipeStrategyTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..d726a8378a444a287b528ba7cf0eb3789282b591
--- /dev/null
+++ b/core/modules/big_pipe/tests/src/Unit/Render/Placeholder/BigPipeStrategyTest.php
@@ -0,0 +1,104 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Tests\big_pipe\Unit\Render\Placeholder\BigPipeStrategyTest.
+ */
+
+namespace Drupal\Tests\big_pipe\Unit\Render\Placeholder;
+
+use Drupal\big_pipe\Render\Placeholder\BigPipeStrategy;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Session\SessionConfigurationInterface;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\Routing\Route;
+
+/**
+ * @coversDefaultClass \Drupal\big_pipe\Render\Placeholder\BigPipeStrategy
+ * @group big_pipe
+ */
+class BigPipeStrategyTest extends UnitTestCase {
+
+  /**
+   * @covers ::processPlaceholders
+   *
+   * @dataProvider placeholdersProvider
+   */
+  public function testProcessPlaceholders(array $placeholders, $route_match_has_no_big_pipe_option, $request_has_session, $request_has_big_pipe_nojs_cookie, array $expected_big_pipe_placeholders) {
+    $request = new Request();
+    if ($request_has_big_pipe_nojs_cookie) {
+      $request->cookies->set(BigPipeStrategy::NOJS_COOKIE, 1);
+    }
+    $request_stack = $this->prophesize(RequestStack::class);
+    $request_stack->getCurrentRequest()
+      ->willReturn($request);
+
+    $session_configuration = $this->prophesize(SessionConfigurationInterface::class);
+    $session_configuration->hasSession(Argument::type(Request::class))
+      ->willReturn($request_has_session);
+
+    $route = $this->prophesize(Route::class);
+    $route->getOption('_no_big_pipe')
+      ->willReturn($route_match_has_no_big_pipe_option);
+    $route_match = $this->prophesize(RouteMatchInterface::class);
+    $route_match->getRouteObject()
+      ->willReturn($route);
+
+    $big_pipe_strategy = new BigPipeStrategy($session_configuration->reveal(), $request_stack->reveal(), $route_match->reveal());
+    $processed_placeholders = $big_pipe_strategy->processPlaceholders($placeholders);
+
+    if (!$route_match_has_no_big_pipe_option && $request_has_session) {
+      $this->assertSameSize($expected_big_pipe_placeholders, $processed_placeholders, 'BigPipe is able to deliver all placeholders.');
+      foreach (array_keys($placeholders) as $placeholder) {
+        $this->assertSame($expected_big_pipe_placeholders[$placeholder], $processed_placeholders[$placeholder], "Verifying how BigPipeStrategy handles the placeholder '$placeholder'");
+      }
+    }
+    else {
+      $this->assertSame(0, count($processed_placeholders));
+    }
+  }
+
+  /**
+   * @see \Drupal\big_pipe\Tests\BigPipePlaceholderTestCases
+   */
+  public function placeholdersProvider() {
+    $cases = \Drupal\big_pipe\Tests\BigPipePlaceholderTestCases::cases();
+
+    // Generate $placeholders variable as expected by
+    // \Drupal\Core\Render\Placeholder\PlaceholderStrategyInterface::processPlaceholders().
+    $placeholders = [
+      $cases['html']->placeholder                             => $cases['html']->placeholderRenderArray,
+      $cases['html_attribute_value']->placeholder             => $cases['html_attribute_value']->placeholderRenderArray,
+      $cases['html_attribute_value_subset']->placeholder      => $cases['html_attribute_value_subset']->placeholderRenderArray,
+      $cases['edge_case__invalid_html']->placeholder          => $cases['edge_case__invalid_html']->placeholderRenderArray,
+      $cases['edge_case__html_non_lazy_builder']->placeholder => $cases['edge_case__html_non_lazy_builder']->placeholderRenderArray,
+    ];
+
+    return [
+      '_no_big_pipe absent, no session, no-JS cookie absent' => [$placeholders, FALSE, FALSE, FALSE, []],
+      '_no_big_pipe absent, no session, no-JS cookie present' => [$placeholders, FALSE, FALSE, TRUE, []],
+      '_no_big_pipe present, no session, no-JS cookie absent' => [$placeholders, TRUE, FALSE, FALSE, []],
+      '_no_big_pipe present, no session, no-JS cookie present' => [$placeholders, TRUE, FALSE, TRUE, []],
+      '_no_big_pipe present, session, no-JS cookie absent' => [$placeholders, TRUE, TRUE, FALSE, []],
+      '_no_big_pipe present, session, no-JS cookie present' => [$placeholders, TRUE, TRUE, TRUE, []],
+      '_no_big_pipe absent, session, no-JS cookie absent: (JS-powered) BigPipe placeholder used for HTML placeholders' => [$placeholders, FALSE, TRUE, FALSE, [
+        $cases['html']->placeholder                             => $cases['html']->bigPipePlaceholderRenderArray,
+        $cases['html_attribute_value']->placeholder             => $cases['html_attribute_value']->bigPipeNoJsPlaceholderRenderArray,
+        $cases['html_attribute_value_subset']->placeholder      => $cases['html_attribute_value_subset']->bigPipeNoJsPlaceholderRenderArray,
+        $cases['edge_case__invalid_html']->placeholder          => $cases['edge_case__invalid_html']->bigPipeNoJsPlaceholderRenderArray,
+        $cases['edge_case__html_non_lazy_builder']->placeholder => $cases['edge_case__html_non_lazy_builder']->bigPipePlaceholderRenderArray,
+      ]],
+      '_no_big_pipe absent, session, no-JS cookie present: no-JS BigPipe placeholder used for HTML placeholders' => [$placeholders, FALSE, TRUE, TRUE, [
+        $cases['html']->placeholder                             => $cases['html']->bigPipeNoJsPlaceholderRenderArray,
+        $cases['html_attribute_value']->placeholder             => $cases['html_attribute_value']->bigPipeNoJsPlaceholderRenderArray,
+        $cases['html_attribute_value_subset']->placeholder      => $cases['html_attribute_value_subset']->bigPipeNoJsPlaceholderRenderArray,
+        $cases['edge_case__invalid_html']->placeholder          => $cases['edge_case__invalid_html']->bigPipeNoJsPlaceholderRenderArray,
+        $cases['edge_case__html_non_lazy_builder']->placeholder => $cases['edge_case__html_non_lazy_builder']->bigPipeNoJsPlaceholderRenderArray,
+      ]],
+    ];
+  }
+
+}