diff --git a/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php b/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php
index 10d2ce9..40fc82e 100644
--- a/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php
+++ b/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php
@@ -183,6 +183,8 @@ protected function prepare(array $main_content, Request $request, RouteMatchInte
       // @todo Remove this once https://www.drupal.org/node/2359901 lands.
       if (!empty($main_content)) {
         $this->renderer->executeInRenderContext(new RenderContext(), function() use (&$main_content) {
+          // Ensure that we at least store the page title in cache.
+          $main_content['#cache_properties'][] = '#title';
           return $this->renderer->render($main_content, FALSE);
         });
         $main_content = $this->renderCache->getCacheableRenderArray($main_content) + [
diff --git a/core/lib/Drupal/Core/Render/RenderCache.php b/core/lib/Drupal/Core/Render/RenderCache.php
index b9e1c92..331832a 100644
--- a/core/lib/Drupal/Core/Render/RenderCache.php
+++ b/core/lib/Drupal/Core/Render/RenderCache.php
@@ -7,6 +7,7 @@
 
 namespace Drupal\Core\Render;
 
+use Drupal\Component\Utility\SafeMarkup;
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Cache\Context\CacheContextsManager;
@@ -335,6 +336,15 @@ public function getCacheableRenderArray(array $elements) {
     // the cache entry size.
     if (!empty($elements['#cache_properties']) && is_array($elements['#cache_properties'])) {
       $data['#cache_properties'] = $elements['#cache_properties'];
+      // Store whether any of the cache properties are safe strings. #markup is
+      // always safe at this point.
+      $data['#safe_cache_properties'] = ['#markup'];
+      foreach (Element::properties(array_flip($elements['#cache_properties'])) as $cache_property) {
+        if (isset($elements[$cache_property]) && SafeMarkup::isSafe($elements[$cache_property])) {
+          $data['#safe_cache_properties'][] = $cache_property;
+        }
+      }
+
       // Extract all the cacheable items from the element using cache
       // properties.
       $cacheable_items = array_intersect_key($elements, array_flip($elements['#cache_properties']));
diff --git a/core/lib/Drupal/Core/Render/Renderer.php b/core/lib/Drupal/Core/Render/Renderer.php
index d98955d..b89a3bc 100644
--- a/core/lib/Drupal/Core/Render/Renderer.php
+++ b/core/lib/Drupal/Core/Render/Renderer.php
@@ -257,16 +257,20 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
         if ($is_root_call) {
           $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
-        // markup, so if the parent markup is safe, then the markup of the
-        // individual children must be safe as well.
-        $elements['#markup'] = SafeMarkup::set($elements['#markup']);
+        // If we have cached children, we need to mark them as safe. The parent
+        // markup contains the child markup, so if the parent markup is safe,
+        // then the markup of the individual children must be safe as well.
         if (!empty($elements['#cache_properties'])) {
           foreach (Element::children($cached_element) as $key) {
             SafeMarkup::set($cached_element[$key]['#markup']);
           }
         }
+        // Mark root element cached properties as safe. This always includes
+        // #markup.
+        foreach ($elements['#safe_cache_properties'] as $cache_property) {
+          SafeMarkup::set($cached_element[$cache_property]);
+        }
+
         // The render cache item contains all the bubbleable rendering metadata
         // for the subtree.
         static::$context->update($elements);
diff --git a/core/modules/node/src/Controller/NodeViewController.php b/core/modules/node/src/Controller/NodeViewController.php
index 3e3fb65..8a756b3 100644
--- a/core/modules/node/src/Controller/NodeViewController.php
+++ b/core/modules/node/src/Controller/NodeViewController.php
@@ -57,7 +57,7 @@ public function view(EntityInterface $node, $view_mode = 'full', $langcode = NUL
    *   The page title.
    */
   public function title(EntityInterface $node) {
-    return SafeMarkup::checkPlain($this->entityManager->getTranslationFromContext($node)->label());
+    return $this->entityManager->getTranslationFromContext($node)->label();
   }
 
 }
diff --git a/core/modules/system/src/Tests/System/PageTitleTest.php b/core/modules/system/src/Tests/System/PageTitleTest.php
index 1fef0a4..1a63f23 100644
--- a/core/modules/system/src/Tests/System/PageTitleTest.php
+++ b/core/modules/system/src/Tests/System/PageTitleTest.php
@@ -55,6 +55,7 @@ function testTitleTags() {
 
     $node = $this->drupalGetNodeByTitle($edit['title[0][value]']);
     $this->assertNotNull($node, 'Node created and found in database');
+    $this->assertText(SafeMarkup::checkPlain($edit['title[0][value]']), 'Check to make sure tags in the node title are converted.');
     $this->drupalGet("node/" . $node->id());
     $this->assertText(SafeMarkup::checkPlain($edit['title[0][value]']), 'Check to make sure tags in the node title are converted.');
   }
@@ -137,6 +138,24 @@ public function testRoutingTitle() {
     $this->assertTitle('Dynamic title | Drupal');
     $result = $this->xpath('//h1');
     $this->assertEqual('Dynamic title', (string) $result[0]);
+
+    // Ensure that titles are cacheable and are escaped normally if the
+    // controller does not escape them.
+    $this->drupalGet('test-page-cached-controller');
+    $this->assertTitle('Cached title | Drupal');
+    $this->assertText(SafeMarkup::checkPlain('<span>Cached title</span>'));
+    $this->drupalGet('test-page-cached-controller');
+    $this->assertTitle('Cached title | Drupal');
+    $this->assertText(SafeMarkup::checkPlain('<span>Cached title</span>'));
+
+    // Ensure that titles are cacheable and are escaped normally if the
+    // controller escapes them use SafeMarkup::checkPlain().
+    $this->drupalGet('test-page-cached-controller-safe');
+    $this->assertTitle('Cached title | Drupal');
+    $this->assertText(SafeMarkup::checkPlain('<span>Cached title</span>'));
+    $this->drupalGet('test-page-cached-controller-safe');
+    $this->assertTitle('Cached title | Drupal');
+    $this->assertText(SafeMarkup::checkPlain('<span>Cached title</span>'));
   }
 
 }
diff --git a/core/modules/system/tests/modules/test_page_test/src/Controller/Test.php b/core/modules/system/tests/modules/test_page_test/src/Controller/Test.php
index 9c56ae2..eeed2d9 100644
--- a/core/modules/system/tests/modules/test_page_test/src/Controller/Test.php
+++ b/core/modules/system/tests/modules/test_page_test/src/Controller/Test.php
@@ -6,6 +6,7 @@
  */
 
 namespace Drupal\test_page_test\Controller;
+use Drupal\Component\Utility\SafeMarkup;
 
 /**
  * Defines a test controller for page titles.
@@ -50,6 +51,26 @@ public function dynamicTitle() {
   }
 
   /**
+   * Defines a controller with a cached render array.
+   *
+   * @param bool $mark_safe
+   *   Whether or not to mark the title as safe use SafeMarkup::checkPlain.
+   *
+   * @return array
+   *   A render array
+   */
+  public function controllerWithCache($mark_safe = FALSE) {
+    $build = [];
+    $build['#title'] = '<span>Cached title</span>';
+    if ($mark_safe) {
+      $build['#title'] = SafeMarkup::checkPlain($build['#title']);
+    }
+    $build['#cache']['keys'] = ['test_controller', 'with_title'];
+
+    return $build;
+  }
+
+  /**
    * Returns a generic page render array for title tests.
    *
    * @return array
diff --git a/core/modules/system/tests/modules/test_page_test/test_page_test.routing.yml b/core/modules/system/tests/modules/test_page_test/test_page_test.routing.yml
index e54d845..5330be8 100644
--- a/core/modules/system/tests/modules/test_page_test/test_page_test.routing.yml
+++ b/core/modules/system/tests/modules/test_page_test/test_page_test.routing.yml
@@ -21,6 +21,21 @@ test_page_test.static_title:
   requirements:
     _access: 'TRUE'
 
+test_page_test.cached_controller:
+  path: '/test-page-cached-controller'
+  defaults:
+    _controller: '\Drupal\test_page_test\Controller\Test::controllerWithCache'
+  requirements:
+    _access: 'TRUE'
+
+test_page_test.cached_controller.safe:
+  path: '/test-page-cached-controller-safe'
+  defaults:
+    _controller: '\Drupal\test_page_test\Controller\Test::controllerWithCache'
+    mark_safe: true
+  requirements:
+    _access: 'TRUE'
+
 test_page_test.dynamic_title:
   path: '/test-page-dynamic-title'
   defaults:
