diff --git a/core/modules/block/src/BlockViewBuilder.php b/core/modules/block/src/BlockViewBuilder.php
index b5a1e4643468bf372e61cfd9809e6564385c4474..99c88116aabc533d3eaddc64f62b93d576bf3c0d 100644
--- a/core/modules/block/src/BlockViewBuilder.php
+++ b/core/modules/block/src/BlockViewBuilder.php
@@ -6,6 +6,7 @@
 use Drupal\Core\Block\TitleBlockPluginInterface;
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Cache\CacheOptionalInterface;
 use Drupal\Core\Entity\EntityViewBuilder;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Extension\ModuleHandlerInterface;
@@ -50,7 +51,6 @@ public function viewMultiple(array $entities = [], $view_mode = 'full', $langcod
       // @see template_preprocess_block().
       $build[$entity_id] = [
         '#cache' => [
-          'keys' => ['entity_view', 'block', $entity->id()],
           'contexts' => Cache::mergeContexts(
             $entity->getCacheContexts(),
             $plugin->getCacheContexts()
@@ -61,6 +61,21 @@ public function viewMultiple(array $entities = [], $view_mode = 'full', $langcod
         '#weight' => $entity->getWeight(),
       ];
 
+      // For block plugins implementing CacheOptionalInterface, the expectation
+      // is that the cost of rendering them is less than retrieving them from
+      // cache. Only add cache keys to the block render array if the block
+      // plugin does not implement CacheOptionalInterface.
+      // If any CacheOptionalInterface block is set to render as a placeholder
+      // (createPlaceholder() returns TRUE), the cached response in the internal
+      // page cache or external caches and proxies will include the block
+      // markup, but the block is not cached anywhere else. If a
+      // CacheOptionalInterface block is not set to render as a placeholder,
+      // then its rendered markup is cached within the rendered page in the
+      // dynamic page cache.
+      if (!$plugin instanceof CacheOptionalInterface) {
+        $build[$entity_id]['#cache']['keys'] = ['entity_view', 'block', $entity->id()];
+      }
+
       // Allow altering of cacheability metadata or setting #create_placeholder.
       $this->moduleHandler->alter(['block_build', "block_build_" . $plugin->getBaseId()], $build[$entity_id], $plugin);
 
diff --git a/core/modules/block/tests/modules/block_test/src/Plugin/Block/TestCacheOptionalBlock.php b/core/modules/block/tests/modules/block_test/src/Plugin/Block/TestCacheOptionalBlock.php
new file mode 100644
index 0000000000000000000000000000000000000000..e39a9d7a870c5de677174f837ab78b30e938458e
--- /dev/null
+++ b/core/modules/block/tests/modules/block_test/src/Plugin/Block/TestCacheOptionalBlock.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\block_test\Plugin\Block;
+
+use Drupal\Core\Block\Attribute\Block;
+use Drupal\Core\Block\BlockBase;
+use Drupal\Core\Cache\CacheOptionalInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+
+/**
+ * Provides a block to test caching.
+ */
+#[Block(
+  id: "test_cache_optional",
+  admin_label: new TranslatableMarkup("Test block cache optional")
+)]
+class TestCacheOptionalBlock extends BlockBase implements CacheOptionalInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function build() {
+    $content = \Drupal::keyValue('block_test')->get('content');
+
+    $build = [];
+    if (!empty($content)) {
+      $build['#markup'] = $content;
+    }
+    return $build;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    return \Drupal::state()->get('block_test.cache_contexts', []);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheMaxAge() {
+    return \Drupal::state()->get('block_test.cache_max_age', parent::getCacheMaxAge());
+  }
+
+}
diff --git a/core/modules/block/tests/src/Kernel/BlockViewBuilderTest.php b/core/modules/block/tests/src/Kernel/BlockViewBuilderTest.php
index a6b66adee2b69e4f52084fe6bfb7f25f2ae5bf31..c384fc40735feda5999739f239aec5fcf69de6b5 100644
--- a/core/modules/block/tests/src/Kernel/BlockViewBuilderTest.php
+++ b/core/modules/block/tests/src/Kernel/BlockViewBuilderTest.php
@@ -228,6 +228,43 @@ protected function verifyRenderCacheHandling(): void {
     $request->setMethod($request_method);
   }
 
+  /**
+   * Tests block render cache handling of cache-optional blocks.
+   */
+  public function testBlockViewBuilderCacheOptional(): void {
+    // Verify cache handling for a non-empty block.
+    $this->verifyRenderCacheHandling();
+
+    // Create a block for a plugin implementing CacheOptionalInterface.
+    $this->block = $this->controller->create([
+      'id' => 'test_block',
+      'theme' => 'stark',
+      'plugin' => 'test_cache_optional',
+    ]);
+    $this->block->save();
+    \Drupal::keyValue('block_test')->set('content', 'This is content for a block that is not render cached.');
+
+    /** @var \Drupal\Core\Cache\VariationCacheFactoryInterface $variation_cache_factory */
+    $variation_cache_factory = \Drupal::service('variation_cache_factory');
+    $cache_bin = $variation_cache_factory->get('render');
+
+    // Force a request via GET so we can test the render cache.
+    $request = \Drupal::request();
+    $request_method = $request->server->get('REQUEST_METHOD');
+    $request->setMethod('GET');
+
+    // Test that a cache entry is not created.
+    $build = $this->getBlockRenderArray();
+    $cache_keys = ['entity_view', 'block', 'test_block'];
+    $markup = $this->renderer->renderRoot($build);
+    $this->assertTrue(str_contains((string) $markup, 'This is content for a block that is not render cached.'));
+    $this->assertFalse($cache_bin->get($cache_keys, CacheableMetadata::createFromRenderArray($build)), 'The block render element has not been cached.');
+    // Confirm that build render array has no cache keys.
+    $this->assertArrayNotHasKey('keys', $build['#cache']);
+    // Restore the previous request method.
+    $request->setMethod($request_method);
+  }
+
   /**
    * Tests block view altering.
    *
diff --git a/core/modules/language/src/Plugin/Block/LanguageBlock.php b/core/modules/language/src/Plugin/Block/LanguageBlock.php
index 8b146db4adbd31b2f96328a9148e3be1b5b611c2..10a9e7fde2ecb55e61678f0c2710626b477c5e94 100644
--- a/core/modules/language/src/Plugin/Block/LanguageBlock.php
+++ b/core/modules/language/src/Plugin/Block/LanguageBlock.php
@@ -5,7 +5,9 @@
 use Drupal\Core\Access\AccessResult;
 use Drupal\Core\Block\Attribute\Block;
 use Drupal\Core\Block\BlockBase;
+use Drupal\Core\Cache\CacheOptionalInterface;
 use Drupal\Core\Path\PathMatcherInterface;
+use Drupal\Core\Render\BubbleableMetadata;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\Core\Language\LanguageManagerInterface;
 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
@@ -23,7 +25,7 @@
   category: new TranslatableMarkup("System"),
   deriver: LanguageBlockDeriver::class
 )]
-class LanguageBlock extends BlockBase implements ContainerFactoryPluginInterface {
+class LanguageBlock extends BlockBase implements ContainerFactoryPluginInterface, CacheOptionalInterface {
 
   /**
    * The language manager.
@@ -101,6 +103,11 @@ public function build() {
     }
     $links = $this->languageManager->getLanguageSwitchLinks($type, $url);
 
+    // In any render cache items wrapping this block, account for variations
+    // by user access to each switcher link, the current path and query
+    // arguments, and language negotiation.
+    $cache_metadata = BubbleableMetadata::createFromRenderArray($build)
+      ->addCacheContexts(['url.path', 'url.query_args', 'url.site', 'languages:' . $type]);
     if (isset($links->links)) {
       $build = [
         '#theme' => 'links__language_block',
@@ -112,17 +119,23 @@ public function build() {
         ],
         '#set_active_class' => TRUE,
       ];
+
+      foreach ($links->links as $link) {
+        if ($link['url'] instanceof Url) {
+          $cache_metadata->addCacheableDependency($link['url']->access(NULL, TRUE));
+        }
+      }
     }
+    $cache_metadata->applyTo($build);
+
     return $build;
   }
 
   /**
    * {@inheritdoc}
-   *
-   * @todo Make cacheable in https://www.drupal.org/node/2232375.
    */
-  public function getCacheMaxAge() {
-    return 0;
+  public function createPlaceholder(): bool {
+    return TRUE;
   }
 
 }