diff --git a/src/Activator/ModuleActivator.php b/src/Activator/ModuleActivator.php
index 6edee6617b58d394e07810e2b5837a76335a011d..b6731d8d498e8d6cf196051bbca438f293e0e0cb 100644
--- a/src/Activator/ModuleActivator.php
+++ b/src/Activator/ModuleActivator.php
@@ -25,8 +25,8 @@ final class ModuleActivator implements InstructionsInterface, TasksInterface {
 
   public function __construct(
     private readonly ModuleInstallerInterface $moduleInstaller,
-    private readonly ModuleExtensionList $moduleList,
-    private readonly ModuleHandlerInterface $moduleHandler,
+    private ModuleExtensionList $moduleList,
+    private ModuleHandlerInterface $moduleHandler,
     private readonly FileUrlGeneratorInterface $fileUrlGenerator,
     private readonly RequestStack $requestStack,
     private readonly AccountInterface $currentUser,
@@ -36,7 +36,7 @@ final class ModuleActivator implements InstructionsInterface, TasksInterface {
    * {@inheritdoc}
    */
   public function getStatus(Project $project): ActivationStatus {
-    if (array_key_exists($project->machineName, $this->moduleList->getAllInstalledInfo())) {
+    if ($this->moduleHandler->moduleExists($project->machineName)) {
       return ActivationStatus::Active;
     }
     elseif (array_key_exists($project->machineName, $this->moduleList->getAllAvailableInfo())) {
@@ -57,6 +57,14 @@ final class ModuleActivator implements InstructionsInterface, TasksInterface {
    */
   public function activate(Project $project): null {
     $this->moduleInstaller->install([$project->machineName]);
+
+    // The container has changed, so we need to reload the module handler and
+    // module list from the global service wrapper.
+    // @phpstan-ignore-next-line
+    $this->moduleHandler = \Drupal::moduleHandler();
+    // @phpstan-ignore-next-line
+    $this->moduleList = \Drupal::service(ModuleExtensionList::class);
+
     return NULL;
   }
 
diff --git a/src/Activator/RecipeActivator.php b/src/Activator/RecipeActivator.php
index 2cff5fc002cbf64b84585f84d386d91cf8a5f2c4..bee7183790cd29bd411ec0d7886a49aaf5c26020 100644
--- a/src/Activator/RecipeActivator.php
+++ b/src/Activator/RecipeActivator.php
@@ -68,6 +68,8 @@ final class RecipeActivator implements InstructionsInterface, EventSubscriberInt
    */
   public function getStatus(Project $project): ActivationStatus {
     $path = $this->getPath($project);
+    // Ensure we're getting the most up-to-date information.
+    $this->state->resetCache();
 
     if (in_array($path, $this->state->get(static::STATE_KEY, []), TRUE)) {
       return ActivationStatus::Active;
diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 0bd3f6b92f1d3dd8b8b1b8bb735ab2155bdadef3..161f4628a4528de320aaf12009b07fb9a2291d42 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -17,7 +17,7 @@ use Drupal\project_browser\ComposerInstaller\Installer;
 use Drupal\project_browser\EnabledSourceHandler;
 use Drupal\project_browser\InstallState;
 use Drupal\project_browser\ProjectBrowser\Normalizer;
-use Drupal\project_browser\RefreshProjectCommand;
+use Drupal\project_browser\RefreshProjectsCommand;
 use Drupal\system\SystemManager;
 use Psr\Log\LoggerInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -436,6 +436,7 @@ final class InstallerController extends ControllerBase {
    */
   public function activate(Request $request): AjaxResponse {
     $response = new AjaxResponse();
+    $activated_projects = [];
 
     $projects = $request->getPayload()->get('projects') ?? [];
     if ($projects) {
@@ -445,6 +446,7 @@ final class InstallerController extends ControllerBase {
     assert(is_array($projects));
     foreach ($projects as $project_id) {
       $this->installState->setState($project_id, 'activating');
+      // $project_id is fully qualified and has the form `SOURCE_ID/LOCAL_ID`.
       [$source_id] = explode('/', $project_id, 2);
 
       try {
@@ -455,10 +457,7 @@ final class InstallerController extends ControllerBase {
           $response->addCommand($command);
         }
         $this->installState->setState($project_id, 'installed');
-
-        $project = $this->normalizer->normalize($project, context: ['source' => $source_id]);
-        assert(is_array($project));
-        $response->addCommand(new RefreshProjectCommand($project));
+        $activated_projects[] = $this->normalizer->normalize($project, context: ['source' => $source_id]);
       }
       catch (\Throwable $e) {
         $message = $e->getMessage();
@@ -472,7 +471,7 @@ final class InstallerController extends ControllerBase {
       }
     }
     $this->installState->deleteAll();
-    return $response;
+    return $response->addCommand(new RefreshProjectsCommand($activated_projects));
   }
 
   /**
diff --git a/src/RefreshProjectCommand.php b/src/RefreshProjectsCommand.php
similarity index 50%
rename from src/RefreshProjectCommand.php
rename to src/RefreshProjectsCommand.php
index c6fab42a027e98558d6776fab416d2840b257364..d3e99d39e2027c272803bcefbc7e390875608d74 100644
--- a/src/RefreshProjectCommand.php
+++ b/src/RefreshProjectsCommand.php
@@ -7,12 +7,12 @@ namespace Drupal\project_browser;
 use Drupal\Core\Ajax\CommandInterface;
 
 /**
- * An AJAX command to refresh a particular project in the Svelte app.
+ * An AJAX command to refresh projects in the Svelte app.
  */
-final class RefreshProjectCommand implements CommandInterface {
+final class RefreshProjectsCommand implements CommandInterface {
 
   public function __construct(
-    private readonly array $project,
+    private readonly array $projects,
   ) {}
 
   /**
@@ -20,8 +20,8 @@ final class RefreshProjectCommand implements CommandInterface {
    */
   public function render(): array {
     return [
-      'command' => 'refresh_project',
-      'project' => $this->project,
+      'command' => 'refresh_projects',
+      'projects' => $this->projects,
     ];
   }
 
diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js
index 2dc635852ab161656e8e26b7c0bd72abc61f7084..cecd5d94d1a117891778ff9a5cfcbc6634c4108f 100644
Binary files a/sveltejs/public/build/bundle.js and b/sveltejs/public/build/bundle.js differ
diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map
index b43efeff6ef60e0e41ec035178f8fc9cb8272d0c..a012e1af66e538537ad02c48022de757983f9f89 100644
Binary files a/sveltejs/public/build/bundle.js.map and b/sveltejs/public/build/bundle.js.map differ
diff --git a/sveltejs/src/ProjectBrowser.svelte b/sveltejs/src/ProjectBrowser.svelte
index 77f62ce984900b3df81b8992e528bbc91c62c656..d126cd6d1985e9dc93b719ab5050e71a4c42eb4d 100644
--- a/sveltejs/src/ProjectBrowser.svelte
+++ b/sveltejs/src/ProjectBrowser.svelte
@@ -8,7 +8,8 @@
   import { numberFormatter } from './util';
   import { updated, installList } from './InstallListProcessor';
   import MediaQuery from './MediaQuery.svelte';
-  import { BASE_URL, FULL_MODULE_PATH, MAX_SELECTIONS } from './constants';
+  import { FULL_MODULE_PATH, MAX_SELECTIONS } from './constants';
+  import QueryManager from './QueryManager';
 
   const { Drupal, drupalSettings } = window;
   const { announce } = Drupal;
@@ -23,6 +24,8 @@
     sortBy,
   } = drupalSettings.project_browser.instances[id];
 
+  const queryManager = new QueryManager();
+
   const filters = writable({});
   Object.entries(filterDefinitions).forEach(([key, definition]) => {
     $filters[key] = definition.value;
@@ -43,11 +46,17 @@
   setContext('mediaQueryValues', mediaQueryValues);
 
   let rowsCount = 0;
-  let data;
   let rows = [];
   const pageIndex = 0; // first row
   const preferredView = writable('Grid');
 
+  // Set up a callback function that will be called whenever project data
+  // is refreshed or loaded from the backend.
+  queryManager.subscribe((projects) => {
+    // `rows` is reactive, so just update it to trigger a re-render.
+    rows = projects;
+  });
+
   let loading = true;
   let toggleView = 'Grid';
   preferredView.subscribe((value) => {
@@ -62,42 +71,15 @@
   let searchComponent;
 
   /**
-   * Load data from the backend.
+   * Load data from QueryManager.
    *
    * @return {Promise<void>}
    *   Empty promise that resolves on content load.*
    */
   async function load() {
     loading = true;
-
-    // Encode the current filter values as URL parameters.
-    const searchParams = new URLSearchParams();
-    Object.entries($filters).forEach(([key, value]) => {
-      if (typeof value === 'boolean') {
-        value = Number(value).toString();
-      }
-      searchParams.set(key, value);
-    });
-    searchParams.set('page', $page);
-    searchParams.set('limit', $pageSize);
-    searchParams.set('sort', $sort);
-    searchParams.set('source', source);
-
-    const url = `${BASE_URL}project-browser/data/project?${searchParams.toString()}`;
-
-    const res = await fetch(url);
-    if (res.ok) {
-      data = await res.json();
-      rows = data.list;
-      rowsCount = data.totalResults;
-
-      if (data.error && data.error.length) {
-        new Drupal.Message().add(data.error, { type: 'error' });
-      }
-    } else {
-      rows = [];
-      rowsCount = 0;
-    }
+    await queryManager.load($filters, $page, $pageSize, $sort, source);
+    rowsCount = queryManager.count;
     loading = false;
   }
 
@@ -216,7 +198,7 @@
 
       <div class="pb-layout__header">
         <div class="pb-search-results">
-          {#if data && source === data.pluginId}
+          {#if rowsCount}
             {Drupal.formatPlural(
               rowsCount,
               `${numberFormatter.format(1)} Result`,
@@ -266,7 +248,7 @@
       </div>
     </div>
     {#key $updated}
-      {#each rows as row, index (row)}
+      {#each rows as row (row.id)}
         <Project {toggleView} project={row} />
       {/each}
     {/key}
diff --git a/sveltejs/src/QueryManager.js b/sveltejs/src/QueryManager.js
new file mode 100644
index 0000000000000000000000000000000000000000..5267398b5067102d6a2e5dac778b87decfad54c5
--- /dev/null
+++ b/sveltejs/src/QueryManager.js
@@ -0,0 +1,132 @@
+import { get, writable } from 'svelte/store';
+import { BASE_URL } from './constants';
+
+// This is the single source of truth for all projects that have been loaded from
+// the backend. It is keyed by fully qualified project ID, and shared by all
+// QueryManager instances.
+const cache =  writable({});
+
+function getFromCache (projects) {
+  const cacheData = get(cache);
+  return projects
+    .map(id => cacheData[id])
+    .filter(item => typeof item === 'object');
+}
+
+function updateProjectsInCache (projects) {
+  // Use `.update()` so that all subscribers (i.e., individual QueryManager
+  // instances) will be notified and receive the latest project data.
+  cache.update((cacheData) => {
+    projects.forEach((project) => {
+      cacheData[project.id] = project;
+    });
+    return cacheData;
+  });
+}
+
+// Allow cached projects to be updated via AJAX.
+Drupal.AjaxCommands.prototype.refresh_projects = (ajax, { projects }) => {
+  updateProjectsInCache(projects);
+};
+
+/**
+ * Handles fetching and temporarily caching project data from the backend.
+ *
+ * This implements a volatile, centralized caching mechanism, ensuring that
+ * all instances of the Project Browser on a single page share a consistent
+ * source of truth for project data.
+ *
+ * The cache lives in memory and is reset upon page reload.
+ */
+export default class {
+  constructor () {
+    // A list of project IDs that were returned by the last query. These are
+    // only the project IDs; the most current data for each of them is stored
+    // in the static cache.
+    this.list = [];
+    // The subscribers that are listening for changes in the projects.
+    this.subscribers = [];
+    // The total (i.e., not paginated) number of results returned by the most
+    // recent query.
+    this.count = 0;
+
+    // Whenever the cache changes, we want to notify our subscribers about any
+    // changes to the projects we have most recently loaded.
+    cache.subscribe(() => {
+      const projects = getFromCache(this.list);
+
+      this.subscribers.forEach((callback) => {
+        callback(projects);
+      });
+    });
+
+    this.lastQueryParams = null;
+  }
+
+  subscribe (callback) {
+    const index = this.subscribers.length;
+    this.subscribers.push(callback);
+
+    // The store contract requires us to immediately call the new subscriber.
+    callback(getFromCache(this.list));
+
+    // The store contract requires us to return an unsubscribe function.
+    return () => {
+      this.subscribers.splice(index, 1);
+    };
+  }
+
+  /**
+   * Fetch projects from the backend and store them in memory.
+   *
+   * @param {Object} filters - The filters to apply in the request.
+   * @param {Number} page - The current page number.
+   * @param {Number} pageSize - Number of items per page.
+   * @param {String} sort - Sorting method.
+   * @param {String} source - Data source.
+   * @return {Promise<Object>} - The list of project objects.
+   */
+  async load(filters, page, pageSize, sort, source) {
+    this.list = [];
+    this.count = 0;
+
+    // Encode the current filter values as URL parameters.
+    const searchParams = new URLSearchParams();
+    Object.entries(filters).forEach(([key, value]) => {
+      if (typeof value === 'boolean') {
+        value = Number(value).toString();
+      }
+      searchParams.set(key, value);
+    });
+    searchParams.set('page', page);
+    searchParams.set('limit', pageSize);
+    searchParams.set('sort', sort);
+    searchParams.set('source', source);
+
+    const queryString = searchParams.toString();
+
+    if (this.lastQueryParams === queryString) {
+      return;
+    }
+
+    this.lastQueryParams = queryString;
+    const res = await fetch(
+      `${BASE_URL}project-browser/data/project?${queryString}`,
+    );
+    if (!res.ok) {
+      return;
+    }
+
+    const data = await res.json();
+    if (data.error && data.error.length) {
+      new Drupal.Message().add(data.error, { type: 'error' });
+    }
+
+    data.list.forEach((project) => {
+      this.list.push(project.id);
+    });
+    this.count = data.totalResults;
+
+    updateProjectsInCache(data.list);
+  }
+}
diff --git a/tests/modules/project_browser_test/src/TestActivator.php b/tests/modules/project_browser_test/src/TestActivator.php
index f10a5e9cde147c10e39c5550de02c1c643598af2..8a8bb74d3174fd516092290a9af2724d362d0f76 100644
--- a/tests/modules/project_browser_test/src/TestActivator.php
+++ b/tests/modules/project_browser_test/src/TestActivator.php
@@ -40,7 +40,12 @@ class TestActivator implements ActivatorInterface {
    * {@inheritdoc}
    */
   public function getStatus(Project $project): ActivationStatus {
-    if ($project->machineName === 'pinky_brain') {
+    $activated_projects = $this->state->get("test activator", []);
+
+    if (in_array($project->id, $activated_projects, TRUE)) {
+      return ActivationStatus::Active;
+    }
+    elseif ($project->machineName === 'pinky_brain') {
       return ActivationStatus::Present;
     }
     return ActivationStatus::Absent;
@@ -50,9 +55,9 @@ class TestActivator implements ActivatorInterface {
    * {@inheritdoc}
    */
   public function activate(Project $project): null {
-    $log_message = $this->state->get("test activator", []);
-    $log_message[] = "$project->title was activated!";
-    $this->state->set("test activator", $log_message);
+    $activated_projects = $this->state->get("test activator", []);
+    $activated_projects[] = $project->id;
+    $this->state->set("test activator", $activated_projects);
     return NULL;
   }
 
diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php
index 68480eb72e0a9cabad73cd2e9411187292a585be..55769e6d47ff1a7e4e7cbd0ac85a7b6ef39e917e 100644
--- a/tests/src/Functional/InstallerControllerTest.php
+++ b/tests/src/Functional/InstallerControllerTest.php
@@ -648,4 +648,30 @@ class InstallerControllerTest extends BrowserTestBase {
     $this->assertSession()->addressEquals('/admin/modules/browse/drupal_core');
   }
 
+  /**
+   * Tests activating multiple core modules in a single request.
+   */
+  public function testActivateMultipleModules(): void {
+    $this->config('project_browser.admin_settings')
+      ->set('enabled_sources', ['drupal_core'])
+      ->save();
+
+    // Prime the project cache.
+    $this->container->get(EnabledSourceHandler::class)
+      ->getProjects('drupal_core');
+
+    $response = $this->getPostResponse(
+      Url::fromRoute('project_browser.activate'),
+      [
+        'projects' => implode(',', [
+          'drupal_core/language',
+          'drupal_core/content_translation',
+          'drupal_core/locale',
+        ]),
+      ],
+    );
+    $this->assertSame(200, $response->getStatusCode());
+    $this->assertSame(3, substr_count((string) $response->getBody(), '"status":"active"'));
+  }
+
 }
diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index c29e717673d2b023cd96712cbabd598706794a63..f5afc262a60a8d2d2aa0d32e1458d3bc3d710415 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
@@ -80,9 +80,10 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
     $this->installProject('Cream cheese on a bagel');
 
-    // The activator in project_browser_test should have logged a message.
+    // The activator in project_browser_test should have logged the unqualified
+    // project ID.
     // @see \Drupal\project_browser_test\TestActivator
-    $this->assertContains('Cream cheese on a bagel was activated!', $this->container->get(StateInterface::class)->get('test activator'));
+    $this->assertContains('cream_cheese', $this->container->get(StateInterface::class)->get('test activator'));
   }
 
   /**
@@ -176,6 +177,8 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
    * @covers ::unlock
    */
   public function testCanBreakStageWithMissingProjectBrowserLock(): void {
+    TestActivator::handle('drupal/cream_cheese');
+
     // Start install begin.
     $this->drupalGet('admin/modules/project_browser/install-begin', [
       'query' => ['source' => 'project_browser_test_mock'],
@@ -203,6 +206,8 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
    * @covers ::unlock
    */
   public function testCanBreakLock(): void {
+    TestActivator::handle('drupal/cream_cheese');
+
     // Start install begin.
     $this->drupalGet('admin/modules/project_browser/install-begin', [
       'query' => ['source' => 'project_browser_test_mock'],
@@ -238,6 +243,8 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
    * Confirm that a status check warning allows download and install.
    */
   public function testPackageManagerWarningAllowsDownloadInstall(): void {
+    TestActivator::handle('drupal/cream_cheese');
+
     // @see \Drupal\project_browser_test\TestInstallReadiness
     $this->container->get(StateInterface::class)
       ->set('project_browser_test.simulated_result_severity', SystemManager::REQUIREMENT_WARNING);
@@ -281,12 +288,13 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $this->waitForProjectToBeInstalled($cream_cheese);
     $this->waitForProjectToBeInstalled($kangaroo);
 
-    // The activator in project_browser_test should have logged a message.
+    // The activator in project_browser_test should have logged the unqualified
+    // project IDs.
     // @see \Drupal\project_browser_test\TestActivator
     $activated = $this->container->get(StateInterface::class)
       ->get('test activator');
-    $this->assertContains('Cream cheese on a bagel was activated!', $activated);
-    $this->assertContains('Kangaroo was activated!', $activated);
+    $this->assertContains('cream_cheese', $activated);
+    $this->assertContains('kangaroo', $activated);
   }
 
   /**
diff --git a/tests/src/Kernel/ModuleActivatorTest.php b/tests/src/Kernel/ModuleActivatorTest.php
index 1977ea6ad40ffd7a0d8c4e52206d770dc3eb2c4b..ceac2d066ec8e98fb702a11fcd2cd992fb6a93e6 100644
--- a/tests/src/Kernel/ModuleActivatorTest.php
+++ b/tests/src/Kernel/ModuleActivatorTest.php
@@ -26,6 +26,7 @@ class ModuleActivatorTest extends KernelTestBase {
   protected static $modules = [
     'block',
     'breakpoint',
+    'help',
     'project_browser',
     'user',
   ];
@@ -49,6 +50,13 @@ class ModuleActivatorTest extends KernelTestBase {
    */
   protected function setUp(): void {
     $this->mockModuleHandler = $this->createMock(ModuleHandlerInterface::class);
+    $this->mockModuleHandler->expects($this->atLeastOnce())
+      ->method('moduleExists')
+      ->willReturnMap(array_map(
+        fn (string $module_name): array => [$module_name, TRUE],
+        static::$modules,
+      ));
+
     parent::setUp();
 
     $this->config('project_browser.admin_settings')
@@ -107,12 +115,6 @@ class ModuleActivatorTest extends KernelTestBase {
    *   [false]
    */
   public function testHelpLinksAreExposed(bool $implements_hook_help): void {
-    $this->enableModules(['help']);
-
-    $this->mockModuleHandler->expects($this->atLeastOnce())
-      ->method('moduleExists')
-      ->with('help')
-      ->willReturn(TRUE);
     $this->mockModuleHandler->expects($this->atLeastOnce())
       ->method('hasImplementations')
       ->with('help', 'breakpoint')