Cache Pages with Dynamic Content in Drupal 8

Sara Olson, Marketing Analyst
#Development | Posted

Drupal 8 includes new features for caching rendered page content, even content that varies by user or each time the page loads. In previous versions of Drupal, dynamic content (for example, a personalized greeting for the current user: “Hello, Jane!”) could not be cached without using an external workaround, such as Ajax or Edge Side Includes.Drupal 8 comes bundled with a built-in solution for dynamic content, which is called the auto-placeholdering system. Briefly, auto-placeholdering works by generating placeholder tags for the parts of the page that have low cacheability, and then swapping those placeholders into the rendered page markup that gets stored in the cache. When the page is served from cache, Drupal dynamically generates the content to swap in for the placeholder tags, then serves the completed page to the user. This approach saves Drupal the expense of generating the entire page on each request, just to allow one or two parts of it to be dynamic.If you were to look at the copy of a page stored in the cache, the markup with placeholders would look something like the following example:

  1. <h3>
  2. <a href="/comment/1" class="permalink" rel="bookmark" hreflang="en">
  3. First comment!!
  4. </a>
  5. </h3>
  6.  
  7. <div class="clearfix text-formatted field field--name-comment-body field--type-text-long field--label-hidden field__item">
  8. First comment!!
  9. </div>
  10.  
  11. <nav>
  12. <drupal-render-placeholder callback="comment.lazy_builders:renderLinks" arguments="0=1&amp;1=full&amp;2=en&amp;3=" token="c0004804"></drupal-render-placeholder>
  13. </nav>

When the placeholder content is swapped in, the page markup becomes what you might expect:

  1. <h3>
  2. <a href="/comment/1" class="permalink" rel="bookmark" hreflang="en">
  3. First comment!!
  4. </a>
  5. </h3>
  6.  
  7. <div class="clearfix text-formatted field field--name-comment-body field--type-text-long field--label-hidden field__item">
  8. First comment!!
  9. </div>
  10.  
  11. <nav>
  12. <ul class="links inline">
  13. <li class="comment-delete">
  14. <a href="/comment/1/delete">
  15. Delete
  16. </a>
  17. </li>
  18. <li class="comment-edit">
  19. <a href="/comment/1/edit" hreflang="en">
  20. Edit
  21. </a>
  22. </li>
  23. <li class="comment-reply">
  24. <a href="/comment/reply/node/1/comments/1">
  25. Reply
  26. </a>
  27. </li>
  28. </ul>
  29. </nav>

Big Pipe

You may be thinking, “That’s great, but what if the dynamic parts of my page that get swapped with the placeholders are also the most expensive parts to generate? The final page will still be blocked from rendering until those parts are built.”To mitigate this issue, you can use the Big Pipe module to increase the perceived load time of the page. Big Pipe is a Drupal 8.0.x contrib module, but will be part of Drupal 8.1.x core as an "experimental" module, meaning that it will be included with the installation but you will have to manually enable it. Big Pipe enhances the auto-placeholdering system by allowing Drupal to stream the HTML markup of the page to the browser before the placeholder content is fully built. The user can start to see parts of the page render while the placeholder content is still being assembled and streamed to the browser. The team behind the Big Pipe module put together a short video demonstrating a side-by-side comparison of the rendering of a Drupal page that uses Big Pipe, and an otherwise identical page that doesn’t use Big Pipe. Check out the video demonstration of Big Pipe.Big Pipe uses the “Transfer-Encoding: chunked” HTTP header, combined with flushing PHP’s output buffer after each placeholder item is built, to continually send chunks of markup to the browser as they are ready. If you have JavaScript enabled, Big Pipe will actually send the main body of the page to the browser with placeholder markup intact. Directly above the closing body tag it will send chunks of JSON code with content to dynamically swap in for the placeholder tags. The content sent to the browser (not just stored in the server cache, as with the previous example) might look like the following:

  1. <h3>
  2. <a href="/comment/1" class="permalink" rel="bookmark" hreflang="en">
  3. First comment!!
  4. </a>
  5. </h3>
  6.  
  7. <div class="clearfix text-formatted field field--name-comment-body field--type-text-long field--label-hidden field__item">
  8. First comment!!
  9. </div>
  10.  
  11. <nav>
  12. <div data-big-pipe-selector="callback=comment.lazy_builders%3ArenderLinks&amp;args[0]=1&amp;args[1]=full&amp;args[2]=en&amp;args[3]=&amp;token=c0004804"></div>
  13. </nav>

The corresponding JSON near the closing body tag would look similar to the following:

  1. <script type="application/json" data-big-pipe-event="start"></script>
  2. <script type="application/json" data-big-pipe-placeholder="callback=comment.lazy_builders%3ArenderLinks&amp;args[0]=1&amp;args[1]=full&amp;args[2]=en&amp;args[3]=&amp;token=c0004804" data-drupal-ajax-processor="big_pipe">
  3.  
  4. [{"command":"insert","method":"replaceWith","selector":"[data-big-pipe-selector=\u0022callback=comment.lazy_builders%3ArenderLinks\u0026args[0]=1\u0026args[1]=full\u0026args[2]=en\u0026args[3]=\u0026token=c0004804\u0022]","data":"\u003Cul class=\u0022links inline\u0022\u003E\u003Cli class=\u0022comment-delete\u0022\u003E\u003Ca href=\u0022\/comment\/1\/delete\u0022\u003EDelete\u003C\/a\u003E\u003C\/li\u003E\u003Cli class=\u0022comment-edit\u0022\u003E\u003Ca href=\u0022\/comment\/1\/edit\u0022 hreflang=\u0022en\u0022\u003EEdit\u003C\/a\u003E\u003C\/li\u003E\u003Cli class=\u0022comment-reply\u0022\u003E\u003Ca href=\u0022\/comment\/reply\/node\/1\/comments\/1\u0022\u003EReply\u003C\/a\u003E\u003C\/li\u003E\u003C\/ul\u003E","settings":null}]
  5.  
  6. </script>
  7. <script type="application/json" data-big-pipe-event="stop"></script>

Using Auto-Placeholdering in Your Code

So how do you take advantage of the auto-placeholdering system in your own code? When you write render array code for content that you want to support placeholders, instead of writing a full render array for that content, specify a #lazy_builder  callback in the part of the render array where you want the placeholder to appear. Then, inside the function you identified in the #lazy_builder  callback, build the missing piece of the render array, and give that piece of the render array cache settings that trigger auto-placeholdering (for example, a max-age  of 0). Here is an example of how Drupal core does this for comment links:

  1. $build[$id]['links'] = array(
  2. '#lazy_builder' => ['comment.lazy_builders:renderLinks', [
  3. $entity->id(),
  4. $view_mode,
  5. $entity->language()->getId(),
  6. !empty($entity->in_preview),
  7. ]],
  8. '#create_placeholder' => TRUE,
  9. );

As you can see in the example above, you assign the #lazy_builder  an array, the first element of which is the name of the callback function, and the second element of which is an inner array consisting of the parameter values to pass to the callback function. The callback function that this render array uses is Drupal\comment\CommentLazyBuilders::renderLinks()  (the Drupal\comment\CommentLazyBuilders  class is mapped to the service name comment.lazy_builders  in the comment.services.yml  file, but that’s another topic in itself). The code for this function is as follows:

  1. /**
  2.   * #lazy_builder callback; builds a comment's links.
  3.   *
  4.   * @param string $comment_entity_id
  5.   * The comment entity ID.
  6.   * @param string $view_mode
  7.   * The view mode in which the comment entity is being viewed.
  8.   * @param string $langcode
  9.   * The language in which the comment entity is being viewed.
  10.   * @param bool $is_in_preview
  11.   * Whether the comment is currently being previewed.
  12.   *
  13.   * @return array
  14.   * A renderable array representing the comment links.
  15.   */
  16. public function renderLinks($comment_entity_id, $view_mode, $langcode, $is_in_preview) {
  17. $links = array(
  18. '#theme' => 'links__comment',
  19. '#pre_render' => array('drupal_pre_render_links'),
  20. '#attributes' => array('class' => array('links', 'inline')),
  21. );
  22.  
  23. if (!$is_in_preview) {
  24. /** @var \Drupal\comment\CommentInterface $entity */
  25. $entity = $this->entityManager->getStorage('comment')->load($comment_entity_id);
  26. $commented_entity = $entity->getCommentedEntity();
  27.  
  28. $links['comment'] = $this->buildLinks($entity, $commented_entity);
  29.  
  30. // Allow other modules to alter the comment links.
  31. $hook_context = array(
  32. 'view_mode' => $view_mode,
  33. 'langcode' => $langcode,
  34. 'commented_entity' => $commented_entity,
  35. );
  36. $this->moduleHandler->alter('comment_links', $links, $entity, $hook_context);
  37. }
  38. return $links;
  39. }

Each of the comment links (delete, edit, and reply) are generated by a separate method, buildLinks() , which you can see invoked in the code above on the line:

$links['comment'] = $this->buildLinks($entity, $commented_entity);

The availability of each of these links varies based on the permissions of the current user, so their cacheability metadata triggers the auto-placeholdering.

One Final Note

Watch out for a potential gotcha when trying to use a #lazy_builder  callback to return a render array with a #type  property but no #theme  or #pre_render . Due to the order of operations in the Drupal\Core\Render::doRender()  function, which contains much of the code for turning render arrays into HTML markup, render arrays returned from #lazy_builder  functions cannot rely on an element #type , because the Drupal\Core\Render::doRender()  function processes the #type  property before it processes the #lazy_builder . The following code excerpt from Drupal\Core\Render::doRender()  shows where the #type  is processed before the first line of code for processing #lazy_builder :

  1. // If the default values for this element have not been loaded yet, populate
  2. // them.
  3. if (isset($elements['#type']) && empty($elements['#defaults_loaded'])) {
  4. $elements += $this->elementInfo->getInfo($elements['#type']);
  5. }
  6.  
  7. // First validate the usage of #lazy_builder; both of the next if-statements
  8. // use it if available.
  9. if (isset($elements['#lazy_builder'])) {

The easiest workaround to this issue is to use #theme  or #markup  properties in the render array returned by your #lazy_builder  callback function. Alternatively, if you must use the element #type  property, manually invoke the Drupal\Core\Render\ElementInfoManager::getInfo()  method in your #lazy_builder  callback function and add the result to your render array. For example:

  1. /**
  2.  * Lazy builder callback to render the created time in time ago.
  3.  *
  4.  * @param int $created_time
  5.  * The Unix timestamp representing the node created time.
  6.  *
  7.  * @return array
  8.  * A render array with the markup for the created time in time ago.
  9.  */
  10. function mymodule_format_created_time($created_time) {
  11. $date_formatter = \Drupal::service('date.formatter');
  12. /** @var \Drupal\Core\Render\ElementInfoManagerInterface $element_info */
  13. $element_info = \Drupal::service('plugin.manager.element_info');
  14. return [
  15. '#cache' => [
  16. 'max-age' => 0,
  17. ],
  18. '#type' => 'html_tag',
  19. '#tag' => 'span',
  20. '#value' => t('Content posted @time ago', ['@time' => $date_formatter->formatTimeDiffSince($created_time)]),
  21. ] + $element_info->getInfo('html_tag');
  22. }

For more information about the Drupal 8 auto-placeholdering system, visit the documentation page.

Sara Olson

Marketing Analyst