diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs
--- a/crates/ui-egui/src/workflows/print_ui.rs
+++ b/crates/ui-egui/src/workflows/print_ui.rs
@@ -38,12 +38,16 @@ pub fn system_printers() -> PrinterSource {
     std::sync::Arc::new(spool::printers)
 }
 
+/// Maximum duration to wait for the system spooler to return printers before timing out (#5020).
+pub const PRINTER_SEARCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
+
 /// The Print dialog's work in the background: the printer list being read, and the jobs being
 /// spooled, oldest first.
 #[derive(Default)]
 pub(crate) struct PrintTasks {
+    /// Background receiver for the printer list, paired with the start instant for timeout handling.
     #[cfg(not(target_arch = "wasm32"))]
-    printers: Option<std::sync::mpsc::Receiver<Vec<spool::Printer>>>,
+    printers: Option<(std::sync::mpsc::Receiver<Vec<spool::Printer>>, std::time::Instant)>,
     #[cfg(not(target_arch = "wasm32"))]
     jobs: Vec<PrintJob>,
     /// The progress card is showing a print job (it is shared with other long jobs).
@@ -218,6 +222,7 @@ impl PdfCraftApp {
         self.print_draft.crop_pages = Some(pages);
     }
 
+    /// Dispatches a background thread to discover available printers from `source` (#5020).
     #[cfg(not(target_arch = "wasm32"))]
     fn list_printers_in_background(&mut self, source: PrinterSource) {
         // A list already being read is as fresh as a new one would be.
@@ -232,7 +237,7 @@ impl PdfCraftApp {
             // The receiver may be gone (the app quit): nothing to report to then.
             let _ = tx.send(source());
         });
-        self.print_tasks.printers = Some(rx);
+        self.print_tasks.printers = Some((rx, std::time::Instant::now()));
     }
 
     /// Read `printer`'s driver options off the GUI thread (#1818); `poll_print` picks them up.
@@ -299,9 +304,14 @@ impl PdfCraftApp {
             for (printer, options) in arrived {
                 self.receive_options(printer, options);
             }
-            if let Some(rx) = &self.print_tasks.printers {
+            if let Some((rx, started)) = &self.print_tasks.printers {
+                let timeout = started.elapsed() >= PRINTER_SEARCH_TIMEOUT;
                 let printers = match rx.try_recv() {
                     Ok(p) => Some(p),
+                    Err(std::sync::mpsc::TryRecvError::Empty) if timeout => {
+                        // The search took too long (e.g. hung Windows spooler or offline network printer; #5020).
+                        Some(self.print_draft.printers.clone())
+                    }
                     Err(std::sync::mpsc::TryRecvError::Empty) => None,
                     // A source that died lists nothing new; the dialog keeps what it has.
                     Err(std::sync::mpsc::TryRecvError::Disconnected) => Some(self.print_draft.printers.clone()),
@@ -316,6 +326,9 @@ impl PdfCraftApp {
                     }
                     d.printers = printers;
                     d.printers_loading = false;
+                } else if let Some(ctx) = &self.ctx {
+                    let remaining = PRINTER_SEARCH_TIMEOUT.saturating_sub(started.elapsed());
+                    ctx.request_repaint_after(remaining);
                 }
             }
             let mut finished = Vec::new();
diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs
--- a/crates/ui-egui/tests/it/print.rs
+++ b/crates/ui-egui/tests/it/print.rs
@@ -913,3 +913,49 @@ fn a_booklet_decides_two_sided_itself() {
     assert!(h.query_by_role_and_label(Role::ComboBox, "Two-sided:").is_some());
     assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::LongEdge), "back to the choice");
 }
+
+/// #5020: if printer scanning hangs (or takes longer than 3 seconds), the dialog
+/// does not get stuck: Save as PDF can be picked immediately while looking, and
+/// printers_loading times out after 3 seconds.
+#[test]
+fn hung_printer_source_times_out_and_allows_save_as_pdf() {
+    let mut h = harness();
+    h.state_mut().printer_source = Some(std::sync::Arc::new(|| {
+        std::thread::sleep(std::time::Duration::from_secs(10));
+        Vec::new()
+    }));
+    assert!(h.state_mut().execute("print.dialog"));
+    h.run_steps(3);
+    assert_eq!(h.state().dialog, Some(Dialog::Print));
+    // Printer search is active initially.
+    assert!(h.state().print_draft.printers_loading);
+    assert_eq!(h.state().print_draft.printer, None);
+    // Choosing Save as PDF while looking makes it clickable immediately without waiting.
+    h.state_mut().print_draft.printer_picked = true;
+    h.run_steps(1);
+    let out = std::env::temp_dir().join(format!("pdfcraft-print-hung-{}.pdf", std::process::id()));
+    h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
+    h.get_by_label("Save as PDF").click();
+    h.run_steps(3);
+    assert_eq!(h.state().dialog, None);
+    let _ = std::fs::remove_file(out);
+}
+
+/// #5020: printer search times out after 3 seconds, clears printers_loading and defaults to Save as PDF.
+#[test]
+fn hung_printer_source_resets_printers_loading_on_timeout() {
+    let mut h = harness();
+    h.state_mut().printer_source = Some(std::sync::Arc::new(|| {
+        std::thread::sleep(std::time::Duration::from_secs(10));
+        Vec::new()
+    }));
+    assert!(h.state_mut().execute("print.dialog"));
+    h.run_steps(3);
+    assert!(h.state().print_draft.printers_loading);
+    // Advance past the 3-second timeout.
+    std::thread::sleep(std::time::Duration::from_millis(3100));
+    h.run_steps(2);
+    assert!(!h.state().print_draft.printers_loading);
+    // Save as PDF is now offered and enabled.
+    assert!(h.query_by_role_and_label(Role::Button, "Save as PDF").is_some());
+}
