diff --git a/core/misc/ajax.es6.js b/core/misc/ajax.es6.js
index a438fbc60c7e8314a1a637f66d05d73a0e40b35a..739afa19747bc2fadafab2228c36d6b55e897706 100644
--- a/core/misc/ajax.es6.js
+++ b/core/misc/ajax.es6.js
@@ -811,6 +811,40 @@
     }
   };
 
+  /**
+   * An animated progress throbber and container element for AJAX operations.
+   *
+   * @param {string} [message]
+   *   (optional) The message shown on the UI.
+   * @return {string}
+   *   The HTML markup for the throbber.
+   */
+  Drupal.theme.ajaxProgressThrobber = (message) => {
+    // Build markup without adding extra white space since it affects rendering.
+    const messageMarkup = typeof message === 'string' ? Drupal.theme('ajaxProgressMessage', message) : '';
+    const throbber = '<div class="throbber">&nbsp;</div>';
+
+    return `<div class="ajax-progress ajax-progress-throbber">${throbber}${messageMarkup}</div>`;
+  };
+
+  /**
+   * An animated progress throbber and container element for AJAX operations.
+   *
+   * @return {string}
+   *   The HTML markup for the throbber.
+   */
+  Drupal.theme.ajaxProgressIndicatorFullscreen = () => '<div class="ajax-progress ajax-progress-fullscreen">&nbsp;</div>';
+
+  /**
+   * Formats text accompanying the AJAX progress throbber.
+   *
+   * @param {string} message
+   *   The message shown on the UI.
+   * @return {string}
+   *   The HTML markup for the throbber.
+   */
+  Drupal.theme.ajaxProgressMessage = message => `<div class="message">${message}</div>`;
+
   /**
    * Sets the progress bar progress indicator.
    */
@@ -831,10 +865,7 @@
    * Sets the throbber progress indicator.
    */
   Drupal.Ajax.prototype.setProgressIndicatorThrobber = function () {
-    this.progress.element = $('<div class="ajax-progress ajax-progress-throbber"><div class="throbber">&nbsp;</div></div>');
-    if (this.progress.message) {
-      this.progress.element.find('.throbber').after(`<div class="message">${this.progress.message}</div>`);
-    }
+    this.progress.element = $(Drupal.theme('ajaxProgressThrobber', this.progress.message));
     $(this.element).after(this.progress.element);
   };
 
@@ -842,7 +873,7 @@
    * Sets the fullscreen progress indicator.
    */
   Drupal.Ajax.prototype.setProgressIndicatorFullscreen = function () {
-    this.progress.element = $('<div class="ajax-progress ajax-progress-fullscreen">&nbsp;</div>');
+    this.progress.element = $(Drupal.theme('ajaxProgressIndicatorFullscreen'));
     $('body').after(this.progress.element);
   };
 
diff --git a/core/misc/ajax.js b/core/misc/ajax.js
index 814d82f8f2348f85aeda9c2c1faa8adce5062859..abe0ec2928df7cb497910d6be9b3d1cbf38e345f 100644
--- a/core/misc/ajax.js
+++ b/core/misc/ajax.js
@@ -368,6 +368,21 @@ function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr
     }
   };
 
+  Drupal.theme.ajaxProgressThrobber = function (message) {
+    var messageMarkup = typeof message === 'string' ? Drupal.theme('ajaxProgressMessage', message) : '';
+    var throbber = '<div class="throbber">&nbsp;</div>';
+
+    return '<div class="ajax-progress ajax-progress-throbber">' + throbber + messageMarkup + '</div>';
+  };
+
+  Drupal.theme.ajaxProgressIndicatorFullscreen = function () {
+    return '<div class="ajax-progress ajax-progress-fullscreen">&nbsp;</div>';
+  };
+
+  Drupal.theme.ajaxProgressMessage = function (message) {
+    return '<div class="message">' + message + '</div>';
+  };
+
   Drupal.Ajax.prototype.setProgressIndicatorBar = function () {
     var progressBar = new Drupal.ProgressBar('ajax-progress-' + this.element.id, $.noop, this.progress.method, $.noop);
     if (this.progress.message) {
@@ -382,15 +397,12 @@ function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr
   };
 
   Drupal.Ajax.prototype.setProgressIndicatorThrobber = function () {
-    this.progress.element = $('<div class="ajax-progress ajax-progress-throbber"><div class="throbber">&nbsp;</div></div>');
-    if (this.progress.message) {
-      this.progress.element.find('.throbber').after('<div class="message">' + this.progress.message + '</div>');
-    }
+    this.progress.element = $(Drupal.theme('ajaxProgressThrobber', this.progress.message));
     $(this.element).after(this.progress.element);
   };
 
   Drupal.Ajax.prototype.setProgressIndicatorFullscreen = function () {
-    this.progress.element = $('<div class="ajax-progress ajax-progress-fullscreen">&nbsp;</div>');
+    this.progress.element = $(Drupal.theme('ajaxProgressIndicatorFullscreen'));
     $('body').after(this.progress.element);
   };
 
diff --git a/core/modules/field_ui/field_ui.es6.js b/core/modules/field_ui/field_ui.es6.js
index 965a2e4c36b07f461140244656588e4f6971847e..cf1284171186bdfddf5a955988941144f8c0aa4f 100644
--- a/core/modules/field_ui/field_ui.es6.js
+++ b/core/modules/field_ui/field_ui.es6.js
@@ -219,7 +219,7 @@
 
       if (rowNames.length) {
         // Add a throbber next each of the ajaxElements.
-        $(ajaxElements).after('<div class="ajax-progress ajax-progress-throbber"><div class="throbber">&nbsp;</div></div>');
+        $(ajaxElements).after(Drupal.theme.ajaxProgressThrobber());
 
         // Fire the Ajax update.
         $('input[name=refresh_rows]').val(rowNames.join(' '));
diff --git a/core/modules/field_ui/field_ui.js b/core/modules/field_ui/field_ui.js
index d0e8a6d7126f0574577be9d7c84aa184f8d22087..5cbeb6458b35c366b4d2da6b97f5424a45599492 100644
--- a/core/modules/field_ui/field_ui.js
+++ b/core/modules/field_ui/field_ui.js
@@ -126,7 +126,7 @@
       });
 
       if (rowNames.length) {
-        $(ajaxElements).after('<div class="ajax-progress ajax-progress-throbber"><div class="throbber">&nbsp;</div></div>');
+        $(ajaxElements).after(Drupal.theme.ajaxProgressThrobber());
 
         $('input[name=refresh_rows]').val(rowNames.join(' '));
         $('input[data-drupal-selector="edit-refresh"]').trigger('mousedown');
diff --git a/core/modules/system/tests/modules/hold_test/hold_test.info.yml b/core/modules/system/tests/modules/hold_test/hold_test.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f7677514228914e6967ff1e9764f933e36ffb6b7
--- /dev/null
+++ b/core/modules/system/tests/modules/hold_test/hold_test.info.yml
@@ -0,0 +1,6 @@
+name: Hold test
+type: module
+description: 'Support testing with request/response hold.'
+package: Testing
+version: VERSION
+core: 8.x
diff --git a/core/modules/system/tests/modules/hold_test/hold_test.install b/core/modules/system/tests/modules/hold_test/hold_test.install
new file mode 100644
index 0000000000000000000000000000000000000000..183463deb3b05a22e782e711d0cd454f05a385f1
--- /dev/null
+++ b/core/modules/system/tests/modules/hold_test/hold_test.install
@@ -0,0 +1,14 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the hold_test module.
+ */
+
+/**
+ * Implements hook_install().
+ */
+function hold_test_install() {
+  hold_test_request(FALSE);
+  hold_test_response(FALSE);
+}
diff --git a/core/modules/system/tests/modules/hold_test/hold_test.module b/core/modules/system/tests/modules/hold_test/hold_test.module
new file mode 100644
index 0000000000000000000000000000000000000000..867eacf90612a40c8dd0b240fd337c85b0b2aa68
--- /dev/null
+++ b/core/modules/system/tests/modules/hold_test/hold_test.module
@@ -0,0 +1,26 @@
+<?php
+
+/**
+ * @file
+ * Contains functions for testing hold request/response.
+ */
+
+/**
+ * Request hold.
+ *
+ * @param bool $status
+ *   TRUE - enable hold, FALSE - disable hold.
+ */
+function hold_test_request($status) {
+  file_put_contents(\Drupal::root() . '/sites/default/files/simpletest/hold_test_request.txt', $status);
+}
+
+/**
+ * Response hold.
+ *
+ * @param bool $status
+ *   TRUE - enable hold, FALSE - disable hold.
+ */
+function hold_test_response($status) {
+  file_put_contents(\Drupal::root() . '/sites/default/files/simpletest/hold_test_response.txt', $status);
+}
diff --git a/core/modules/system/tests/modules/hold_test/hold_test.services.yml b/core/modules/system/tests/modules/hold_test/hold_test.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..88e7babdff32adcf91dd5d4a1018572a4afe45fb
--- /dev/null
+++ b/core/modules/system/tests/modules/hold_test/hold_test.services.yml
@@ -0,0 +1,5 @@
+services:
+  hold_test.response:
+    class: Drupal\hold_test\EventSubscriber\HoldTestSubscriber
+    tags:
+      - { name: event_subscriber }
diff --git a/core/modules/system/tests/modules/hold_test/src/EventSubscriber/HoldTestSubscriber.php b/core/modules/system/tests/modules/hold_test/src/EventSubscriber/HoldTestSubscriber.php
new file mode 100644
index 0000000000000000000000000000000000000000..332f4c00b9a8f01b5409f44c90ca287cc564d819
--- /dev/null
+++ b/core/modules/system/tests/modules/hold_test/src/EventSubscriber/HoldTestSubscriber.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Drupal\hold_test\EventSubscriber;
+
+use Symfony\Component\HttpKernel\KernelEvents;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Response subscriber to test hold.
+ */
+class HoldTestSubscriber implements EventSubscriberInterface {
+
+  const HOLD_REQUEST = 'request';
+  const HOLD_RESPONSE = 'response';
+
+  /**
+   * Request hold.
+   */
+  public function onRequest() {
+    $this->hold(static::HOLD_REQUEST);
+  }
+
+  /**
+   * Response hold.
+   */
+  public function onRespond() {
+    $this->hold(static::HOLD_RESPONSE);
+  }
+
+  /**
+   * Hold process by type.
+   *
+   * @param string $type
+   *   Type of hold.
+   */
+  protected function hold($type) {
+    $path = \Drupal::root() . "/sites/default/files/simpletest/hold_test_$type.txt";
+    do {
+      $status = (bool) file_get_contents($path);
+    } while ($status && (NULL === usleep(100000)));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    $events[KernelEvents::REQUEST][] = ['onRequest'];
+    $events[KernelEvents::RESPONSE][] = ['onRespond'];
+    return $events;
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Ajax/ThrobberTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Ajax/ThrobberTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..e2ecaea7a468d9aeb9f1fa5fc86d4b6372216f90
--- /dev/null
+++ b/core/tests/Drupal/FunctionalJavascriptTests/Ajax/ThrobberTest.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace Drupal\FunctionalJavascriptTests\Ajax;
+
+use Drupal\FunctionalJavascriptTests\DrupalSelenium2Driver;
+use Drupal\FunctionalJavascriptTests\JavascriptTestBase;
+
+/**
+ * Tests the throbber.
+ *
+ * @group Ajax
+ */
+class ThrobberTest extends JavascriptTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $minkDefaultDriverClass = DrupalSelenium2Driver::class;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'node',
+    'views',
+    'views_ui',
+    'views_ui_test_field',
+    'hold_test',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+
+    $admin_user = $this->drupalCreateUser([
+      'administer views',
+    ]);
+    $this->drupalLogin($admin_user);
+  }
+
+  /**
+   * Tests theming throbber element.
+   */
+  public function testThemingThrobberElement() {
+    $session = $this->getSession();
+    $web_assert = $this->assertSession();
+    $page = $session->getPage();
+
+    $custom_ajax_progress_indicator_fullscreen = <<<JS
+      Drupal.theme.ajaxProgressIndicatorFullscreen = function () {
+        return '<div class="custom-ajax-progress-fullscreen"></div>';
+      };
+JS;
+    $custom_ajax_progress_throbber = <<<JS
+      Drupal.theme.ajaxProgressThrobber = function (message) {
+        return '<div class="custom-ajax-progress-throbber"></div>';
+      };
+JS;
+    $custom_ajax_progress_message = <<<JS
+      Drupal.theme.ajaxProgressMessage = function (message) {
+        return '<div class="custom-ajax-progress-message">Hold door!</div>';
+      };
+JS;
+
+    $this->drupalGet('admin/structure/views/view/content');
+    $this->waitForNoElement('.ajax-progress-fullscreen');
+
+    // Test theming fullscreen throbber.
+    $session->executeScript($custom_ajax_progress_indicator_fullscreen);
+    hold_test_response(TRUE);
+    $page->clickLink('Content: Published (grouped)');
+    $this->assertNotNull($web_assert->waitForElement('css', '.custom-ajax-progress-fullscreen'), 'Custom ajaxProgressIndicatorFullscreen.');
+    hold_test_response(FALSE);
+    $this->waitForNoElement('.custom-ajax-progress-fullscreen');
+
+    // Test theming throbber message.
+    $web_assert->waitForElementVisible('css', '[data-drupal-selector="edit-options-group-info-add-group"]');
+    $session->executeScript($custom_ajax_progress_message);
+    hold_test_response(TRUE);
+    $page->pressButton('Add another item');
+    $this->assertNotNull($web_assert->waitForElement('css', '.ajax-progress-throbber .custom-ajax-progress-message'), 'Custom ajaxProgressMessage.');
+    hold_test_response(FALSE);
+    $this->waitForNoElement('.ajax-progress-throbber');
+
+    // Test theming throbber.
+    $web_assert->waitForElementVisible('css', '[data-drupal-selector="edit-options-group-info-group-items-3-title"]');
+    $session->executeScript($custom_ajax_progress_throbber);
+    hold_test_response(TRUE);
+    $page->pressButton('Add another item');
+    $this->assertNotNull($web_assert->waitForElement('css', '.custom-ajax-progress-throbber'), 'Custom ajaxProgressThrobber.');
+    hold_test_response(FALSE);
+    $this->waitForNoElement('.custom-ajax-progress-throbber');
+  }
+
+  /**
+   * Waits for an element to be removed from the page.
+   *
+   * @param string $selector
+   *   CSS selector.
+   * @param int $timeout
+   *   (optional) Timeout in milliseconds, defaults to 10000.
+   *
+   * @todo Remove in https://www.drupal.org/node/2892440.
+   */
+  protected function waitForNoElement($selector, $timeout = 10000) {
+    $condition = "(typeof jQuery !== 'undefined' && jQuery('$selector').length === 0)";
+    $this->assertJsCondition($condition, $timeout);
+  }
+
+}