diff --git a/src/core/system.h b/src/core/system.h
index b451dc737..73004cef0 100644
--- a/src/core/system.h
+++ b/src/core/system.h
@@ -481,7 +481,7 @@ void RequestResizeHostDisplay(s32 width, s32 height);
 
 /// Requests shut down and exit of the hosting application. This may not actually exit,
 /// if the user cancels the shutdown confirmation.
-void RequestExit(bool save_state_if_running);
+void RequestExit(bool allow_confirm);
 
 /// Requests shut down of the current virtual machine.
 void RequestSystemShutdown(bool allow_confirm, bool save_state);
diff --git a/src/duckstation-nogui/nogui_host.cpp b/src/duckstation-nogui/nogui_host.cpp
index 617aeec7b..e0d6564e6 100644
--- a/src/duckstation-nogui/nogui_host.cpp
+++ b/src/duckstation-nogui/nogui_host.cpp
@@ -981,11 +981,11 @@ std::optional<WindowInfo> Host::GetTopLevelWindowInfo()
   return g_nogui_window->GetPlatformWindowInfo();
 }
 
-void Host::RequestExit(bool save_state_if_running)
+void Host::RequestExit(bool allow_confirm)
 {
   if (System::IsValid())
   {
-    Host::RunOnCPUThread([save_state_if_running]() { System::ShutdownSystem(save_state_if_running); });
+    Host::RunOnCPUThread([]() { System::ShutdownSystem(g_settings.save_state_on_exit); });
   }
 
   // clear the running flag, this'll break out of the main CPU loop once the VM is shutdown.
@@ -1022,7 +1022,7 @@ static void SignalHandler(int signal)
   {
     std::fprintf(stderr, "Received CTRL+C, attempting graceful shutdown. Press CTRL+C again to force.\n");
     graceful_shutdown_attempted = true;
-    Host::RequestExit(true);
+    Host::RequestExit(false);
     return;
   }
 
diff --git a/src/duckstation-nogui/wayland_nogui_platform.cpp b/src/duckstation-nogui/wayland_nogui_platform.cpp
index d5a05429c..c6d4ec217 100644
--- a/src/duckstation-nogui/wayland_nogui_platform.cpp
+++ b/src/duckstation-nogui/wayland_nogui_platform.cpp
@@ -270,7 +270,7 @@ void WaylandNoGUIPlatform::TopLevelConfigure(void* data, struct xdg_toplevel* xd
 
 void WaylandNoGUIPlatform::TopLevelClose(void* data, struct xdg_toplevel* xdg_toplevel)
 {
-  Host::RunOnCPUThread([]() { Host::RequestExit(g_settings.save_state_on_exit); });
+  Host::RunOnCPUThread([]() { Host::RequestExit(false); });
 }
 
 void WaylandNoGUIPlatform::SeatCapabilities(void* data, wl_seat* seat, uint32_t capabilities)
diff --git a/src/duckstation-nogui/win32_nogui_platform.cpp b/src/duckstation-nogui/win32_nogui_platform.cpp
index 43a506110..9e34935d7 100644
--- a/src/duckstation-nogui/win32_nogui_platform.cpp
+++ b/src/duckstation-nogui/win32_nogui_platform.cpp
@@ -399,7 +399,8 @@ LRESULT CALLBACK Win32NoGUIPlatform::WndProc(HWND hwnd, UINT msg, WPARAM wParam,
     case WM_CLOSE:
     case WM_QUIT:
     {
-      Host::RunOnCPUThread([]() { Host::RequestExit(g_settings.save_state_on_exit); });
+      Host::RunOnCPUThread([]() { Host::RequestExit(false); });
+      return 0;
     }
     break;
 
diff --git a/src/duckstation-nogui/x11_nogui_platform.cpp b/src/duckstation-nogui/x11_nogui_platform.cpp
index 68ac16ddf..760d0d555 100644
--- a/src/duckstation-nogui/x11_nogui_platform.cpp
+++ b/src/duckstation-nogui/x11_nogui_platform.cpp
@@ -233,7 +233,7 @@ void X11NoGUIPlatform::ProcessXEvents()
       case ClientMessage:
       {
         if (static_cast<Atom>(event.xclient.data.l[0]) == XInternAtom(m_display, "WM_DELETE_WINDOW", False))
-          Host::RequestExit(true);
+          Host::RequestExit(false);
       }
       break;
 
diff --git a/src/duckstation-qt/mainwindow.cpp b/src/duckstation-qt/mainwindow.cpp
index 488254969..fed124cc6 100644
--- a/src/duckstation-qt/mainwindow.cpp
+++ b/src/duckstation-qt/mainwindow.cpp
@@ -615,6 +615,15 @@ void MainWindow::onSystemDestroyed()
 
   s_system_valid = false;
   s_system_paused = false;
+
+  // If we're closing or in batch mode, quit the whole application now.
+  if (m_is_closing || QtHost::InBatchMode())
+  {
+    QApplication::processEvents(QEventLoop::ExcludeUserInputEvents, 1);
+    QCoreApplication::quit();
+    return;
+  }
+
   updateEmulationActions(false, false, Achievements::ChallengeModeActive());
   if (m_display_widget)
     updateDisplayWidgetCursor();
@@ -727,7 +736,12 @@ std::string MainWindow::getDeviceDiscPath(const QString& title)
 void MainWindow::recreate()
 {
   if (s_system_valid)
-    requestShutdown(false, true, true, true);
+  {
+    requestShutdown(false, true, true);
+
+    while (s_system_valid)
+      QApplication::processEvents(QEventLoop::ExcludeUserInputEvents, 1);
+  }
 
   // We need to close input sources, because e.g. DInput uses our window handle.
   g_emu_thread->closeInputSources();
@@ -2375,16 +2389,24 @@ void MainWindow::showEvent(QShowEvent* event)
 
 void MainWindow::closeEvent(QCloseEvent* event)
 {
-  if (!requestShutdown(true, true, true))
+  // If there's no VM, we can just exit as normal.
+  if (!s_system_valid)
   {
-    event->ignore();
+    QMainWindow::closeEvent(event);
     return;
   }
 
+  // But if there is, we have to cancel the action, regardless of whether we ended exiting
+  // or not. The window still needs to be visible while GS is shutting down.
+  event->ignore();
+
+  // Exit cancelled?
+  if (!requestShutdown(true, true, g_settings.save_state_on_exit))
+    return;
+
+  // Application will be exited in VM stopped handler.
   saveGeometryToConfig();
   m_is_closing = true;
-
-  QMainWindow::closeEvent(event);
 }
 
 void MainWindow::changeEvent(QEvent* event)
@@ -2473,7 +2495,7 @@ void MainWindow::runOnUIThread(const std::function<void()>& func)
 }
 
 bool MainWindow::requestShutdown(bool allow_confirm /* = true */, bool allow_save_to_state /* = true */,
-                                 bool save_state /* = true */, bool block_until_done /* = false */)
+                                 bool save_state /* = true */)
 {
   if (!s_system_valid)
     return true;
@@ -2518,34 +2540,21 @@ bool MainWindow::requestShutdown(bool allow_confirm /* = true */, bool allow_sav
 
   // Now we can actually shut down the VM.
   g_emu_thread->shutdownSystem(save_state);
-
-  if (block_until_done || m_is_closing || QtHost::InBatchMode())
-  {
-    // We need to yield here, since the display gets destroyed.
-    while (s_system_valid || System::GetState() != System::State::Shutdown)
-      QApplication::processEvents(QEventLoop::ExcludeUserInputEvents, 1);
-  }
-
-  if (!m_is_closing && QtHost::InBatchMode())
-  {
-    // Closing the window should shut down everything. If we don't set the closing flag here,
-    // the VM shutdown may not complete by the time closeEvent() is called, leading to a confirm.
-    m_is_closing = true;
-    QGuiApplication::quit();
-  }
-
   return true;
 }
 
-void MainWindow::requestExit(bool allow_save_to_state /* = true */)
+void MainWindow::requestExit(bool allow_confirm /* = true */)
 {
   // this is block, because otherwise closeEvent() will also prompt
-  if (!requestShutdown(true, allow_save_to_state, g_settings.save_state_on_exit))
+  if (!requestShutdown(allow_confirm, true, g_settings.save_state_on_exit))
     return;
 
-  // We could use close here, but if we're not visible (e.g. quitting from fullscreen), closing the window
-  // doesn't quit the application.
-  QGuiApplication::quit();
+  // VM stopped signal won't have fired yet, so queue an exit if we still have one.
+  // Otherwise, immediately exit, because there's no VM to exit us later.
+  if (s_system_valid)
+    m_is_closing = true;
+  else
+    QGuiApplication::quit();
 }
 
 void MainWindow::checkForSettingChanges()
diff --git a/src/duckstation-qt/mainwindow.h b/src/duckstation-qt/mainwindow.h
index 42fee11a6..c0adae732 100644
--- a/src/duckstation-qt/mainwindow.h
+++ b/src/duckstation-qt/mainwindow.h
@@ -99,8 +99,8 @@ public Q_SLOTS:
   void cancelGameListRefresh();
 
   void runOnUIThread(const std::function<void()>& func);
-  bool requestShutdown(bool allow_confirm = true, bool allow_save_to_state = true, bool save_state = true, bool block_until_done = false);
-  void requestExit(bool allow_save_to_state = true);
+  bool requestShutdown(bool allow_confirm = true, bool allow_save_to_state = true, bool save_state = true);
+  void requestExit(bool allow_confirm = true);
   void checkForSettingChanges();
   void getWindowInfo(WindowInfo* wi);
 
diff --git a/src/duckstation-qt/qthost.cpp b/src/duckstation-qt/qthost.cpp
index 1e4d50039..fd235b618 100644
--- a/src/duckstation-qt/qthost.cpp
+++ b/src/duckstation-qt/qthost.cpp
@@ -1746,12 +1746,12 @@ void Host::RequestSystemShutdown(bool allow_confirm, bool save_state)
     return;
 
   QMetaObject::invokeMethod(g_main_window, "requestShutdown", Qt::QueuedConnection, Q_ARG(bool, allow_confirm),
-                            Q_ARG(bool, true), Q_ARG(bool, save_state), Q_ARG(bool, false));
+                            Q_ARG(bool, true), Q_ARG(bool, save_state));
 }
 
-void Host::RequestExit(bool save_state_if_running)
+void Host::RequestExit(bool allow_confirm)
 {
-  QMetaObject::invokeMethod(g_main_window, "requestExit", Qt::QueuedConnection, Q_ARG(bool, save_state_if_running));
+  QMetaObject::invokeMethod(g_main_window, "requestExit", Qt::QueuedConnection, Q_ARG(bool, allow_confirm));
 }
 
 std::optional<WindowInfo> Host::GetTopLevelWindowInfo()
diff --git a/src/frontend-common/fullscreen_ui.cpp b/src/frontend-common/fullscreen_ui.cpp
index 09db22ba1..415e8523f 100644
--- a/src/frontend-common/fullscreen_ui.cpp
+++ b/src/frontend-common/fullscreen_ui.cpp
@@ -1073,7 +1073,7 @@ void FullscreenUI::DoToggleAnalogMode()
 
 void FullscreenUI::DoRequestExit()
 {
-  Host::RunOnCPUThread([]() { Host::RequestExit(g_settings.save_state_on_exit); });
+  Host::RunOnCPUThread([]() { Host::RequestExit(true); });
 }
 
 void FullscreenUI::DoToggleFullscreen()