diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
index cd5ce3e2976d46aabe414637c65c14f69ff5c223..c66a1eecfc5084e4cc0a82d2cdb2ba58078fb0d0 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -12,6 +12,7 @@ import {
   isSearchFiltered,
 } from 'ee_else_ce/runner/runner_search_utils';
 import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.graphql';
+import allRunnersCountQuery from 'ee_else_ce/runner/graphql/list/all_runners_count.query.graphql';
 
 import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
 import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
@@ -138,11 +139,15 @@ export default {
     onToggledPaused() {
       // When a runner becomes Paused, the tab count can
       // become stale, refetch outdated counts.
-      this.$refs['runner-type-tabs'].refetch();
+      this.refetchCounts();
     },
     onDeleted({ message }) {
+      this.refetchCounts();
       this.$root.$toast?.show(message);
     },
+    refetchCounts() {
+      this.$apollo.getClient().refetchQueries({ include: [allRunnersCountQuery] });
+    },
     reportToSentry(error) {
       captureException({ error, component: this.$options.name });
     },
@@ -166,7 +171,6 @@ export default {
       class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0"
     >
       <runner-type-tabs
-        ref="runner-type-tabs"
         v-model="search"
         :count-scope="$options.INSTANCE_TYPE"
         :count-variables="countVariables"
@@ -199,7 +203,7 @@ export default {
       :filtered-svg-path="emptyStateFilteredSvgPath"
     />
     <template v-else>
-      <runner-bulk-delete v-if="isBulkDeleteEnabled" />
+      <runner-bulk-delete v-if="isBulkDeleteEnabled" @deleted="onDeleted" />
       <runner-list
         :runners="runners.items"
         :loading="runnersLoading"
diff --git a/app/assets/javascripts/runner/components/runner_bulk_delete.vue b/app/assets/javascripts/runner/components/runner_bulk_delete.vue
index 6f065c38b255711249aece5023183f438f8b1abb..fd55487c4586a465f5319088ed4f89f32a8329ee 100644
--- a/app/assets/javascripts/runner/components/runner_bulk_delete.vue
+++ b/app/assets/javascripts/runner/components/runner_bulk_delete.vue
@@ -1,13 +1,15 @@
 <script>
-import { GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui';
-import { n__, sprintf } from '~/locale';
-import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
-import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import { GlButton, GlModalDirective, GlModal, GlSprintf } from '@gitlab/ui';
+import { createAlert } from '~/flash';
+import { __, s__, n__, sprintf } from '~/locale';
 import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql';
+import BulkRunnerDelete from '../graphql/list/bulk_runner_delete.mutation.graphql';
+import { RUNNER_TYPENAME } from '../constants';
 
 export default {
   components: {
     GlButton,
+    GlModal,
     GlSprintf,
   },
   directives: {
@@ -16,6 +18,7 @@ export default {
   inject: ['localMutations'],
   data() {
     return {
+      isDeleting: false,
       checkedRunnerIds: [],
     };
   },
@@ -43,48 +46,103 @@ export default {
     modalTitle() {
       return n__('Runners|Delete %d runner', 'Runners|Delete %d runners', this.checkedCount);
     },
-    modalHtmlMessage() {
+    modalActionPrimary() {
+      return {
+        text: n__(
+          'Runners|Permanently delete %d runner',
+          'Runners|Permanently delete %d runners',
+          this.checkedCount,
+        ),
+        attributes: {
+          loading: this.isDeleting,
+          variant: 'danger',
+        },
+      };
+    },
+    modalActionCancel() {
+      return {
+        text: __('Cancel'),
+        attributes: {
+          loading: this.isDeleting,
+        },
+      };
+    },
+    modalMessage() {
       return sprintf(
         n__(
           'Runners|%{strongStart}%{count}%{strongEnd} runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?',
           'Runners|%{strongStart}%{count}%{strongEnd} runners will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?',
           this.checkedCount,
         ),
-        {
-          strongStart: '<strong>',
-          strongEnd: '</strong>',
-          count: this.checkedCount,
-        },
-        false,
+        { count: this.checkedCount },
       );
     },
-    primaryBtnText() {
+  },
+  methods: {
+    toastConfirmationMessage(deletedCount) {
       return n__(
-        'Runners|Permanently delete %d runner',
-        'Runners|Permanently delete %d runners',
-        this.checkedCount,
+        'Runners|%d selected runner deleted',
+        'Runners|%d selected runners deleted',
+        deletedCount,
       );
     },
-  },
-  methods: {
     onClearChecked() {
       this.localMutations.clearChecked();
     },
-    onClickDelete: ignoreWhilePending(async function onClickDelete() {
-      const confirmed = await confirmAction(null, {
-        title: this.modalTitle,
-        modalHtmlMessage: this.modalHtmlMessage,
-        primaryBtnVariant: 'danger',
-        primaryBtnText: this.primaryBtnText,
-      });
+    async onConfirmDelete(e) {
+      this.isDeleting = true;
+      e.preventDefault(); // don't close modal until deletion is complete
+
+      try {
+        await this.$apollo.mutate({
+          mutation: BulkRunnerDelete,
+          variables: {
+            input: {
+              ids: this.checkedRunnerIds,
+            },
+          },
+          update: (cache, { data }) => {
+            const { errors, deletedIds } = data.bulkRunnerDelete;
+
+            if (errors?.length) {
+              this.onError(new Error(errors.join(' ')));
+              this.$refs.modal.hide();
+              return;
+            }
+
+            this.$emit('deleted', {
+              message: this.toastConfirmationMessage(deletedIds.length),
+            });
+
+            // Clean up
 
-      if (confirmed) {
-        // TODO Call $apollo.mutate with list of runner
-        // ids in `this.checkedRunnerIds`.
-        // See https://gitlab.com/gitlab-org/gitlab/-/issues/339525/
+            // Remove deleted runners from the cache
+            deletedIds.forEach((id) => {
+              const cacheId = cache.identify({ __typename: RUNNER_TYPENAME, id });
+              cache.evict({ id: cacheId });
+            });
+            cache.gc();
+
+            this.$refs.modal.hide();
+          },
+        });
+      } catch (error) {
+        this.onError(error);
+      } finally {
+        this.isDeleting = false;
       }
-    }),
+    },
+    onError(error) {
+      createAlert({
+        message: s__(
+          'Runners|Something went wrong while deleting. Please refresh the page to try again.',
+        ),
+        captureError: true,
+        error,
+      });
+    },
   },
+  BULK_DELETE_MODAL_ID: 'bulk-delete-modal',
 };
 </script>
 
@@ -102,10 +160,25 @@ export default {
         <gl-button variant="default" @click="onClearChecked">{{
           s__('Runners|Clear selection')
         }}</gl-button>
-        <gl-button variant="danger" @click="onClickDelete">{{
+        <gl-button v-gl-modal="$options.BULK_DELETE_MODAL_ID" variant="danger">{{
           s__('Runners|Delete selected')
         }}</gl-button>
       </div>
     </div>
+    <gl-modal
+      ref="modal"
+      size="sm"
+      :modal-id="$options.BULK_DELETE_MODAL_ID"
+      :title="modalTitle"
+      :action-primary="modalActionPrimary"
+      :action-cancel="modalActionCancel"
+      @primary="onConfirmDelete"
+    >
+      <gl-sprintf :message="modalMessage">
+        <template #strong="{ content }">
+          <strong>{{ content }}</strong>
+        </template>
+      </gl-sprintf>
+    </gl-modal>
   </div>
 </template>
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index 57cc9a645fad56813f74a64120eb8e9cb0c5e8f0..ed1afcbf691d92801702530181114d15209532d5 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -1,5 +1,7 @@
 import { __, s__ } from '~/locale';
 
+export const RUNNER_TYPENAME = 'CiRunner'; // __typename
+
 export const RUNNER_PAGE_SIZE = 20;
 export const RUNNER_JOB_COUNT_LIMIT = 1000;
 
diff --git a/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql b/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..b73c016b1decb56a51365370ebf4787e2d95d423
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql
@@ -0,0 +1,6 @@
+mutation bulkRunnerDelete($input: BulkRunnerDeleteInput!) {
+  bulkRunnerDelete(input: $input) {
+    deletedIds
+    errors
+  }
+}
diff --git a/app/assets/javascripts/runner/graphql/list/local_state.js b/app/assets/javascripts/runner/graphql/list/local_state.js
index e87bc72c86a86ca952a192e01a6a9175d907b2eb..ce6d125311ce552af5a3aada7e62d8f2f2766c64 100644
--- a/app/assets/javascripts/runner/graphql/list/local_state.js
+++ b/app/assets/javascripts/runner/graphql/list/local_state.js
@@ -1,4 +1,5 @@
 import { makeVar } from '@apollo/client/core';
+import { RUNNER_TYPENAME } from '../../constants';
 import typeDefs from './typedefs.graphql';
 
 /**
@@ -33,10 +34,16 @@ export const createLocalState = () => {
     typePolicies: {
       Query: {
         fields: {
-          checkedRunnerIds() {
+          checkedRunnerIds(_, { canRead, toReference }) {
             return Object.entries(checkedRunnerIdsVar())
+              .filter(([id]) => {
+                // Some runners may be deleted by the user separately.
+                // Skip dangling references, those not in the cache.
+                // See: https://www.apollographql.com/docs/react/caching/garbage-collection/#dangling-references
+                return canRead(toReference({ __typename: RUNNER_TYPENAME, id }));
+              })
               .filter(([, isChecked]) => isChecked)
-              .map(([key]) => key);
+              .map(([id]) => id);
           },
         },
       },
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 8a3e4220fe8ab4fb17520a088e2b4ff89d8c9631..370df98cc71dc1a45f31f44cab1e6abcd7a6534e 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -33606,6 +33606,11 @@ msgstr ""
 msgid "Runners page."
 msgstr ""
 
+msgid "Runners|%d selected runner deleted"
+msgid_plural "Runners|%d selected runners deleted"
+msgstr[0] ""
+msgstr[1] ""
+
 msgid "Runners|%{percentage} spot."
 msgstr ""
 
@@ -33965,6 +33970,9 @@ msgstr ""
 msgid "Runners|Show runner installation instructions"
 msgstr ""
 
+msgid "Runners|Something went wrong while deleting. Please refresh the page to try again."
+msgstr ""
+
 msgid "Runners|Something went wrong while fetching runner data."
 msgstr ""
 
diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
index 531c60c9c3e05a53180e3caaf6201412018f6ee3..f8f834c7ad4dd691236fb31f19802a65435d3777 100644
--- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
+++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
@@ -19,6 +19,7 @@ import { createLocalState } from '~/runner/graphql/list/local_state';
 import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue';
 import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
 import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
+import RunnerBulkDelete from '~/runner/components/runner_bulk_delete.vue';
 import RunnerList from '~/runner/components/runner_list.vue';
 import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue';
 import RunnerStats from '~/runner/components/stat/runner_stats.vue';
@@ -36,8 +37,6 @@ import {
   PARAM_KEY_STATUS,
   PARAM_KEY_TAG,
   STATUS_ONLINE,
-  STATUS_OFFLINE,
-  STATUS_STALE,
   RUNNER_PAGE_SIZE,
 } from '~/runner/constants';
 import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.graphql';
@@ -72,15 +71,19 @@ jest.mock('~/lib/utils/url_utility', () => ({
 Vue.use(VueApollo);
 Vue.use(GlToast);
 
+const COUNT_QUERIES = 7; // 4 tabs + 3 status queries
+
 describe('AdminRunnersApp', () => {
   let wrapper;
   let cacheConfig;
   let localMutations;
+  let showToast;
 
   const findRunnerStats = () => wrapper.findComponent(RunnerStats);
   const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell);
   const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown);
   const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs);
+  const findRunnerBulkDelete = () => wrapper.findComponent(RunnerBulkDelete);
   const findRunnerList = () => wrapper.findComponent(RunnerList);
   const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState);
   const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
@@ -117,6 +120,8 @@ describe('AdminRunnersApp', () => {
       ...options,
     });
 
+    showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show');
+
     return waitForPromises();
   };
 
@@ -128,17 +133,10 @@ describe('AdminRunnersApp', () => {
   afterEach(() => {
     mockRunnersHandler.mockReset();
     mockRunnersCountHandler.mockReset();
+    showToast.mockReset();
     wrapper.destroy();
   });
 
-  it('shows the runner tabs with a runner count for each type', async () => {
-    await createComponent({ mountFn: mountExtended });
-
-    expect(findRunnerTypeTabs().text()).toMatchInterpolatedText(
-      `All ${mockRunnersCount} Instance ${mockRunnersCount} Group ${mockRunnersCount} Project ${mockRunnersCount}`,
-    );
-  });
-
   it('shows the runner setup instructions', () => {
     createComponent();
 
@@ -146,27 +144,38 @@ describe('AdminRunnersApp', () => {
     expect(findRegistrationDropdown().props('type')).toBe(INSTANCE_TYPE);
   });
 
-  it('shows total runner counts', async () => {
-    await createComponent({ mountFn: mountExtended });
+  describe('shows total runner counts', () => {
+    beforeEach(async () => {
+      await createComponent({ mountFn: mountExtended });
+    });
 
-    expect(mockRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_ONLINE });
-    expect(mockRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_OFFLINE });
-    expect(mockRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_STALE });
-
-    expect(findRunnerStats().text()).toContain(
-      `${s__('Runners|Online runners')} ${mockRunnersCount}`,
-    );
-    expect(findRunnerStats().text()).toContain(
-      `${s__('Runners|Offline runners')} ${mockRunnersCount}`,
-    );
-    expect(findRunnerStats().text()).toContain(
-      `${s__('Runners|Stale runners')} ${mockRunnersCount}`,
-    );
+    it('fetches counts', () => {
+      expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES);
+    });
+
+    it('shows the runner tabs', () => {
+      expect(findRunnerTypeTabs().text()).toMatchInterpolatedText(
+        `All ${mockRunnersCount} Instance ${mockRunnersCount} Group ${mockRunnersCount} Project ${mockRunnersCount}`,
+      );
+    });
+
+    it('shows the total', () => {
+      expect(findRunnerStats().text()).toContain(
+        `${s__('Runners|Online runners')} ${mockRunnersCount}`,
+      );
+      expect(findRunnerStats().text()).toContain(
+        `${s__('Runners|Offline runners')} ${mockRunnersCount}`,
+      );
+      expect(findRunnerStats().text()).toContain(
+        `${s__('Runners|Stale runners')} ${mockRunnersCount}`,
+      );
+    });
   });
 
   it('shows the runners list', async () => {
     await createComponent();
 
+    expect(mockRunnersHandler).toHaveBeenCalledTimes(1);
     expect(findRunnerList().props('runners')).toEqualGraphqlFixture(mockRunners);
   });
 
@@ -226,18 +235,13 @@ describe('AdminRunnersApp', () => {
   });
 
   describe('Single runner row', () => {
-    let showToast;
-
     const { id: graphqlId, shortSha } = mockRunners[0];
     const id = getIdFromGraphQLId(graphqlId);
-    const COUNT_QUERIES = 7; // Smart queries that display a filtered count of runners
-    const FILTERED_COUNT_QUERIES = 4; // Smart queries that display a count of runners in tabs
 
     beforeEach(async () => {
       mockRunnersCountHandler.mockClear();
 
       await createComponent({ mountFn: mountExtended });
-      showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show');
     });
 
     it('Links to the runner page', async () => {
@@ -252,7 +256,7 @@ describe('AdminRunnersApp', () => {
 
       findRunnerActionsCell().vm.$emit('toggledPaused');
 
-      expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES + FILTERED_COUNT_QUERIES);
+      expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES * 2);
       expect(showToast).toHaveBeenCalledTimes(0);
     });
 
@@ -344,36 +348,70 @@ describe('AdminRunnersApp', () => {
   });
 
   describe('when bulk delete is enabled', () => {
-    beforeEach(() => {
-      createComponent({
-        provide: {
-          glFeatures: { adminRunnersBulkDelete: true },
-        },
+    describe('Before runners are deleted', () => {
+      beforeEach(async () => {
+        await createComponent({
+          provide: {
+            glFeatures: { adminRunnersBulkDelete: true },
+          },
+        });
       });
-    });
 
-    it('runner list is checkable', () => {
-      expect(findRunnerList().props('checkable')).toBe(true);
+      it('runner bulk delete is available', () => {
+        expect(findRunnerBulkDelete().exists()).toBe(true);
+      });
+
+      it('runner list is checkable', () => {
+        expect(findRunnerList().props('checkable')).toBe(true);
+      });
+
+      it('responds to checked items by updating the local cache', () => {
+        const setRunnerCheckedMock = jest
+          .spyOn(localMutations, 'setRunnerChecked')
+          .mockImplementation(() => {});
+
+        const runner = mockRunners[0];
+
+        expect(setRunnerCheckedMock).toHaveBeenCalledTimes(0);
+
+        findRunnerList().vm.$emit('checked', {
+          runner,
+          isChecked: true,
+        });
+
+        expect(setRunnerCheckedMock).toHaveBeenCalledTimes(1);
+        expect(setRunnerCheckedMock).toHaveBeenCalledWith({
+          runner,
+          isChecked: true,
+        });
+      });
     });
 
-    it('responds to checked items by updating the local cache', () => {
-      const setRunnerCheckedMock = jest
-        .spyOn(localMutations, 'setRunnerChecked')
-        .mockImplementation(() => {});
+    describe('When runners are deleted', () => {
+      beforeEach(async () => {
+        await createComponent({
+          mountFn: mountExtended,
+          provide: {
+            glFeatures: { adminRunnersBulkDelete: true },
+          },
+        });
+      });
 
-      const runner = mockRunners[0];
+      it('count data is refetched', async () => {
+        expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES);
 
-      expect(setRunnerCheckedMock).toHaveBeenCalledTimes(0);
+        findRunnerBulkDelete().vm.$emit('deleted', { message: 'Runners deleted' });
 
-      findRunnerList().vm.$emit('checked', {
-        runner,
-        isChecked: true,
+        expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES * 2);
       });
 
-      expect(setRunnerCheckedMock).toHaveBeenCalledTimes(1);
-      expect(setRunnerCheckedMock).toHaveBeenCalledWith({
-        runner,
-        isChecked: true,
+      it('toast is shown', async () => {
+        expect(showToast).toHaveBeenCalledTimes(0);
+
+        findRunnerBulkDelete().vm.$emit('deleted', { message: 'Runners deleted' });
+
+        expect(showToast).toHaveBeenCalledTimes(1);
+        expect(showToast).toHaveBeenCalledWith('Runners deleted');
       });
     });
   });
diff --git a/spec/frontend/runner/components/runner_bulk_delete_spec.js b/spec/frontend/runner/components/runner_bulk_delete_spec.js
index cc679a52b348446e35001410d6c04ff96ee0828c..e14d1bba5beca8fe46024f29c541ced470fc4803 100644
--- a/spec/frontend/runner/components/runner_bulk_delete_spec.js
+++ b/spec/frontend/runner/components/runner_bulk_delete_spec.js
@@ -1,38 +1,57 @@
 import Vue from 'vue';
-import { GlSprintf } from '@gitlab/ui';
+import { GlModal, GlSprintf } from '@gitlab/ui';
 import VueApollo from 'vue-apollo';
+import { createAlert } from '~/flash';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
 import { s__ } from '~/locale';
-import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
 import RunnerBulkDelete from '~/runner/components/runner_bulk_delete.vue';
 import createMockApollo from 'helpers/mock_apollo_helper';
+import BulkRunnerDeleteMutation from '~/runner/graphql/list/bulk_runner_delete.mutation.graphql';
 import { createLocalState } from '~/runner/graphql/list/local_state';
 import waitForPromises from 'helpers/wait_for_promises';
 
 Vue.use(VueApollo);
 
-jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
+jest.mock('~/flash');
 
 describe('RunnerBulkDelete', () => {
   let wrapper;
+  let apolloCache;
   let mockState;
   let mockCheckedRunnerIds;
 
   const findClearBtn = () => wrapper.findByText(s__('Runners|Clear selection'));
   const findDeleteBtn = () => wrapper.findByText(s__('Runners|Delete selected'));
+  const findModal = () => wrapper.findComponent(GlModal);
+
+  const bulkRunnerDeleteHandler = jest.fn();
 
   const createComponent = () => {
     const { cacheConfig, localMutations } = mockState;
+    const apolloProvider = createMockApollo(
+      [[BulkRunnerDeleteMutation, bulkRunnerDeleteHandler]],
+      undefined,
+      cacheConfig,
+    );
 
     wrapper = shallowMountExtended(RunnerBulkDelete, {
-      apolloProvider: createMockApollo(undefined, undefined, cacheConfig),
+      apolloProvider,
       provide: {
         localMutations,
       },
+      directives: {
+        GlTooltip: createMockDirective(),
+      },
       stubs: {
         GlSprintf,
+        GlModal,
       },
     });
+
+    apolloCache = apolloProvider.defaultClient.cache;
+    jest.spyOn(apolloCache, 'evict');
+    jest.spyOn(apolloCache, 'gc');
   };
 
   beforeEach(() => {
@@ -44,6 +63,7 @@ describe('RunnerBulkDelete', () => {
   });
 
   afterEach(() => {
+    bulkRunnerDeleteHandler.mockReset();
     wrapper.destroy();
   });
 
@@ -65,7 +85,7 @@ describe('RunnerBulkDelete', () => {
     count | ids                                 | text
     ${1}  | ${['gid:Runner/1']}                 | ${'1 runner'}
     ${2}  | ${['gid:Runner/1', 'gid:Runner/2']} | ${'2 runners'}
-  `('When $count runner(s) are checked', ({ count, ids, text }) => {
+  `('When $count runner(s) are checked', ({ ids, text }) => {
     beforeEach(() => {
       mockCheckedRunnerIds = ids;
 
@@ -87,18 +107,129 @@ describe('RunnerBulkDelete', () => {
     });
 
     it('shows confirmation modal', () => {
-      expect(confirmAction).toHaveBeenCalledTimes(0);
+      const modalId = getBinding(findDeleteBtn().element, 'gl-modal');
+
+      expect(findModal().props('modal-id')).toBe(modalId);
+      expect(findModal().text()).toContain(text);
+    });
+  });
+
+  describe('when runners are deleted', () => {
+    let evt;
+    let mockHideModal;
+
+    beforeEach(() => {
+      mockCheckedRunnerIds = ['gid:Runner/1', 'gid:Runner/2'];
+
+      createComponent();
+
+      jest.spyOn(mockState.localMutations, 'clearChecked').mockImplementation(() => {});
+      mockHideModal = jest.spyOn(findModal().vm, 'hide');
+    });
+
+    describe('when deletion is successful', () => {
+      beforeEach(() => {
+        bulkRunnerDeleteHandler.mockResolvedValue({
+          data: {
+            bulkRunnerDelete: { deletedIds: mockCheckedRunnerIds, errors: [] },
+          },
+        });
+
+        evt = {
+          preventDefault: jest.fn(),
+        };
+        findModal().vm.$emit('primary', evt);
+      });
+
+      it('has loading state', async () => {
+        expect(findModal().props('actionPrimary').attributes.loading).toBe(true);
+        expect(findModal().props('actionCancel').attributes.loading).toBe(true);
+
+        await waitForPromises();
+
+        expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
+        expect(findModal().props('actionCancel').attributes.loading).toBe(false);
+      });
+
+      it('modal is not prevented from closing', () => {
+        expect(evt.preventDefault).toHaveBeenCalledTimes(1);
+      });
+
+      it('mutation is called', async () => {
+        expect(bulkRunnerDeleteHandler).toHaveBeenCalledWith({
+          input: { ids: mockCheckedRunnerIds },
+        });
+      });
+
+      it('user interface is updated', async () => {
+        const { evict, gc } = apolloCache;
+
+        expect(evict).toHaveBeenCalledTimes(mockCheckedRunnerIds.length);
+        expect(evict).toHaveBeenCalledWith({
+          id: expect.stringContaining(mockCheckedRunnerIds[0]),
+        });
+        expect(evict).toHaveBeenCalledWith({
+          id: expect.stringContaining(mockCheckedRunnerIds[1]),
+        });
+
+        expect(gc).toHaveBeenCalledTimes(1);
+      });
+
+      it('modal is hidden', () => {
+        expect(mockHideModal).toHaveBeenCalledTimes(1);
+      });
+    });
+
+    describe('when deletion fails', () => {
+      beforeEach(() => {
+        bulkRunnerDeleteHandler.mockRejectedValue(new Error('error!'));
+
+        evt = {
+          preventDefault: jest.fn(),
+        };
+        findModal().vm.$emit('primary', evt);
+      });
+
+      it('has loading state', async () => {
+        expect(findModal().props('actionPrimary').attributes.loading).toBe(true);
+        expect(findModal().props('actionCancel').attributes.loading).toBe(true);
+
+        await waitForPromises();
+
+        expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
+        expect(findModal().props('actionCancel').attributes.loading).toBe(false);
+      });
+
+      it('modal is not prevented from closing', () => {
+        expect(evt.preventDefault).toHaveBeenCalledTimes(1);
+      });
+
+      it('mutation is called', () => {
+        expect(bulkRunnerDeleteHandler).toHaveBeenCalledWith({
+          input: { ids: mockCheckedRunnerIds },
+        });
+      });
+
+      it('user interface is not updated', async () => {
+        await waitForPromises();
 
-      findDeleteBtn().vm.$emit('click');
+        const { evict, gc } = apolloCache;
 
-      expect(confirmAction).toHaveBeenCalledTimes(1);
+        expect(evict).not.toHaveBeenCalled();
+        expect(gc).not.toHaveBeenCalled();
+        expect(mockState.localMutations.clearChecked).not.toHaveBeenCalled();
+      });
 
-      const [, confirmOptions] = confirmAction.mock.calls[0];
-      const { title, modalHtmlMessage, primaryBtnText } = confirmOptions;
+      it('alert is called', async () => {
+        await waitForPromises();
 
-      expect(title).toMatch(text);
-      expect(primaryBtnText).toMatch(text);
-      expect(modalHtmlMessage).toMatch(`<strong>${count}</strong>`);
+        expect(createAlert).toHaveBeenCalled();
+        expect(createAlert).toHaveBeenCalledWith({
+          message: expect.any(String),
+          captureError: true,
+          error: expect.any(Error),
+        });
+      });
     });
   });
 });
diff --git a/spec/frontend/runner/graphql/local_state_spec.js b/spec/frontend/runner/graphql/local_state_spec.js
index 5c4302e4aa2824b829015885701bcb2b4a5533ee..8530df43791c899013b8e3050fefe2fdfebfbf2f 100644
--- a/spec/frontend/runner/graphql/local_state_spec.js
+++ b/spec/frontend/runner/graphql/local_state_spec.js
@@ -1,6 +1,8 @@
+import { gql } from '@apollo/client/core';
 import createApolloClient from '~/lib/graphql';
 import { createLocalState } from '~/runner/graphql/list/local_state';
 import getCheckedRunnerIdsQuery from '~/runner/graphql/list/checked_runner_ids.query.graphql';
+import { RUNNER_TYPENAME } from '~/runner/constants';
 
 describe('~/runner/graphql/list/local_state', () => {
   let localState;
@@ -18,6 +20,21 @@ describe('~/runner/graphql/list/local_state', () => {
     apolloClient = createApolloClient({}, { cacheConfig, typeDefs });
   };
 
+  const addMockRunnerToCache = (id) => {
+    // mock some runners in the cache to prevent dangling references
+    apolloClient.writeFragment({
+      id: `${RUNNER_TYPENAME}:${id}`,
+      fragment: gql`
+        fragment DummyRunner on CiRunner {
+          __typename
+        }
+      `,
+      data: {
+        __typename: RUNNER_TYPENAME,
+      },
+    });
+  };
+
   const queryCheckedRunnerIds = () => {
     const { checkedRunnerIds } = apolloClient.readQuery({
       query: getCheckedRunnerIdsQuery,
@@ -34,10 +51,25 @@ describe('~/runner/graphql/list/local_state', () => {
     apolloClient = null;
   });
 
-  describe('default', () => {
-    it('has empty checked list', () => {
+  describe('queryCheckedRunnerIds', () => {
+    it('has empty checked list by default', () => {
       expect(queryCheckedRunnerIds()).toEqual([]);
     });
+
+    it('returns checked runners that have a reference in the cache', () => {
+      addMockRunnerToCache('a');
+      localState.localMutations.setRunnerChecked({ runner: { id: 'a' }, isChecked: true });
+
+      expect(queryCheckedRunnerIds()).toEqual(['a']);
+    });
+
+    it('return checked runners that are not dangling references', () => {
+      addMockRunnerToCache('a'); // 'b' is missing from the cache, perhaps because it was deleted
+      localState.localMutations.setRunnerChecked({ runner: { id: 'a' }, isChecked: true });
+      localState.localMutations.setRunnerChecked({ runner: { id: 'b' }, isChecked: true });
+
+      expect(queryCheckedRunnerIds()).toEqual(['a']);
+    });
   });
 
   describe.each`
@@ -48,6 +80,7 @@ describe('~/runner/graphql/list/local_state', () => {
   `('setRunnerChecked', ({ inputs, expected }) => {
     beforeEach(() => {
       inputs.forEach(([id, isChecked]) => {
+        addMockRunnerToCache(id);
         localState.localMutations.setRunnerChecked({ runner: { id }, isChecked });
       });
     });
@@ -59,6 +92,7 @@ describe('~/runner/graphql/list/local_state', () => {
   describe('clearChecked', () => {
     it('clears all checked items', () => {
       ['a', 'b', 'c'].forEach((id) => {
+        addMockRunnerToCache(id);
         localState.localMutations.setRunnerChecked({ runner: { id }, isChecked: true });
       });