diff --git a/src/Datasource/DatasourceInterface.php b/src/Datasource/DatasourceInterface.php
index 5cd4ed1a..4845dfb0 100644
--- a/src/Datasource/DatasourceInterface.php
+++ b/src/Datasource/DatasourceInterface.php
@@ -137,9 +137,28 @@ public function getItemUrl(ComplexDataInterface $item);
    *
    * @return bool
    *   TRUE if access is granted, FALSE otherwise.
+   *
+   * @deprecated in search_api:8.x-1.13 and will be removed from
+   *   search_api:9.x-1.0. Use getItemAccessResult() instead.
+   *
+   * @see https://www.drupal.org/node/3051902
    */
   public function checkItemAccess(ComplexDataInterface $item, AccountInterface $account = NULL);
 
+  /**
+   * Checks whether a user has permission to view the given item.
+   *
+   * @param \Drupal\Core\TypedData\ComplexDataInterface $item
+   *   An item of this datasource's type.
+   * @param \Drupal\Core\Session\AccountInterface|null $account
+   *   (optional) The user session for which to check access, or NULL to check
+   *   access for the current user.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface
+   *   The access result.
+   */
+  public function getItemAccessResult(ComplexDataInterface $item, AccountInterface $account = NULL);
+
   /**
    * Returns the available view modes for this datasource.
    *
@@ -238,4 +257,15 @@ public function getItemIds($page = NULL);
    */
   public function getFieldDependencies(array $fields);
 
+  /**
+   * Returns the cacheability metadata for the given item.
+   *
+   * @param \Drupal\Core\TypedData\ComplexDataInterface $item
+   *   The item of this datasource's type for which to return the metadata.
+   *
+   * @return \Drupal\Core\Cache\CacheableMetadata
+   *   The cacheability metadata.
+   */
+  public function getItemCacheableMetadata(ComplexDataInterface $item);
+
 }
diff --git a/src/Datasource/DatasourcePluginBase.php b/src/Datasource/DatasourcePluginBase.php
index e6cfea03..bb43310d 100644
--- a/src/Datasource/DatasourcePluginBase.php
+++ b/src/Datasource/DatasourcePluginBase.php
@@ -2,6 +2,8 @@
 
 namespace Drupal\search_api\Datasource;
 
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Language\Language;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\Core\TypedData\ComplexDataInterface;
@@ -97,7 +99,15 @@ public function getItemUrl(ComplexDataInterface $item) {
    * {@inheritdoc}
    */
   public function checkItemAccess(ComplexDataInterface $item, AccountInterface $account = NULL) {
-    return TRUE;
+    @trigger_error('\Drupal\search_api\Datasource\DatasourceInterface::checkItemAccess() is deprecated in search_api:8.x-1.13. It will be removed from search_api:9.x-1.0. Use getItemAccessResult() instead. See https://www.drupal.org/node/3051902', E_USER_DEPRECATED);
+    return $this->getItemAccessResult($item, $account)->isAllowed();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getItemAccessResult(ComplexDataInterface $item, AccountInterface $account = NULL) {
+    return AccessResult::allowed();
   }
 
   /**
@@ -155,4 +165,11 @@ public function getFieldDependencies(array $fields) {
     return [];
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getItemCacheableMetadata(ComplexDataInterface $item) {
+    return new CacheableMetadata();
+  }
+
 }
diff --git a/src/Item/Item.php b/src/Item/Item.php
index d120b6f9..abb37062 100644
--- a/src/Item/Item.php
+++ b/src/Item/Item.php
@@ -2,6 +2,8 @@
 
 namespace Drupal\search_api\Item;
 
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\Core\TypedData\ComplexDataInterface;
 use Drupal\search_api\Datasource\DatasourceInterface;
@@ -406,13 +408,43 @@ public function setExtraData($key, $data = NULL) {
    * {@inheritdoc}
    */
   public function checkAccess(AccountInterface $account = NULL) {
+    @trigger_error('\Drupal\search_api\Item\ItemInterface::checkAccess() is deprecated in search_api:8.x-1.13. It will be removed from search_api:9.x-1.0. Use getAccessResult() instead. See https://www.drupal.org/node/3051902', E_USER_DEPRECATED);
+    return $this->getAccessResult($account)->isAllowed();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAccessResult(AccountInterface $account = NULL) {
+    // @fixme Statically cache this by account.
     try {
       return $this->getDatasource()
-        ->checkItemAccess($this->getOriginalObject(), $account);
+        ->getItemAccessResult($this->getOriginalObject(), $account);
     }
     catch (SearchApiException $e) {
-      return FALSE;
+      return AccessResult::neutral('Item could not be loaded, so cannot check access');
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheableMetadata(AccountInterface $account = NULL) {
+    // @fixme Statically cache this by account.
+    $cacheability = new CacheableMetadata();
+    $access = $this->getAccessResult($account);
+    $cacheability->addCacheableDependency($access);
+    if ($access->isAllowed()) {
+      try {
+        $item_cacheability = $this->getDatasource()
+          ->getItemCacheableMetadata($this->getOriginalObject());
+        $cacheability->addCacheableDependency($item_cacheability);
+      }
+      catch (SearchApiException $e) {
+        // Ignore here.
+      }
     }
+    return $cacheability;
   }
 
   /**
diff --git a/src/Item/ItemInterface.php b/src/Item/ItemInterface.php
index a3fdbb6f..626af487 100644
--- a/src/Item/ItemInterface.php
+++ b/src/Item/ItemInterface.php
@@ -295,7 +295,35 @@ public function setExtraData($key, $data = NULL);
    *
    * @return bool
    *   TRUE if access is granted, FALSE otherwise.
+   *
+   * @deprecated in search_api:8.x-1.13 and will be removed from
+   *   search_api:9.x-1.0. Use getAccessResult() instead.
+   *
+   * @see https://www.drupal.org/node/3051902
    */
   public function checkAccess(AccountInterface $account = NULL);
 
+  /**
+   * Checks whether a user has permission to view this item.
+   *
+   * @param \Drupal\Core\Session\AccountInterface|null $account
+   *   (optional) The user for which to check access, or NULL to check access
+   *   for the current user.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface
+   *   The access result.
+   */
+  public function getAccessResult(AccountInterface $account = NULL);
+
+  /**
+   * Returns cacheability metadata.
+   *
+   * @param \Drupal\Core\Session\AccountInterface|null $account
+   *   The user for which to return the metadata.
+   *
+   * @return \Drupal\Core\Cache\CacheableMetadata
+   *   The cacheability metadata.
+   */
+  public function getCacheableMetadata(AccountInterface $account = NULL);
+
 }
diff --git a/src/Plugin/search_api/datasource/ContentEntity.php b/src/Plugin/search_api/datasource/ContentEntity.php
index 69915d35..ccc7d8e0 100644
--- a/src/Plugin/search_api/datasource/ContentEntity.php
+++ b/src/Plugin/search_api/datasource/ContentEntity.php
@@ -3,6 +3,7 @@
 namespace Drupal\search_api\Plugin\search_api\datasource;
 
 use Drupal\Component\Utility\Crypt;
+use Drupal\Core\Access\AccessResult;
 use Drupal\Core\Cache\CacheBackendInterface;
 use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\Entity\ContentEntityInterface;
@@ -684,13 +685,14 @@ public function getItemUrl(ComplexDataInterface $item) {
   /**
    * {@inheritdoc}
    */
-  public function checkItemAccess(ComplexDataInterface $item, AccountInterface $account = NULL) {
-    if ($entity = $this->getEntity($item)) {
+  public function getItemAccessResult(ComplexDataInterface $item, AccountInterface $account = NULL) {
+    $entity = $this->getEntity($item);
+    if ($entity) {
       return $this->getEntityTypeManager()
         ->getAccessControlHandler($this->getEntityTypeId())
-        ->access($entity, 'view', $account);
+        ->access($entity, 'view', $account, TRUE);
     }
-    return FALSE;
+    return AccessResult::neutral('Item is not an entity, so cannot check access');
   }
 
   /**
@@ -1169,4 +1171,18 @@ public static function filterValidItemIds(IndexInterface $index, $datasource_id,
     return $valid_ids;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getItemCacheableMetadata(ComplexDataInterface $item) {
+    $cacheability = parent::getItemCacheableMetadata($item);
+
+    $entity = $this->getEntity($item);
+    if ($entity) {
+      $cacheability->addCacheableDependency($entity);
+    }
+
+    return $cacheability;
+  }
+
 }
diff --git a/src/Plugin/views/cache/SearchApiCachePluginTrait.php b/src/Plugin/views/cache/SearchApiCachePluginTrait.php
index 9f6e7100..df175bb1 100644
--- a/src/Plugin/views/cache/SearchApiCachePluginTrait.php
+++ b/src/Plugin/views/cache/SearchApiCachePluginTrait.php
@@ -173,8 +173,8 @@ public function cacheGet($type) {
    */
   public function generateResultsKey() {
     if (!isset($this->resultsKey)) {
-      $query = $this->getQuery()->getSearchApiQuery();
-      $query->preExecute();
+      // @todo Why is this here? Is this needed?
+      $this->getQuery()->getSearchApiQuery()->preExecute();
 
       $view = $this->getView();
       $build_info = $view->build_info;
diff --git a/src/Plugin/views/cache/SearchApiTagCache.php b/src/Plugin/views/cache/SearchApiTagCache.php
index 48e7728f..f2ffb320 100644
--- a/src/Plugin/views/cache/SearchApiTagCache.php
+++ b/src/Plugin/views/cache/SearchApiTagCache.php
@@ -78,8 +78,14 @@ public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_man
    */
   public function getCacheTags() {
     $tags = $this->view->storage->getCacheTags();
+    // Add the list cache tag of the search index, so that the view will be
+    // invalidated whenever the index is updated.
     $tag = 'search_api_list:' . $this->getQuery()->getIndex()->id();
     $tags = Cache::mergeTags([$tag], $tags);
+    // Also add the cache tags of the index itself, so that the view will be
+    // invalidated if the configuration of the index changes.
+    $index_tags = $this->getQuery()->getIndex()->getCacheTagsToInvalidate();
+    $tags = Cache::mergeTags($index_tags, $tags);
     return $tags;
   }
 
diff --git a/src/Plugin/views/query/SearchApiQuery.php b/src/Plugin/views/query/SearchApiQuery.php
index f77a15aa..2d936cde 100644
--- a/src/Plugin/views/query/SearchApiQuery.php
+++ b/src/Plugin/views/query/SearchApiQuery.php
@@ -3,6 +3,8 @@
 namespace Drupal\search_api\Plugin\views\query;
 
 use Drupal\Component\Render\FormattableMarkup;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Database\Query\ConditionInterface;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
@@ -119,6 +121,33 @@ class SearchApiQuery extends QueryPluginBase {
    */
   protected $messenger;
 
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The cacheability metadata of the result.
+   *
+   * Set in ::addResults, so only available after ::execute.
+   *
+   * Used in the ::getCache*() methods.
+   *
+   * @var \Drupal\Core\Cache\RefinableCacheableDependencyInterface
+   */
+  protected $cacheableMetadata;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+
+    $this->cacheableMetadata = new CacheableMetadata();
+  }
+
   /**
    * {@inheritdoc}
    */
@@ -129,6 +158,7 @@ public static function create(ContainerInterface $container, array $configuratio
     $plugin->setModuleHandler($container->get('module_handler'));
     $plugin->setMessenger($container->get('messenger'));
     $plugin->setLogger($container->get('logger.channel.search_api'));
+    $plugin->setEntityTypeManager($container->get('entity_type.manager'));
 
     return $plugin;
   }
@@ -241,6 +271,29 @@ public function setMessenger(MessengerInterface $messenger) {
     return $this;
   }
 
+  /**
+   * Returns the entity type manager.
+   *
+   * @return \Drupal\Core\Entity\EntityTypeManagerInterface
+   *   The entity type manager.
+   */
+  public function getEntityTypeManager() {
+    return $this->entityTypeManager;
+  }
+
+  /**
+   * Sets the entity type manager.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   *
+   * @return $this
+   */
+  public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+    return $this;
+  }
+
   /**
    * {@inheritdoc}
    */
@@ -631,13 +684,13 @@ protected function addResults(ResultSetInterface $result_set, ViewExecutable $vi
     $count = 0;
 
     // First, unless disabled, check access for all entities in the results.
+    $account = $this->getAccessAccount();
     if (!$this->options['skip_access']) {
-      $account = $this->getAccessAccount();
       // If search items are not loaded already, pre-load them now in bulk to
       // avoid them being individually loaded inside checkAccess().
       $result_set->preLoadResultItems();
       foreach ($results as $item_id => $result) {
-        if (!$result->checkAccess($account)) {
+        if (!$result->getAccessResult($account)->isAllowed()) {
           unset($results[$item_id]);
         }
       }
@@ -685,8 +738,65 @@ protected function addResults(ResultSetInterface $result_set, ViewExecutable $vi
 
       $view->result[] = new ResultRow($values);
     }
+
+    // Retrieve the cacheability metadata from the result set.
+    $this->cacheableMetadata = $result_set->getCacheableMetadata($account);
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheContexts() {
+    $contexts = [];
+
+    foreach ($this->getIndex()->getEntityTypes() as $entity_type_id) {
+      $entity_type_definition = $this->entityTypeManager->getDefinition($entity_type_id);
+      $contexts = Cache::mergeContexts($entity_type_definition->getListCacheContexts(), $contexts);
+    }
+
+    return $contexts;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheTags() {
+    $tags = parent::getCacheTags();
+
+    if ($this->getIndex()->getOption('index_directly')) {
+      // @todo If the entities are indexed directly we can add the cache tags
+      //   of the entities themselves.
+    }
+    else {
+      // @todo If we are indexing asynchronously the best we can do is
+      //   invalidate the result when the index changes.
+      // $this->getIndex()->getEntityType()->getListCacheTags();
+    }
+
+    // If the configuration of the search index changes we should invalidate the
+    // views that show results from this index.
+    $index_tags = $this->getIndex()->getCacheTagsToInvalidate();
+    $tags = Cache::mergeTags($index_tags, $tags);
+
+    return $tags;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheMaxAge() {
+    $max_age = parent::getCacheMaxAge();
+
+    // @todo We should get the max ages from the viewed items.
+    // @ref \Drupal\views\Plugin\views\query\Sql::getCacheMaxAge()
+    // foreach ($this->getAllEntities() as $entity) {
+    //   $max_age = Cache::mergeMaxAges($max_age, $entity->getCacheMaxAge());
+    // }
+
+    return $max_age;
+  }
+
+
   /**
    * Retrieves the conditions placed on this query.
    *
diff --git a/src/Query/ResultSet.php b/src/Query/ResultSet.php
index 781a9dfe..86992af4 100644
--- a/src/Query/ResultSet.php
+++ b/src/Query/ResultSet.php
@@ -2,6 +2,8 @@
 
 namespace Drupal\search_api\Query;
 
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Session\AccountInterface;
 use Drupal\search_api\Item\ItemInterface;
 use Drupal\search_api\SearchApiException;
 
@@ -228,6 +230,18 @@ public function getIterator() {
     return new \ArrayIterator($this->resultItems);
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheableMetadata(AccountInterface $account = NULL) {
+    $cacheability = new CacheableMetadata();
+    foreach ($this->getResultItems() as $item) {
+      $other_cacheability = $item->getCacheableMetadata($account);
+      $cacheability = $cacheability->merge($other_cacheability);
+    }
+    return $cacheability;
+  }
+
   /**
    * Implements the magic __toString() method to simplify debugging.
    */
diff --git a/src/Query/ResultSetInterface.php b/src/Query/ResultSetInterface.php
index a32a46ee..1b2e1f34 100644
--- a/src/Query/ResultSetInterface.php
+++ b/src/Query/ResultSetInterface.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\search_api\Query;
 
+use Drupal\Core\Session\AccountInterface;
 use Drupal\search_api\Item\ItemInterface;
 
 /**
@@ -197,4 +198,15 @@ public function setExtraData($key, $data = NULL);
    */
   public function getCloneForQuery(QueryInterface $query);
 
+  /**
+   * Returns cacheability metadata.
+   *
+   * @param \Drupal\Core\Session\AccountInterface|null $account
+   *   The user account for which to return the metadata.
+   *
+   * @return \Drupal\Core\Cache\CacheableMetadata
+   *   The cacheability metadata.
+   */
+  public function getCacheableMetadata(AccountInterface $account = NULL);
+
 }
diff --git a/tests/search_api_test/search_api_test.module b/tests/search_api_test/search_api_test.module
index deeb41a7..eb14868a 100644
--- a/tests/search_api_test/search_api_test.module
+++ b/tests/search_api_test/search_api_test.module
@@ -14,7 +14,7 @@
 function search_api_test_node_grants(AccountInterface $account, $op) {
   $grants = [];
 
-  if (\Drupal::state()->get('search_api_test_add_node_access_grant', TRUE)) {
+  if (\Drupal::state()->get('search_api_test_add_node_access_grant', FALSE)) {
     $grants['search_api_test'] = [$account->id()];
   }
 
@@ -27,7 +27,7 @@ function search_api_test_node_grants(AccountInterface $account, $op) {
 function search_api_test_node_access_records(NodeInterface $node) {
   $grants = [];
 
-  if (\Drupal::state()->get('search_api_test_add_node_access_grant', TRUE)) {
+  if (\Drupal::state()->get('search_api_test_add_node_access_grant', FALSE)) {
     $grants[] = [
       'realm' => 'search_api_test',
       'gid' => $node->getOwnerId(),
diff --git a/tests/search_api_test_node_indexing/config/install/search_api.index.test_node_index.yml b/tests/search_api_test_node_indexing/config/install/search_api.index.test_node_index.yml
new file mode 100644
index 00000000..b2af7e1d
--- /dev/null
+++ b/tests/search_api_test_node_indexing/config/install/search_api.index.test_node_index.yml
@@ -0,0 +1,48 @@
+id: test_node_index
+name: 'Test node index'
+description: 'An index of node entities used for testing'
+read_only: false
+field_settings:
+  status:
+    label: Published
+    datasource_id: 'entity:node'
+    property_path: status
+    type: boolean
+    dependencies:
+      module:
+        - node
+  title:
+    label: Title
+    datasource_id: 'entity:node'
+    property_path: title
+    type: string
+    dependencies:
+      module:
+        - node
+processor_settings:
+  add_url: {  }
+  aggregated_field: {  }
+  rendered_item: {  }
+options:
+  cron_limit: -1
+  index_directly: false
+datasource_settings:
+  'entity:node':
+    bundles:
+      default: true
+      selected: {  }
+    languages:
+      default: true
+      selected: {  }
+tracker_settings:
+  default:
+    indexing_order: fifo
+server: database_search_server
+status: true
+langcode: en
+dependencies:
+  config:
+    - search_api.server.database_search_server
+  module:
+    - node
+    - search_api
diff --git a/tests/search_api_test_node_indexing/config/install/search_api.server.database_search_server.yml b/tests/search_api_test_node_indexing/config/install/search_api.server.database_search_server.yml
new file mode 100644
index 00000000..0e79fda4
--- /dev/null
+++ b/tests/search_api_test_node_indexing/config/install/search_api.server.database_search_server.yml
@@ -0,0 +1,13 @@
+id: database_search_server
+name: 'Database search server'
+description: 'A server used for testing'
+backend: search_api_db
+backend_config:
+  database: 'default:default'
+  min_chars: 3
+  matching: words
+status: true
+langcode: en
+dependencies:
+  module:
+    - search_api_db
diff --git a/tests/search_api_test_node_indexing/config/install/views.view.search_api_test_node_view.yml b/tests/search_api_test_node_indexing/config/install/views.view.search_api_test_node_view.yml
new file mode 100644
index 00000000..0ee6bce9
--- /dev/null
+++ b/tests/search_api_test_node_indexing/config/install/views.view.search_api_test_node_view.yml
@@ -0,0 +1,107 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - search_api.index.test_node_index
+  module:
+    - search_api
+id: search_api_test_node_view
+label: 'Search API test node view'
+module: views
+description: ''
+tag: ''
+base_table: search_api_index_test_node_index
+base_field: search_api_id
+core: 8.x
+display:
+  default:
+    display_plugin: default
+    id: default
+    display_title: Master
+    position: 0
+    display_options:
+      access:
+        type: none
+        options: {  }
+      cache:
+        type: search_api_tag
+        options: {  }
+      query:
+        type: search_api_query
+        options:
+          bypass_access: false
+          skip_access: false
+          preserve_facet_query_args: false
+      exposed_form:
+        type: basic
+        options:
+          submit_button: Search
+          reset_button: false
+          reset_button_label: Reset
+          exposed_sorts_label: 'Sort by'
+          expose_sort_order: true
+          sort_asc_label: Asc
+          sort_desc_label: Desc
+      pager:
+        type: full
+        options:
+          items_per_page: 10
+          offset: 0
+          id: 0
+          total_pages: null
+          expose:
+            items_per_page: false
+            items_per_page_label: 'Items per page'
+            items_per_page_options: '5, 10, 20, 40, 60'
+            items_per_page_options_all: false
+            items_per_page_options_all_label: '- All -'
+            offset: false
+            offset_label: Offset
+          tags:
+            previous: ‹‹
+            next: ››
+      style:
+        type: default
+      row:
+        type: fields
+      fields:
+        title:
+          id: title
+          table: search_api_index_test_node_index
+          field: title
+          plugin_id: search_api_field
+      filters: {  }
+      sorts: {  }
+      title: 'Search API test node view'
+      header: {  }
+      footer: {  }
+      empty: {  }
+      relationships: {  }
+      arguments: {  }
+      display_extenders: {  }
+    cache_metadata:
+      max-age: -1
+      contexts:
+        - 'languages:language_content'
+        - 'languages:language_interface'
+        - url.query_args
+        - 'user.node_grants:view'
+      tags:
+        - 'config:search_api.index.test_node_index'
+  page_1:
+    display_plugin: page
+    id: page_1
+    display_title: Page
+    position: 1
+    display_options:
+      display_extenders: {  }
+      path: search-api-test-node-view
+    cache_metadata:
+      max-age: -1
+      contexts:
+        - 'languages:language_content'
+        - 'languages:language_interface'
+        - url.query_args
+        - 'user.node_grants:view'
+      tags:
+        - 'config:search_api.index.test_node_index'
diff --git a/tests/search_api_test_node_indexing/search_api_test_node_indexing.info.yml b/tests/search_api_test_node_indexing/search_api_test_node_indexing.info.yml
new file mode 100644
index 00000000..10cdc56f
--- /dev/null
+++ b/tests/search_api_test_node_indexing/search_api_test_node_indexing.info.yml
@@ -0,0 +1,8 @@
+type: module
+name: 'Search API node indexing test'
+description: 'Test module for testing indexing of nodes in Search API.'
+package: 'Search API'
+dependencies:
+  - search_api:search_api_db
+core: 8.x
+hidden: true
diff --git a/tests/src/Kernel/Processor/ContentAccessTest.php b/tests/src/Kernel/Processor/ContentAccessTest.php
index 0d287112..df59c584 100644
--- a/tests/src/Kernel/Processor/ContentAccessTest.php
+++ b/tests/src/Kernel/Processor/ContentAccessTest.php
@@ -48,6 +48,9 @@ class ContentAccessTest extends ProcessorTestBase {
   public function setUp($processor = NULL) {
     parent::setUp('content_access');
 
+    // Activate our custom grant.
+    \Drupal::state()->set('search_api_test_add_node_access_grant', TRUE);
+
     // Create a node type for testing.
     $type = NodeType::create(['type' => 'page', 'name' => 'page']);
     $type->save();
diff --git a/tests/src/Kernel/Views/ViewsCacheInvalidationTest.php b/tests/src/Kernel/Views/ViewsCacheInvalidationTest.php
new file mode 100644
index 00000000..a855ae43
--- /dev/null
+++ b/tests/src/Kernel/Views/ViewsCacheInvalidationTest.php
@@ -0,0 +1,458 @@
+<?php
+
+namespace Drupal\Tests\search_api\Kernel\Views;
+
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Datetime\Entity\DateFormat;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\search_api\Entity\Index;
+use Drupal\Tests\search_api\Kernel\PostRequestIndexingTrait;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+use Drupal\views\Tests\AssertViewsCacheTagsTrait;
+
+/**
+ * Tests that cached Search API views get invalidated at the right occasions.
+ *
+ * @group search_api
+ */
+class ViewsCacheInvalidationTest extends KernelTestBase {
+
+  use AssertViewsCacheTagsTrait;
+  use PostRequestIndexingTrait;
+  use UserCreationTrait;
+
+  /**
+   * The ID of the view used in the test.
+   */
+  const TEST_VIEW_ID = 'search_api_test_node_view';
+
+  /**
+   * The display ID used in the test.
+   */
+  const TEST_VIEW_DISPLAY_ID = 'default';
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The service that is responsible for creating Views executable objects.
+   *
+   * @var \Drupal\views\ViewExecutableFactory
+   */
+  protected $viewExecutableFactory;
+
+  /**
+   * The renderer.
+   *
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
+  /**
+   * The render cache.
+   *
+   * @var \Drupal\Core\Render\PlaceholderingRenderCache
+   */
+  protected $renderCache;
+
+  /**
+   * The cache tags invalidator.
+   *
+   * @var \Drupal\Core\Cache\CacheTagsInvalidator
+   */
+  protected $cacheTagsInvalidator;
+
+  /**
+   * The current user service.
+   *
+   * @var \Drupal\Core\Session\AccountProxyInterface
+   */
+  protected $currentUser;
+
+  /**
+   * The search index used for testing.
+   *
+   * @var \Drupal\search_api\IndexInterface
+   */
+  protected $index;
+
+  /**
+   * Test users.
+   *
+   * @var \Drupal\user\UserInterface[]
+   */
+  protected $users;
+
+  /**
+   * A test content type.
+   *
+   * @var \Drupal\node\NodeTypeInterface
+   */
+  protected $contentType;
+
+  /**
+   * Test nodes.
+   *
+   * @var \Drupal\node\NodeInterface[]
+   */
+  protected $nodes;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'field',
+    'node',
+    'rest',
+    'search_api',
+    'search_api_db',
+    'search_api_test',
+    'search_api_test_node_indexing',
+    'search_api_test_views',
+    'serialization',
+    'system',
+    'text',
+    'user',
+    'views',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->installSchema('node', ['node_access']);
+    $this->installSchema('search_api', ['search_api_item']);
+    $this->installSchema('system', ['sequences']);
+
+    $this->installEntitySchema('node');
+    $this->installEntitySchema('search_api_task');
+    $this->installEntitySchema('user');
+
+    $this->installConfig([
+      'node',
+      'search_api',
+      'search_api_test_node_indexing',
+      'search_api_test_views',
+    ]);
+
+    $this->entityTypeManager = $this->container->get('entity_type.manager');
+    $this->viewExecutableFactory = $this->container->get('views.executable');
+    $this->renderer = $this->container->get('renderer');
+    $this->renderCache = $this->container->get('render_cache');
+    $this->cacheTagsInvalidator = $this->container->get('cache_tags.invalidator');
+    $this->currentUser = $this->container->get('current_user');
+
+    DateFormat::create([
+      'id' => 'fallback',
+      'label' => 'Fallback',
+      'pattern' => 'Y-m-d',
+    ])->save();
+
+    // Use the test search index from the search_api_test_db module.
+    $this->index = Index::load('test_node_index');
+
+    // Create a test content type.
+    $this->contentType = NodeType::create([
+      'name' => 'Page',
+      'type' => 'page',
+    ]);
+    $this->contentType->save();
+
+    // Create some test content and index it.
+    foreach (['Cheery' => TRUE, 'Carrot' => TRUE, 'Detritus' => FALSE] as $title => $status) {
+      $this->createNode($title, $status);
+    }
+    $this->index->indexItems();
+
+    // Create a dummy test user. This user will get UID 1 which is handled as
+    // the root user and can bypass all access restrictions. This is not used
+    // in the test.
+    $this->createUser();
+
+    // Create two test users, one with permission to view unpublished entities,
+    // and one without.
+    $this->users['no-access'] = $this->createUser(['access content']);
+    $this->users['has-access'] = $this->createUser(['access content', 'bypass node access']);
+  }
+
+  /**
+   * Tests that a cached views display is invalidated at the right occasions.
+   */
+  public function testDisplayCacheInvalidation() {
+    // We are testing two variants of the view, one for users that have
+    // permission to view test entities, and one for users that do not.
+    // Initially both variants should be uncached.
+    $this->assertNotCached('no-access');
+    $this->assertNotCached('has-access');
+
+    // Check that the user with the 'bypass node access' permission can see all
+    // 3 items.
+    $this->assertViewsResult('has-access', ['Cheery', 'Carrot', 'Detritus']);
+
+    // The result should now be cached for the privileged user.
+    $this->assertNotCached('no-access');
+    $this->assertCached('has-access');
+
+    // Check that the user without the 'bypass node access' permission can only
+    // see the published items.
+    $this->assertViewsResult('no-access', ['Cheery', 'Carrot']);
+
+    // Both results should now be cached.
+    $this->assertCached('no-access');
+    $this->assertCached('has-access');
+
+    // Add another unpublished item.
+    $this->createNode('Angua', FALSE);
+
+    // Our search index is not configured to automatically index items, so just
+    // creating a node should not invalidate the caches.
+    $this->assertCached('no-access');
+    $this->assertCached('has-access');
+
+    // Index the item, this should invalidate the caches.
+    $this->index->indexItems();
+    $this->assertNotCached('no-access');
+    $this->assertNotCached('has-access');
+
+    // Check that the user without the 'bypass node access' permission can still
+    // only see the published items.
+    $this->assertViewsResult('no-access', ['Cheery', 'Carrot']);
+    $this->assertCached('no-access');
+    $this->assertNotCached('has-access');
+
+    // Check that the user with the 'bypass node access' permission can see all
+    // 4 items.
+    $this->assertViewsResult('has-access', ['Angua', 'Cheery', 'Carrot', 'Detritus']);
+    $this->assertCached('no-access');
+    $this->assertCached('has-access');
+
+    // Grant the permission to 'bypass node access' to the unprivileged user.
+    $privileged_role = $this->users['has-access']->getRoles()[1];
+    $this->users['no-access']->addRole($privileged_role);
+    $this->users['no-access']->save();
+
+    // The user should now be able to see all 4 items.
+    $this->assertViewsResult('no-access', ['Angua', 'Cheery', 'Carrot', 'Detritus']);
+    $this->assertCached('no-access');
+    $this->assertCached('has-access');
+
+    // Edit one of the test content entities. This should not affect the cached
+    // view until the search index is updated.
+    $this->nodes['Cheery']->set('title', 'Cheery Littlebottom')->save();
+    $this->assertCached('no-access');
+    $this->assertCached('has-access');
+
+    $this->index->indexItems();
+    $this->assertNotCached('no-access');
+    $this->assertNotCached('has-access');
+
+    // The view should show the updated title when displayed, and the result
+    // should be cached.
+    $this->assertViewsResult('has-access', ['Angua', 'Cheery', 'Carrot', 'Detritus']);
+    $this->assertCached('has-access');
+
+    // Delete one of the test content entities. This takes effect immediately,
+    // there is no need to wait until the search index is updated.
+    // @see search_api_entity_delete()
+    $this->nodes['Carrot']->delete();
+    $this->assertNotCached('has-access');
+
+    // The view should no longer include the deleted content now, and the result
+    // should be cached after the view has been displayed.
+    $this->assertViewsResult('has-access', ['Angua', 'Cheery', 'Detritus']);
+    $this->assertCached('has-access');
+
+    // Update the search index configuration so it will index items immediately
+    // when they are created or updated.
+    $this->index->setOption('index_directly', TRUE)->save();
+
+    // Changing the configuration of the index should invalidate all views that
+    // show its data.
+    $this->assertNotCached('no-access');
+    $this->assertNotCached('has-access');
+
+    // Check that the expected results are still returned and are cacheable.
+    $this->assertViewsResult('no-access', ['Angua', 'Cheery', 'Detritus']);
+    $this->assertViewsResult('has-access', ['Angua', 'Cheery', 'Detritus']);
+    $this->assertCached('no-access');
+    $this->assertCached('has-access');
+
+    // Change the configuration of the view. This should also invalidate all
+    // displays of the view.
+    $view = $this->getView();
+    $view->setItemsPerPage(20);
+    $view->save();
+    $this->assertNotCached('no-access');
+    $this->assertNotCached('has-access');
+
+    // Check that the expected results are still returned and are cacheable.
+    $this->assertViewsResult('no-access', ['Angua', 'Cheery', 'Detritus']);
+    $this->assertViewsResult('has-access', ['Angua', 'Cheery', 'Detritus']);
+    $this->assertCached('no-access');
+    $this->assertCached('has-access');
+
+    // Edit one of the test content entities. Because the search index is being
+    // updated immediately, the cached views should be cleared without having to
+    // perform a manual indexing step.
+    $this->nodes['Angua']->set('title', 'Angua von Überwald')->save();
+    $this->assertNotCached('no-access');
+    $this->assertNotCached('has-access');
+
+    // Check that the updated results are shown and are cacheable.
+    $this->assertViewsResult('no-access', ['Angua', 'Cheery', 'Detritus']);
+    $this->assertViewsResult('has-access', ['Angua', 'Cheery', 'Detritus']);
+    $this->assertCached('no-access');
+    $this->assertCached('has-access');
+  }
+
+  /**
+   * Checks that the view is cached for the given user.
+   *
+   * @param string $user_key
+   *   The key of the user for which to perform the check.
+   */
+  protected function assertCached($user_key) {
+    $this->doAssertCached('assertNotEmpty', $user_key);
+  }
+
+  /**
+   * Checks that the view is not cached for the given user.
+   *
+   * @param string $user_key
+   *   The key of the user for which to perform the check.
+   */
+  protected function assertNotCached($user_key) {
+    $this->doAssertCached('assertEmpty', $user_key);
+  }
+
+  /**
+   * Checks the cache status of the view for the given user.
+   *
+   * @param string $assert_method
+   *   The method to use for asserting that the view is cached or not cached.
+   * @param int $user_key
+   *   The key of the user for which to perform the check.
+   */
+  protected function doAssertCached($assert_method, $user_key) {
+    // Ensure that any post request indexing is done. This is normally handled
+    // at the end of the request but since we are running a KernelTest we are
+    // not executing any requests and need to trigger this manually.
+    $this->triggerPostRequestIndexing();
+
+    // Set the user that will be used to check the cache status.
+    $this->setCurrentUser($user_key);
+
+    // Retrieve the cached data and perform the assertion.
+    $view = $this->getView();
+    $view->build();
+    /** @var \Drupal\views\Plugin\views\cache\CachePluginBase $cache */
+    $cache = $view->getDisplay()->getPlugin('cache');
+    $cached_data = $cache->cacheGet('results');
+
+    $this->$assert_method($cached_data);
+  }
+
+  /**
+   * Checks that the view for the given user contains the expected results.
+   *
+   * @param string $user_key
+   *   The key of the user to check.
+   * @param array $node_keys
+   *   The keys of the nodes that are expected to be present in the result set.
+   */
+  protected function assertViewsResult($user_key, array $node_keys) {
+    // Clear the static caches of the cache tags invalidators. The invalidators
+    // will only invalidate cache tags once per request to improve performance.
+    // Unfortunately they cannot distinguish between an actual Drupal page
+    // request and a PHPUnit test that simulates visiting multiple pages.
+    // We are pretending that every time this method is called a new page has
+    // been requested, and the static caches are empty.
+    $this->cacheTagsInvalidator->resetChecksums();
+
+    $this->setCurrentUser($user_key);
+
+    $render_array = $this->getRenderableView();
+    $html = (string) $this->renderer->renderRoot($render_array);
+
+    // Check that the titles of the expected results are present.
+    foreach ($node_keys as $node_key) {
+      $label = $this->nodes[$node_key]->label();
+      $this->assertContains($label, $html);
+    }
+
+    // Also check that none of the titles of the remaining search items are
+    // unexpectedly present.
+    $unexpected_keys = array_diff(array_keys($this->nodes), $node_keys);
+    foreach ($unexpected_keys as $unexpected_key) {
+      $label = $this->nodes[$unexpected_key]->label();
+      $this->assertNotContains($label, $html);
+    }
+  }
+
+  /**
+   * Sets the user with the given key as the currently active user.
+   *
+   * @param string $user_key
+   *   The key of the user to set as currently active user.
+   */
+  protected function setCurrentUser($user_key) {
+    $this->currentUser->setAccount($this->users[$user_key]);
+  }
+
+  /**
+   * Returns the test view as a render array.
+   *
+   * @return array|null
+   *   The render array, or NULL if the view cannot be rendered.
+   */
+  protected function getRenderableView() {
+    $render_array = $this->getView()->buildRenderable();
+    $render_array['#cache']['contexts'] = Cache::mergeContexts($render_array['#cache']['contexts'], $this->container->getParameter('renderer.config')['required_cache_contexts']);
+
+    return $render_array;
+  }
+
+  /**
+   * Returns the test view.
+   *
+   * @return \Drupal\views\ViewExecutable
+   *   The view.
+   */
+  protected function getView() {
+    /** @var \Drupal\views\ViewEntityInterface $view */
+    $view = $this->entityTypeManager->getStorage('view')->load(self::TEST_VIEW_ID);
+    $executable = $this->viewExecutableFactory->get($view);
+    $executable->setDisplay(self::TEST_VIEW_DISPLAY_ID);
+    return $executable;
+  }
+
+  /**
+   * Creates a node with the given title and publication status.
+   *
+   * @param string $title
+   *   The title for the node.
+   * @param bool $status
+   *   The publication status to set.
+   *
+   * @throws \Drupal\Core\Entity\EntityStorageException
+   *   Thrown if an error occurred during the saving of the node.
+   */
+  protected function createNode($title, $status) {
+    $values = [
+      'title' => $title,
+      'status' => $status,
+      'type' => $this->contentType->id(),
+    ];
+    $this->nodes[$title] = Node::create($values);
+    $this->nodes[$title]->save();
+  }
+
+}
diff --git a/tests/src/Kernel/Views/ViewsDisplayCachingTest.php b/tests/src/Kernel/Views/ViewsDisplayCachingTest.php
index ce6d8505..9d669d6a 100644
--- a/tests/src/Kernel/Views/ViewsDisplayCachingTest.php
+++ b/tests/src/Kernel/Views/ViewsDisplayCachingTest.php
@@ -317,15 +317,16 @@ public function displayCacheabilityProvider() {
       // Views. This is expected to disable caching.
       [
         'none',
-        // It is expected that only the configuration of the view itself is
-        // available as a cache tag.
+        // Cache tags for index and view config are included at the query level,
+        // so should still be present even when disabling caching.
         [
+          'config:search_api.index.database_search_index',
           'config:views.view.search_api_test_cache',
         ],
         // No specific cache contexts are expected to be present.
         [],
-        // It is expected that the cache max-age is set to zero, effectively
-        // disabling the cache.
+        // The cache max-age should be returned as zero, effectively disabling
+        // the cache.
         0,
         // It is expected that no results are cached.
         FALSE,
@@ -337,9 +338,9 @@ public function displayCacheabilityProvider() {
       [
         'tag',
         [
-          // It is expected that the configuration of the view itself is
-          // available as a cache tag, so that the caches are invalidated if the
-          // view configuration changes.
+          // The cache should be invalidated when either index or view are
+          // modified.
+          'config:search_api.index.database_search_index',
           'config:views.view.search_api_test_cache',
           // The view shows an entity, so it should be invalidated when that
           // entity changes.
@@ -362,9 +363,9 @@ public function displayCacheabilityProvider() {
       [
         'time',
         [
-          // It is expected that the configuration of the view itself is
-          // available as a cache tag, so that the caches are invalidated if the
-          // view configuration changes. No other tags should be available.
+          // The cache should be invalidated when either index or view are
+          // modified.
+          'config:search_api.index.database_search_index',
           'config:views.view.search_api_test_cache',
         ],
         // No specific cache contexts are expected to be present.
