diff --git a/src/Entity/Index.php b/src/Entity/Index.php
index 0709574..5a72cd6 100644
--- a/src/Entity/Index.php
+++ b/src/Entity/Index.php
@@ -475,7 +475,7 @@ public function setTracker(TrackerInterface $tracker) {
    * {@inheritdoc}
    */
   public function hasValidServer() {
-    return $this->server !== NULL && Server::load($this->server) !== NULL;
+    return $this->server !== NULL && ($this->serverInstance || Server::load($this->server) !== NULL);
   }
 
   /**
@@ -813,28 +813,59 @@ public function loadItem($item_id) {
    * {@inheritdoc}
    */
   public function loadItemsMultiple(array $item_ids, $group_by_datasource = FALSE) {
+    // Group the requested items by datasource. This will also later be used to
+    // determine whether all items were loaded successfully.
     $items_by_datasource = array();
     foreach ($item_ids as $item_id) {
       list($datasource_id, $raw_id) = Utility::splitCombinedId($item_id);
-      $items_by_datasource[$datasource_id][$item_id] = $raw_id;
+      $items_by_datasource[$datasource_id][$raw_id] = $item_id;
     }
+
+    // Load the items from the datasources and keep track of which were
+    // successfully retrieved.
     $items = array();
     foreach ($items_by_datasource as $datasource_id => $raw_ids) {
       try {
-        foreach ($this->getDatasource($datasource_id)->loadMultiple($raw_ids) as $raw_id => $item) {
-          $id = Utility::createCombinedId($datasource_id, $raw_id);
+        $datasource = $this->getDatasource($datasource_id);
+        $datasource_items = $datasource->loadMultiple(array_keys($raw_ids));
+        foreach ($datasource_items as $raw_id => $item) {
+          $id = $raw_ids[$raw_id];
           if ($group_by_datasource) {
             $items[$datasource_id][$id] = $item;
           }
           else {
             $items[$id] = $item;
           }
+          // Remember that we successfully loaded this item.
+          unset($items_by_datasource[$datasource_id][$raw_id]);
         }
       }
       catch (SearchApiException $e) {
         watchdog_exception('search_api', $e);
+        // If the complete datasource could not be loaded, don't report all its
+        // individual requested items as missing.
+        unset($items_by_datasource[$datasource_id]);
+      }
+    }
+
+    // Check whether there are requested items that couldn't be loaded.
+    $items_by_datasource = array_filter($items_by_datasource);
+    if ($items_by_datasource) {
+      // Extract the second-level values of the two-dimensional array (i.e., the
+      // combined item IDs) and log a warning reporting their absence.
+      $missing_ids = array_reduce(array_map('array_values', $items_by_datasource), 'array_merge', array());
+      $args['%index'] = $this->label();
+      $args['@items'] = '"' . implode('", "', $missing_ids) . '"';
+      \Drupal::logger('search_api')
+        ->warning('Could not load the following items on index %index: @items.', $args);
+      // Also remove those items from tracking so we don't keep trying to load
+      // them.
+      foreach ($items_by_datasource as $datasource_id => $raw_ids) {
+        $this->trackItemsDeleted($datasource_id, array_keys($raw_ids));
       }
     }
+
+    // Return the loaded items.
     return $items;
   }
 
@@ -846,12 +877,6 @@ public function indexItems($limit = '-1', $datasource_id = NULL) {
       $tracker = $this->getTrackerInstance();
       $next_set = $tracker->getRemainingItems($limit, $datasource_id);
       $items = $this->loadItemsMultiple($next_set);
-      if (count($items) != count($next_set)) {
-        $args['%index'] = $this->label();
-        $missing_ids = array_keys(array_diff_key(array_flip($next_set), $items));
-        $args['@items'] = '"' . implode('", "', $missing_ids) . '"';
-        \Drupal::logger('search_api')->warning('Could not load the following items for indexing on index %index: @items.', $args);
-      }
       if ($items) {
         try {
           return count($this->indexSpecificItems($items));
diff --git a/src/Plugin/search_api/datasource/ContentEntity.php b/src/Plugin/search_api/datasource/ContentEntity.php
index 43a42bc..ecaf56d 100644
--- a/src/Plugin/search_api/datasource/ContentEntity.php
+++ b/src/Plugin/search_api/datasource/ContentEntity.php
@@ -371,7 +371,6 @@ public function loadMultiple(array $ids) {
 
     /** @var \Drupal\Core\Entity\ContentEntityInterface[] $entities */
     $entities = $this->getEntityStorage()->loadMultiple(array_keys($entity_ids));
-    $missing = array();
     $items = array();
     foreach ($entity_ids as $entity_id => $langcodes) {
       foreach ($langcodes as $item_id => $langcode) {
@@ -381,17 +380,9 @@ public function loadMultiple(array $ids) {
         if (!empty($entities[$entity_id]) && $entities[$entity_id]->hasTranslation($langcode)) {
           $items[$item_id] = $entities[$entity_id]->getTranslation($langcode)->getTypedData();
         }
-        else {
-          $missing[] = $item_id;
-        }
       }
     }
-    // If we were unable to load some of the items, mark them as deleted.
-    // @todo The index should be responsible for this, not individual
-    //   datasources. See #2574589.
-    if ($missing) {
-      $this->getIndex()->trackItemsDeleted($this->getPluginId(), array_keys($missing));
-    }
+
     return $items;
   }
 
diff --git a/tests/search_api_test/search_api_test.info.yml b/tests/search_api_test/search_api_test.info.yml
new file mode 100644
index 0000000..7bf224d
--- /dev/null
+++ b/tests/search_api_test/search_api_test.info.yml
@@ -0,0 +1,8 @@
+name: 'Search API Test'
+type: module
+description: 'Support module for Search API tests'
+package: Search
+dependencies:
+  - search_api:search_api
+core: 8.x
+hidden: true
diff --git a/tests/search_api_test/src/Plugin/search_api/datasource/TestDatasource.php b/tests/search_api_test/src/Plugin/search_api/datasource/TestDatasource.php
new file mode 100644
index 0000000..48b0620
--- /dev/null
+++ b/tests/search_api_test/src/Plugin/search_api/datasource/TestDatasource.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Drupal\search_api_test\Plugin\search_api\datasource;
+
+use Drupal\Core\TypedData\ComplexDataInterface;
+use Drupal\search_api\Datasource\DatasourcePluginBase;
+use Drupal\search_api_test\TestPluginTrait;
+
+/**
+ * Provides a datasource for testing purposes.
+ *
+ * @SearchApiDatasource(
+ *   id = "search_api_test",
+ *   label = @Translation("Test datasource"),
+ * )
+ */
+class TestDatasource extends DatasourcePluginBase {
+
+  use TestPluginTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getItemId(ComplexDataInterface $item) {
+    return NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function loadMultiple(array $ids) {
+    return $this->getReturnValue(__FUNCTION__, array());
+  }
+
+}
diff --git a/tests/search_api_test/src/Plugin/search_api/tracker/TestTracker.php b/tests/search_api_test/src/Plugin/search_api/tracker/TestTracker.php
new file mode 100644
index 0000000..79d6a42
--- /dev/null
+++ b/tests/search_api_test/src/Plugin/search_api/tracker/TestTracker.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Drupal\search_api_test\Plugin\search_api\tracker;
+
+use Drupal\search_api\Plugin\search_api\tracker\Basic as BasicTracker;
+use Drupal\search_api_test\TestPluginTrait;
+
+/**
+ * Provides a tracker with dependencies, for the dependency removal tests.
+ *
+ * @SearchApiTracker(
+ *   id = "search_api_test",
+ *   label = @Translation("Test tracker"),
+ * )
+ */
+class TestTracker extends BasicTracker {
+
+  use TestPluginTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function trackItemsDeleted(array $ids = NULL) {
+    $this->logMethodCall(__FUNCTION__, func_get_args());
+  }
+
+}
diff --git a/tests/search_api_test/src/PluginTestTrait.php b/tests/search_api_test/src/PluginTestTrait.php
new file mode 100644
index 0000000..00d78e1
--- /dev/null
+++ b/tests/search_api_test/src/PluginTestTrait.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Drupal\search_api_test;
+
+/**
+ * Provides functionality for tests that deal with test plugins.
+ *
+ * @see \Drupal\search_api_test\TestPluginTrait
+ */
+trait PluginTestTrait {
+
+  /**
+   * Sets the return value for a certain method on a test plugin.
+   *
+   * @param string $plugin_type
+   *   The "short" plugin type.
+   * @param string $method
+   *   The method name.
+   * @param mixed $value
+   *   The value that should be returned.
+   */
+  protected function setReturnValue($plugin_type, $method, $value) {
+    $key = "search_api_test.$plugin_type.return.$method";
+    \Drupal::state()->set($key, $value);
+  }
+
+  /**
+   * Retrieves the methods called on a given plugin.
+   *
+   * @param string $plugin_type
+   *   The "short" plugin type.
+   * @param bool $reset
+   *   (optional) If TRUE, also clear the list of called methods for that type.
+   *
+   * @return string[]
+   *   The methods called on the given plugin.
+   */
+  protected function getCalledMethods($plugin_type, $reset = TRUE) {
+    $key = "search_api_test.$plugin_type.methods_called";
+    $methods = \Drupal::state()->get($key, array());
+    if ($reset) {
+      \Drupal::state()->delete($key);
+    }
+    return $methods;
+  }
+
+  /**
+   * Retrieves the methods called on a given plugin.
+   *
+   * @param string $plugin_type
+   *   The "short" plugin type.
+   * @param string $method
+   *   The method name.
+   *
+   * @return array
+   *   The arguments of the last call to the method.
+   */
+  protected function getMethodArguments($plugin_type, $method) {
+    $key = "search_api_test.$plugin_type.method_arguments.$method";
+    return \Drupal::state()->get($key, array());
+  }
+
+}
diff --git a/tests/search_api_test/src/TestPluginTrait.php b/tests/search_api_test/src/TestPluginTrait.php
new file mode 100644
index 0000000..e4e5013
--- /dev/null
+++ b/tests/search_api_test/src/TestPluginTrait.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Drupal\search_api_test;
+
+use Drupal\search_api\SearchApiException;
+
+/**
+ * Provides common functionality for test plugins.
+ */
+trait TestPluginTrait {
+
+  /**
+   * This object's plugin type.
+   *
+   * @var string
+   */
+  protected $pluginType;
+
+  /**
+   * Throws an exception if set in the Drupal state for the given method.
+   *
+   * Also records (successful) calls to these methods.
+   *
+   * @param string $method
+   *   The method on this object from which this method was called.
+   *
+   * @throws \Drupal\search_api\SearchApiException
+   *   Thrown if state "search_api_test.TYPE.exception.$method" is TRUE.
+   */
+  protected function checkError($method) {
+    $type = $this->getPluginType();
+    $state = \Drupal::state();
+    if ($state->get("search_api_test.$type.exception.$method")) {
+      throw new SearchApiException($method);
+    }
+  }
+
+  /**
+   * Logs a method call to the site state.
+   *
+   * @param string $method
+   *   The name of the called method.
+   * @param array $args
+   *   (optional) The method arguments.
+   */
+  protected function logMethodCall($method, array $args = array()) {
+    $type = $this->getPluginType();
+    $state = \Drupal::state();
+
+    // Log call.
+    $key = "search_api_test.$type.methods_called";
+    $methods_called = $state->get($key, array());
+    $methods_called[] = $method;
+    $state->set($key, $methods_called);
+
+    // Log arguments.
+    $key = "search_api_test.$type.method_arguments.$method";
+    $state->set($key, $args);
+  }
+
+  /**
+   * Retrieves the value to return for a certain method.
+   *
+   * @param string $method
+   *   The name of the called method.
+   * @param mixed $default
+   *   (optional) The default return value.
+   *
+   * @return mixed
+   *   The value to return from the method.
+   */
+  protected function getReturnValue($method, $default = NULL) {
+    $type = $this->getPluginType();
+    $key = "search_api_test.$type.return.$method";
+    return \Drupal::state()->get($key, $default);
+  }
+
+  /**
+   * Returns the plugin type of this object.
+   *
+   * Equivalent to the last part of the namespace, i.e., without the module
+   * prefix.
+   *
+   * @return string
+   *   The "short" plugin type.
+   */
+  protected function getPluginType() {
+    if (!isset($this->pluginType)) {
+      $class = explode("\\", get_class($this));
+      array_pop($class);
+      $this->pluginType = array_pop($class);
+    }
+
+    return $this->pluginType;
+  }
+
+}
diff --git a/tests/src/Kernel/IndexLoadItemsTest.php b/tests/src/Kernel/IndexLoadItemsTest.php
new file mode 100644
index 0000000..d5da3e4
--- /dev/null
+++ b/tests/src/Kernel/IndexLoadItemsTest.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace Drupal\Tests\search_api\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\search_api\Entity\Index;
+use Drupal\search_api\Entity\Server;
+use Drupal\search_api_test\PluginTestTrait;
+
+/**
+ * Tests whether loading items works correctly.
+ *
+ * @group search_api
+ */
+class IndexLoadItemsTest extends KernelTestBase {
+
+  use PluginTestTrait;
+
+  /**
+   * The test index object.
+   *
+   * @var \Drupal\search_api\IndexInterface
+   */
+  protected $index;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = array(
+    'search_api',
+    'search_api_test',
+    'search_api_test_backend',
+    'user',
+    );
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->installSchema('search_api', array('search_api_task'));
+
+    $server = Server::create(array(
+      'id' => 'test',
+      'backend' => 'search_api_test_backend',
+    ));
+    $this->index = Index::create(array(
+      'tracker_settings' => array(
+        'search_api_test' => array(
+          'plugin_id' => 'search_api_test',
+          'settings' => array(),
+        ),
+      ),
+      'datasource_settings' => array(
+        'search_api_test' => array(
+          'plugin_id' => 'search_api_test',
+          'settings' => array(),
+        ),
+      ),
+    ));
+    $this->index->setServer($server);
+  }
+
+  /**
+   * Verifies that missing items are correctly detected and removed.
+   */
+  public function testMissingItems() {
+    $state = \Drupal::state();
+
+    $item_ids = array(
+      'search_api_test/1',
+      'search_api_test/2',
+    );
+    $items = $this->index->loadItemsMultiple($item_ids);
+    $this->assertEquals(array(), $items, 'No items loaded from test datasource.');
+    $methods = $this->getCalledMethods('tracker');
+    $this->assertContains('trackItemsDeleted', $methods, 'Unknown items deleted from tracker.');
+    $args = $this->getMethodArguments('tracker', 'trackItemsDeleted');
+    $this->assertEquals(array($item_ids), $args, 'Correct items deleted from tracker.');
+    $methods = $state->get('search_api_test_backend.methods_called.test', array());
+    $this->assertContains('deleteItems', $methods, 'Unknown items deleted from server.');
+
+    // If an error occurs while retrieving the datasource (which will happen for
+    // "unknown/1"), the items should not be deleted from tracking and the
+    // server.
+    $expected_deletions = $item_ids;
+    $item_ids = array(
+      'search_api_test/1',
+      'search_api_test/2',
+      'search_api_test/3',
+      'unknown/1',
+    );
+    $this->setReturnValue('datasource', 'loadMultiple', array('3' => ''));
+    $items = $this->index->loadItemsMultiple($item_ids);
+    $expected_items = array('search_api_test/3' => '');
+    $this->assertEquals($expected_items, $items, 'Expected items loaded from test datasource.');
+    $methods = $this->getCalledMethods('tracker');
+    $this->assertContains('trackItemsDeleted', $methods, 'Unknown items deleted from tracker.');
+    $args = $this->getMethodArguments('tracker', 'trackItemsDeleted');
+    $this->assertEquals(array($expected_deletions), $args, 'Correct items deleted from tracker.');
+    $methods = $state->get('search_api_test_backend.methods_called.test', array());
+    $this->assertContains('deleteItems', $methods, 'Unknown items deleted from server.');
+  }
+
+}
