diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index b26bf5fff6277b3f5f04b0a10dc83e1b2fd4230f..d7a0461893f95220e9bab0df0b27627947ab9763 100644
--- a/core/core.libraries.yml
+++ b/core/core.libraries.yml
@@ -370,6 +370,7 @@ drupal.ajax:
     - core/drupal.progress
     - core/once
     - core/tabbable
+    - core/loadjs
 
 drupal.announce:
   version: VERSION
diff --git a/core/lib/Drupal/Core/Ajax/AddJsCommand.php b/core/lib/Drupal/Core/Ajax/AddJsCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..5d6f623c4f9404f8bdb3c70b53e9fd1fc01a41cc
--- /dev/null
+++ b/core/lib/Drupal/Core/Ajax/AddJsCommand.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Drupal\Core\Ajax;
+
+/**
+ * An AJAX command for adding JS to the page via AJAX.
+ *
+ * This command will make sure all the files are loaded before continuing
+ * executing the next AJAX command. This command is implemented by
+ * Drupal.AjaxCommands.prototype.add_js() defined in misc/ajax.js.
+ *
+ * @see misc/ajax.js
+ *
+ * @ingroup ajax
+ */
+class AddJsCommand implements CommandInterface {
+
+  /**
+   * An array containing attributes of the scripts to be added to the page.
+   *
+   * @var string[]
+   */
+  protected $scripts;
+
+  /**
+   * A CSS selector string.
+   *
+   * If the command is a response to a request from an #ajax form element then
+   * this value will default to 'body'.
+   *
+   * @var string
+   */
+  protected $selector;
+
+  /**
+   * Constructs an AddJsCommand.
+   *
+   * @param array $scripts
+   *   An array containing the attributes of the 'script' tags to be added to
+   *   the page. i.e. `['src' => 'someURL', 'defer' => TRUE]` becomes
+   *   `<script src="someURL" defer>`.
+   * @param string $selector
+   *   A CSS selector of the element where the script tags will be appended.
+   */
+  public function __construct(array $scripts, string $selector = 'body') {
+    $this->scripts = $scripts;
+    $this->selector = $selector;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function render() {
+    return [
+      'command' => 'add_js',
+      'selector' => $this->selector,
+      'data' => $this->scripts,
+    ];
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Ajax/AjaxResponseAttachmentsProcessor.php b/core/lib/Drupal/Core/Ajax/AjaxResponseAttachmentsProcessor.php
index 4f6acbf9664f8992239bfeb61bb77be374f3edd4..eaeed18ef2cac1b3992a92b97b4d6c8de3e23ef6 100644
--- a/core/lib/Drupal/Core/Ajax/AjaxResponseAttachmentsProcessor.php
+++ b/core/lib/Drupal/Core/Ajax/AjaxResponseAttachmentsProcessor.php
@@ -174,11 +174,11 @@ protected function buildAttachmentsCommands(AjaxResponse $response, Request $req
     }
     if ($js_assets_header) {
       $js_header_render_array = $this->jsCollectionRenderer->render($js_assets_header);
-      $resource_commands[] = new PrependCommand('head', $this->renderer->renderPlain($js_header_render_array));
+      $resource_commands[] = new AddJsCommand(array_column($js_header_render_array, '#attributes'), 'head');
     }
     if ($js_assets_footer) {
       $js_footer_render_array = $this->jsCollectionRenderer->render($js_assets_footer);
-      $resource_commands[] = new AppendCommand('body', $this->renderer->renderPlain($js_footer_render_array));
+      $resource_commands[] = new AddJsCommand(array_column($js_footer_render_array, '#attributes'));
     }
     foreach (array_reverse($resource_commands) as $resource_command) {
       $response->addCommand($resource_command, TRUE);
diff --git a/core/misc/ajax.es6.js b/core/misc/ajax.es6.js
index 08923ae52d04d7d9f567b67ecd3273546078d8d1..c8c8eb1cd39ebc1ceadf1e2dc7ab1a0c81481b13 100644
--- a/core/misc/ajax.es6.js
+++ b/core/misc/ajax.es6.js
@@ -11,7 +11,14 @@
  * included to provide Ajax capabilities.
  */
 
-(function ($, window, Drupal, drupalSettings, { isFocusable, tabbable }) {
+(function (
+  $,
+  window,
+  Drupal,
+  drupalSettings,
+  loadjs,
+  { isFocusable, tabbable },
+) {
   /**
    * Attaches the Ajax behavior to each Ajax form element.
    *
@@ -537,10 +544,23 @@
           }
         }
 
-        return ajax.success(response, status);
+        return (
+          // Ensure that the return of the success callback is a Promise.
+          // When the return is a Promise, using resolve will unwrap it, and
+          // when the return is not a Promise we make sure it can be used as
+          // one. This is useful for code that overrides the success method.
+          Promise.resolve(ajax.success(response, status))
+            // Ajaxing status is back to false when all the AJAX commands have
+            // finished executing.
+            .then(() => {
+              ajax.ajaxing = false;
+            })
+        );
       },
-      complete(xmlhttprequest, status) {
+      error(xmlhttprequest, status, error) {
         ajax.ajaxing = false;
+      },
+      complete(xmlhttprequest, status) {
         if (status === 'error' || status === 'parsererror') {
           return ajax.error(xmlhttprequest, ajax.url);
         }
@@ -950,6 +970,36 @@
     $('body').append(this.progress.element);
   };
 
+  /**
+   * Helper method to make sure commands are executed in sequence.
+   *
+   * @param {Array.<Drupal.AjaxCommands~commandDefinition>} response
+   *   Drupal Ajax response.
+   * @param {number} status
+   *   XMLHttpRequest status.
+   *
+   * @return {Promise}
+   *  The promise that will resolve once all commands have finished executing.
+   */
+  Drupal.Ajax.prototype.commandExecutionQueue = function (response, status) {
+    const ajaxCommands = this.commands;
+    return Object.keys(response || {}).reduce(
+      // Add all commands to a single execution queue.
+      (executionQueue, key) =>
+        executionQueue.then(() => {
+          const { command } = response[key];
+          if (command && ajaxCommands[command]) {
+            // When a command returns a promise, the remaining commands will not
+            // execute until that promise has been fulfilled. This is typically
+            // used to ensure JavaScript files added via the 'add_js' command
+            // have loaded before subsequent commands execute.
+            return ajaxCommands[command](this, response[key], status);
+          }
+        }),
+      Promise.resolve(),
+    );
+  };
+
   /**
    * Handler for the form redirection completion.
    *
@@ -957,6 +1007,9 @@
    *   Drupal Ajax response.
    * @param {number} status
    *   XMLHttpRequest status.
+   *
+   * @return {Promise}
+   * The promise that will resolve once all commands have finished executing.
    */
   Drupal.Ajax.prototype.success = function (response, status) {
     // Remove the progress element.
@@ -979,55 +1032,61 @@
 
     // Track if any command is altering the focus so we can avoid changing the
     // focus set by the Ajax command.
-    let focusChanged = false;
-    Object.keys(response || {}).forEach((i) => {
-      if (response[i].command && this.commands[response[i].command]) {
-        this.commands[response[i].command](this, response[i], status);
-        if (
-          (response[i].command === 'invoke' &&
-            response[i].method === 'focus') ||
-          response[i].command === 'focusFirst'
-        ) {
-          focusChanged = true;
-        }
-      }
+    const focusChanged = Object.keys(response || {}).some((key) => {
+      const { command, method } = response[key];
+      return (
+        command === 'focusFirst' || (command === 'invoke' && method === 'focus')
+      );
     });
 
-    // If the focus hasn't be changed by the ajax commands, try to refocus the
-    // triggering element or one of its parents if that element does not exist
-    // anymore.
-    if (
-      !focusChanged &&
-      this.element &&
-      !$(this.element).data('disable-refocus')
-    ) {
-      let target = false;
-
-      for (let n = elementParents.length - 1; !target && n >= 0; n--) {
-        target = document.querySelector(
-          `[data-drupal-selector="${elementParents[n].getAttribute(
-            'data-drupal-selector',
-          )}"]`,
-        );
-      }
-
-      if (target) {
-        $(target).trigger('focus');
-      }
-    }
-
-    // Reattach behaviors, if they were detached in beforeSerialize(). The
-    // attachBehaviors() called on the new content from processing the response
-    // commands is not sufficient, because behaviors from the entire form need
-    // to be reattached.
-    if (this.$form && document.body.contains(this.$form.get(0))) {
-      const settings = this.settings || drupalSettings;
-      Drupal.attachBehaviors(this.$form.get(0), settings);
-    }
-
-    // Remove any response-specific settings so they don't get used on the next
-    // call by mistake.
-    this.settings = null;
+    return (
+      this.commandExecutionQueue(response, status)
+        // If the focus hasn't been changed by the AJAX commands, try to refocus
+        // the triggering element or one of its parents if that element does not
+        // exist anymore.
+        .then(() => {
+          if (
+            !focusChanged &&
+            this.element &&
+            !$(this.element).data('disable-refocus')
+          ) {
+            let target = false;
+
+            for (let n = elementParents.length - 1; !target && n >= 0; n--) {
+              target = document.querySelector(
+                `[data-drupal-selector="${elementParents[n].getAttribute(
+                  'data-drupal-selector',
+                )}"]`,
+              );
+            }
+            if (target) {
+              $(target).trigger('focus');
+            }
+          }
+          // Reattach behaviors, if they were detached in beforeSerialize(). The
+          // attachBehaviors() called on the new content from processing the
+          // response commands is not sufficient, because behaviors from the
+          // entire form need to be reattached.
+          if (this.$form && document.body.contains(this.$form.get(0))) {
+            const settings = this.settings || drupalSettings;
+            Drupal.attachBehaviors(this.$form.get(0), settings);
+          }
+          // Remove any response-specific settings so they don't get used on the
+          // next call by mistake.
+          this.settings = null;
+        })
+        .catch((error) =>
+          // eslint-disable-next-line no-console
+          console.error(
+            Drupal.t(
+              'An error occurred during the execution of the Ajax response: !error',
+              {
+                '!error': error,
+              },
+            ),
+          ),
+        )
+    );
   };
 
   /**
@@ -1610,5 +1669,72 @@
       }
       messages.add(response.message, response.messageOptions);
     },
+
+    /**
+     * Command to add JS.
+     *
+     * @param {Drupal.Ajax} [ajax]
+     *   {@link Drupal.Ajax} object created by {@link Drupal.ajax}.
+     * @param {object} response
+     *   The response from the Ajax request.
+     * @param {Array} response.data
+     *   An array of objects of script attributes.
+     * @param {number} [status]
+     *   The XMLHttpRequest status.
+     */
+    add_js(ajax, response, status) {
+      const parentEl = document.querySelector(response.selector || 'body');
+      const settings = ajax.settings || drupalSettings;
+      const allUniqueBundleIds = response.data.map((script) => {
+        // loadjs requires a unique ID, and an AJAX instance's `instanceIndex`
+        // is guaranteed to be unique.
+        // @see Drupal.behaviors.AJAX.detach
+        const uniqueBundleId = script.src + ajax.instanceIndex;
+        loadjs(script.src, uniqueBundleId, {
+          // The default loadjs behavior is to load script with async, in Drupal
+          // we need to explicitly tell scripts to load async, this is set in
+          // the before callback below if necessary.
+          async: false,
+          before(path, scriptEl) {
+            // This allows all attributes to be added, like defer, async and
+            // crossorigin.
+            Object.keys(script).forEach((attributeKey) => {
+              scriptEl.setAttribute(attributeKey, script[attributeKey]);
+            });
+
+            // By default, loadjs appends the script to the head. When scripts
+            // are loaded via AJAX, their location has no impact on
+            // functionality. But, since non-AJAX loaded scripts can choose
+            // their parent element, we provide that option here for the sake of
+            // consistency.
+            parentEl.appendChild(scriptEl);
+            // Return false to bypass loadjs' default DOM insertion mechanism.
+            return false;
+          },
+        });
+        return uniqueBundleId;
+      });
+      // Returns the promise so that the next AJAX command waits on the
+      // completion of this one to execute, ensuring the JS is loaded before
+      // executing.
+      return new Promise((resolve, reject) => {
+        loadjs.ready(allUniqueBundleIds, {
+          success() {
+            Drupal.attachBehaviors(parentEl, settings);
+            // All JS files were loaded and new and old behaviors have
+            // been attached. Resolve the promise and let the remaining
+            // commands execute.
+            resolve();
+          },
+          error(depsNotFound) {
+            const message = Drupal.t(
+              `The following files could not be loaded: @dependencies`,
+              { '@dependencies': depsNotFound.join(', ') },
+            );
+            reject(message);
+          },
+        });
+      });
+    },
   };
-})(jQuery, window, Drupal, drupalSettings, window.tabbable);
+})(jQuery, window, Drupal, drupalSettings, loadjs, window.tabbable);
diff --git a/core/misc/ajax.js b/core/misc/ajax.js
index b2f572c9d592236696b0b88e8c415d2f61696370..75c97f62abe894cbc8eef437c9d07539ce31c653 100644
--- a/core/misc/ajax.js
+++ b/core/misc/ajax.js
@@ -5,7 +5,7 @@
 * @preserve
 **/
 
-(function ($, window, Drupal, drupalSettings, _ref) {
+(function ($, window, Drupal, drupalSettings, loadjs, _ref) {
   let {
     isFocusable,
     tabbable
@@ -229,12 +229,16 @@
           }
         }
 
-        return ajax.success(response, status);
+        return Promise.resolve(ajax.success(response, status)).then(() => {
+          ajax.ajaxing = false;
+        });
       },
 
-      complete(xmlhttprequest, status) {
+      error(xmlhttprequest, status, error) {
         ajax.ajaxing = false;
+      },
 
+      complete(xmlhttprequest, status) {
         if (status === 'error' || status === 'parsererror') {
           return ajax.error(xmlhttprequest, ajax.url);
         }
@@ -414,6 +418,19 @@
     $('body').append(this.progress.element);
   };
 
+  Drupal.Ajax.prototype.commandExecutionQueue = function (response, status) {
+    const ajaxCommands = this.commands;
+    return Object.keys(response || {}).reduce((executionQueue, key) => executionQueue.then(() => {
+      const {
+        command
+      } = response[key];
+
+      if (command && ajaxCommands[command]) {
+        return ajaxCommands[command](this, response[key], status);
+      }
+    }), Promise.resolve());
+  };
+
   Drupal.Ajax.prototype.success = function (response, status) {
     if (this.progress.element) {
       $(this.progress.element).remove();
@@ -425,35 +442,35 @@
 
     $(this.element).prop('disabled', false);
     const elementParents = $(this.element).parents('[data-drupal-selector]').addBack().toArray();
-    let focusChanged = false;
-    Object.keys(response || {}).forEach(i => {
-      if (response[i].command && this.commands[response[i].command]) {
-        this.commands[response[i].command](this, response[i], status);
-
-        if (response[i].command === 'invoke' && response[i].method === 'focus' || response[i].command === 'focusFirst') {
-          focusChanged = true;
-        }
-      }
+    const focusChanged = Object.keys(response || {}).some(key => {
+      const {
+        command,
+        method
+      } = response[key];
+      return command === 'focusFirst' || command === 'invoke' && method === 'focus';
     });
+    return this.commandExecutionQueue(response, status).then(() => {
+      if (!focusChanged && this.element && !$(this.element).data('disable-refocus')) {
+        let target = false;
 
-    if (!focusChanged && this.element && !$(this.element).data('disable-refocus')) {
-      let target = false;
+        for (let n = elementParents.length - 1; !target && n >= 0; n--) {
+          target = document.querySelector(`[data-drupal-selector="${elementParents[n].getAttribute('data-drupal-selector')}"]`);
+        }
 
-      for (let n = elementParents.length - 1; !target && n >= 0; n--) {
-        target = document.querySelector(`[data-drupal-selector="${elementParents[n].getAttribute('data-drupal-selector')}"]`);
+        if (target) {
+          $(target).trigger('focus');
+        }
       }
 
-      if (target) {
-        $(target).trigger('focus');
+      if (this.$form && document.body.contains(this.$form.get(0))) {
+        const settings = this.settings || drupalSettings;
+        Drupal.attachBehaviors(this.$form.get(0), settings);
       }
-    }
 
-    if (this.$form && document.body.contains(this.$form.get(0))) {
-      const settings = this.settings || drupalSettings;
-      Drupal.attachBehaviors(this.$form.get(0), settings);
-    }
-
-    this.settings = null;
+      this.settings = null;
+    }).catch(error => console.error(Drupal.t('An error occurred during the execution of the Ajax response: !error', {
+      '!error': error
+    })));
   };
 
   Drupal.Ajax.prototype.getEffect = function (response) {
@@ -664,7 +681,44 @@
       }
 
       messages.add(response.message, response.messageOptions);
+    },
+
+    add_js(ajax, response, status) {
+      const parentEl = document.querySelector(response.selector || 'body');
+      const settings = ajax.settings || drupalSettings;
+      const allUniqueBundleIds = response.data.map(script => {
+        const uniqueBundleId = script.src + ajax.instanceIndex;
+        loadjs(script.src, uniqueBundleId, {
+          async: false,
+
+          before(path, scriptEl) {
+            Object.keys(script).forEach(attributeKey => {
+              scriptEl.setAttribute(attributeKey, script[attributeKey]);
+            });
+            parentEl.appendChild(scriptEl);
+            return false;
+          }
+
+        });
+        return uniqueBundleId;
+      });
+      return new Promise((resolve, reject) => {
+        loadjs.ready(allUniqueBundleIds, {
+          success() {
+            Drupal.attachBehaviors(parentEl, settings);
+            resolve();
+          },
+
+          error(depsNotFound) {
+            const message = Drupal.t(`The following files could not be loaded: @dependencies`, {
+              '@dependencies': depsNotFound.join(', ')
+            });
+            reject(message);
+          }
+
+        });
+      });
     }
 
   };
-})(jQuery, window, Drupal, drupalSettings, window.tabbable);
\ No newline at end of file
+})(jQuery, window, Drupal, drupalSettings, loadjs, window.tabbable);
\ No newline at end of file
diff --git a/core/modules/ckeditor/tests/src/FunctionalJavascript/BigPipeRegressionTest.php b/core/modules/ckeditor/tests/src/FunctionalJavascript/BigPipeRegressionTest.php
index da99c5c2ab0cea4e8d59f3fd239c5db70fd3b8ea..188aef4ff4853b666ccd7f8f5a65cba1e78fddcd 100644
--- a/core/modules/ckeditor/tests/src/FunctionalJavascript/BigPipeRegressionTest.php
+++ b/core/modules/ckeditor/tests/src/FunctionalJavascript/BigPipeRegressionTest.php
@@ -110,7 +110,7 @@ public function testCommentForm_2698811() {
     // Confirm that CKEditor loaded.
     $javascript = <<<JS
     (function(){
-      return Object.keys(CKEDITOR.instances).length > 0;
+      return window.CKEDITOR && Object.keys(CKEDITOR.instances).length > 0;
     }())
 JS;
     $this->assertJsCondition($javascript);
diff --git a/core/modules/media_library/js/media_library.ui.es6.js b/core/modules/media_library/js/media_library.ui.es6.js
index 95bc01ac3fe51236569a605eaf99a9727533c6c1..569b4431211038dcca7b7da386bda37dc3519500 100644
--- a/core/modules/media_library/js/media_library.ui.es6.js
+++ b/core/modules/media_library/js/media_library.ui.es6.js
@@ -83,37 +83,21 @@
           // Override the AJAX success callback to shift focus to the media
           // library content.
           ajaxObject.success = function (response, status) {
-            // Remove the progress element.
-            if (this.progress.element) {
-              $(this.progress.element).remove();
-            }
-            if (this.progress.object) {
-              this.progress.object.stopMonitoring();
-            }
-            $(this.element).prop('disabled', false);
-
-            // Execute the AJAX commands.
-            Object.keys(response || {}).forEach((i) => {
-              if (response[i].command && this.commands[response[i].command]) {
-                this.commands[response[i].command](this, response[i], status);
+            return Promise.resolve(
+              Drupal.Ajax.prototype.success.call(ajaxObject, response, status),
+            ).then(() => {
+              // Set focus to the first tabbable element in the media library
+              // content.
+              const mediaLibraryContent = document.getElementById(
+                'media-library-content',
+              );
+              if (mediaLibraryContent) {
+                const tabbableContent = tabbable(mediaLibraryContent);
+                if (tabbableContent.length) {
+                  tabbableContent[0].focus();
+                }
               }
             });
-
-            // Set focus to the first tabbable element in the media library
-            // content.
-            const mediaLibraryContent = document.getElementById(
-              'media-library-content',
-            );
-            if (mediaLibraryContent) {
-              const tabbableContent = tabbable(mediaLibraryContent);
-              if (tabbableContent.length) {
-                tabbableContent[0].focus();
-              }
-            }
-
-            // Remove any response-specific settings so they don't get used on
-            // the next call by mistake.
-            this.settings = null;
           };
           ajaxObject.execute();
 
diff --git a/core/modules/media_library/js/media_library.ui.js b/core/modules/media_library/js/media_library.ui.js
index 96ed3914fae333d66d7a928a740b63b7eb84f7bc..b7d5c61386e0ed86cb5e3ecae20839793d447a88 100644
--- a/core/modules/media_library/js/media_library.ui.js
+++ b/core/modules/media_library/js/media_library.ui.js
@@ -42,31 +42,17 @@
         });
 
         ajaxObject.success = function (response, status) {
-          if (this.progress.element) {
-            $(this.progress.element).remove();
-          }
+          return Promise.resolve(Drupal.Ajax.prototype.success.call(ajaxObject, response, status)).then(() => {
+            const mediaLibraryContent = document.getElementById('media-library-content');
 
-          if (this.progress.object) {
-            this.progress.object.stopMonitoring();
-          }
+            if (mediaLibraryContent) {
+              const tabbableContent = tabbable(mediaLibraryContent);
 
-          $(this.element).prop('disabled', false);
-          Object.keys(response || {}).forEach(i => {
-            if (response[i].command && this.commands[response[i].command]) {
-              this.commands[response[i].command](this, response[i], status);
+              if (tabbableContent.length) {
+                tabbableContent[0].focus();
+              }
             }
           });
-          const mediaLibraryContent = document.getElementById('media-library-content');
-
-          if (mediaLibraryContent) {
-            const tabbableContent = tabbable(mediaLibraryContent);
-
-            if (tabbableContent.length) {
-              tabbableContent[0].focus();
-            }
-          }
-
-          this.settings = null;
         };
 
         ajaxObject.execute();
diff --git a/core/modules/quickedit/js/quickedit.es6.js b/core/modules/quickedit/js/quickedit.es6.js
index fe0dc6a8b6691071ad77bfca977b6c71a34d8f44..d399a1323635a5c79cb8193d2e3bf20055e61d79 100644
--- a/core/modules/quickedit/js/quickedit.es6.js
+++ b/core/modules/quickedit/js/quickedit.es6.js
@@ -180,16 +180,9 @@
       url: Drupal.url('quickedit/attachments'),
       submit: { 'editors[]': missingEditors },
     });
-    // Implement a scoped insert AJAX command: calls the callback after all AJAX
-    // command functions have been executed (hence the deferred calling).
-    const realInsert = Drupal.AjaxCommands.prototype.insert;
-    loadEditorsAjax.commands.insert = function (ajax, response, status) {
-      _.defer(callback);
-      realInsert(ajax, response, status);
-    };
     // Trigger the AJAX request, which will should return AJAX commands to
     // insert any missing attachments.
-    loadEditorsAjax.execute();
+    loadEditorsAjax.execute().then(callback);
   }
 
   /**
diff --git a/core/modules/quickedit/js/quickedit.js b/core/modules/quickedit/js/quickedit.js
index 5b7ef22fb998efabfe90b34a6ed069576aca306a..91d07d9acca7ff6b4d5d637c8dedd2c2915b15cb 100644
--- a/core/modules/quickedit/js/quickedit.js
+++ b/core/modules/quickedit/js/quickedit.js
@@ -82,15 +82,7 @@
         'editors[]': missingEditors
       }
     });
-    const realInsert = Drupal.AjaxCommands.prototype.insert;
-
-    loadEditorsAjax.commands.insert = function (ajax, response, status) {
-      _.defer(callback);
-
-      realInsert(ajax, response, status);
-    };
-
-    loadEditorsAjax.execute();
+    loadEditorsAjax.execute().then(callback);
   }
 
   function initializeEntityContextualLink(contextualLink) {
diff --git a/core/modules/system/tests/modules/ajax_test/ajax_test.libraries.yml b/core/modules/system/tests/modules/ajax_test/ajax_test.libraries.yml
index 9d390854bba261ee2619f096c7d04b5b12922cef..eb4ae93d92e463eebdb6350d5d614a560f6dffa2 100644
--- a/core/modules/system/tests/modules/ajax_test/ajax_test.libraries.yml
+++ b/core/modules/system/tests/modules/ajax_test/ajax_test.libraries.yml
@@ -33,3 +33,12 @@ focus.first:
   dependencies:
     - core/drupal
     - core/once
+
+command_promise:
+  version: VERSION
+  js:
+    js/command_promise-ajax.js: {}
+  dependencies:
+    - core/jquery
+    - core/drupal
+    - core/drupal.ajax
diff --git a/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml b/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml
index 01bf512adb96d84a6b0a6cb745deb9cad36baf22..0ee765e664bc75f85d398e8080d44a1c869f1040 100644
--- a/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml
+++ b/core/modules/system/tests/modules/ajax_test/ajax_test.routing.yml
@@ -93,3 +93,11 @@ ajax_test.focus_first_form:
     _form: '\Drupal\ajax_test\Form\AjaxTestFocusFirstForm'
   requirements:
     _access: 'TRUE'
+
+ajax_test.promise:
+  path: '/ajax-test/promise-form'
+  defaults:
+    _title: 'Ajax Form Command Promise'
+    _form: '\Drupal\ajax_test\Form\AjaxTestFormPromise'
+  requirements:
+    _access: 'TRUE'
diff --git a/core/modules/system/tests/modules/ajax_test/js/command_promise-ajax.es6.js b/core/modules/system/tests/modules/ajax_test/js/command_promise-ajax.es6.js
new file mode 100644
index 0000000000000000000000000000000000000000..8f4e40d85ba5f2127ed00163ddde1c7e62ddbf69
--- /dev/null
+++ b/core/modules/system/tests/modules/ajax_test/js/command_promise-ajax.es6.js
@@ -0,0 +1,31 @@
+/**
+ * @file
+ *  Testing behavior for the add_js command.
+ */
+
+(($, Drupal) => {
+  /**
+   * Test Ajax execution Order.
+   *
+   * @param {Drupal.Ajax} [ajax]
+   *   {@link Drupal.Ajax} object created by {@link Drupal.Ajax}.
+   * @param {object} response
+   *   The response from the Ajax request.
+   * @param {string} response.selector
+   *   A jQuery selector string.
+   *
+   * @return {Promise}
+   *  The promise that will resolve once this command has finished executing.
+   */
+  Drupal.AjaxCommands.prototype.ajaxCommandReturnPromise = function (
+    ajax,
+    response,
+  ) {
+    return new Promise((resolve, reject) => {
+      setTimeout(() => {
+        this.insert(ajax, response);
+        resolve();
+      }, Math.random() * 500);
+    });
+  };
+})(jQuery, Drupal);
diff --git a/core/modules/system/tests/modules/ajax_test/js/command_promise-ajax.js b/core/modules/system/tests/modules/ajax_test/js/command_promise-ajax.js
new file mode 100644
index 0000000000000000000000000000000000000000..162ac73f3c65fb4c6ad07f4d078e0069b93dd431
--- /dev/null
+++ b/core/modules/system/tests/modules/ajax_test/js/command_promise-ajax.js
@@ -0,0 +1,17 @@
+/**
+* DO NOT EDIT THIS FILE.
+* See the following change record for more information,
+* https://www.drupal.org/node/2815083
+* @preserve
+**/
+
+(($, Drupal) => {
+  Drupal.AjaxCommands.prototype.ajaxCommandReturnPromise = function (ajax, response) {
+    return new Promise((resolve, reject) => {
+      setTimeout(() => {
+        this.insert(ajax, response);
+        resolve();
+      }, Math.random() * 500);
+    });
+  };
+})(jQuery, Drupal);
\ No newline at end of file
diff --git a/core/modules/system/tests/modules/ajax_test/src/Ajax/AjaxTestCommandReturnPromise.php b/core/modules/system/tests/modules/ajax_test/src/Ajax/AjaxTestCommandReturnPromise.php
new file mode 100644
index 0000000000000000000000000000000000000000..fbb0dd94b7978d1ef158e8ff51b434e562ece331
--- /dev/null
+++ b/core/modules/system/tests/modules/ajax_test/src/Ajax/AjaxTestCommandReturnPromise.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Drupal\ajax_test\Ajax;
+
+use Drupal\Core\Ajax\AppendCommand;
+
+/**
+ * Test Ajax command.
+ */
+class AjaxTestCommandReturnPromise extends AppendCommand {
+
+  /**
+   * Implements Drupal\Core\Ajax\CommandInterface:render().
+   */
+  public function render() {
+
+    return [
+      'command' => 'ajaxCommandReturnPromise',
+      'method' => 'append',
+      'selector' => $this->selector,
+      'data' => $this->getRenderedContent(),
+      'settings' => $this->settings,
+    ];
+  }
+
+}
diff --git a/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestFormPromise.php b/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestFormPromise.php
new file mode 100644
index 0000000000000000000000000000000000000000..038b0b5bcd7b140b8b2bc26eb68c1ad64a61403b
--- /dev/null
+++ b/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestFormPromise.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Drupal\ajax_test\Form;
+
+use Drupal\ajax_test\Ajax\AjaxTestCommandReturnPromise;
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Ajax\AppendCommand;
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Test form for ajax_test_form_promise.
+ *
+ * @internal
+ */
+class AjaxTestFormPromise extends FormBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'ajax_test_form_promise';
+  }
+
+  /**
+   * Form for testing the addition of various types of elements via Ajax.
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $form['#attached']['library'][] = 'ajax_test/command_promise';
+    $form['custom']['#prefix'] = '<div id="ajax_test_form_promise_wrapper">';
+    $form['custom']['#suffix'] = '</div>';
+
+    // Button to test for the execution order of Ajax commands.
+    $form['test_execution_order_button'] = [
+      '#type' => 'submit',
+      '#value' => $this->t('Execute commands button'),
+      '#button_type' => 'primary',
+      '#ajax' => [
+        'callback' => [static::class, 'executeCommands'],
+        'progress' => [
+          'type' => 'throbber',
+          'message' => NULL,
+        ],
+        'wrapper' => 'ajax_test_form_promise_wrapper',
+      ],
+    ];
+    return $form;
+  }
+
+  /**
+   * Ajax callback for the "Execute commands button" button.
+   */
+  public static function executeCommands(array $form, FormStateInterface $form_state) {
+    $selector = '#ajax_test_form_promise_wrapper';
+    $response = new AjaxResponse();
+
+    $response->addCommand(new AppendCommand($selector, '1'));
+    $response->addCommand(new AjaxTestCommandReturnPromise($selector, '2'));
+    $response->addCommand(new AppendCommand($selector, '3'));
+    $response->addCommand(new AppendCommand($selector, '4'));
+    $response->addCommand(new AjaxTestCommandReturnPromise($selector, '5'));
+
+    return $response;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    // An empty implementation, as we never submit the actual form.
+  }
+
+}
diff --git a/core/modules/system/tests/src/Functional/Ajax/FrameworkTest.php b/core/modules/system/tests/src/Functional/Ajax/FrameworkTest.php
index de95fb5761684f511f8a9be6d5bf5940db277eb9..669037f82d05f0c86c0f6ad2228cd1311e183aa8 100644
--- a/core/modules/system/tests/src/Functional/Ajax/FrameworkTest.php
+++ b/core/modules/system/tests/src/Functional/Ajax/FrameworkTest.php
@@ -4,10 +4,9 @@
 
 use Drupal\Component\Serialization\Json;
 use Drupal\Core\Ajax\AddCssCommand;
+use Drupal\Core\Ajax\AddJsCommand;
 use Drupal\Core\Ajax\AlertCommand;
-use Drupal\Core\Ajax\AppendCommand;
 use Drupal\Core\Ajax\HtmlCommand;
-use Drupal\Core\Ajax\PrependCommand;
 use Drupal\Core\Ajax\SettingsCommand;
 use Drupal\Core\Asset\AttachedAssets;
 use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
@@ -61,8 +60,8 @@ public function testOrder() {
     [$js_assets_header, $js_assets_footer] = $asset_resolver->getJsAssets($assets, FALSE);
     $js_header_render_array = $js_collection_renderer->render($js_assets_header);
     $js_footer_render_array = $js_collection_renderer->render($js_assets_footer);
-    $expected_commands[2] = new PrependCommand('head', $js_header_render_array);
-    $expected_commands[3] = new AppendCommand('body', $js_footer_render_array);
+    $expected_commands[2] = new AddJsCommand(array_column($js_header_render_array, '#attributes'), 'head');
+    $expected_commands[3] = new AddJsCommand(array_column($js_footer_render_array, '#attributes'));
     $expected_commands[4] = new HtmlCommand('body', 'Hello, world!');
 
     // Verify AJAX command order — this should always be the order:
diff --git a/core/tests/Drupal/Nightwatch/Tests/ajaxExecutionOrderTest.js b/core/tests/Drupal/Nightwatch/Tests/ajaxExecutionOrderTest.js
new file mode 100644
index 0000000000000000000000000000000000000000..da54f6015a6c5a52527433f761f58d498ca3fd4e
--- /dev/null
+++ b/core/tests/Drupal/Nightwatch/Tests/ajaxExecutionOrderTest.js
@@ -0,0 +1,34 @@
+module.exports = {
+  '@tags': ['core', 'ajax'],
+  before(browser) {
+    browser.drupalInstall().drupalLoginAsAdmin(() => {
+      browser
+        .drupalRelativeURL('/admin/modules')
+        .setValue('input[type="search"]', 'Ajax test')
+        .waitForElementVisible('input[name="modules[ajax_test][enable]"]', 1000)
+        .click('input[name="modules[ajax_test][enable]"]')
+        .submitForm('input[type="submit"]') // Submit module form.
+        .waitForElementVisible(
+          '.system-modules-confirm-form input[value="Continue"]',
+          2000,
+        )
+        .submitForm('input[value="Continue"]') // Confirm installation of dependencies.
+        .waitForElementVisible('.system-modules', 10000);
+    });
+  },
+  after(browser) {
+    browser.drupalUninstall();
+  },
+  'Test Execution Order': (browser) => {
+    browser
+      .drupalRelativeURL('/ajax-test/promise-form')
+      .waitForElementVisible('body', 1000)
+      .click('[data-drupal-selector="edit-test-execution-order-button"]')
+      .waitForElementVisible('#ajax_test_form_promise_wrapper', 1000)
+      .assert.containsText(
+        '#ajax_test_form_promise_wrapper',
+        '12345',
+        'Ajax commands execution order confirmed',
+      );
+  },
+};