 core/includes/common.inc                           |  27 -
 core/lib/Drupal/Core/Render/BubbleableMetadata.php |  73 +-
 .../Drupal/Core/Render/Element/StatusMessages.php  |  44 +-
 .../Core/Render/MainContent/HtmlRenderer.php       |  21 +-
 core/lib/Drupal/Core/Render/RenderCache.php        |   1 -
 core/lib/Drupal/Core/Render/Renderer.php           | 245 ++++--
 core/lib/Drupal/Core/Render/RendererInterface.php  |  92 +--
 core/modules/ckeditor/ckeditor.module              |   2 +-
 core/modules/comment/comment.services.yml          |   4 +-
 ...PostRenderCache.php => CommentLazyBuilders.php} |  32 +-
 core/modules/comment/src/CommentLinkBuilder.php    |   4 +-
 core/modules/comment/src/CommentViewBuilder.php    |  20 +-
 .../FieldFormatter/CommentDefaultFormatter.php     |  13 +-
 core/modules/filter/filter.module                  |   4 +-
 core/modules/filter/src/FilterProcessResult.php    |  55 +-
 core/modules/filter/src/Plugin/FilterInterface.php |   6 +-
 core/modules/filter/src/Tests/FilterAPITest.php    |  17 +-
 ...tRenderCache.php => FilterTestPlaceholders.php} |  25 +-
 core/modules/history/history.module                |   2 +-
 core/modules/node/src/NodeViewBuilder.php          |  17 +-
 .../src/Plugin/Block/SystemMessagesBlock.php       |   4 +-
 .../system/src/Tests/Common/PageRenderTest.php     |   4 +-
 core/modules/system/theme.api.php                  |   8 +-
 .../src/Plugin/views/cache/CachePluginBase.php     |   1 -
 .../src/Plugin/views/display/DisplayPluginBase.php |   1 -
 .../Plugin/views/field/FieldHandlerInterface.php   |   2 +-
 core/modules/views/src/Tests/Plugin/CacheTest.php  |   2 +-
 .../views_test_data.views_execution.inc            |   6 +-
 .../Tests/Core/Render/BubbleableMetadataTest.php   |  16 +-
 .../Tests/Core/Render/RendererBubblingTest.php     |  39 +-
 ...rCacheTest.php => RendererPlaceholdersTest.php} | 908 ++++++++++-----------
 .../Tests/Core/Render/RendererRecursionTest.php    |  37 +-
 .../Drupal/Tests/Core/Render/RendererTest.php      |   1 -
 .../Drupal/Tests/Core/Render/RendererTestBase.php  |  51 +-
 34 files changed, 849 insertions(+), 935 deletions(-)

diff --git a/core/includes/common.inc b/core/includes/common.inc
index edcbe2c..6a773b9 100644
--- a/core/includes/common.inc
+++ b/core/includes/common.inc
@@ -1228,33 +1228,6 @@ function show(&$element) {
 }
 
 /**
- * Generates a render cache placeholder.
- *
- * This can be used to generate placeholders, and hence should also be used by
- * #post_render_cache callbacks that want to replace the placeholder with the
- * final markup.
- *
- * @param string $callback
- *   The #post_render_cache callback that will replace the placeholder with its
- *   eventual markup.
- * @param array $context
- *   An array providing context for the #post_render_cache callback. This array
- *   will be altered to provide a 'token' key/value pair, if not already
- *   provided, to uniquely identify the generated placeholder.
- *
- * @return string
- *   The generated placeholder HTML.
- *
- * @throws \Exception
- *
- * @deprecated in Drupal 8.0.x-dev, will be removed before Drupal 9.0.0.
- *   Use \Drupal::service('renderer')->generateCachePlaceholder().
- */
-function drupal_render_cache_generate_placeholder($callback, array &$context) {
-  return \Drupal::service('renderer')->generateCachePlaceholder($callback, $context);
-}
-
-/**
  * Retrieves the default properties for the defined element type.
  *
  * @param $type
diff --git a/core/lib/Drupal/Core/Render/BubbleableMetadata.php b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
index 93a9310..911194e 100644
--- a/core/lib/Drupal/Core/Render/BubbleableMetadata.php
+++ b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
@@ -25,13 +25,6 @@ class BubbleableMetadata extends CacheableMetadata {
   protected $attached = [];
 
   /**
-   * #post_render_cache metadata.
-   *
-   * @var array[]
-   */
-  protected $postRenderCache = [];
-
-  /**
    * Merges the values of another bubbleable metadata object with this one.
    *
    * @param \Drupal\Core\Cache\CacheableMetadata $other
@@ -44,7 +37,6 @@ public function merge(CacheableMetadata $other) {
     $result = parent::merge($other);
     if ($other instanceof BubbleableMetadata) {
       $result->attached = \Drupal::service('renderer')->mergeAttachments($this->attached, $other->attached);
-      $result->postRenderCache = NestedArray::mergeDeep($this->postRenderCache, $other->postRenderCache);
     }
     return $result;
   }
@@ -58,7 +50,6 @@ public function merge(CacheableMetadata $other) {
   public function applyTo(array &$build) {
     parent::applyTo($build);
     $build['#attached'] = $this->attached;
-    $build['#post_render_cache'] = $this->postRenderCache;
   }
 
   /**
@@ -72,82 +63,82 @@ public function applyTo(array &$build) {
   public static function createFromRenderArray(array $build) {
     $meta = parent::createFromRenderArray($build);
     $meta->attached = (isset($build['#attached'])) ? $build['#attached'] : [];
-    $meta->postRenderCache = (isset($build['#post_render_cache'])) ? $build['#post_render_cache'] : [];
     return $meta;
   }
 
   /**
-   * Gets assets.
+   * Gets attachments.
    *
    * @return array
+   *   The attachments
    */
-  public function getAssets() {
+  public function getAttachments() {
     return $this->attached;
   }
 
   /**
-   * Adds assets.
+   * Adds attachments.
    *
-   * @param array $assets
-   *   The associated assets to be attached.
+   * @param array $attachments
+   *   The attachments to add.
    *
    * @return $this
    */
-  public function addAssets(array $assets) {
-    $this->attached = NestedArray::mergeDeep($this->attached, $assets);
+  public function addAttachments(array $attachments) {
+    $this->attached = \Drupal::service('renderer')->mergeAttachments($this->attached, $attachments);
     return $this;
   }
 
   /**
-   * Sets assets.
+   * Sets attachments.
    *
-   * @param array $assets
-   *   The associated assets to be attached.
+   * @param array $attachments
+   *   The attachments to set.
    *
    * @return $this
    */
-  public function setAssets(array $assets) {
-    $this->attached = $assets;
+  public function setAttachments(array $attachments) {
+    $this->attached = $attachments;
     return $this;
   }
 
   /**
-   * Gets #post_render_cache callbacks.
+   * Gets assets.
    *
    * @return array
+   *
+   * @deprecated Use ::getAttachments() instead.
    */
-  public function getPostRenderCacheCallbacks() {
-    return $this->postRenderCache;
+  public function getAssets() {
+    return $this->attached;
   }
 
   /**
-   * Adds #post_render_cache callbacks.
-   *
-   * @param string $callback
-   *   The #post_render_cache callback that will replace the placeholder with
-   *   its eventual markup.
-   * @param array $context
-   *   An array providing context for the #post_render_cache callback.
+   * Adds assets.
    *
-   * @see \Drupal\Core\Render\RendererInterface::generateCachePlaceholder()
+   * @param array $assets
+   *   The associated assets to be attached.
    *
    * @return $this
+   *
+   * @deprecated Use ::addAttachments() instead.
    */
-  public function addPostRenderCacheCallback($callback, array $context) {
-    $this->postRenderCache[$callback][] = $context;
-    return $this;
+  public function addAssets(array $assets) {
+    return $this->addAttachments($assets);
   }
 
   /**
-   * Sets #post_render_cache callbacks.
+   * Sets assets.
    *
-   * @param array $post_render_cache_callbacks
-   *   The associated #post_render_cache callbacks to be executed.
+   * @param array $assets
+   *   The associated assets to be attached.
    *
    * @return $this
+   *
+   * @deprecated Use ::setAttachments() instead.
    */
-  public function setPostRenderCacheCallbacks(array $post_render_cache_callbacks) {
-    $this->postRenderCache = $post_render_cache_callbacks;
+  public function setAssets(array $assets) {
+    $this->attached = $assets;
     return $this;
   }
 
diff --git a/core/lib/Drupal/Core/Render/Element/StatusMessages.php b/core/lib/Drupal/Core/Render/Element/StatusMessages.php
index c169b40..eed1156 100644
--- a/core/lib/Drupal/Core/Render/Element/StatusMessages.php
+++ b/core/lib/Drupal/Core/Render/Element/StatusMessages.php
@@ -41,10 +41,6 @@ public function getInfo() {
    * same placeholder for all places rendering the status messages for this
    * request (e.g. in multiple blocks). This ensures we can put the rendered
    * messages in all placeholders in one go.
-   * Also ensures the same context key is used for the #post_render_cache
-   * property, this ensures that if status messages are rendered multiple times,
-   * their individual (but identical!) #post_render_cache properties are merged,
-   * ensuring the callback is only invoked once.
    *
    * @see ::renderMessages()
    *
@@ -69,25 +65,24 @@ public static function generatePlaceholder(array $element) {
     $key = $plugin_id . $element['#display'];
     $context = [
       'display' => $element['#display'],
-      'token' => Crypt::hmacBase64($key, $hash_salt),
+      'placeholder_token' => Crypt::hmacBase64($key, $hash_salt),
     ];
-    $placeholder = static::renderer()->generateCachePlaceholder($callback, $context);
-    $element['#post_render_cache'] = [
-      $callback => [
-        $key => $context,
+    $element['messages_placeholder'] = [
+      '#lazy_builder' => [
+        $callback => $context,
       ],
+      '#create_placeholder' => TRUE,
     ];
-    $element['#markup'] = $placeholder;
 
     return $element;
   }
 
   /**
-   * #post_render_cache callback; replaces placeholder with messages.
+   * #lazy_builder callback; replaces placeholder with messages.
    *
-   * Note: this is designed to replace all #post_render_cache placeholders for
-   *   messages in a single #post_render_cache callback; hence all placeholders
-   *   must be identical.
+   * Note: this is designed to replace all #lazy_builder placeholders for
+   *   messages in a single #lazy_builder callback; hence all placeholders must
+   *   be identical.
    *
    * @see ::getInfo()
    *
@@ -100,10 +95,8 @@ public static function generatePlaceholder(array $element) {
    *   A renderable array containing the messages.
    */
   public static function renderMessages(array $element, array $context) {
-    $renderer = static::renderer();
-
     // Render the messages.
-    $messages = [
+    $element['messages'] = [
       '#theme' => 'status_messages',
       // @todo Improve when https://www.drupal.org/node/2278383 lands.
       '#message_list' => drupal_get_messages($context['display']),
@@ -113,24 +106,7 @@ public static function renderMessages(array $element, array $context) {
         'warning' => t('Warning message'),
       ],
     ];
-    $markup = $renderer->render($messages);
-
-    // Replace the placeholder.
-    $callback = get_class() . '::renderMessages';
-    $placeholder = $renderer->generateCachePlaceholder($callback, $context);
-    $element['#markup'] = str_replace($placeholder, $markup, $element['#markup']);
-    $element = $renderer->mergeBubbleableMetadata($element, $messages);
 
     return $element;
   }
-
-  /**
-   * Wraps the renderer.
-   *
-   * @return \Drupal\Core\Render\RendererInterface
-   */
-  protected static function renderer() {
-    return \Drupal::service('renderer');
-  }
-
 }
diff --git a/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php b/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php
index addd13c..8240cde 100644
--- a/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php
+++ b/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php
@@ -121,12 +121,11 @@ public function renderResponse(array $main_content, Request $request, RouteMatch
 
     // The three parts of rendered markup in html.html.twig (page_top, page and
     // page_bottom) must be rendered with drupal_render_root(), so that their
-    // #post_render_cache callbacks are executed (which may attach additional
-    // assets).
+    // placeholders are replaced (which may attach additional assets).
     // html.html.twig must be able to render the final list of attached assets,
-    // and hence may not execute any #post_render_cache_callbacks (because they
-    // might add yet more assets to be attached), and therefore it must be
-    // rendered with drupal_render(), not drupal_render_root().
+    // and hence may not replace any placeholders (because they might add yet
+    // more assets to be attached), and therefore it must be rendered with
+    // drupal_render(), not drupal_render_root().
     $this->renderer->render($html['page'], TRUE);
     if (isset($html['page_top'])) {
       $this->renderer->render($html['page_top'], TRUE);
@@ -197,8 +196,8 @@ protected function prepare(array $main_content, Request $request, RouteMatchInte
 
       // We must render the main content now already, because it might provide a
       // title. We set its $is_root_call parameter to FALSE, to ensure
-      // #post_render_cache callbacks are not yet applied. This is essentially
-      // "pre-rendering" the main content, the "full rendering" will happen in
+      // placeholders are not yet replaced. This is essentially "pre-rendering"
+      // the main content, the "full rendering" will happen in
       // ::renderResponse().
       // @todo Remove this once https://www.drupal.org/node/2359901 lands.
       if (!empty($main_content)) {
@@ -264,15 +263,15 @@ public function invokePageAttachmentHooks(array &$page) {
       $function = $module . '_page_attachments';
       $function($attachments);
     }
-    if (array_diff(array_keys($attachments), ['#attached', '#post_render_cache', '#cache']) !== []) {
-      throw new \LogicException('Only #attached, #post_render_cache and #cache may be set in hook_page_attachments().');
+    if (array_diff(array_keys($attachments), ['#attached', '#cache']) !== []) {
+      throw new \LogicException('Only #attached and #cache may be set in hook_page_attachments().');
     }
 
     // Modules and themes can alter page attachments.
     $this->moduleHandler->alter('page_attachments', $attachments);
     \Drupal::theme()->alter('page_attachments', $attachments);
-    if (array_diff(array_keys($attachments), ['#attached', '#post_render_cache', '#cache']) !== []) {
-      throw new \LogicException('Only #attached, #post_render_cache and #cache may be set in hook_page_attachments_alter().');
+    if (array_diff(array_keys($attachments), ['#attached', '#cache']) !== []) {
+      throw new \LogicException('Only #attached and #cache may be set in hook_page_attachments_alter().');
     }
 
     // Merge the attachments onto the $page render array.
diff --git a/core/lib/Drupal/Core/Render/RenderCache.php b/core/lib/Drupal/Core/Render/RenderCache.php
index aa071cc..c1a2e84 100644
--- a/core/lib/Drupal/Core/Render/RenderCache.php
+++ b/core/lib/Drupal/Core/Render/RenderCache.php
@@ -298,7 +298,6 @@ public function getCacheableRenderArray(array $elements) {
     $data = [
       '#markup' => $elements['#markup'],
       '#attached' => $elements['#attached'],
-      '#post_render_cache' => $elements['#post_render_cache'],
       '#cache' => [
         'contexts' => $elements['#cache']['contexts'],
         'tags' => $elements['#cache']['tags'],
diff --git a/core/lib/Drupal/Core/Render/Renderer.php b/core/lib/Drupal/Core/Render/Renderer.php
index 9ba7613..c9ae440 100644
--- a/core/lib/Drupal/Core/Render/Renderer.php
+++ b/core/lib/Drupal/Core/Render/Renderer.php
@@ -10,9 +10,11 @@
 use Drupal\Component\Utility\Crypt;
 use Drupal\Component\Utility\NestedArray;
 use Drupal\Component\Utility\SafeMarkup;
+use Drupal\Component\Utility\UrlHelper;
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Controller\ControllerResolverInterface;
+use Drupal\Core\Template\Attribute;
 use Drupal\Core\Theme\ThemeManagerInterface;
 
 /**
@@ -105,12 +107,37 @@ public function renderPlain(&$elements) {
   /**
    * {@inheritdoc}
    */
+  public function renderPlaceholder($placeholder, array $elements) {
+    // Get the render array for the given placeholder
+    $placeholder_elements = $elements['#attached']['placeholders'][$placeholder];
+    // Make sure the same placeholder is not created again; that'd lead to an
+    // infinite loop.
+    $placeholder_elements['#create_placeholder'] = FALSE;
+
+    // Render the placeholder into markup.
+    $markup = $this->renderPlain($placeholder_elements);
+
+    // Replace the placeholder with its rendered markup, and merge its
+    // bubbleable metadata with the main element's.
+    $elements['#markup'] = str_replace($placeholder, $markup, $elements['#markup']);
+    $elements = $this->mergeBubbleableMetadata($elements, $placeholder_elements);
+
+    // Remove the placeholder that we've just rendered.
+    unset($elements['#attached']['placeholders'][$placeholder]);
+
+    return $elements;
+  }
+
+
+  /**
+   * {@inheritdoc}
+   */
   public function render(&$elements, $is_root_call = FALSE) {
-    // Since #pre_render, #post_render, #post_render_cache callbacks and theme
-    // functions/templates may be used for generating a render array's content,
-    // and we might be rendering the main content for the page, it is possible
-    // that any of them throw an exception that will cause a different page to
-    // be rendered (e.g. throwing
+    // Since #pre_render, #post_render, #lazy_builder callbacks and theme
+    // functions or templates may be used for generating a render array's
+    // content, and we might be rendering the main content for the page, it is
+    // possible that any of them throw an exception that will cause a different
+    // page to be rendered (e.g. throwing
     // \Symfony\Component\HttpKernel\Exception\NotFoundHttpException will cause
     // the 404 page to be rendered). That page might also use Renderer::render()
     // but if exceptions aren't caught here, the stack will be left in an
@@ -167,17 +194,17 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
       }
     }
 
-    // Try to fetch the prerendered element from cache, run any
-    // #post_render_cache callbacks and return the final markup.
+    // Try to fetch the prerendered element from cache, replace any placeholders
+    // and return the final markup.
     if (isset($elements['#cache']['keys'])) {
       $cached_element = $this->renderCache->get($elements);
       if ($cached_element !== FALSE) {
         $elements = $cached_element;
-        // Only when we're not in a root (non-recursive) drupal_render() call,
-        // #post_render_cache callbacks must be executed, to prevent breaking
-        // the render cache in case of nested elements with #cache set.
+        // Only when we're in a root (non-recursive) Renderer::render() call,
+        // placeholders must be processed, to prevent breaking the render cache
+        // in case of nested elements with #cache set.
         if ($is_root_call) {
-          $this->processPostRenderCache($elements);
+          $this->replacePlaceholders($elements);
         }
         // Mark the element markup as safe. If we have cached children, we need
         // to mark them as safe too. The parent markup contains the child
@@ -211,9 +238,58 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
       $elements += $this->elementInfo->getInfo($elements['#type']);
     }
 
+    // First validate the usage of #lazy_builder; both of the next if-statements
+    // use it if available.
+    if (isset($elements['#lazy_builder'])) {
+      // @todo Convert to assertions once https://www.drupal.org/node/2408013
+      //   lands.
+      if (count($elements['#lazy_builder']) > 1) {
+        throw new \DomainException('Only a single #lazy_builder callback can be specified.');
+      }
+      $lazy_builder_context = $elements['#lazy_builder'][array_keys($elements['#lazy_builder'])[0]];
+      if (count($lazy_builder_context) !== count(array_filter($lazy_builder_context, function($v) { return is_null($v) || is_scalar($v); }))) {
+        throw new \DomainException("A #lazy_builder callback's context may only contain scalar values or NULL.");
+      }
+      $children = Element::children($elements);
+      if ($children) {
+        throw new \DomainException(sprintf('When a #lazy_builder callback is specified, no children can exist; all children must be generated by the #lazy_builder callback. You specified the following children: %s.', implode(', ', $children)));
+      }
+      $supported_keys = [
+        '#lazy_builder',
+        '#cache',
+        '#create_placeholder',
+        // These keys are not actually supported, but they are added automatically
+        // by the Renderer, so we don't crash on them; them being missing when
+        // their #lazy_builder callback is invoked won't surprise the developer.
+        '#weight',
+        '#printed'
+      ];
+      $unsupported_keys = array_diff(array_keys($elements), $supported_keys);
+      if (count($unsupported_keys)) {
+        throw new \DomainException(sprintf('When a #lazy_builder callback is specified, no properties can exist; all properties must be generated by the #lazy_builder callback. You specified the following properties: %s.', implode(', ', $unsupported_keys)));
+      }
+    }
+    // If instructed to create a placeholder, and a #lazy_builder callback are
+    // present (without such a callback, it would be impossible to replace the
+    // placeholder), replace the current element with a placeholder.
+    if (isset($elements['#create_placeholder']) && $elements['#create_placeholder'] === TRUE && isset($elements['#lazy_builder'])) {
+      $elements = $this->createPlaceholder($elements);
+    }
     // Make any final changes to the element before it is rendered. This means
     // that the $element or the children can be altered or corrected before the
     // element is rendered into the final text.
+    // #lazy_builder is recommended, but #pre_render is still supported: we
+    // don't want to break backwards compatibility.
+    if (isset($elements['#lazy_builder'])) {
+      foreach ($elements['#lazy_builder'] as $callable => $context) {
+        if (is_string($callable) && strpos($callable, '::') === FALSE) {
+          $callable = $this->controllerResolver->getControllerFromDefinition($callable);
+        }
+        $elements = call_user_func($callable, $elements, $context);
+      }
+      unset($elements['#lazy_builder']);
+      $elements['#built'] = TRUE;
+    }
     if (isset($elements['#pre_render'])) {
       foreach ($elements['#pre_render'] as $callable) {
         if (is_string($callable) && strpos($callable, '::') === FALSE) {
@@ -227,7 +303,6 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
     $elements['#cache']['tags'] = isset($elements['#cache']['tags']) ? $elements['#cache']['tags'] : array();
     $elements['#cache']['max-age'] = isset($elements['#cache']['max-age']) ? $elements['#cache']['max-age'] : Cache::PERMANENT;
     $elements['#attached'] = isset($elements['#attached']) ? $elements['#attached'] : array();
-    $elements['#post_render_cache'] = isset($elements['#post_render_cache']) ? $elements['#post_render_cache'] : array();
 
     // Allow #pre_render to abort rendering.
     if (!empty($elements['#printed'])) {
@@ -352,9 +427,9 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
     }
 
     // We store the resulting output in $elements['#markup'], to be consistent
-    // with how render cached output gets stored. This ensures that
-    // #post_render_cache callbacks get the same data to work with, no matter if
-    // #cache is disabled, #cache is enabled, there is a cache hit or miss.
+    // with how render cached output gets stored. This ensures that placeholder
+    // replacement logic gets the same data to work with, no matter if #cache is
+    // disabled, #cache is enabled, there is a cache hit or miss.
     $prefix = isset($elements['#prefix']) ? SafeMarkup::checkAdminXss($elements['#prefix']) : '';
     $suffix = isset($elements['#suffix']) ? SafeMarkup::checkAdminXss($elements['#suffix']) : '';
 
@@ -372,9 +447,9 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
       $this->renderCache->set($elements, $pre_bubbling_elements);
     }
 
-    // Only when we're in a root (non-recursive) drupal_render() call,
-    // #post_render_cache callbacks must be executed, to prevent breaking the
-    // render cache in case of nested elements with #cache set.
+    // Only when we're in a root (non-recursive) Renderer::render() call,
+    // placeholders must be processed, to prevent breaking the render cache in
+    // case of nested elements with #cache set.
     //
     // By running them here, we ensure that:
     // - they run when #cache is disabled,
@@ -382,21 +457,7 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
     // Only the case of a cache hit when #cache is enabled, is not handled here,
     // that is handled earlier in Renderer::render().
     if ($is_root_call) {
-      // We've already called ::updateStack() earlier, which updated both the
-      // element and current stack frame. However,
-      // Renderer::processPostRenderCache() can both change the element
-      // further and create and render new child elements, so provide a fresh
-      // stack frame to collect those additions, merge them back to the element,
-      // and then update the current frame to match the modified element state.
-      do {
-        static::$stack->push(new BubbleableMetadata());
-        $this->processPostRenderCache($elements);
-        $post_render_additions = static::$stack->pop();
-        $elements['#post_render_cache'] = NULL;
-        BubbleableMetadata::createFromRenderArray($elements)
-          ->merge($post_render_additions)
-          ->applyTo($elements);
-      } while (!empty($elements['#post_render_cache']));
+      $this->replacePlaceholders($elements);
       if (static::$stack->count() !== 1) {
         throw new \LogicException('A stray drupal_render() invocation with $is_root_call = TRUE is causing bubbling of attached assets to break.');
       }
@@ -460,36 +521,85 @@ protected function bubbleStack() {
   }
 
   /**
-   * Processes #post_render_cache callbacks.
+   * Replaces placeholders.
    *
-   * #post_render_cache callbacks may modify:
-   * - #markup: to replace placeholders
-   * - #attached: to add libraries or JavaScript settings
-   * - #post_render_cache: to execute additional #post_render_cache callbacks
+   * Placeholders may have:
+   * - #lazy_builder callback, to build a render array to be rendered into
+   *   markup that can replace the placeholder
+   * - #cache: to cache the result of the placeholder
    *
-   * Note that in either of these cases, #post_render_cache callbacks are
-   * implicitly idempotent: a placeholder that has been replaced can't be
-   * replaced again, and duplicate attachments are ignored.
+   * Also merges the bubbleable metadata resulting from the rendering of the
+   * contents of the placeholders. Hence $elements will be contain the entirety
+   * of bubbleable metadata.
    *
    * @param array &$elements
-   *   The structured array describing the data being rendered.
+   *   The structured array describing the data being rendered. Including the
+   *   bubbleable metadata associated with the markup that replaced the
+   *   placeholders.
+   *
+   * @returns bool
+   *   Whether placeholders were replaced.
    */
-  protected function processPostRenderCache(array &$elements) {
-    if (isset($elements['#post_render_cache'])) {
+  protected function replacePlaceholders(array &$elements) {
+    if (!isset($elements['#attached']['placeholders']) || empty($elements['#attached']['placeholders'])) {
+      return FALSE;
+    }
 
-      // Call all #post_render_cache callbacks, passing the provided context.
-      foreach (array_keys($elements['#post_render_cache']) as $callback) {
-        if (strpos($callback, '::') === FALSE) {
-          $callable = $this->controllerResolver->getControllerFromDefinition($callback);
-        }
-        else {
-          $callable = $callback;
-        }
-        foreach ($elements['#post_render_cache'][$callback] as $context) {
-          $elements = call_user_func_array($callable, array($elements, $context));
-        }
-      }
+    foreach (array_keys($elements['#attached']['placeholders']) as $placeholder) {
+      $elements = $this->renderPlaceholder($placeholder, $elements);
+    }
+
+    return TRUE;
+  }
+
+  /**
+   * Turns this element into a placeholder.
+   *
+   * Placeholdering allows us to avoid "poor cacheability infection": this maps
+   * the current render array to one that only has #markup and #attached, and
+   * #attached contains a placeholder with this element's prior cacheability
+   * metadata. In other words: this placeholder is perfectly cacheable, the
+   * placeholder replacement logic effectively cordons off poor cacheability.
+   *
+   * @param array $element
+   *   The render array to create a placeholder for.
+   *
+   * @return array
+   *   Render array with placeholder markup and the attached placeholder
+   *   replacement metadata.
+   */
+  protected function createPlaceholder(array $element) {
+    $placeholder_render_array = array_intersect_key($element, [
+      // Placeholders are replaced with markup by executing the associated
+      // #lazy_builder callback, which generates a render array, and which the
+      // Renderer will render and replace the placeholder with.
+      '#lazy_builder' => TRUE,
+      // The cacheability metadata for the placeholder. The rendered result of
+      // the placeholder may itself be cached, if [#cache][keys] are specified.
+      '#cache' => TRUE,
+    ]);
+
+    // Generate placeholder markup.
+    $attributes = new Attribute();
+    $callback = array_keys($placeholder_render_array['#lazy_builder'])[0];
+    $args = $placeholder_render_array['#lazy_builder'][$callback];
+    // Also generate a unique placeholder token if it doesn't exist already,
+    // to prevent collisions.
+    $args += ['placeholder_token' => Crypt::randomBytesBase64(55)];
+
+    $value = $callback;
+    $query = UrlHelper::buildQuery($args);
+    if ($query) {
+      $value .= '?' . $query;
     }
+    $attributes['callback'] = $value;
+    $placeholder_markup = '<drupal-render-cache-placeholder' . $attributes . '></drupal-render-cache-placeholder>';
+
+    // Build the placeholder element to return.
+    $placeholder_element = [];
+    $placeholder_element['#markup'] = $placeholder_markup;
+    $placeholder_element['#attached']['placeholders'][$placeholder_markup] = $placeholder_render_array;
+    return $placeholder_element;
   }
 
   /**
@@ -533,27 +643,4 @@ public function mergeAttachments(array $a, array $b) {
     return $a;
   }
 
-  /**
-   * {@inheritdoc}
-   */
-  public function generateCachePlaceholder($callback, array &$context) {
-    if (is_string($callback) && strpos($callback, '::') === FALSE) {
-      $callable = $this->controllerResolver->getControllerFromDefinition($callback);
-    }
-    else {
-      $callable = $callback;
-    }
-
-    if (!is_callable($callable)) {
-      throw new \InvalidArgumentException('$callable must be a callable function or of the form service_id:method.');
-    }
-
-    // Generate a unique token if one is not already provided.
-    $context += [
-      'token' => Crypt::randomBytesBase64(55),
-    ];
-
-    return '<drupal-render-cache-placeholder callback="' . $callback . '" token="' . $context['token'] . '"></drupal-render-cache-placeholder>';
-  }
-
 }
diff --git a/core/lib/Drupal/Core/Render/RendererInterface.php b/core/lib/Drupal/Core/Render/RendererInterface.php
index d407f0f..dfad73f 100644
--- a/core/lib/Drupal/Core/Render/RendererInterface.php
+++ b/core/lib/Drupal/Core/Render/RendererInterface.php
@@ -15,8 +15,7 @@
   /**
    * Renders final HTML given a structured array tree.
    *
-   * Calls ::render() in such a way that #post_render_cache callbacks are
-   * applied.
+   * Calls ::render() in such a way that placeholders are replaced.
    *
    * Should therefore only be used in occasions where the final rendering is
    * happening, just before sending a Response:
@@ -36,8 +35,7 @@ public function renderRoot(&$elements);
   /**
    * Renders final HTML in situations where no assets are needed.
    *
-   * Calls ::render() in such a way that #post_render_cache callbacks are
-   * applied.
+   * Calls ::render() in such a way that placeholders are replaced.
    *
    * Useful for e.g. rendering the values of tokens or e-mails, which need a
    * render array being turned into a string, but don't need any of the
@@ -88,8 +86,8 @@ public function renderPlain(&$elements);
    *   retrieval.
    * - Cache tags, so that cached renderings are invalidated when site content
    *   or configuration that can affect that rendering changes.
-   * - #post_render_cache callbacks, for executing code to handle dynamic
-   *   requirements that cannot be cached.
+   * - Placeholders, with associated self-contained placeholder render arrays,
+   *   for executing code to handle dynamic requirements that cannot be cached.
    * A stack of \Drupal\Core\Render\BubbleableMetadata objects can be used to
    * perform this bubbling.
    *
@@ -148,15 +146,31 @@ public function renderPlain(&$elements);
    *     process render arrays and call the element info service before passing
    *     the array to Renderer::render(), such as form_builder() in the Form
    *     API.
-   *   - If this element has an array of #pre_render functions defined, they are
-   *     called sequentially to modify the element before rendering. After all
-   *     the #pre_render functions have been called, #printed is checked a
-   *     second time in case a #pre_render function flags the element as
-   *     printed. If #printed is set, we return early and hence no rendering
-   *     work is left to be done, similarly to a render cache hit. Once again,
-   *     the empty (and topmost) frame that was just pushed onto the stack is
-   *     updated with all bubbleable rendering metadata from the element whose
-   *     #printed = TRUE.
+   *   - If this element has #create_placeholder set to TRUE, and it has a
+   *     #lazy_builder callback, then the element is replaced with another
+   *     element that has only two properties: #markup and #attached. #markup
+   *     will contain placeholder markup, and #attached contains the placeholder
+   *     metadata, that will be used for replacing this placeholder. That
+   *     metadata contains a very compact render array (containing only
+   *     #lazy_builder and #cache) that will be rendered to replace the
+   *     placeholder with its final markup. This means that when the
+   *     #lazy_builder callback is called, it received a render array to add to
+   *     that only contains #cache.
+   *   - If this element has a #lazy_builder or an array of #pre_render
+   *     functions defined, they are called sequentially to modify the element
+   *     before rendering. #lazy_builder is preferred, since it allows for
+   *     placeholdering (see previous step), but #pre_render is still supported.
+   *     Both have their use case: #lazy_builder is for building a render array,
+   *     #pre_render is for decorating an existing render array.
+   *     After the #lazy_builder function is called, #lazy_builder is removed,
+   *     and #built is set to TRUE.
+   *     After the #lazy_builder and all #pre_render functions have been called,
+   *     #printed is checked a second time in case a #lazy_builder or
+   *     #pre_render function flags the element as printed. If #printed is set,
+   *     we return early and hence no rendering work is left to be done,
+   *     similarly to a render cache hit. Once again, the empty (and topmost)
+   *     frame that was just pushed onto the stack is updated with all
+   *     bubbleable rendering metadata from the element whose #printed = TRUE.
    *     Then, this stack frame is bubbled: the two topmost frames are popped
    *     from the stack, they are merged, and the result is pushed back onto the
    *     stack.
@@ -253,25 +267,14 @@ public function renderPlain(&$elements);
    *     assumes only children's individual markup is relevant and ignores the
    *     parent markup. This approach is normally not needed and should be
    *     adopted only when dealing with very advanced use cases.
-   *   - If this element has an array of #post_render_cache functions defined,
+   *   - If this element has attached placeholders ([#attached][placeholders]),
    *     or any of its children has (which we would know thanks to the stack
-   *     having been updated just before the render caching step), they are
-   *     called sequentially to replace placeholders in the final #markup and
-   *     extend #attached. Placeholders must contain a unique token, to
-   *     guarantee that e.g. samples of placeholders are not replaced also. But,
-   *     since #post_render_cache callbacks add attach additional assets, the
-   *     correct bubbling of those must once again be taken into account. This
-   *     final stage of rendering should be considered as if it were the parent
-   *     of the current element, because it takes that as its input, and then
-   *     alters its #markup. Hence, just before calling the #post_render_cache
-   *     callbacks, a new empty frame is pushed onto the stack, where all assets
-   *     #attached during the execution of those callbacks will end up in. Then,
-   *     after the execution of those callbacks, we merge that back into the
-   *     element. Note that these callbacks run always: when hitting the render
-   *     cache, when missing, or when render caching is not used at all. This is
-   *     done to allow any Drupal module to customize other render arrays
-   *     without breaking the render cache if it is enabled, and to not require
-   *     it to use other logic when render caching is disabled.
+   *     having been updated just before the render caching step), its
+   *     placeholder element containing a #lazy_builder function is rendered in
+   *     isolation. The resulting markup is used to replace the placeholder, and
+   *     any bubbleable metadata is merged.
+   *     Placeholders must be unique, to guarantee that e.g. samples of
+   *     placeholders are not replaced as well.
    *   - Just before finishing the rendering of this element, this element's
    *     stack frame (the topmost one) is bubbled: the two topmost frames are
    *     popped from the stack, they are merged and the result is pushed back
@@ -390,27 +393,4 @@ public function addCacheableDependency(array &$elements, $dependency);
    */
   public function mergeAttachments(array $a, array $b);
 
-  /**
-   * Generates a render cache placeholder.
-   *
-   * This can be used to generate placeholders, and hence should also be used by
-   * #post_render_cache callbacks that want to replace the placeholder with the
-   * final markup.
-   *
-   * @param string $callback
-   *   The #post_render_cache callback that will replace the placeholder with its
-   *   eventual markup.
-   * @param array $context
-   *   An array providing context for the #post_render_cache callback. This array
-   *   will be altered to provide a 'token' key/value pair, if not already
-   *   provided, to uniquely identify the generated placeholder.
-   *
-   * @return string
-   *   The generated placeholder HTML.
-   *
-   * @throws \InvalidArgumentException
-   *   Thrown when no valid callable got passed in.
-   */
-  public function generateCachePlaceholder($callback, array &$context);
-
 }
diff --git a/core/modules/ckeditor/ckeditor.module b/core/modules/ckeditor/ckeditor.module
index 674fa0f..f5c7339 100644
--- a/core/modules/ckeditor/ckeditor.module
+++ b/core/modules/ckeditor/ckeditor.module
@@ -131,7 +131,7 @@ function ckeditor_filter_format_update() {
  * cause the CKEditor::getJSSettings() to be called, which will cause
  * Internal::generateFormatTagsSetting() to be called, which calls
  * check_markup(), which finally calls drupal_render() non-recursively, because
- * a filter might apply #post_render_cache callbacks.
+ * a filter might add placeholders to replace.
  * This would be a root call inside a root call, which breaks the stack-based
  * logic for bubbling rendering metadata.
  * Therefore this pre-calculates the needed values, and hence performs the
diff --git a/core/modules/comment/comment.services.yml b/core/modules/comment/comment.services.yml
index 7f82f97..daa579a 100644
--- a/core/modules/comment/comment.services.yml
+++ b/core/modules/comment/comment.services.yml
@@ -15,8 +15,8 @@ services:
     tags:
       - { name: backend_overridable }
 
-  comment.post_render_cache:
-    class: Drupal\comment\CommentPostRenderCache
+  comment.lazy_builders:
+    class: Drupal\comment\CommentLazyBuilders
     arguments: ['@entity.manager', '@entity.form_builder', '@current_user', '@comment.manager', '@module_handler', '@renderer']
 
   comment.link_builder:
diff --git a/core/modules/comment/src/CommentPostRenderCache.php b/core/modules/comment/src/CommentLazyBuilders.php
similarity index 88%
rename from core/modules/comment/src/CommentPostRenderCache.php
rename to core/modules/comment/src/CommentLazyBuilders.php
index 960c06f..bd3d394 100644
--- a/core/modules/comment/src/CommentPostRenderCache.php
+++ b/core/modules/comment/src/CommentLazyBuilders.php
@@ -2,7 +2,7 @@
 
 /**
  * @file
- * Contains \Drupal\comment\CommentPostRenderCache.
+ * Contains \Drupal\comment\CommentLazyBuilders.
  */
 
 namespace Drupal\comment;
@@ -17,9 +17,9 @@
 use Drupal\Core\Render\Renderer;
 
 /**
- * Defines a service for comment post render cache callbacks.
+ * Defines a service for comment #lazy_builder callbacks.
  */
-class CommentPostRenderCache {
+class CommentLazyBuilders {
 
   /**
    * The entity manager service.
@@ -64,7 +64,7 @@ class CommentPostRenderCache {
   protected $renderer;
 
   /**
-   * Constructs a new CommentPostRenderCache object.
+   * Constructs a new CommentLazyBuilders object.
    *
    * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
    *   The entity manager service.
@@ -89,7 +89,7 @@ public function __construct(EntityManagerInterface $entity_manager, EntityFormBu
   }
 
   /**
-   * #post_render_cache callback; replaces placeholder with comment form.
+   * #lazy_builder callback; replaces placeholder with comment form.
    *
    * @param array $element
    *   The renderable array that contains the to be replaced placeholder.
@@ -112,19 +112,13 @@ public function renderForm(array $element, array $context) {
       'pid' => NULL,
     );
     $comment = $this->entityManager->getStorage('comment')->create($values);
-    $form = $this->entityFormBuilder->getForm($comment);
-    $markup = $this->renderer->render($form);
-
-    $callback = 'comment.post_render_cache:renderForm';
-    $placeholder = $this->generatePlaceholder($callback, $context);
-    $element['#markup'] = str_replace($placeholder, $markup, $element['#markup']);
-    $element = $this->renderer->mergeBubbleableMetadata($element, $form);
+    $element['form'] = $this->entityFormBuilder->getForm($comment);
 
     return $element;
   }
 
   /**
-   * #post_render_cache callback; replaces the placeholder with comment links.
+   * #lazy_builder callback; replaces the placeholder with comment links.
    *
    * Renders the links on a comment.
    *
@@ -143,8 +137,6 @@ public function renderForm(array $element, array $context) {
    *   A renderable array representing the comment links.
    */
   public function renderLinks(array $element, array $context) {
-    $callback = 'comment.post_render_cache:renderLinks';
-    $placeholder = $this->generatePlaceholder($callback, $context);
     $links = array(
       '#theme' => 'links__comment',
       '#pre_render' => array('drupal_pre_render_links'),
@@ -166,8 +158,7 @@ public function renderLinks(array $element, array $context) {
       );
       $this->moduleHandler->alter('comment_links', $links, $entity, $hook_context);
     }
-    $markup = $this->renderer->render($links);
-    $element['#markup'] = str_replace($placeholder, $markup, $element['#markup']);
+    $element['links'] = $links;
 
     return $element;
   }
@@ -298,13 +289,6 @@ public function attachNewCommentsLinkMetadata(array $element, array $context) {
   }
 
   /**
-   * Wraps drupal_render_cache_generate_placeholder().
-   */
-  protected function generatePlaceholder($callback, $context) {
-    return drupal_render_cache_generate_placeholder($callback, $context);
-  }
-
-  /**
    * Wraps content_translation_translate_access.
    */
   protected function access(EntityInterface $entity) {
diff --git a/core/modules/comment/src/CommentLinkBuilder.php b/core/modules/comment/src/CommentLinkBuilder.php
index 951a69f..5605698 100644
--- a/core/modules/comment/src/CommentLinkBuilder.php
+++ b/core/modules/comment/src/CommentLinkBuilder.php
@@ -202,10 +202,12 @@ public function buildCommentedEntityLinks(FieldableEntityInterface $entity, arra
 
           // Embed the metadata for the "X new comments" link (if any) on this
           // entity.
+          // @todo Update these to use #lazy_builder once
+          //   https://www.drupal.org/node/2496399 is fixed.
           $entity_links['comment__' . $field_name]['#post_render_cache']['history_attach_timestamp'] = array(
             array('node_id' => $entity->id()),
           );
-          $entity_links['comment__' . $field_name]['#post_render_cache']['comment.post_render_cache:attachNewCommentsLinkMetadata'] = array(
+          $entity_links['comment__' . $field_name]['#post_render_cache']['comment.lazy_builders:attachNewCommentsLinkMetadata'] = array(
             array(
               'entity_type' => $entity->getEntityTypeId(),
               'entity_id' => $entity->id(),
diff --git a/core/modules/comment/src/CommentViewBuilder.php b/core/modules/comment/src/CommentViewBuilder.php
index 33187d2..a5a5ade 100644
--- a/core/modules/comment/src/CommentViewBuilder.php
+++ b/core/modules/comment/src/CommentViewBuilder.php
@@ -134,7 +134,7 @@ public function buildComponents(array &$build, array $entities, array $displays,
 
       $display = $displays[$entity->bundle()];
       if ($display->getComponent('links')) {
-        $callback = 'comment.post_render_cache:renderLinks';
+        $callback = 'comment.lazy_builders:renderLinks';
         $context = array(
           'comment_entity_id' => $entity->id(),
           'view_mode' => $view_mode,
@@ -143,14 +143,11 @@ public function buildComponents(array &$build, array $entities, array $displays,
           'commented_entity_id' => $commented_entity->id(),
           'in_preview' => !empty($entity->in_preview),
         );
-        $placeholder = drupal_render_cache_generate_placeholder($callback, $context);
         $build[$id]['links'] = array(
-          '#post_render_cache' => array(
-            $callback => array(
-              $context,
-            ),
+          '#lazy_builder' => array(
+            $callback => $context,
           ),
-          '#markup' => $placeholder,
+          '#create_placeholder' => TRUE,
         );
       }
 
@@ -162,9 +159,12 @@ public function buildComponents(array &$build, array $entities, array $displays,
         $build[$id]['#attached']['library'][] = 'comment/drupal.comment-new-indicator';
 
         // Embed the metadata for the comment "new" indicators on this node.
-        $build[$id]['#post_render_cache']['history_attach_timestamp'] = array(
-          array('node_id' => $commented_entity->id()),
-        );
+        $build[$id]['history'] = [
+          '#lazy_builder' => [
+            'history_attach_timestamp' => array('node_id' => $commented_entity->id()),
+          ],
+          '#create_placeholder' => TRUE,
+        ];
       }
     }
     if ($build[$id]['#comment_threaded']) {
diff --git a/core/modules/comment/src/Plugin/Field/FieldFormatter/CommentDefaultFormatter.php b/core/modules/comment/src/Plugin/Field/FieldFormatter/CommentDefaultFormatter.php
index fbca55b..f851603 100644
--- a/core/modules/comment/src/Plugin/Field/FieldFormatter/CommentDefaultFormatter.php
+++ b/core/modules/comment/src/Plugin/Field/FieldFormatter/CommentDefaultFormatter.php
@@ -197,23 +197,20 @@ public function viewElements(FieldItemListInterface $items) {
             $output['comment_form'] = $this->entityFormBuilder->getForm($comment);
           }
           // All other users need a user-specific form, which would break the
-          // render cache: hence use a #post_render_cache callback.
+          // render cache: hence use a #lazy_builder callback.
           else {
-            $callback = 'comment.post_render_cache:renderForm';
+            $callback = 'comment.lazy_builders:renderForm';
             $context = array(
               'entity_type' => $entity->getEntityTypeId(),
               'entity_id' => $entity->id(),
               'field_name' => $field_name,
               'comment_type' => $this->getFieldSetting('comment_type'),
             );
-            $placeholder = drupal_render_cache_generate_placeholder($callback, $context);
             $output['comment_form'] = array(
-              '#post_render_cache' => array(
-                $callback => array(
-                  $context,
-                ),
+              '#lazy_builder' => array(
+                 $callback => $context,
               ),
-              '#markup' => $placeholder,
+              '#create_placeholder' => TRUE,
             );
           }
         }
diff --git a/core/modules/filter/filter.module b/core/modules/filter/filter.module
index 51b89e7..afdaadf 100644
--- a/core/modules/filter/filter.module
+++ b/core/modules/filter/filter.module
@@ -306,8 +306,8 @@ function filter_fallback_format() {
  * Note: this function should only be used when filtering text for use elsewhere
  * than on a rendered HTML page. If this is part of a HTML page, then a
  * renderable array with a #type 'processed_text' element should be used instead
- * of this, because that will allow cache tags to be set and bubbled up, assets
- * to be loaded and #post_render_cache callbacks to be associated. In other
+ * of this, because that will allow cacheability metadata to be set and bubbled
+ * up and attachments to be associated (assets, placeholders, etc.). In other
  * words: if you are presenting the filtered text in a HTML page, the only way
  * this will be presented correctly, is by using the 'processed_text' element.
  *
diff --git a/core/modules/filter/src/FilterProcessResult.php b/core/modules/filter/src/FilterProcessResult.php
index 80ad9e5..cd5ff5b 100644
--- a/core/modules/filter/src/FilterProcessResult.php
+++ b/core/modules/filter/src/FilterProcessResult.php
@@ -7,9 +7,13 @@
 
 namespace Drupal\filter;
 
+use Drupal\Component\Utility\Crypt;
+use Drupal\Component\Utility\Html;
 use Drupal\Component\Utility\NestedArray;
+use Drupal\Component\Utility\UrlHelper;
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Render\BubbleableMetadata;
+use Drupal\Core\Template\Attribute;
 
 /**
  * Used to return values from a text filter plugin's processing method.
@@ -46,7 +50,7 @@
  *   $result = new FilterProcess($text);
  *
  *   // Associate assets to be attached.
- *   $result->setAssets(array(
+ *   $result->setAttachments(array(
  *     'library' => array(
  *        'filter/caption',
  *     ),
@@ -116,4 +120,53 @@ public function setProcessedText($processed_text) {
     $this->processedText = $processed_text;
     return $this;
   }
+
+  /**
+   * Creates a placeholder.
+   *
+   * This generates its own placeholder markup for one major reason: to not have
+   * FilterProcessResult depend on the Renderer service, because this is a value
+   * object. As a side-effect and added benefit, this makes it easier to
+   * distinguish placeholders for filtered text versus generic render system
+   * placeholders.
+   *
+   * @param string $callback
+   *   The #lazy_builder callback that will replace the placeholder with its
+   *   eventual markup.
+   * @param array $context
+   *   An array providing context for the #lazy_builder callback.
+   *
+   * @return string
+   *   The placeholder markup.
+   */
+  public function createPlaceholder($callback, array &$context) {
+    // Generate a unique placeholder token if it doesn't exist already, to
+    // prevent collisions.
+    $context += ['placeholder_token' => Crypt::randomBytesBase64(55)];
+
+    // Generate the placeholder markup ourselves to not depend on the Renderer.
+    $value = $callback;
+    $query = UrlHelper::buildQuery($context);
+    if ($query) {
+      $value .= '?' . $query;
+    }
+    $placeholder_markup = Html::normalize('<drupal-filter-placeholder callback="' . $value . '"></drupal-filter-placeholder>');
+
+    // Add the placeholder attachment.
+    $this->addAttachments([
+      'placeholders' => [
+        $placeholder_markup => [
+          '#lazy_builder' => [
+            $callback => $context,
+          ],
+        ]
+      ],
+    ]);
+
+    // Return the placeholder markup, so that the filter wanting to use a
+    // placeholder can actually insert the placeholder markup where it needs the
+    // placeholder to be replaced.
+    return $placeholder_markup;
+  }
+
 }
diff --git a/core/modules/filter/src/Plugin/FilterInterface.php b/core/modules/filter/src/Plugin/FilterInterface.php
index aa2438b..5643a22 100644
--- a/core/modules/filter/src/Plugin/FilterInterface.php
+++ b/core/modules/filter/src/Plugin/FilterInterface.php
@@ -46,8 +46,8 @@
  * - declare cache tags that the resulting filtered text depends upon, so when
  *   either of those cache tags is invalidated, the render-cached HTML that the
  *   filtered text is part of should also be invalidated;
- * - declare #post_render_cache callbacks to apply uncacheable filtering, for
- *   example because it differs per user.
+ * - create placeholders to apply uncacheable filtering, for example because it
+ *   changes every few seconds.
  *
  * @see \Drupal\filter\Plugin\FilterInterface::process()
  *
@@ -169,7 +169,7 @@ public function prepare($text, $langcode);
    *
    * @return \Drupal\filter\FilterProcessResult
    *   The filtered text, wrapped in a FilterProcessResult object, and possibly
-   *   with associated assets, cache tags and #post_render_cache callbacks.
+   *   with associated assets, cacheability metadata and placeholders.
    *
    * @see \Drupal\filter\FilterProcessResult
    */
diff --git a/core/modules/filter/src/Tests/FilterAPITest.php b/core/modules/filter/src/Tests/FilterAPITest.php
index 483bc22..c0bf1d5 100644
--- a/core/modules/filter/src/Tests/FilterAPITest.php
+++ b/core/modules/filter/src/Tests/FilterAPITest.php
@@ -201,7 +201,7 @@ function testFilterFormatAPI() {
    * check_markup() is a wrapper for the 'processed_text' element, for use in
    * simple scenarios; the 'processed_text' element has more advanced features:
    * it lets filters attach assets, associate cache tags and define
-   * #post_render_cache callbacks.
+   * #lazy_builder callbacks.
    * This test focuses solely on those advanced features.
    */
   function testProcessedTextElement() {
@@ -221,13 +221,12 @@ function testProcessedTextElement() {
           'weight' => 0,
           'status' => TRUE,
         ),
-        'filter_test_post_render_cache' => array(
+        'filter_test_placeholders' => array(
           'weight' => 1,
           'status' => TRUE,
         ),
         // Run the HTML corrector filter last, because it has the potential to
-        // break the render cache placeholders added by the
-        // filter_test_post_render_cache filter.
+        // break the placeholders added by the filter_test_placeholders filter.
         'filter_htmlcorrector' => array(
           'weight' => 10,
           'status' => TRUE,
@@ -242,14 +241,16 @@ function testProcessedTextElement() {
     );
     drupal_render_root($build);
 
-    // Verify the assets, cache tags and #post_render_cache callbacks.
-    $expected_assets = array(
+    // Verify the attachments and cacheability metadata.
+    $expected_attachments = array(
       // The assets attached by the filter_test_assets filter.
       'library' => array(
         'filter/caption',
       ),
+      // The placeholders attached that still need to be processed.
+      'placeholders' => [],
     );
-    $this->assertEqual($expected_assets, $build['#attached'], 'Expected assets present');
+    $this->assertEqual($expected_attachments, $build['#attached'], 'Expected attachments present');
     $expected_cache_tags = array(
       // The cache tag set by the processed_text element itself.
       'config:filter.format.element_test',
@@ -267,7 +268,7 @@ function testProcessedTextElement() {
     ];
     $this->assertEqual($expected_cache_contexts, $build['#cache']['contexts'], 'Expected cache contexts present.');
     $expected_markup = '<p>Hello, world!</p><p>This is a dynamic llama.</p>';
-    $this->assertEqual($expected_markup, $build['#markup'], 'Expected #post_render_cache callback has been applied.');
+    $this->assertEqual($expected_markup, $build['#markup'], 'Expected #lazy_builder callback has been applied.');
   }
 
   /**
diff --git a/core/modules/filter/tests/filter_test/src/Plugin/Filter/FilterTestPostRenderCache.php b/core/modules/filter/tests/filter_test/src/Plugin/Filter/FilterTestPlaceholders.php
similarity index 51%
rename from core/modules/filter/tests/filter_test/src/Plugin/Filter/FilterTestPostRenderCache.php
rename to core/modules/filter/tests/filter_test/src/Plugin/Filter/FilterTestPlaceholders.php
index aaeb400..125566a 100644
--- a/core/modules/filter/tests/filter_test/src/Plugin/Filter/FilterTestPostRenderCache.php
+++ b/core/modules/filter/tests/filter_test/src/Plugin/Filter/FilterTestPlaceholders.php
@@ -2,7 +2,7 @@
 
 /**
  * @file
- * Contains \Drupal\filter_test\Plugin\Filter\FilterTestPostRenderCache.
+ * Contains \Drupal\filter_test\Plugin\Filter\FilterTestPlaceholders.
  */
 
 namespace Drupal\filter_test\Plugin\Filter;
@@ -11,33 +11,34 @@
 use Drupal\filter\Plugin\FilterBase;
 
 /**
- * Provides a test filter to associate #post_render_cache callbacks.
+ * Provides a test filter to use placeholders.
  *
  * @Filter(
- *   id = "filter_test_post_render_cache",
+ *   id = "filter_test_placeholders",
  *   title = @Translation("Testing filter"),
- *   description = @Translation("Appends a placeholder to the content; associates #post_render_cache callbacks."),
+ *   description = @Translation("Appends a placeholder to the content; associates #lazy_builder callback."),
  *   type = Drupal\filter\Plugin\FilterInterface::TYPE_TRANSFORM_REVERSIBLE
  * )
  */
-class FilterTestPostRenderCache extends FilterBase {
+class FilterTestPlaceholders extends FilterBase {
 
   /**
    * {@inheritdoc}
    */
   public function process($text, $langcode) {
-    $callback = '\Drupal\filter_test\Plugin\Filter\FilterTestPostRenderCache::renderDynamicThing';
+    $callback = '\Drupal\filter_test\Plugin\Filter\FilterTestPlaceholders::renderDynamicThing';
     $context = array(
       'thing' => 'llama',
     );
-    $placeholder = drupal_render_cache_generate_placeholder($callback, $context);
-    $result = new FilterProcessResult($text . '<p>' . $placeholder . '</p>');
-    $result->addPostRenderCacheCallback($callback, $context);
+
+    $result = new FilterProcessResult($text);
+    $placeholder = $result->createPlaceholder($callback, $context);
+    $result->setProcessedText($text . '<p>' . $placeholder . '</p>');
     return $result;
   }
 
   /**
-   * #post_render_cache callback; replaces placeholder with a dynamic thing.
+   * #lazy_builder callback; replaces placeholder with a dynamic thing.
    *
    * @param array $element
    *   The renderable array that contains the to be replaced placeholder.
@@ -49,10 +50,8 @@ public function process($text, $langcode) {
    *   A renderable array containing the comment form.
    */
   public static function renderDynamicThing(array $element, array $context) {
-    $callback = '\Drupal\filter_test\Plugin\Filter\FilterTestPostRenderCache::renderDynamicThing';
-    $placeholder = drupal_render_cache_generate_placeholder($callback, $context);
     $markup = format_string('This is a dynamic @thing.', array('@thing' => $context['thing']));
-    $element['#markup'] = str_replace($placeholder, $markup, $element['#markup']);
+    $element['#markup'] = $markup;
     return $element;
   }
 
diff --git a/core/modules/history/history.module b/core/modules/history/history.module
index 02d05cf..153aba5 100644
--- a/core/modules/history/history.module
+++ b/core/modules/history/history.module
@@ -176,7 +176,7 @@ function history_user_delete($account) {
 }
 
 /**
- * #post_render_cache callback; attaches the last read timestamp for a node.
+ * #lazy_builder callback; attaches the last read timestamp for a node.
  *
  * @param array $element
  *  A render array with the following keys:
diff --git a/core/modules/node/src/NodeViewBuilder.php b/core/modules/node/src/NodeViewBuilder.php
index ef6d2d5..10cac6c 100644
--- a/core/modules/node/src/NodeViewBuilder.php
+++ b/core/modules/node/src/NodeViewBuilder.php
@@ -41,14 +41,11 @@ public function buildComponents(array &$build, array $entities, array $displays,
           'langcode' => $langcode,
           'in_preview' => !empty($entity->in_preview),
         );
-        $placeholder = drupal_render_cache_generate_placeholder($callback, $context);
         $build[$id]['links'] = array(
-          '#post_render_cache' => array(
-            $callback => array(
-              $context,
-            ),
+          '#lazy_builder' => array(
+            $callback => $context,
           ),
-          '#markup' => $placeholder,
+          '#create_placeholder' => TRUE,
         );
       }
 
@@ -80,7 +77,7 @@ protected function getBuildDefaults(EntityInterface $entity, $view_mode, $langco
   }
 
   /**
-   * #post_render_cache callback; replaces the placeholder with node links.
+   * #lazy_builder callback; replaces the placeholder with node links.
    *
    * Renders the links on a node.
    *
@@ -97,9 +94,6 @@ protected function getBuildDefaults(EntityInterface $entity, $view_mode, $langco
    *   A renderable array representing the node links.
    */
   public static function renderLinks(array $element, array $context) {
-    $callback = get_called_class() . '::renderLinks';
-    $placeholder = drupal_render_cache_generate_placeholder($callback, $context);
-
     $links = array(
       '#theme' => 'links__node',
       '#pre_render' => array('drupal_pre_render_links'),
@@ -117,8 +111,7 @@ public static function renderLinks(array $element, array $context) {
       );
       \Drupal::moduleHandler()->alter('node_links', $links, $entity, $hook_context);
     }
-    $markup = drupal_render($links);
-    $element['#markup'] = str_replace($placeholder, $markup, $element['#markup']);
+    $element['links'] = $links;
 
     return $element;
   }
diff --git a/core/modules/system/src/Plugin/Block/SystemMessagesBlock.php b/core/modules/system/src/Plugin/Block/SystemMessagesBlock.php
index 611fd23..11f312a 100644
--- a/core/modules/system/src/Plugin/Block/SystemMessagesBlock.php
+++ b/core/modules/system/src/Plugin/Block/SystemMessagesBlock.php
@@ -59,8 +59,8 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta
    */
   public function getCacheMaxAge() {
     // The messages are session-specific and hence aren't cacheable, but the
-    // block itself *is* cacheable because it uses a #post_render_cache callback
-    // and hence the block has a globally cacheable render array.
+    // block itself *is* cacheable because it uses a #lazy_builder callback and
+    // hence the block has a globally cacheable render array.
     return Cache::PERMANENT;
   }
 
diff --git a/core/modules/system/src/Tests/Common/PageRenderTest.php b/core/modules/system/src/Tests/Common/PageRenderTest.php
index 332239c..ec16e36 100644
--- a/core/modules/system/src/Tests/Common/PageRenderTest.php
+++ b/core/modules/system/src/Tests/Common/PageRenderTest.php
@@ -68,7 +68,7 @@ function assertPageRenderHookExceptions($module, $hook) {
     }
     catch (\LogicException $e) {
       $this->pass($assertion);
-      $this->assertEqual($e->getMessage(), 'Only #attached, #post_render_cache and #cache may be set in ' . $hook . '().');
+      $this->assertEqual($e->getMessage(), 'Only #attached and #cache may be set in ' . $hook . '().');
     }
     \Drupal::state()->set('bc_test.' . $hook . '.descendant_attached', FALSE);
 
@@ -82,7 +82,7 @@ function assertPageRenderHookExceptions($module, $hook) {
     }
     catch (\LogicException $e) {
       $this->pass($assertion);
-      $this->assertEqual($e->getMessage(), 'Only #attached, #post_render_cache and #cache may be set in ' . $hook . '().');
+      $this->assertEqual($e->getMessage(), 'Only #attached and #cache may be set in ' . $hook . '().');
     }
     \Drupal::state()->set($module . '.' . $hook . '.render_array', FALSE);
   }
diff --git a/core/modules/system/theme.api.php b/core/modules/system/theme.api.php
index e51f03c..fae23f2 100644
--- a/core/modules/system/theme.api.php
+++ b/core/modules/system/theme.api.php
@@ -906,8 +906,8 @@ function hook_css_alter(&$css, \Drupal\Core\Asset\AttachedAssetsInterface $asset
  * depends on the elements of other modules, use hook_page_attachments_alter()
  * instead, which runs after this hook.
  *
- * If you try to add anything but #attached and #post_render_cache to the array
- * an exception is thrown.
+ * If you try to add anything but #attached and #cache to the array, an
+ * exception is thrown.
  *
  * @param array &$attachments
  *   An array that you can add attachments to.
@@ -931,8 +931,8 @@ function hook_page_attachments(array &$attachments) {
  * add attachments to the page that depend on another module's attachments (this
  * hook runs after hook_page_attachments().
  *
- * If you try to add anything but #attached and #post_render_cache to the array
- * an exception is thrown.
+ * If you try to add anything but #attached and #cache to the array, an
+ * exception is thrown.
  *
  * @param array &$attachments
  *   Array of all attachments provided by hook_page_attachments() implementations.
diff --git a/core/modules/views/src/Plugin/views/cache/CachePluginBase.php b/core/modules/views/src/Plugin/views/cache/CachePluginBase.php
index 23c917a..2285c53 100644
--- a/core/modules/views/src/Plugin/views/cache/CachePluginBase.php
+++ b/core/modules/views/src/Plugin/views/cache/CachePluginBase.php
@@ -242,7 +242,6 @@ public function cacheGet($type) {
             $this->view->display_handler->output = $this->storage;
             $this->view->element['#attached'] = &$this->view->display_handler->output['#attached'];
             $this->view->element['#cache']['tags'] = &$this->view->display_handler->output['#cache']['tags'];
-            $this->view->element['#post_render_cache'] = &$this->view->display_handler->output['#post_render_cache'];
             return TRUE;
           }
         }
diff --git a/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php b/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php
index 739d1aa..8204bef 100644
--- a/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php
+++ b/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php
@@ -2133,7 +2133,6 @@ public function render() {
       // be available on the view.
       '#attached' => &$this->view->element['#attached'],
       '#cache' => &$this->view->element['#cache'],
-      '#post_render_cache' => &$this->view->element['#post_render_cache'],
     );
 
     if (!isset($element['#cache'])) {
diff --git a/core/modules/views/src/Plugin/views/field/FieldHandlerInterface.php b/core/modules/views/src/Plugin/views/field/FieldHandlerInterface.php
index 9cb1684..2181264 100644
--- a/core/modules/views/src/Plugin/views/field/FieldHandlerInterface.php
+++ b/core/modules/views/src/Plugin/views/field/FieldHandlerInterface.php
@@ -179,7 +179,7 @@ public function render(ResultRow $values);
    * This is meant to be used mainly to deal with field handlers whose output
    * cannot be cached at row level but can be cached at display level. The
    * typical example is the row counter. For completely uncacheable field output
-   * #post_render_cache should be used.
+   * placeholders should be used.
    *
    * @param \Drupal\views\ResultRow $row
    *   An array of all ResultRow objects returned from the query.
diff --git a/core/modules/views/src/Tests/Plugin/CacheTest.php b/core/modules/views/src/Tests/Plugin/CacheTest.php
index 679a764..741ad0f 100644
--- a/core/modules/views/src/Tests/Plugin/CacheTest.php
+++ b/core/modules/views/src/Tests/Plugin/CacheTest.php
@@ -288,7 +288,7 @@ function testHeaderStorage() {
     $this->assertEqual(['foo' => 'bar'], $output['#attached']['drupalSettings'], 'Make sure drupalSettings are added for cached views.');
     // Note: views_test_data_views_pre_render() adds some cache tags.
     $this->assertEqual(['config:views.view.test_cache_header_storage', 'views_test_data:1'], $output['#cache']['tags']);
-    $this->assertEqual(['views_test_data_post_render_cache' => [['foo' => 'bar']]], $output['#post_render_cache']);
+    $this->assertEqual(['non-existing-placeholder-just-for-testing-purposes' => ['#lazy_builder' => ['views_test_data_placeholders' => ['foo' => 'bar']]]], $output['#attached']['placeholders']);
     $this->assertFalse(!empty($view->build_info['pre_render_called']), 'Make sure hook_views_pre_render is not called for the cached view.');
   }
 
diff --git a/core/modules/views/tests/modules/views_test_data/views_test_data.views_execution.inc b/core/modules/views/tests/modules/views_test_data/views_test_data.views_execution.inc
index 0cff1db..7a224f7 100644
--- a/core/modules/views/tests/modules/views_test_data/views_test_data.views_execution.inc
+++ b/core/modules/views/tests/modules/views_test_data/views_test_data.views_execution.inc
@@ -48,17 +48,17 @@ function views_test_data_views_pre_render(ViewExecutable $view) {
   if (isset($view) && ($view->storage->id() == 'test_cache_header_storage')) {
     $view->element['#attached']['library'][] = 'views_test_data/test';
     $view->element['#attached']['drupalSettings']['foo'] = 'bar';
+    $view->element['#attached']['placeholders']['non-existing-placeholder-just-for-testing-purposes']['#lazy_builder']['views_test_data_placeholders'] = ['foo' => 'bar'];
     $view->element['#cache']['tags'][] = 'views_test_data:1';
-    $view->element['#post_render_cache']['views_test_data_post_render_cache'][] = ['foo' => 'bar'];
     $view->build_info['pre_render_called'] = TRUE;
   }
 
 }
 
 /**
- * #post_render_cache callback; for testing purposes only.
+ * #lazy_builder callback; for testing purposes only.
  */
-function views_test_data_post_render_cache(array $element, array $context) {
+function views_test_data_placeholders(array $element, array $context) {
   // No-op.
   return $element;
 }
diff --git a/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php b/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php
index c6a217a..3cc3d33 100644
--- a/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php
@@ -32,7 +32,7 @@ class BubbleableMetadataTest extends UnitTestCase {
    * @see \Drupal\Tests\Core\Cache\CacheTest::testMergeMaxAges()
    * @see \Drupal\Tests\Core\Cache\CacheContextsTest
    * @see \Drupal\system\Tests\Common\MergeAttachmentsTest
-   * @see \Drupal\Tests\Core\Render\RendererPostRenderCacheTest
+   * @see \Drupal\Tests\Core\Render\RendererPlaceholdersTest
    */
   public function testMerge(BubbleableMetadata $a, CacheableMetadata $b, BubbleableMetadata $expected) {
     // Verify that if the second operand is a CacheableMetadata object, not a
@@ -81,9 +81,9 @@ public function providerTestMerge() {
       // Cache max-ages.
       [(new BubbleableMetadata())->setCacheMaxAge(60), (new BubbleableMetadata())->setCacheMaxAge(Cache::PERMANENT), (new BubbleableMetadata())->setCacheMaxAge(60)],
       // Assets.
-      [(new BubbleableMetadata())->setAssets(['library' => ['core/foo']]), (new BubbleableMetadata())->setAssets(['library' => ['core/bar']]), (new BubbleableMetadata())->setAssets(['library' => ['core/foo', 'core/bar']])],
-      // #post_render_cache callbacks.
-      [(new BubbleableMetadata())->setPostRenderCacheCallbacks(['callback' => [['token' => 'A']]]), (new BubbleableMetadata())->setPostRenderCacheCallbacks(['callback' => [['token' => 'B']]]), (new BubbleableMetadata())->setPostRenderCacheCallbacks(['callback' => [['token' => 'A'], ['token' => 'B']]])],
+      [(new BubbleableMetadata())->setAttachments(['library' => ['core/foo']]), (new BubbleableMetadata())->setAttachments(['library' => ['core/bar']]), (new BubbleableMetadata())->setAttachments(['library' => ['core/foo', 'core/bar']])],
+      // Placeholders.
+      [(new BubbleableMetadata())->setAttachments(['placeholders' => ['callback' => [['token' => 'A']]]]), (new BubbleableMetadata())->setAttachments(['placeholders' => ['callback' => [['token' => 'B']]]]), (new BubbleableMetadata())->setAttachments(['placeholders' => ['callback' => [['token' => 'A'], ['token' => 'B']]]])],
 
       // Second operand is a CacheableMetadata object.
       // All empty.
@@ -118,7 +118,7 @@ public function providerTestApplyTo() {
     $nonempty_metadata = new BubbleableMetadata();
     $nonempty_metadata->setCacheContexts(['qux'])
       ->setCacheTags(['foo:bar'])
-      ->setAssets(['settings' => ['foo' => 'bar']]);
+      ->setAttachments(['settings' => ['foo' => 'bar']]);
 
     $empty_render_array = [];
     $nonempty_render_array = [
@@ -132,7 +132,6 @@ public function providerTestApplyTo() {
           'core/jquery',
         ],
       ],
-      '#post_render_cache' => [],
     ];
 
 
@@ -143,7 +142,6 @@ public function providerTestApplyTo() {
         'max-age' => Cache::PERMANENT,
       ],
       '#attached' => [],
-      '#post_render_cache' => [],
     ];
     $data[] = [$empty_metadata, $empty_render_array, $expected_when_empty_metadata];
     $data[] = [$empty_metadata, $nonempty_render_array, $expected_when_empty_metadata];
@@ -158,7 +156,6 @@ public function providerTestApplyTo() {
           'foo' => 'bar',
         ],
       ],
-      '#post_render_cache' => [],
     ];
     $data[] = [$nonempty_metadata, $empty_render_array, $expected_when_nonempty_metadata];
     $data[] = [$nonempty_metadata, $nonempty_render_array, $expected_when_nonempty_metadata];
@@ -186,7 +183,7 @@ public function providerTestCreateFromRenderArray() {
     $nonempty_metadata = new BubbleableMetadata();
     $nonempty_metadata->setCacheContexts(['qux'])
       ->setCacheTags(['foo:bar'])
-      ->setAssets(['settings' => ['foo' => 'bar']]);
+      ->setAttachments(['settings' => ['foo' => 'bar']]);
 
     $empty_render_array = [];
     $nonempty_render_array = [
@@ -200,7 +197,6 @@ public function providerTestCreateFromRenderArray() {
           'foo' => 'bar',
         ],
       ],
-      '#post_render_cache' => [],
     ];
 
 
diff --git a/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php b/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php
index 5389b02..71ff3b1 100644
--- a/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php
@@ -124,7 +124,6 @@ public function providerTestContextBubblingEdgeCases() {
           'tags' => [],
           'max-age' => Cache::PERMANENT,
         ],
-        '#post_render_cache' => [],
         '#markup' => 'parent',
       ],
     ];
@@ -148,7 +147,6 @@ public function providerTestContextBubblingEdgeCases() {
           'tags' => [],
           'max-age' => Cache::PERMANENT,
         ],
-        '#post_render_cache' => [],
         '#markup' => '',
       ],
     ];
@@ -190,7 +188,6 @@ public function providerTestContextBubblingEdgeCases() {
           'tags' => [],
           'max-age' => 3600,
         ],
-        '#post_render_cache' => [],
         '#markup' => 'parent',
       ],
     ];
@@ -237,7 +234,6 @@ public function providerTestContextBubblingEdgeCases() {
           'tags' => ['dee', 'fiddle', 'har', 'yar'],
           'max-age' => Cache::PERMANENT,
         ],
-        '#post_render_cache' => [],
         '#markup' => 'parent',
       ],
     ];
@@ -311,7 +307,6 @@ public function testConditionalCacheContextBubblingSelfHealing() {
         'tags' => ['a', 'b'],
         'max-age' => Cache::PERMANENT,
       ],
-      '#post_render_cache' => [],
       '#markup' => 'parent',
     ]);
 
@@ -335,7 +330,6 @@ public function testConditionalCacheContextBubblingSelfHealing() {
         'tags' => ['a', 'b', 'c'],
         'max-age' => Cache::PERMANENT,
       ],
-      '#post_render_cache' => [],
       '#markup' => 'parent',
     ]);
 
@@ -367,7 +361,6 @@ public function testConditionalCacheContextBubblingSelfHealing() {
         'tags' => ['a', 'b'],
         'max-age' => Cache::PERMANENT,
       ],
-      '#post_render_cache' => [],
       '#markup' => 'parent',
     ]);
 
@@ -392,7 +385,6 @@ public function testConditionalCacheContextBubblingSelfHealing() {
         'tags' => ['a', 'b', 'c', 'd'],
         'max-age' => Cache::PERMANENT,
       ],
-      '#post_render_cache' => [],
       '#markup' => 'parent',
     ]);
 
@@ -408,7 +400,6 @@ public function testConditionalCacheContextBubblingSelfHealing() {
         'tags' => ['a', 'b'],
         'max-age' => Cache::PERMANENT,
       ],
-      '#post_render_cache' => [],
       '#markup' => 'parent',
     ]);
 
@@ -424,7 +415,6 @@ public function testConditionalCacheContextBubblingSelfHealing() {
         'tags' => ['a', 'b', 'c'],
         'max-age' => Cache::PERMANENT,
       ],
-      '#post_render_cache' => [],
       '#markup' => 'parent',
     ]);
   }
@@ -456,20 +446,20 @@ public function testBubblingWithPrerender($test_element) {
     // - … is not cached DOES get called.
     \Drupal::state()->set('bubbling_nested_pre_render_cached', FALSE);
     \Drupal::state()->set('bubbling_nested_pre_render_uncached', FALSE);
-    $this->memoryCache->set('cached_nested', ['#markup' => 'Cached nested!', '#attached' => [], '#cache' => ['contexts' => [], 'tags' => []], '#post_render_cache' => []]);
+    $this->memoryCache->set('cached_nested', ['#markup' => 'Cached nested!', '#attached' => [], '#cache' => ['contexts' => [], 'tags' => []]]);
 
     // Simulate the rendering of an entire response (i.e. a root call).
     $output = $this->renderer->renderRoot($test_element);
 
     // First, assert the render array is of the expected form.
-    $this->assertEquals('Cache context!Cache tag!Asset!Post-render cache!barquxNested!Cached nested!', trim($output), 'Expected HTML generated.');
+    $this->assertEquals('Cache context!Cache tag!Asset!Pre-render cache!barquxNested!Cached nested!', trim($output), 'Expected HTML generated.');
     $this->assertEquals(['child.cache_context'], $test_element['#cache']['contexts'], 'Expected cache contexts found.');
     $this->assertEquals(['child:cache_tag'], $test_element['#cache']['tags'], 'Expected cache tags found.');
     $expected_attached = [
       'drupalSettings' => ['foo' => 'bar'],
+      'placeholders' => [],
     ];
-    $this->assertEquals($expected_attached, $test_element['#attached'], 'Expected assets found.');
-    $this->assertEquals([], $test_element['#post_render_cache'], '#post_render_cache property is empty after rendering');
+    $this->assertEquals($expected_attached, $test_element['#attached'], 'Expected attachments found.');
 
     // Second, assert that #pre_render callbacks are only executed if they don't
     // have a render cache hit (and hence a #pre_render callback for a render
@@ -530,12 +520,11 @@ class BubblingTest {
    * #pre_render callback for testBubblingWithPrerender().
    */
   public static function bubblingPreRender($elements) {
-    $callback = __CLASS__ . '::bubblingPostRenderCache';
+    $callback = __CLASS__ . '::bubblingPreRenderCache';
     $context = [
       'foo' => 'bar',
       'baz' => 'qux',
     ];
-    $placeholder = \Drupal::service('renderer')->generateCachePlaceholder($callback, $context);
     $elements += [
       'child_cache_context' => [
         '#cache' => [
@@ -555,13 +544,11 @@ public static function bubblingPreRender($elements) {
         ],
         '#markup' => 'Asset!',
       ],
-      'child_post_render_cache' => [
-        '#post_render_cache' => [
-          $callback => [
-            $context,
-          ],
+      'child_placeholder' => [
+        '#create_placeholder' => TRUE,
+        '#lazy_builder' => [
+          $callback => $context,
         ],
-        '#markup' => $placeholder,
       ],
       'child_nested_pre_render_uncached' => [
         '#cache' => ['keys' => ['uncached_nested']],
@@ -593,12 +580,10 @@ public static function bubblingNestedPreRenderCached($elements) {
   }
 
   /**
-   * #post_render_cache callback for testBubblingWithPrerender().
+   * #lazy_builder callback for testBubblingWithPrerender().
    */
-  public static function bubblingPostRenderCache(array $element, array $context) {
-    $callback = __CLASS__ . '::bubblingPostRenderCache';
-    $placeholder = \Drupal::service('renderer')->generateCachePlaceholder($callback, $context);
-    $element['#markup'] = str_replace($placeholder, 'Post-render cache!' . $context['foo'] . $context['baz'], $element['#markup']);
+  public static function bubblingPreRenderCache(array $element, array $context) {
+    $element['#markup'] = 'Pre-render cache!' . $context['foo'] . $context['baz'];
     return $element;
   }
 
diff --git a/core/tests/Drupal/Tests/Core/Render/RendererPostRenderCacheTest.php b/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php
similarity index 26%
rename from core/tests/Drupal/Tests/Core/Render/RendererPostRenderCacheTest.php
rename to core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php
index 50ccc2e..fb3f629 100644
--- a/core/tests/Drupal/Tests/Core/Render/RendererPostRenderCacheTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php
@@ -2,7 +2,7 @@
 
 /**
  * @file
- * Contains \Drupal\Tests\Core\Render\RendererPostRenderCacheTest.
+ * Contains \Drupal\Tests\Core\Render\RendererPlaceholdersTest.
  */
 
 namespace Drupal\Tests\Core\Render;
@@ -15,57 +15,226 @@
  * @coversDefaultClass \Drupal\Core\Render\Renderer
  * @group Render
  */
-class RendererPostRenderCacheTest extends RendererTestBase {
+class RendererPlaceholdersTest extends RendererTestBase {
 
   /**
    * {@inheritdoc}
    */
   protected function setUp() {
     // Disable the required cache contexts, so that this test can test just the
-    // #post_render_cache behavior.
+    // placeholder replacement behavior.
     $this->rendererConfig['required_cache_contexts'] = [];
 
     parent::setUp();
   }
 
   /**
-   * Generates an element with a #post_render_cache callback.
+   * Provides the two classes of placeholders: cacheable and uncacheable.
+   *
+   * i.e. with or without #cache[keys].
+   *
+   * Also, different types:
+   * - A) automatically generated placeholder
+   *   - 1) manually triggered (#create_placeholder = TRUE)
+   *   - 2) automatically triggered (based on max-age = 0 in its subtree)
+   * - B) manually generated placeholder
+   *
+   * So, in total 2*3 = 6 permutations.
+   *
+   * @todo Case A2 is not yet supported by core. So that makes for only 4
+   *   permutations currently.
+   *
+   * @return array
+   */
+  public function providerPlaceholders() {
+    $context = [
+      'foo' => $this->randomContextValue(),
+      // Provide a token instead of letting one be generated by the Renderer,
+      // otherwise we cannot know what the token is.
+      'placeholder_token' => 123456789,
+    ];
+
+    $generate_placeholder_markup = function() use ($context) {
+      return '<drupal-render-cache-placeholder callback="Drupal\Tests\Core\Render\PlaceholdersTest::callback?foo=' . $context['foo'] . '&amp;placeholder_token=123456789"></drupal-render-cache-placeholder>';
+    };
+
+    $base_element_a1 = [
+      '#attached' => [
+        'drupalSettings' => [
+          'foo' => 'bar',
+        ],
+      ],
+      'placeholder' => [
+        '#cache' => [
+          'contexts' => [],
+        ],
+        '#create_placeholder' => TRUE,
+        '#lazy_builder' => [
+          'Drupal\Tests\Core\Render\PlaceholdersTest::callback' => $context,
+        ],
+      ],
+    ];
+    $base_element_a2 = [
+      // @todo, see docblock
+    ];
+    $base_element_b = [
+      '#markup' => $generate_placeholder_markup(),
+      '#attached' => [
+        'drupalSettings' => [
+          'foo' => 'bar',
+        ],
+        'placeholders' => [
+          $generate_placeholder_markup() => [
+            '#lazy_builder' => [
+              'Drupal\Tests\Core\Render\PlaceholdersTest::callback' => $context,
+            ]
+          ],
+        ],
+      ],
+    ];
+
+    $keys = ['placeholder', 'output', 'can', 'be', 'render', 'cached', 'too'];
+
+    $cases = [];
+
+    // Case one: render array that has a placeholder that is:
+    // - automatically created, but manually triggered (#create_placeholder = TRUE)
+    // - uncacheable
+    $element_without_cache_keys = $base_element_a1;
+    $cases[] = [
+      $element_without_cache_keys,
+      $context,
+      FALSE,
+      [],
+    ];
+
+    // Case two: render array that has a placeholder that is:
+    // - automatically created, but manually triggered (#create_placeholder = TRUE)
+    // - cacheable
+    $element_with_cache_keys = $base_element_a1;
+    $element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
+    $cases[] = [
+      $element_with_cache_keys,
+      $context,
+      $keys,
+      [
+        '#markup' => '<p>overridden</p>',
+        '#attached' => [
+          'drupalSettings' => [
+            'common_test' => $context,
+          ],
+        ],
+        '#cache' => [
+          'contexts' => [],
+          'tags' => [],
+          'max-age' => Cache::PERMANENT,
+        ],
+      ],
+    ];
+
+    // Case three: render array that has a placeholder that is:
+    // - manually created
+    // - uncacheable
+    $x = $base_element_b;
+    $cases[] = [
+      $x,
+      $context,
+      FALSE,
+      [],
+    ];
+
+    // Case four: render array that has a placeholder that is:
+    // - manually created
+    // - cacheable
+    $x = $base_element_b;
+    $x['#attached']['placeholders'][$generate_placeholder_markup()]['#cache']['keys'] = $keys;
+    $cases[] = [
+      $x,
+      $context,
+      $keys,
+      [
+        '#markup' => '<p>overridden</p>',
+        '#attached' => [
+          'drupalSettings' => [
+            'common_test' => $context,
+          ],
+        ],
+        '#cache' => [
+          'contexts' => [],
+          'tags' => [],
+          'max-age' => Cache::PERMANENT,
+        ],
+      ],
+    ];
+
+    return $cases;
+  }
+
+  /**
+   * Generates an element with a placeholder.
    *
    * @return array
    *   An array containing:
-   *   - A render array containing a #post_render_cache callback.
-   *   - The context used for that #post_render_cache callback.
+   *   - A render array containing a placeholder.
+   *   - The context used for that #lazy_builder callback.
    */
-  protected function generatePostRenderCacheElement() {
-    $context = ['foo' => $this->randomContextValue()];
+  protected function generatePlaceholderElement() {
+    $context = [
+      'foo' => $this->randomContextValue(),
+      // Generate the placeholder token to use here already, otherwise it is
+      // impossible to test.
+      'placeholder_token' => 123456789,
+    ];
     $test_element = [];
-    $test_element['#markup'] = '';
     $test_element['#attached']['drupalSettings']['foo'] = 'bar';
-    $test_element['#post_render_cache']['Drupal\Tests\Core\Render\PostRenderCache::callback'] = [
-      $context
-    ];
+    $test_element['placeholder']['#cache']['keys'] = ['placeholder', 'output', 'can', 'be', 'render', 'cached', 'too'];
+    $test_element['placeholder']['#cache']['contexts'] = [];
+    $test_element['placeholder']['#create_placeholder'] = TRUE;
+    $test_element['placeholder']['#lazy_builder']['Drupal\Tests\Core\Render\PlaceholdersTest::callback'] = $context;
 
     return [$test_element, $context];
   }
 
   /**
+   * @param FALSE|array $cid_parts
+   * @param array $expected_data
+   *   FALSE if no render cache item is expected, a render array with the
+   *   expected values if a render cache item is expected.
+   */
+  protected function assertPlaceholderRenderCache($cid_parts, array $expected_data) {
+    if ($cid_parts !== FALSE) {
+      // Verify render cached placeholder.
+      $cached_element = $this->memoryCache->get(implode(':', $cid_parts))->data;
+      $this->assertSame($expected_data, $cached_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by the placeholder being replaced.');
+    }
+  }
+  /**
    * @covers ::render
    * @covers ::doRender
+   *
+   * @dataProvider providerPlaceholders
    */
-  public function testPostRenderCacheWithCacheDisabled() {
-    list($element, $context) = $this->generatePostRenderCacheElement();
-    $this->setUpUnusedCache();
+  public function testUncacheableParent($element, $context, $placeholder_cid_keys, array $placeholder_expected_render_cache_array) {
+    if ($placeholder_cid_keys) {
+      $this->setupMemoryCache();
+    }
+    else {
+      $this->setUpUnusedCache();
+    }
+
+    $this->setUpRequest('GET');
 
-    // #cache disabled.
-    $element['#markup'] = '<p>#cache disabled</p>';
+    // No #cache on parent element.
+    $element['#prefix'] = '<p>#cache disabled</p>';
     $output = $this->renderer->renderRoot($element);
-    $this->assertSame($output, '<p>overridden</p>', 'Output is overridden.');
-    $this->assertSame($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
+    $this->assertSame('<p>#cache disabled</p><p>overridden</p>', $output, 'Output is overridden.');
+    $this->assertSame('<p>#cache disabled</p><p>overridden</p>', $element['#markup'], '#markup is overridden.');
     $expected_js_settings = [
       'foo' => 'bar',
       'common_test' => $context,
     ];
-    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.');
+    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.');
+    $this->assertPlaceholderRenderCache($placeholder_cid_keys, $placeholder_expected_render_cache_array);
   }
 
   /**
@@ -74,220 +243,237 @@ public function testPostRenderCacheWithCacheDisabled() {
    * @covers \Drupal\Core\Render\RenderCache::get
    * @covers \Drupal\Core\Render\RenderCache::set
    * @covers \Drupal\Core\Render\RenderCache::createCacheID
+   *
+   * @dataProvider providerPlaceholders
    */
-  public function testPostRenderCacheWithColdCache() {
-    list($test_element, $context) = $this->generatePostRenderCacheElement();
+  public function testCacheableParent($test_element, $context, $placeholder_cid_keys, array $placeholder_expected_render_cache_array) {
     $element = $test_element;
     $this->setupMemoryCache();
 
     $this->setUpRequest('GET');
 
     // GET request: #cache enabled, cache miss.
-    $element['#cache'] = ['keys' => ['post_render_cache_test_GET']];
-    $element['#markup'] = '<p>#cache enabled, GET</p>';
+    $element['#cache'] = ['keys' => ['placeholder_test_GET']];
+    $element['#prefix'] = '<p>#cache enabled, GET</p>';
     $output = $this->renderer->renderRoot($element);
-    $this->assertSame($output, '<p>overridden</p>', 'Output is overridden.');
+    $this->assertSame('<p>#cache enabled, GET</p><p>overridden</p>', $output, 'Output is overridden.');
     $this->assertTrue(isset($element['#printed']), 'No cache hit');
-    $this->assertSame($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
+    $this->assertSame('<p>#cache enabled, GET</p><p>overridden</p>', $element['#markup'], '#markup is overridden.');
     $expected_js_settings = [
       'foo' => 'bar',
       'common_test' => $context,
     ];
-    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.');
+    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.');
+    $this->assertPlaceholderRenderCache($placeholder_cid_keys, $placeholder_expected_render_cache_array);
 
     // GET request: validate cached data.
-    $cached_element = $this->memoryCache->get('post_render_cache_test_GET')->data;
+    $cached_element = $this->memoryCache->get('placeholder_test_GET')->data;
+    $placeholder_markup = '<drupal-render-cache-placeholder callback="Drupal\Tests\Core\Render\PlaceholdersTest::callback?foo=' . $context['foo'] . '&amp;placeholder_token=123456789"></drupal-render-cache-placeholder>';
+    $this->assertSame($placeholder_markup, Html::normalize($placeholder_markup), 'Placeholder unaltered by Html::normalize() which is used by FilterHtmlCorrector.');
     $expected_element = [
-      '#markup' => '<p>#cache enabled, GET</p>',
-      '#attached' => $test_element['#attached'],
-      '#post_render_cache' => $test_element['#post_render_cache'],
+      '#markup' => '<p>#cache enabled, GET</p>' . $placeholder_markup,
+      '#attached' => [
+        'drupalSettings' => [
+          'foo' => 'bar',
+        ],
+        'placeholders' => [
+          $placeholder_markup => [
+            '#lazy_builder' => [
+              'Drupal\Tests\Core\Render\PlaceholdersTest::callback' => $context,
+            ],
+          ],
+        ],
+      ],
       '#cache' => [
         'contexts' => [],
         'tags' => [],
         'max-age' => Cache::PERMANENT,
       ],
     ];
-    $this->assertSame($cached_element, $expected_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by #post_render_cache callbacks.');
+    // When there was a child element that created a placeholder, the Renderer
+    // automatically initializes #cache[contexts].
+    if (Element::children($test_element)) {
+      $expected_element['#attached']['placeholders'][$placeholder_markup]['#cache']['contexts'] = [];
+    }
+    // When the placeholder itself is cacheable, its cache keys are present.
+    if ($placeholder_cid_keys) {
+      $expected_element['#attached']['placeholders'][$placeholder_markup]['#cache']['keys'] = $placeholder_cid_keys;
+    }
+    $this->assertEquals($cached_element, $expected_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by placeholder #lazy_builder callbacks.');
 
     // GET request: #cache enabled, cache hit.
     $element = $test_element;
-    $element['#cache'] = ['keys' => ['post_render_cache_test_GET']];
-    $element['#markup'] = '<p>#cache enabled, GET</p>';
+    $element['#cache'] = ['keys' => ['placeholder_test_GET']];
+    $element['#prefix'] = '<p>#cache enabled, GET</p>';
     $output = $this->renderer->renderRoot($element);
-    $this->assertSame($output, '<p>overridden</p>', 'Output is overridden.');
+    $this->assertSame('<p>#cache enabled, GET</p><p>overridden</p>', $output, 'Output is overridden.');
     $this->assertFalse(isset($element['#printed']), 'Cache hit');
-    $this->assertSame($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
+    $this->assertSame('<p>#cache enabled, GET</p><p>overridden</p>', $element['#markup'], '#markup is overridden.');
     $expected_js_settings = [
       'foo' => 'bar',
       'common_test' => $context,
     ];
-    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.');
+    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.');
   }
 
   /**
    * @covers ::render
    * @covers ::doRender
    * @covers \Drupal\Core\Render\RenderCache::get
-   * @covers ::processPostRenderCache
+   * @covers ::replacePlaceholders
+   *
+   * @dataProvider providerPlaceholders
    */
-  public function testPostRenderCacheWithPostRequest() {
-    list($test_element, $context) = $this->generatePostRenderCacheElement();
+  public function testCacheableParentWithPostRequest($test_element, $context) {
     $this->setUpUnusedCache();
 
     // Verify behavior when handling a non-GET request, e.g. a POST request:
-    // also in that case, #post_render_cache callbacks must be called.
+    // also in that case, placeholders must be replaced.
     $this->setUpRequest('POST');
 
     // POST request: #cache enabled, cache miss.
     $element = $test_element;
-    $element['#cache'] = ['keys' => ['post_render_cache_test_POST']];
-    $element['#markup'] = '<p>#cache enabled, POST</p>';
+    $element['#cache'] = ['keys' => ['placeholder_test_POST']];
+    $element['#prefix'] = '<p>#cache enabled, POST</p>';
     $output = $this->renderer->renderRoot($element);
-    $this->assertSame($output, '<p>overridden</p>', 'Output is overridden.');
+    $this->assertSame('<p>#cache enabled, POST</p><p>overridden</p>', $output, 'Output is overridden.');
     $this->assertTrue(isset($element['#printed']), 'No cache hit');
-    $this->assertSame($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
+    $this->assertSame('<p>#cache enabled, POST</p><p>overridden</p>', $element['#markup'], '#markup is overridden.');
     $expected_js_settings = [
       'foo' => 'bar',
       'common_test' => $context,
     ];
-    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.');
+    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.');
+
+    // Even when the child element's placeholder is cacheable, it should not
+    // generate a render cache item.
+    $this->assertPlaceholderRenderCache(FALSE, []);
   }
 
   /**
-   * Tests a #post_render_cache callback that adds another #post_render_cache
-   * callback.
+   * Tests a placeholder that adds another placeholder.
    *
-   * E.g. when rendering a node in a #post_render_cache callback, the rendering
-   * of that node needs a #post_render_cache callback of its own to be executed
-   * (to render the node links).
+   * E.g. when rendering a node in a placeholder the rendering of that node
+   * needs a placeholder of its own to be executed (to render the node links).
    *
    * @covers ::render
    * @covers ::doRender
-   * @covers ::processPostRenderCache
+   * @covers ::replacePlaceholders
    */
-  public function testRenderRecursivePostRenderCache() {
+  public function testRecursivePlaceholder() {
     $context = ['foo' => $this->randomContextValue()];
     $element = [];
-    $element['#markup'] = '';
-
-    $element['#post_render_cache']['Drupal\Tests\Core\Render\PostRenderCacheRecursion::callback'] = [
-      $context
-    ];
+    $element['#create_placeholder'] = TRUE;
+    $element['#lazy_builder']['Drupal\Tests\Core\Render\RecursivePlaceholdersTest::callback'] = $context;
 
     $output = $this->renderer->renderRoot($element);
-    $this->assertEquals('<p>overridden</p>', $output, 'The output has been modified by the indirect, recursive #post_render_cache callback.');
-    $this->assertSame($element['#markup'], '<p>overridden</p>', '#markup is overridden by the indirect, recursive #post_render_cache callback.');
+    $this->assertEquals('<p>overridden</p>', $output, 'The output has been modified by the indirect, recursive placeholder #lazy_builder callback.');
+    $this->assertSame($element['#markup'], '<p>overridden</p>', '#markup is overridden by the indirect, recursive placeholder #lazy_builder callback.');
     $expected_js_settings = [
       'common_test' => $context,
     ];
-    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified by the indirect, recursive #post_render_cache callback.');
+    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified by the indirect, recursive placeholder #lazy_builder callback.');
   }
 
   /**
-   * Create an element with a child and subchild. Each element has the same
-   * #post_render_cache callback, but with different contexts.
-   *
    * @covers ::render
    * @covers ::doRender
-   * @covers \Drupal\Core\Render\RenderCache::get
-   * @covers ::processPostRenderCache
+   *
+   * @see testNonScalarLazybuilderCallbackContext
    */
-  public function testRenderChildrenPostRenderCacheDifferentContexts() {
-    $this->setUpRequest();
-    $this->setupMemoryCache();
-    $this->cacheContextsManager->expects($this->any())
-      ->method('convertTokensToKeys')
-      ->willReturnArgument(0);
-    $this->elementInfo->expects($this->any())
-      ->method('getInfo')
-      ->with('details')
-      ->willReturn(['#theme_wrappers' => ['details']]);
-    $this->controllerResolver->expects($this->any())
-      ->method('getControllerFromDefinition')
-      ->willReturnArgument(0);
-    $this->setupThemeManagerForDetails();
+  public function testScalarLazybuilderCallbackContext() {
+    $element = [];
+    $element['#lazy_builder']['\Drupal\Tests\Core\Render\PlaceholdersTest::callback'] = [
+      'string' => 'foo',
+      'bool' => TRUE,
+      'int' => 1337,
+      'float' => 3.14,
+      'null' => NULL,
+    ];
 
-    $context_1 = ['foo' => $this->randomContextValue()];
-    $context_2 = ['bar' => $this->randomContextValue()];
-    $context_3 = ['baz' => $this->randomContextValue()];
-    $test_element = $this->generatePostRenderCacheWithChildrenTestElement($context_1, $context_2, $context_3);
+    $this->renderer->renderRoot($element);
+  }
 
-    $element = $test_element;
-    $output = $this->renderer->renderRoot($element);
-    $this->assertSame($output, '<p>overridden</p>', 'Output is overridden.');
-    $this->assertTrue(isset($element['#printed']), 'No cache hit');
-    $this->assertSame($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
-    $expected_js_settings = [
-      'foo' => 'bar',
-      'common_test' => $context_1 + $context_2 + $context_3,
+  /**
+   * @covers ::render
+   * @covers ::doRender
+   *
+   * @expectedException \DomainException
+   * @expectedExceptionMessage A #lazy_builder callback's context may only contain scalar values or NULL.
+   */
+  public function testNonScalarLazybuilderCallbackContext() {
+    $element = [];
+    $element['#lazy_builder']['\Drupal\Tests\Core\Render\PlaceholdersTest::callback'] = [
+      'string' => 'foo',
+      'bool' => TRUE,
+      'int' => 1337,
+      'float' => 3.14,
+      'null' => NULL,
+      // array is not one of the scalar types.
+      'array' => ['hi!'],
     ];
-    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
 
-    // GET request: validate cached data.
-    $cached_element = $this->memoryCache->get('simpletest:drupal_render:children_post_render_cache')->data;
-    $expected_element = [
-      '#attached' => [
-        'drupalSettings' => [
-          'foo' => 'bar',
-        ],
-      ],
-      '#post_render_cache' => [
-        'Drupal\Tests\Core\Render\PostRenderCache::callback' => [
-          $context_1,
-          $context_2,
-          $context_3,
-        ]
-      ],
-      '#cache' => [
-        'contexts' => [],
-        'tags' => [],
-        'max-age' => Cache::PERMANENT,
-      ],
-    ];
+    $this->renderer->renderRoot($element);
+  }
 
-    $dom = Html::load($cached_element['#markup']);
-    $xpath = new \DOMXPath($dom);
-    $parent = $xpath->query('//details/summary[text()="Parent"]')->length;
-    $child =  $xpath->query('//details/div[@class="details-wrapper"]/details/summary[text()="Child"]')->length;
-    $subchild = $xpath->query('//details/div[@class="details-wrapper"]/details/div[@class="details-wrapper" and text()="Subchild"]')->length;
-    $this->assertTrue($parent && $child && $subchild, 'The correct data is cached: the stored #markup is not affected by #post_render_cache callbacks.');
+  /**
+   * @covers ::render
+   * @covers ::doRender
+   *
+   * @expectedException \DomainException
+   * @expectedExceptionMessage Only a single #lazy_builder callback can be specified.
+   */
+  public function testMultipleBuilderCallbacks() {
+    $element = [];
+    $element['#lazy_builder']['\Drupal\Tests\Core\Render\PlaceholdersTest::callback'] = [];
+    $element['#lazy_builder']['Drupal\Tests\Core\Render\RecursivePlaceholdersTest::callback'] = [];
 
-    // Remove markup because it's compared above in the xpath.
-    unset($cached_element['#markup']);
-    $this->assertEquals($cached_element, $expected_element, 'The correct data is cached: the stored #attached properties are not affected by #post_render_cache callbacks.');
+    $this->renderer->renderRoot($element);
+  }
 
-    // GET request: #cache enabled, cache hit.
-    $element = $test_element;
-    $output = $this->renderer->renderRoot($element);
-    $this->assertSame($output, '<p>overridden</p>', 'Output is overridden.');
-    $this->assertFalse(isset($element['#printed']), 'Cache hit');
-    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
+  /**
+   * @covers ::render
+   * @covers ::doRender
+   *
+   * @expectedException \DomainException
+   * @expectedExceptionMessage When a #lazy_builder callback is specified, no children can exist; all children must be generated by the #lazy_builder callback. You specified the following children: child_a, child_b.
+   */
+  public function testChildrenPlusBuilder() {
+    $element = [];
+    $element['#lazy_builder']['Drupal\Tests\Core\Render\RecursivePlaceholdersTest::callback'] = [];
+    $element['child_a']['#markup'] = 'Oh hai!';
+    $element['child_b']['#markup'] = 'kthxbai';
 
-    // Use the exact same element, but now unset #cache; ensure we get the same
-    // result.
-    unset($test_element['#cache']);
-    $element = $test_element;
-    $output = $this->renderer->renderRoot($element);
-    $this->assertSame($output, '<p>overridden</p>', 'Output is overridden.');
-    $this->assertSame($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
-    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
+    $this->renderer->renderRoot($element);
+  }
+
+  /**
+   * @covers ::render
+   * @covers ::doRender
+   *
+   * @expectedException \DomainException
+   * @expectedExceptionMessage When a #lazy_builder callback is specified, no properties can exist; all properties must be generated by the #lazy_builder callback. You specified the following properties: #llama, #piglet.
+   */
+  public function testPropertiesPlusBuilder() {
+    $element = [];
+    $element['#lazy_builder']['Drupal\Tests\Core\Render\RecursivePlaceholdersTest::callback'] = [];
+    $element['#llama'] = '#awesome';
+    $element['#piglet'] = '#cute';
+
+    $this->renderer->renderRoot($element);
   }
 
   /**
    * Create an element with a child and subchild. Each element has the same
-   * #post_render_cache callback, but with different contexts. Both the
-   * parent and the child elements have #cache set. The cached parent element
-   * must contain the pristine child element, i.e. unaffected by its
-   * #post_render_cache callbacks. I.e. the #post_render_cache callbacks may
-   * not yet have run, or otherwise the cached parent element would contain
-   * personalized data, thereby breaking the render cache.
+   * #lazy_builder callback, but with different contexts. They don't modify
+   * markup, only attach additional drupalSettings.
    *
    * @covers ::render
    * @covers ::doRender
    * @covers \Drupal\Core\Render\RenderCache::get
-   * @covers ::processPostRenderCache
+   * @covers ::replacePlaceholders
    */
-  public function testRenderChildrenPostRenderCacheComplex() {
+  public function testRenderChildrenPlaceholdersDifferentContexts() {
     $this->setUpRequest();
     $this->setupMemoryCache();
     $this->cacheContextsManager->expects($this->any())
@@ -297,42 +483,60 @@ public function testRenderChildrenPostRenderCacheComplex() {
       ->method('getInfo')
       ->with('details')
       ->willReturn(['#theme_wrappers' => ['details']]);
+    $this->controllerResolver->expects($this->any())
+      ->method('getControllerFromDefinition')
+      ->willReturnArgument(0);
     $this->setupThemeManagerForDetails();
 
     $context_1 = ['foo' => $this->randomContextValue()];
     $context_2 = ['bar' => $this->randomContextValue()];
     $context_3 = ['baz' => $this->randomContextValue()];
-    $test_element = $this->generatePostRenderCacheWithChildrenTestElement($context_1, $context_2, $context_3);
+    $test_element = $this->generatePlaceholdersWithChildrenTestElement($context_1, $context_2, $context_3);
 
+    $element = $test_element;
+    $output = $this->renderer->renderRoot($element);
+    $expected_output = <<<HTML
+<details>
+  <summary>Parent</summary>
+  <div class="details-wrapper"><details>
+  <summary>Child</summary>
+  <div class="details-wrapper">Subchild</div>
+</details></div>
+</details>
+HTML;
+    $this->assertSame($expected_output, $output, 'Output is not overridden.');
+    $this->assertTrue(isset($element['#printed']), 'No cache hit');
+    $this->assertSame($expected_output, $output, '#markup is not overridden.');
     $expected_js_settings = [
       'foo' => 'bar',
       'common_test' => $context_1 + $context_2 + $context_3,
     ];
+    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each placeholder #lazy_builder callback exist.');
 
-    $element = $test_element;
-    $element['#cache']['keys'] = ['simpletest', 'drupal_render', 'children_post_render_cache', 'nested_cache_parent'];
-    $element['child']['#cache']['keys'] = ['simpletest', 'drupal_render', 'children_post_render_cache', 'nested_cache_child'];
-    $output = $this->renderer->renderRoot($element);
-    $this->assertSame($output, '<p>overridden</p>', 'Output is overridden.');
-    $this->assertTrue(isset($element['#printed']), 'No cache hit');
-    $this->assertSame($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
-    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
-
-    // GET request: validate cached data for both the parent and child.
-    $cached_parent_element = $this->memoryCache->get('simpletest:drupal_render:children_post_render_cache:nested_cache_parent')->data;
-    $cached_child_element = $this->memoryCache->get('simpletest:drupal_render:children_post_render_cache:nested_cache_child')->data;
-    $expected_parent_element = [
+    // GET request: validate cached data.
+    $cached_element = $this->memoryCache->get('simpletest:drupal_render:children_placeholders')->data;
+    $expected_element = [
       '#attached' => [
         'drupalSettings' => [
           'foo' => 'bar',
         ],
-      ],
-      '#post_render_cache' => [
-        'Drupal\Tests\Core\Render\PostRenderCache::callback' => [
-          $context_1,
-          $context_2,
-          $context_3,
-        ]
+        'placeholders' => [
+          'parent-x-parent' => [
+            '#lazy_builder' => [
+              __NAMESPACE__ . '\\PlaceholdersTest::callback' => $context_1,
+            ],
+          ],
+          'child-x-child' => [
+            '#lazy_builder' => [
+              __NAMESPACE__ . '\\PlaceholdersTest::callback' => $context_2,
+            ],
+          ],
+          'subchild-x-subchild' => [
+            '#lazy_builder' => [
+              __NAMESPACE__ . '\\PlaceholdersTest::callback' => $context_3,
+            ],
+          ],
+        ],
       ],
       '#cache' => [
         'contexts' => [],
@@ -341,359 +545,89 @@ public function testRenderChildrenPostRenderCacheComplex() {
       ],
     ];
 
-    $dom = Html::load($cached_parent_element['#markup']);
+    $dom = Html::load($cached_element['#markup']);
     $xpath = new \DOMXPath($dom);
     $parent = $xpath->query('//details/summary[text()="Parent"]')->length;
     $child =  $xpath->query('//details/div[@class="details-wrapper"]/details/summary[text()="Child"]')->length;
-    $subchild = $xpath->query('//details/div[@class="details-wrapper"]/details/div [@class="details-wrapper" and text()="Subchild"]')->length;
-    $this->assertTrue($parent && $child && $subchild, 'The correct data is cached for the parent: the stored #markup is not affected by #post_render_cache callbacks.');
-
-    // Remove markup because it's compared above in the xpath.
-    unset($cached_parent_element['#markup']);
-    $this->assertEquals($cached_parent_element, $expected_parent_element, 'The correct data is cached for the parent: the stored #attached properties are not affected by #post_render_cache callbacks.');
-
-    $expected_child_element = [
-      '#attached' => [
-      ],
-      '#post_render_cache' => [
-        'Drupal\Tests\Core\Render\PostRenderCache::callback' => [
-          $context_2,
-          $context_3,
-        ]
-      ],
-      '#cache' => [
-        'contexts' => [],
-        'tags' => [],
-        'max-age' => Cache::PERMANENT,
-      ],
-    ];
-
-    $dom = Html::load($cached_child_element['#markup']);
-    $xpath = new \DOMXPath($dom);
-    $child =  $xpath->query('//details/summary[text()="Child"]')->length;
-    $subchild = $xpath->query('//details/div [@class="details-wrapper" and text()="Subchild"]')->length;
-    $this->assertTrue($child && $subchild, 'The correct data is cached for the child: the stored #markup is not affected by #post_render_cache callbacks.');
+    $subchild = $xpath->query('//details/div[@class="details-wrapper"]/details/div[@class="details-wrapper" and text()="Subchild"]')->length;
+    $this->assertTrue($parent && $child && $subchild, 'The correct data is cached: the stored #markup is not affected by placeholder #lazy_builder callbacks.');
 
     // Remove markup because it's compared above in the xpath.
-    unset($cached_child_element['#markup']);
-    $this->assertEquals($cached_child_element, $expected_child_element, 'The correct data is cached for the child: the stored #attached properties are not affected by #post_render_cache callbacks.');
-
-    // GET request: #cache enabled, cache hit, parent element.
-    $element = $test_element;
-    $element['#cache']['keys'] = ['simpletest', 'drupal_render', 'children_post_render_cache', 'nested_cache_parent'];
-    $output = $this->renderer->renderRoot($element);
-    $this->assertSame($output, '<p>overridden</p>', 'Output is overridden.');
-    $this->assertFalse(isset($element['#printed']), 'Cache hit');
-    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
-
-    // GET request: #cache enabled, cache hit, child element.
-    $element = $test_element;
-    $element['child']['#cache']['keys'] = ['simpletest', 'drupal_render', 'children_post_render_cache', 'nested_cache_child'];
-    $element = $element['child'];
-    $output = $this->renderer->renderRoot($element);
-    $this->assertSame($output, '<p>overridden</p>', 'Output is overridden.');
-    $this->assertFalse(isset($element['#printed']), 'Cache hit');
-    $expected_js_settings = [
-      'common_test' => $context_2 + $context_3,
-    ];
-    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
-  }
-
-  /**
-   * Tests #post_render_cache placeholders.
-   *
-   * @covers ::render
-   * @covers ::doRender
-   * @covers \Drupal\Core\Render\RenderCache::get
-   * @covers ::processPostRenderCache
-   * @covers ::generateCachePlaceholder
-   */
-  public function testPlaceholder() {
-    $this->setupMemoryCache();
-
-    $context = [
-      'bar' => $this->randomContextValue(),
-      // Provide a token instead of letting one be generated by
-      // RendererInterface::generateCachePlaceholder(), otherwise we cannot know
-      // what the token is.
-      'token' => \Drupal\Component\Utility\Crypt::randomBytesBase64(55),
-    ];
-    $callback =  __NAMESPACE__ . '\\PostRenderCache::placeholder';
-    $placeholder = \Drupal::service('renderer')->generateCachePlaceholder($callback, $context);
-    $this->assertSame($placeholder, Html::normalize($placeholder), 'Placeholder unaltered by Html::normalize() which is used by FilterHtmlCorrector.');
-
-    $test_element = [
-      '#post_render_cache' => [
-        $callback => [
-          $context
-        ],
-      ],
-      '#markup' => $placeholder,
-      '#prefix' => '<pre>',
-      '#suffix' => '</pre>',
-    ];
-    $expected_output = '<pre><bar>' . $context['bar'] . '</bar></pre>';
-
-    // #cache disabled.
-    $element = $test_element;
-    $output = $this->renderer->renderRoot($element);
-    $this->assertSame($output, $expected_output, 'Placeholder was replaced in output');
-    $expected_js_settings = [
-      'common_test' => $context,
-    ];
-    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; JavaScript setting is added to page.');
-
-    // GET request: #cache enabled, cache miss.
-    $this->setUpRequest();
-    $element = $test_element;
-    $element['#cache'] = ['keys' => ['render_cache_placeholder_test_GET']];
-    $output = $this->renderer->renderRoot($element);
-    $this->assertSame($output, $expected_output, 'Placeholder was replaced in output');
-    $this->assertTrue(isset($element['#printed']), 'No cache hit');
-    $this->assertSame($element['#markup'], $expected_output, 'Placeholder was replaced in #markup.');
-    $this->assertSame($output, $expected_output, 'Placeholder was replaced in output');
-    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; JavaScript setting is added to page.');
-
-    // GET request: validate cached data.
-    $expected_token = $context['token'];
-    $cached_element = $this->memoryCache->get('render_cache_placeholder_test_GET')->data;
-    // Parse unique token out of the cached markup.
-    $dom = Html::load($cached_element['#markup']);
-    $xpath = new \DOMXPath($dom);
-    $nodes = $xpath->query('//*[@token]');
-    $this->assertEquals(1, $nodes->length, 'The token attribute was found in the cached markup');
-    $token = '';
-    if ($nodes->length) {
-      $token = $nodes->item(0)->getAttribute('token');
-    }
-    $this->assertSame($token, $expected_token, 'The tokens are identical');
-    // Verify the token is in the cached element.
-    $expected_element = [
-      '#markup' => '<pre><drupal-render-cache-placeholder callback="' . $callback . '" token="'. $expected_token . '"></drupal-render-cache-placeholder></pre>',
-      '#attached' => [],
-      '#post_render_cache' => [
-        $callback => [
-          $context
-        ],
-      ],
-      '#cache' => [
-        'contexts' => [],
-        'tags' => [],
-        'max-age' => Cache::PERMANENT,
-      ],
-    ];
-    $this->assertSame($cached_element, $expected_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by #post_render_cache callbacks.');
+    unset($cached_element['#markup']);
+    $this->assertEquals($cached_element, $expected_element, 'The correct data is cached: the stored #attached properties are not affected by placeholder #lazy_builder callbacks.');
 
     // GET request: #cache enabled, cache hit.
     $element = $test_element;
-    $element['#cache'] = ['keys' => ['render_cache_placeholder_test_GET']];
     $output = $this->renderer->renderRoot($element);
-    $this->assertSame($output, $expected_output, 'Placeholder was replaced in output');
+    $this->assertSame($expected_output, $output, 'Output is not overridden.');
     $this->assertFalse(isset($element['#printed']), 'Cache hit');
-    $this->assertSame($element['#markup'], $expected_output, 'Placeholder was replaced in #markup.');
-    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; JavaScript setting is added to page.');
-  }
-
-  /**
-   * Tests child element that uses #post_render_cache but that is rendered via a
-   * template.
-   */
-  public function testChildElementPlaceholder() {
-    $this->setupMemoryCache();
-    // Simulate the theme system/Twig: a recursive call to Renderer::render(),
-    // just like the theme system or a Twig template would have done.
-    $this->themeManager->expects($this->any())
-      ->method('render')
-      ->willReturnCallback(function ($hook, $vars) {
-        return $this->renderer->render($vars['foo']) . "\n";
-      });
-
-    $context = [
-      'bar' => $this->randomContextValue(),
-      // Provide a token instead of letting one be generated by
-      // drupal_render_cache_generate_placeholder(), otherwise we cannot know
-      // what the token is.
-      'token' => \Drupal\Component\Utility\Crypt::randomBytesBase64(55),
-    ];
-    $callback =  __NAMESPACE__ . '\\PostRenderCache::placeholder';
-    $placeholder = \Drupal::service('renderer')->generateCachePlaceholder($callback, $context);
-    $test_element = [
-      '#theme' => 'some_theme_function',
-      'foo' => [
-        '#post_render_cache' => [
-          $callback => [
-            $context
-          ],
-        ],
-        '#markup' => $placeholder,
-        '#prefix' => '<pre>',
-        '#suffix' => '</pre>'
-      ],
-    ];
-    $expected_output = '<pre><bar>' . $context['bar'] . '</bar></pre>' . "\n";
-
-    // #cache disabled.
-    $element = $test_element;
-    $output = $this->renderer->renderRoot($element);
-    $this->assertSame($output, $expected_output, 'Placeholder was replaced in output');
-    $expected_js_settings = [
-      'common_test' => $context,
-    ];
-    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; JavaScript setting is added to page.');
+    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each placeholder #lazy_builder callback exist.');
 
-    // GET request: #cache enabled, cache miss.
-    $this->setUpRequest();
-    $element = $test_element;
-    $element['#cache'] = ['keys' => ['render_cache_placeholder_test_GET']];
-    $element['foo']['#cache'] = ['keys' => ['render_cache_placeholder_test_child_GET']];
-    // Render, which will use the common-test-render-element.html.twig template.
-    $output = $this->renderer->renderRoot($element);
-    $this->assertSame($output, $expected_output, 'Placeholder was replaced in output');
-    $this->assertTrue(isset($element['#printed']), 'No cache hit');
-    $this->assertSame($element['#markup'], $expected_output, 'Placeholder was replaced in #markup.');
-    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; JavaScript setting is added to page.');
-
-    // GET request: validate cached data for child element.
-    $expected_token = $context['token'];
-    $cached_element = $this->memoryCache->get('render_cache_placeholder_test_child_GET')->data;
-    // Parse unique token out of the cached markup.
-    $dom = Html::load($cached_element['#markup']);
-    $xpath = new \DOMXPath($dom);
-    $nodes = $xpath->query('//*[@token]');
-    $this->assertEquals(1, $nodes->length, 'The token attribute was found in the cached child element markup');
-    $token = '';
-    if ($nodes->length) {
-      $token = $nodes->item(0)->getAttribute('token');
-    }
-    $this->assertSame($token, $expected_token, 'The tokens are identical for the child element');
-    // Verify the token is in the cached element.
-    $expected_element = [
-      '#markup' => '<pre><drupal-render-cache-placeholder callback="' . $callback . '" token="'. $expected_token . '"></drupal-render-cache-placeholder></pre>',
-      '#attached' => [],
-      '#post_render_cache' => [
-        $callback => [
-          $context,
-        ],
-      ],
-      '#cache' => [
-        'contexts' => [],
-        'tags' => [],
-        'max-age' => Cache::PERMANENT,
-      ],
-    ];
-    $this->assertSame($cached_element, $expected_element, 'The correct data is cached for the child element: the stored #markup and #attached properties are not affected by #post_render_cache callbacks.');
-
-    // GET request: validate cached data (for the parent/entire render array).
-    $cached_element = $this->memoryCache->get('render_cache_placeholder_test_GET')->data;
-    // Parse unique token out of the cached markup.
-    $dom = Html::load($cached_element['#markup']);
-    $xpath = new \DOMXPath($dom);
-    $nodes = $xpath->query('//*[@token]');
-    $this->assertEquals(1, $nodes->length, 'The token attribute was found in the cached parent element markup');
-    $token = '';
-    if ($nodes->length) {
-      $token = $nodes->item(0)->getAttribute('token');
-    }
-    $this->assertSame($token, $expected_token, 'The tokens are identical for the parent element');
-    // Verify the token is in the cached element.
-    $expected_element = [
-      '#markup' => '<pre><drupal-render-cache-placeholder callback="' . $callback . '" token="'. $expected_token . '"></drupal-render-cache-placeholder></pre>' . "\n",
-      '#attached' => [],
-      '#post_render_cache' => [
-        $callback => [
-          $context,
-        ],
-      ],
-      '#cache' => [
-        'contexts' => [],
-        'tags' => [],
-        'max-age' => Cache::PERMANENT,
-      ],
-    ];
-    $this->assertSame($cached_element, $expected_element, 'The correct data is cached for the parent element: the stored #markup and #attached properties are not affected by #post_render_cache callbacks.');
-
-    // GET request: validate cached data.
-    // Check the cache of the child element again after the parent has been
-    // rendered.
-    $cached_element = $this->memoryCache->get('render_cache_placeholder_test_child_GET')->data;
-    // Verify that the child element contains the correct
-    // render_cache_placeholder markup.
-    $dom = Html::load($cached_element['#markup']);
-    $xpath = new \DOMXPath($dom);
-    $nodes = $xpath->query('//*[@token]');
-    $this->assertEquals(1, $nodes->length, 'The token attribute was found in the cached child element markup');
-    $token = '';
-    if ($nodes->length) {
-      $token = $nodes->item(0)->getAttribute('token');
-    }
-    $this->assertSame($token, $expected_token, 'The tokens are identical for the child element');
-    // Verify the token is in the cached element.
-    $expected_element = [
-      '#markup' => '<pre><drupal-render-cache-placeholder callback="' . $callback . '" token="'. $expected_token . '"></drupal-render-cache-placeholder></pre>',
-      '#attached' => [],
-      '#post_render_cache' => [
-        $callback => [
-          $context,
-        ],
-      ],
-      '#cache' => [
-        'contexts' => [],
-        'tags' => [],
-        'max-age' => Cache::PERMANENT,
-      ],
-    ];
-    $this->assertSame($cached_element, $expected_element, 'The correct data is cached for the child element: the stored #markup and #attached properties are not affected by #post_render_cache callbacks.');
-
-    // GET request: #cache enabled, cache hit.
+    // Use the exact same element, but now unset #cache; ensure we get the same
+    // result.
+    unset($test_element['#cache']);
     $element = $test_element;
-    $element['#cache'] = ['keys' => ['render_cache_placeholder_test_GET']];
-    // Render, which will use the common-test-render-element.html.twig template.
     $output = $this->renderer->renderRoot($element);
-    $this->assertSame($output, $expected_output, 'Placeholder was replaced in output');
-    $this->assertFalse(isset($element['#printed']), 'Cache hit');
-    $this->assertSame($element['#markup'], $expected_output, 'Placeholder was replaced in #markup.');
-    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; JavaScript setting is added to page.');
+    $this->assertSame($expected_output, $output, 'Output is not overridden.');
+    $this->assertSame($expected_output, $output, '#markup is not overridden.');
+    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #lazy_builder callback exist.');
   }
 
   /**
-   * Generates an element with a #post_render_cache callback at 3 levels.
+   * Generates an element with a placeholder callback at 3 levels.
    *
    * @param array $context_1
-   *   The context for the #post_render_cache calback at level 1.
+   *   The context for the placeholderat level 1.
    * @param array $context_2
-   *   The context for the #post_render_cache calback at level 2.
+   *   The context for the placeholder at level 2.
    * @param array $context_3
-   *   The context for the #post_render_cache calback at level 3.
+   *   The context for the placeholder at level 3.
    *
    * @return array
    *   The generated render array for testing.
    */
-  protected function generatePostRenderCacheWithChildrenTestElement(array $context_1, array $context_2, array $context_3) {
+  protected function generatePlaceholdersWithChildrenTestElement(array $context_1, array $context_2, array $context_3) {
     $test_element = [
       '#type' => 'details',
       '#cache' => [
-        'keys' => ['simpletest', 'drupal_render', 'children_post_render_cache'],
-      ],
-      '#post_render_cache' => [
-        __NAMESPACE__ . '\\PostRenderCache::callback' => [$context_1]
+        'keys' => ['simpletest', 'drupal_render', 'children_placeholders'],
       ],
       '#title' => 'Parent',
       '#attached' => [
         'drupalSettings' => [
           'foo' => 'bar',
         ],
+        'placeholders' => [
+          'parent-x-parent' => [
+            '#lazy_builder' => [
+              __NAMESPACE__ . '\\PlaceholdersTest::callback' => $context_1,
+            ],
+          ],
+        ],
       ],
     ];
     $test_element['child'] = [
       '#type' => 'details',
-      '#post_render_cache' => [
-        __NAMESPACE__ . '\\PostRenderCache::callback' => [$context_2],
+      '#attached' => [
+        'placeholders' => [
+          'child-x-child' => [
+            '#lazy_builder' => [
+              __NAMESPACE__ . '\\PlaceholdersTest::callback' => $context_2,
+            ],
+          ],
+        ],
       ],
       '#title' => 'Child',
     ];
     $test_element['child']['subchild'] = [
-      '#post_render_cache' => [
-        __NAMESPACE__ . '\\PostRenderCache::callback' => [$context_3]
+      '#attached' => [
+        'placeholders' => [
+          'subchild-x-subchild' => [
+            '#lazy_builder' => [
+              __NAMESPACE__ . '\\PlaceholdersTest::callback' => $context_3,
+            ],
+          ],
+        ],
       ],
       '#markup' => 'Subchild',
     ];
@@ -723,10 +657,13 @@ protected function setupThemeManagerForDetails() {
 
 }
 
-class PostRenderCacheRecursion {
+/**
+ * @see \Drupal\Tests\Core\Render\RendererPlaceholdersTest::testRecursivePlaceholder()
+ */
+class RecursivePlaceholdersTest {
 
   /**
-   * #post_render_cache callback; bubbles another #post_render_cache callback.
+   * #lazy_builder callback; bubbles another placeholder.
    *
    * @param array $element
    *  A render array with the following keys:
@@ -740,15 +677,12 @@ class PostRenderCacheRecursion {
    *   The updated $element.
    */
   public static function callback(array $element, array $context) {
-    // Render a child which itself also has a #post_render_cache callback that
-    // must be bubbled.
+    // Render a child which itself also has a placeholder that must be bubbled.
     $child = [];
-    $child['#markup'] = 'foo';
-    $child['#post_render_cache']['Drupal\Tests\Core\Render\PostRenderCache::callback'][] = $context;
-
-    // Render the child.
-    $element['#markup'] = \Drupal::service('renderer')->render($child);
+    $child['#create_placeholder'] = TRUE;
+    $child['#lazy_builder']['Drupal\Tests\Core\Render\PlaceholdersTest::callback'] = $context;
 
+    $element['child'] = $child;
     return $element;
   }
 
diff --git a/core/tests/Drupal/Tests/Core/Render/RendererRecursionTest.php b/core/tests/Drupal/Tests/Core/Render/RendererRecursionTest.php
index 7b41128..ef3130f 100644
--- a/core/tests/Drupal/Tests/Core/Render/RendererRecursionTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/RendererRecursionTest.php
@@ -20,22 +20,20 @@ protected function setUpRenderRecursionComplexElements() {
     $parent_markup = '<p>Rendered!</p>';
 
     $complex_child_template = [
-      '#markup' => $complex_child_markup,
-      '#attached' => [
-        'library' => [
-          'core/drupal',
-        ],
-      ],
       '#cache' => [
         'tags' => [
           'test:complex_child',
         ],
       ],
-      '#post_render_cache' => [
-        'Drupal\Tests\Core\Render\PostRenderCache::callback' => [
-          ['foo' => $this->getRandomGenerator()->string()],
+      '#lazy_builder' => [
+        'Drupal\Tests\Core\Render\PlaceholdersTest::callback' => [
+          'foo' => $this->getRandomGenerator()->string(),
+          // Provide a token instead of letting one be generated by the
+          // Renderer, otherwise we cannot know what the token is.
+          'placeholder_token' => 123456789,
         ],
       ],
+      '#create_placeholder' => TRUE,
     ];
 
     return [$complex_child_markup, $parent_markup, $complex_child_template];
@@ -86,17 +84,16 @@ public function testRenderRecursionWithNestedRender() {
     $renderer = $this->renderer;
     $this->setUpRequest();
 
-    $complex_child = $complex_child_template;
-
-    $callable = function ($elements) use ($renderer, $complex_child, $complex_child_markup, $parent_markup) {
-      $elements['#markup'] = $renderer->render($complex_child);
-      $this->assertEquals($complex_child_markup, $elements['#markup'], 'Rendered complex child output as expected, without the #post_render_cache callback executed.');
-      return $elements;
+    $callable = function ($markup) use ($renderer, $complex_child_template) {
+      $placeholder = $renderer->render($complex_child_template);
+      $this->assertEquals($placeholder, $markup, 'Rendered complex child output as expected, without the placeholder replaced, i.e. with just the placeholder.');
+      return $markup;
     };
 
     $page = [
       'content' => [
-        '#pre_render' => [
+        'complex_child' => $complex_child_template,
+        '#post_render' => [
           $callable
         ],
         '#suffix' => $parent_markup,
@@ -104,9 +101,9 @@ public function testRenderRecursionWithNestedRender() {
     ];
     $output = $renderer->renderRoot($page);
 
-    $this->assertEquals('<p>overridden</p>', $output, 'Rendered output as expected, with the #post_render_cache callback executed.');
+    $this->assertEquals('<p>overridden</p><p>Rendered!</p>', $output, 'Rendered output as expected, with the placeholder replaced.');
     $this->assertTrue(in_array('test:complex_child', $page['#cache']['tags']), 'Cache tag bubbling performed.');
-    $this->assertTrue(in_array('core/drupal', $page['#attached']['library']), 'Asset bubbling performed.');
+    $this->assertTrue(in_array('common_test', array_keys($page['#attached']['drupalSettings'])), 'Asset bubbling performed.');
   }
 
   /**
@@ -126,7 +123,7 @@ public function testRenderRecursionWithNestedRenderPlain() {
 
     $callable = function ($elements) use ($renderer, $complex_child, $parent_markup) {
       $elements['#markup'] = $renderer->renderPlain($complex_child);
-      $this->assertEquals('<p>overridden</p>', $elements['#markup'], 'Rendered complex child output as expected, with the #post_render_cache callback executed.');
+      $this->assertEquals('<p>overridden</p>', $elements['#markup'], 'Rendered complex child output as expected, with the placeholder replaced.');
       return $elements;
     };
 
@@ -139,7 +136,7 @@ public function testRenderRecursionWithNestedRenderPlain() {
       ]
     ];
     $output = $renderer->renderRoot($page);
-    $this->assertEquals('<p>overridden</p>' . $parent_markup, $output, 'Rendered output as expected, with the #post_render_cache callback executed.');
+    $this->assertEquals('<p>overridden</p>' . $parent_markup, $output, 'Rendered output as expected, with the placeholder replaced.');
     $this->assertFalse(in_array('test:complex_child', $page['#cache']['tags']), 'Cache tag bubbling not performed.');
     $this->assertTrue(empty($page['#attached']), 'Asset bubbling not performed.');
   }
diff --git a/core/tests/Drupal/Tests/Core/Render/RendererTest.php b/core/tests/Drupal/Tests/Core/Render/RendererTest.php
index 78fca24..7310a61 100644
--- a/core/tests/Drupal/Tests/Core/Render/RendererTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/RendererTest.php
@@ -28,7 +28,6 @@ class RendererTest extends RendererTestBase {
       'max-age' => Cache::PERMANENT,
     ],
     '#attached' => [],
-    '#post_render_cache' => [],
     '#children' => '',
   ];
 
diff --git a/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php b/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php
index 0a8a28a..7565070 100644
--- a/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php
+++ b/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php
@@ -137,18 +137,17 @@ protected function setUp() {
   }
 
   /**
-   * Generates a random context value for the post-render cache tests.
+   * Generates a random context value for the placeholder tests.
    *
-   * The #context array used by the post-render cache callback will generally
-   * be used to provide metadata like entity IDs, field machine names, paths,
-   * etc. for JavaScript replacement of content or assets. In this test, the
-   * callbacks PostRenderCache::callback() and PostRenderCache::placeholder()
-   * render the context inside test HTML, so using any random string would
-   * sometimes cause random test failures because the test output would be
-   * unparseable. Instead, we provide random tokens for replacement.
+   * The #context array used by the placeholder #lazy_builder callback will
+   * generally be used to provide metadata like entity IDs, field machine names,
+   * paths, etc. for JavaScript replacement of content or assets. In this test,
+   * the #lazy_builder callback PlaceholdersTest::callback() renders the context
+   * inside test HTML, so using any random string would sometimes cause random
+   * test failures because the test output would be unparseable. Instead, we
+   * provide random tokens for replacement.
    *
-   * @see PostRenderCache::callback()
-   * @see PostRenderCache::placeholder()
+   * @see PlaceholdersTest::callback()
    * @see https://www.drupal.org/node/2151609
    */
   protected function randomContextValue() {
@@ -208,10 +207,10 @@ protected function assertRenderCacheItem($cid, $data) {
 }
 
 
-class PostRenderCache {
+class PlaceholdersTest {
 
   /**
-   * #post_render_cache callback; modifies #markup, #attached and #context_test.
+   * #lazy_builder callback; modifies #markup, #attached and #context_test.
    *
    * @param array $element
    *  A render array with the following keys:
@@ -240,32 +239,4 @@ public static function callback(array $element, array $context) {
     return $element;
   }
 
-  /**
-   * #post_render_cache callback; replaces placeholder, extends #attached.
-   *
-   * @param array $element
-   *   The renderable array that contains the to be replaced placeholder.
-   * @param array $context
-   *  An array with the following keys:
-   *    - bar: contains a random string.
-   *
-   * @return array
-   *   A render array.
-   */
-  public static function placeholder(array $element, array $context) {
-    $placeholder = \Drupal::service('renderer')->generateCachePlaceholder(__NAMESPACE__ . '\\PostRenderCache::placeholder', $context);
-    $replace_element = array(
-      '#markup' => '<bar>' . $context['bar'] . '</bar>',
-      '#attached' => array(
-        'drupalSettings' => [
-          'common_test' => $context,
-        ],
-      ),
-    );
-    $markup = \Drupal::service('renderer')->render($replace_element);
-    $element['#markup'] = str_replace($placeholder, $markup, $element['#markup']);
-
-    return $element;
-  }
-
 }
