diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs
--- a/crates/core/src/protocol.rs
+++ b/crates/core/src/protocol.rs
@@ -57,6 +57,9 @@ pub enum Command {
     },
     /// Change how the backend behaves.
     Configure(BackendOptions),
+    /// The app ids in the order they are on screen. Downloads run one at a time, and those
+    /// waiting go in this order.
+    DownloadOrder(Vec<String>),
 }
 
 /// Parent folders for new installs. Existing installations keep their recorded location.
@@ -82,6 +85,8 @@ pub struct BackendOptions {
     pub kinds: Vec<PackageKind>,
     /// Offer pre-releases (`0.6.0-rc.1`) as updates.
     pub prereleases: bool,
+    /// Downloads that may run at once (0 counts as 1).
+    pub simultaneous_downloads: u8,
 }
 
 /// The answer to "what's the latest release of this app?".
@@ -107,6 +112,8 @@ pub enum JobPhase {
         total: u64,
     },
     Verifying,
+    /// Another app is downloading; this one's download starts when its turn comes.
+    Queued,
     /// The system's installer is busy with another app; this one goes next.
     Waiting,
     /// Waiting on the OS installer (MSI) or copying files.
diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs
--- a/crates/native/src/backend.rs
+++ b/crates/native/src/backend.rs
@@ -297,6 +297,8 @@ impl NativeBackend {
         });
     }
 
+    /// Starts an install, update or removal job for `app_id` on its own thread. An install takes
+    /// its place in the download line first, so apps started together download in screen order.
     fn start_job(&self, app_id: &str, work: Work) {
         let uninstall = work == Work::Uninstall;
         let Some(app) = CraftApp::by_id(app_id) else { return };
@@ -324,6 +326,9 @@ impl NativeBackend {
         let http = self.http.clone();
         let platform = self.platform;
         let options = self.options();
+        // Taken now, before the job's thread runs, so apps started together (Update all) download
+        // in screen order whichever job reaches its download first.
+        let place = (!uninstall).then(|| install::DOWNLOADS.join(app.id));
         std::thread::spawn(move || {
             let job = job;
             let existing = shared.installed.lock().unwrap_or_else(PoisonError::into_inner).get(app.id).cloned();
@@ -353,6 +358,7 @@ impl NativeBackend {
                     record.as_ref(),
                     &stop,
                     work == Work::Replace,
+                    place,
                 );
                 // Stopped by the connection rather than by the user: say so, and how to go on.
                 match r {
@@ -453,6 +459,8 @@ enum Work {
     Uninstall,
 }
 
+/// Plans and runs an install or update of `app` from its latest known release, and returns the
+/// message to show. `place` is the job's spot in the download line, used when the download starts.
 #[allow(clippy::too_many_arguments)]
 fn run_install(
     app: &CraftApp,
@@ -465,6 +473,7 @@ fn run_install(
     record: Option<&crate::manifest::Record>,
     stop: &Stop,
     replace: bool,
+    place: Option<crate::downloads::Place<'static>>,
 ) -> Result<String, String> {
     if let Some(msg) = existing.and_then(|e| install::managed_elsewhere(app, e, false)) {
         return Err(msg);
@@ -502,7 +511,7 @@ fn run_install(
         last_emit = Instant::now();
         shared.emit(Event::Progress { app: app.id.to_string(), phase: p });
     };
-    let mut job = Job { http, paths, stop, phase: &mut phase, install_locations: &options.install_locations, replace };
+    let mut job = Job { http, paths, stop, phase: &mut phase, install_locations: &options.install_locations, replace, place };
     let mut commit_record = |record: &crate::manifest::Record| {
         let mut manifest = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner);
         let mut updated = manifest.clone();
@@ -1035,6 +1044,8 @@ impl NativeBackend {
 }
 
 impl Backend for NativeBackend {
+    /// Carries out a command from the UI. Long work (checks, installs, removals) runs on its own
+    /// thread.
     fn send(&mut self, command: Command) {
         match command {
             Command::CheckForUpdates { force } => self.check(force),
@@ -1084,10 +1095,12 @@ impl Backend for NativeBackend {
             }
             Command::UpdateLauncher => self.update_launcher(),
             Command::FetchScreenshots { app } => self.fetch_screenshots(&app),
+            Command::DownloadOrder(order) => install::DOWNLOADS.set_order(order),
             Command::Configure(options) => {
                 let changed_pre = {
                     let mut o = self.shared.options.lock().unwrap_or_else(PoisonError::into_inner);
                     let changed = o.prereleases != options.prereleases;
+                    install::DOWNLOADS.set_limit(options.simultaneous_downloads.into());
                     *o = options;
                     changed
                 };
diff --git a/crates/native/src/downloads.rs b/crates/native/src/downloads.rs
new file mode 100644
--- /dev/null
+++ b/crates/native/src/downloads.rs
@@ -0,0 +1,401 @@
+//! One download at a time, or as many as the user allows ([`Downloads::set_limit`]).
+//!
+//! Downloads running side by side split the connection, so each crawls and none is ready soon.
+//! Here they take turns: a job that asks while the limit is reached waits, and the waiting jobs go
+//! in the order their apps are on screen ([`Downloads::set_order`]), not the order they were
+//! asked for. A job takes its place in line when it starts ([`Downloads::join`]), so an app higher
+//! on screen goes first even when a job below it reaches its download sooner.
+
+use std::sync::{Condvar, Mutex, MutexGuard, PoisonError};
+use std::time::Duration;
+
+use artcraft_launcher_core::SUITE;
+
+struct Waiting {
+    id: u64,
+    app: String,
+}
+
+/// The most downloads the user can let run at once.
+pub const MAX_AT_ONCE: usize = 4;
+
+struct State {
+    /// Downloads running now.
+    running: usize,
+    /// Downloads that may run at once (1 to [`MAX_AT_ONCE`]).
+    limit: usize,
+    next_id: u64,
+    waiting: Vec<Waiting>,
+    /// App ids in the order they are on screen.
+    order: Vec<String>,
+}
+
+impl State {
+    /// Where `app` goes in line: its place on screen, then (for apps not on screen) its place in
+    /// the suite. `id` breaks ties, so the first to ask goes first.
+    fn rank(&self, app: &str, id: u64) -> (usize, usize, u64) {
+        let on_screen = self.order.iter().position(|a| a == app).unwrap_or(usize::MAX);
+        let in_suite = SUITE.iter().position(|a| a.id == app).unwrap_or(usize::MAX);
+        (on_screen, in_suite, id)
+    }
+
+    /// The waiting job whose turn is next.
+    fn next(&self) -> Option<u64> {
+        self.waiting.iter().min_by_key(|w| self.rank(&w.app, w.id)).map(|w| w.id)
+    }
+}
+
+pub struct Downloads {
+    state: Mutex<State>,
+    changed: Condvar,
+}
+
+/// A job's place in line, held from when the job starts until its turn comes. Dropping it (the
+/// job failed, was stopped or found its download already done) leaves the line.
+pub struct Place<'a> {
+    queue: &'a Downloads,
+    id: u64,
+}
+
+impl Drop for Place<'_> {
+    /// Leaves the line and wakes the others, so the next one can start.
+    fn drop(&mut self) {
+        self.queue.lock().waiting.retain(|w| w.id != self.id);
+        self.queue.changed.notify_all();
+    }
+}
+
+impl<'a> Place<'a> {
+    /// Waits until it is this job's turn to download. `waiting` is called once, when the job has
+    /// to wait. `stop` is asked a few times a second while it waits (so it must not block); its
+    /// error ends the wait, and the job leaves the line.
+    pub fn turn(self, stop: &dyn Fn() -> Result<(), String>, waiting: &mut dyn FnMut()) -> Result<Turn<'a>, String> {
+        let queue = self.queue;
+        let mut state = queue.lock();
+        let mut told = false;
+        loop {
+            if state.running < state.limit && state.next() == Some(self.id) {
+                break;
+            }
+            // The lock is let go before the place leaves the line (locals drop before `self`).
+            stop()?;
+            if told {
+                state = queue.changed.wait_timeout(state, Duration::from_millis(100)).unwrap_or_else(PoisonError::into_inner).0;
+            } else {
+                told = true;
+                drop(state);
+                waiting();
+                state = queue.lock();
+            }
+        }
+        state.running += 1;
+        drop(state);
+        // Leaving the line wakes the others: with another free slot, the next one can start too.
+        drop(self);
+        Ok(Turn { queue })
+    }
+}
+
+/// A job's turn to download. The next job's turn starts when it is dropped (even by unwinding).
+pub struct Turn<'a> {
+    queue: &'a Downloads,
+}
+
+impl Drop for Turn<'_> {
+    /// Frees the slot and wakes the waiting jobs.
+    fn drop(&mut self) {
+        let mut state = self.queue.lock();
+        state.running = state.running.saturating_sub(1);
+        drop(state);
+        self.queue.changed.notify_all();
+    }
+}
+
+impl Default for Downloads {
+    /// An empty queue that lets one download run at a time.
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl Downloads {
+    /// An empty queue that lets one download run at a time; `const` so it can be a `static`.
+    pub const fn new() -> Self {
+        let state = State { running: 0, limit: 1, next_id: 0, waiting: Vec::new(), order: Vec::new() };
+        Self { state: Mutex::new(state), changed: Condvar::new() }
+    }
+
+    /// Locks the state; a job that panicked while holding it doesn't stop the others.
+    fn lock(&self) -> MutexGuard<'_, State> {
+        self.state.lock().unwrap_or_else(PoisonError::into_inner)
+    }
+
+    /// The app ids in the order they are on screen: waiting jobs go in this order.
+    pub fn set_order(&self, order: Vec<String>) {
+        self.lock().order = order;
+        self.changed.notify_all();
+    }
+
+    /// How many downloads may run at once: 0 counts as 1, and more than [`MAX_AT_ONCE`] as that.
+    /// Lowering it lets running downloads finish; waiting ones start as they do.
+    pub fn set_limit(&self, limit: usize) {
+        self.lock().limit = limit.clamp(1, MAX_AT_ONCE);
+        self.changed.notify_all();
+    }
+
+    /// Takes a place in line for `app`. Until it is dropped or used, apps below it on screen wait
+    /// for it, even while its job hasn't reached its download yet.
+    pub fn join(&self, app: &str) -> Place<'_> {
+        let mut state = self.lock();
+        let id = state.next_id;
+        state.next_id = state.next_id.wrapping_add(1);
+        state.waiting.push(Waiting { id, app: app.to_string() });
+        Place { queue: self, id }
+    }
+
+    /// Takes a place in line for `app` and waits for its turn: [`Place::turn`].
+    pub fn turn(&self, app: &str, stop: &dyn Fn() -> Result<(), String>, waiting: &mut dyn FnMut()) -> Result<Turn<'_>, String> {
+        self.join(app).turn(stop, waiting)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+    use std::sync::mpsc;
+
+    use super::*;
+
+    const GO: fn() -> Result<(), String> = || Ok(());
+
+    /// Holds the line with a running download until the returned sender is used.
+    fn hold<'s, 'q>(scope: &'s std::thread::Scope<'s, 'q>, queue: &'q Downloads) -> mpsc::Sender<()> {
+        let (started, has_started) = mpsc::channel();
+        let (release, released) = mpsc::channel::<()>();
+        scope.spawn(move || {
+            let _turn = queue.turn("photocraft", &GO, &mut || panic!("the first download never waits")).unwrap();
+            started.send(()).unwrap();
+            released.recv().unwrap();
+        });
+        has_started.recv().unwrap();
+        release
+    }
+
+    /// A download with nothing else running starts at once, and so does the next one after it.
+    #[test]
+    fn a_download_alone_never_waits() {
+        let queue = Downloads::new();
+        drop(queue.turn("photocraft", &GO, &mut || panic!("nothing else is downloading")).unwrap());
+        drop(queue.turn("filmcraft", &GO, &mut || panic!("the first download is over")).unwrap());
+    }
+
+    /// With the default limit, no two of the suite's apps download together.
+    #[test]
+    fn downloads_never_run_at_the_same_time() {
+        let queue = Downloads::new();
+        let (inside, most) = (AtomicUsize::new(0), AtomicUsize::new(0));
+        std::thread::scope(|scope| {
+            for (i, app) in SUITE.iter().enumerate() {
+                let (queue, inside, most) = (&queue, &inside, &most);
+                scope.spawn(move || {
+                    std::thread::sleep(Duration::from_millis(i as u64 % 4));
+                    let _turn = queue.turn(app.id, &GO, &mut || {}).unwrap();
+                    most.fetch_max(inside.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
+                    std::thread::sleep(Duration::from_millis(3));
+                    inside.fetch_sub(1, Ordering::SeqCst);
+                });
+            }
+        });
+        assert_eq!(most.load(Ordering::SeqCst), 1);
+    }
+
+    /// A higher limit lets several run together, never more than it allows.
+    #[test]
+    fn downloads_never_run_beyond_the_limit() {
+        let queue = Downloads::new();
+        queue.set_limit(3);
+        let (inside, most) = (AtomicUsize::new(0), AtomicUsize::new(0));
+        std::thread::scope(|scope| {
+            for (i, app) in SUITE.iter().enumerate() {
+                let (queue, inside, most) = (&queue, &inside, &most);
+                scope.spawn(move || {
+                    std::thread::sleep(Duration::from_millis(i as u64 % 4));
+                    let _turn = queue.turn(app.id, &GO, &mut || {}).unwrap();
+                    most.fetch_max(inside.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
+                    std::thread::sleep(Duration::from_millis(3));
+                    inside.fetch_sub(1, Ordering::SeqCst);
+                });
+            }
+        });
+        assert!(most.load(Ordering::SeqCst) <= 3);
+    }
+
+    /// With a limit of two, a second download starts while the first is still running.
+    #[test]
+    fn a_second_download_runs_alongside_when_the_limit_allows() {
+        let queue = Downloads::new();
+        queue.set_limit(2);
+        std::thread::scope(|scope| {
+            let release = hold(scope, &queue);
+            drop(queue.turn("wordcraft", &GO, &mut || panic!("one slot is free")).unwrap());
+            release.send(()).unwrap();
+        });
+    }
+
+    /// A waiting download starts at once when the limit is raised, without the running one
+    /// finishing.
+    #[test]
+    fn raising_the_limit_starts_a_waiting_download() {
+        let queue = Downloads::new();
+        std::thread::scope(|scope| {
+            let release = hold(scope, &queue);
+            let (turned, turns) = mpsc::channel();
+            let queue = &queue;
+            let waiting = scope.spawn(move || queue.turn("wordcraft", &GO, &mut || turned.send(()).unwrap()).map(drop));
+            turns.recv().unwrap();
+            queue.set_limit(2);
+            // It starts while the other download is still running.
+            assert_eq!(waiting.join().unwrap(), Ok(()));
+            release.send(()).unwrap();
+        });
+    }
+
+    /// Zero counts as one, and anything above the most counts as the most.
+    #[test]
+    fn the_limit_stays_between_one_and_the_most() {
+        let queue = Downloads::new();
+        queue.set_limit(0);
+        assert_eq!(queue.lock().limit, 1);
+        queue.set_limit(9);
+        assert_eq!(queue.lock().limit, MAX_AT_ONCE);
+    }
+
+    /// Queues `apps` (in that order) behind a running download, then lets them all through.
+    /// Returns the order they downloaded in.
+    fn finishing_order(queue: &Downloads, apps: &[&'static str], before_release: impl FnOnce()) -> Vec<&'static str> {
+        let done = Mutex::new(Vec::new());
+        std::thread::scope(|scope| {
+            let release = hold(scope, queue);
+            let (turned, turns) = mpsc::channel();
+            for app in apps {
+                let (turned, done) = (turned.clone(), &done);
+                scope.spawn(move || {
+                    let _turn = queue.turn(app, &GO, &mut || turned.send(()).unwrap()).unwrap();
+                    done.lock().unwrap().push(*app);
+                });
+                // Each one is waiting before the next is asked for.
+                turns.recv().unwrap();
+            }
+            before_release();
+            release.send(()).unwrap();
+        });
+        done.into_inner().unwrap()
+    }
+
+    /// With no screen order sent, waiting downloads go in suite order, not the order they asked in.
+    #[test]
+    fn waiting_downloads_go_in_suite_order_without_a_screen_order() {
+        let queue = Downloads::new();
+        assert_eq!(finishing_order(&queue, &["soundcraft", "wordcraft", "lightcraft"], || {}), ["lightcraft", "wordcraft", "soundcraft"]);
+    }
+
+    /// Waiting downloads go in the order their apps are on screen.
+    #[test]
+    fn waiting_downloads_go_in_screen_order() {
+        let queue = Downloads::new();
+        queue.set_order(vec!["wordcraft".into(), "soundcraft".into(), "lightcraft".into()]);
+        assert_eq!(finishing_order(&queue, &["lightcraft", "soundcraft", "wordcraft"], || {}), ["wordcraft", "soundcraft", "lightcraft"]);
+    }
+
+    /// Apps missing from the screen order (hidden by a filter or search) go after the listed ones,
+    /// in suite order.
+    #[test]
+    fn waiting_downloads_not_on_screen_go_last_in_suite_order() {
+        let queue = Downloads::new();
+        queue.set_order(vec!["wordcraft".into(), "photocraft".into()]);
+        let apps = ["soundcraft", "wordcraft", "lightcraft", "photocraft"];
+        assert_eq!(finishing_order(&queue, &apps, || {}), ["wordcraft", "photocraft", "lightcraft", "soundcraft"]);
+    }
+
+    /// Sending a new screen order changes who goes next among the waiting downloads.
+    #[test]
+    fn resorting_the_screen_reorders_the_waiting_downloads() {
+        let queue = Downloads::new();
+        queue.set_order(vec!["wordcraft".into(), "soundcraft".into(), "lightcraft".into()]);
+        let resort = || queue.set_order(vec!["lightcraft".into(), "soundcraft".into(), "wordcraft".into()]);
+        assert_eq!(finishing_order(&queue, &["wordcraft", "soundcraft", "lightcraft"], resort), ["lightcraft", "soundcraft", "wordcraft"]);
+    }
+
+    /// A stopped job leaves the line while the running download carries on, and the line works
+    /// afterwards.
+    #[test]
+    fn a_waiting_download_can_be_stopped_and_leaves_the_line() {
+        let queue = Downloads::new();
+        let cancel = AtomicBool::new(false);
+        std::thread::scope(|scope| {
+            let release = hold(scope, &queue);
+            let (turned, turns) = mpsc::channel();
+            let (queue, cancel) = (&queue, &cancel);
+            let stop = move || if cancel.load(Ordering::SeqCst) { Err("cancelled".to_string()) } else { Ok(()) };
+            let leaving = scope.spawn(move || queue.turn("lightcraft", &stop, &mut || turned.send(()).unwrap()).map(drop));
+            turns.recv().unwrap();
+            cancel.store(true, Ordering::SeqCst);
+            // It leaves while the other download is still running.
+            assert_eq!(leaving.join().unwrap(), Err("cancelled".to_string()));
+            release.send(()).unwrap();
+        });
+        assert!(queue.lock().waiting.is_empty());
+        drop(queue.turn("wordcraft", &GO, &mut || panic!("the line is empty")).unwrap());
+    }
+
+    /// A job that joined the line waits its turn even when a job below it on screen gets to its
+    /// download first.
+    #[test]
+    fn a_job_higher_on_screen_goes_first_even_when_it_reaches_its_download_later() {
+        let queue = Downloads::new();
+        queue.set_order(vec!["wordcraft".into(), "lightcraft".into()]);
+        let word = queue.join("wordcraft");
+        std::thread::scope(|scope| {
+            let (turned, turns) = mpsc::channel();
+            let queue = &queue;
+            let light = scope.spawn(move || queue.turn("lightcraft", &GO, &mut || turned.send(()).unwrap()).map(drop));
+            // Nothing is downloading, yet LightCraft waits for WordCraft, which is above it.
+            turns.recv().unwrap();
+            drop(word.turn(&GO, &mut || panic!("WordCraft is first in line")).unwrap());
+            assert_eq!(light.join().unwrap(), Ok(()));
+        });
+    }
+
+    /// A place dropped before its turn (the job failed or found its file already downloaded) lets
+    /// the next job start.
+    #[test]
+    fn a_place_given_up_before_its_turn_frees_the_line() {
+        let queue = Downloads::new();
+        queue.set_order(vec!["wordcraft".into(), "lightcraft".into()]);
+        let word = queue.join("wordcraft");
+        std::thread::scope(|scope| {
+            let (turned, turns) = mpsc::channel();
+            let queue = &queue;
+            let light = scope.spawn(move || queue.turn("lightcraft", &GO, &mut || turned.send(()).unwrap()).map(drop));
+            turns.recv().unwrap();
+            // WordCraft's job failed, or found its download already done.
+            drop(word);
+            assert_eq!(light.join().unwrap(), Ok(()));
+        });
+        assert!(queue.lock().waiting.is_empty());
+    }
+
+    /// A download that panics still frees its slot.
+    #[test]
+    fn a_download_that_dies_frees_the_line() {
+        let queue = Downloads::new();
+        std::thread::scope(|scope| {
+            let died = scope.spawn(|| {
+                let _turn = queue.turn("photocraft", &GO, &mut || {}).unwrap();
+                panic!("the download died");
+            });
+            assert!(died.join().is_err());
+        });
+        drop(queue.turn("wordcraft", &GO, &mut || panic!("the line is free")).unwrap());
+    }
+}
diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs
--- a/crates/native/src/install.rs
+++ b/crates/native/src/install.rs
@@ -39,6 +39,9 @@ pub struct Job<'a> {
     pub install_locations: &'a InstallLocations,
     /// Write over whatever [`occupied`] found.
     pub replace: bool,
+    /// The job's place in the download line, taken when it started (none: it takes one when it
+    /// gets to its download).
+    pub place: Option<crate::downloads::Place<'static>>,
 }
 
 /// Downloads and installs `asset` of `release`. Returns a record when the launcher owns the
@@ -58,6 +61,9 @@ pub fn install(
 
 type RecordCommit<'a> = Option<&'a mut dyn FnMut(&Record) -> Result<(), String>>;
 
+/// Does the work of [`install`], plus `commit` (the caller's way of saving the new record). The
+/// download waits its turn in the line, using the job's place if it has one; a file already
+/// downloaded skips the line.
 #[allow(clippy::too_many_arguments)]
 pub fn install_at(
     app: &CraftApp,
@@ -90,7 +96,30 @@ pub fn install_at(
         return Err(format!("Cannot update {} to {}: installed version {} is newer", app.name, release.version_label(), inst_v));
     }
     log::info!("{}: starting {} to {} ({})", app.name, if existing.is_some() { "update" } else { "install" }, release.version_label(), kind.label());
-    let file = download(release, asset, job)?;
+    let file = {
+        let dest = download_path(job.paths, asset);
+        // A download finished on an earlier try only needs checking: it doesn't wait its turn.
+        let place = job.place.take();
+        let _turn = if crate::http::finished(&dest, asset.size) {
+            drop(place);
+            None
+        } else {
+            let phase = &mut *job.phase;
+            let mut queued = false;
+            let place = place.unwrap_or_else(|| DOWNLOADS.join(app.id));
+            let turn = place.turn(&|| job.stop.check(), &mut || {
+                queued = true;
+                phase(JobPhase::Queued);
+            })?;
+            if queued {
+                log::info!("{}: its turn to download came", app.name);
+                let done = crate::http::partial_bytes(&dest, asset.size).unwrap_or(0);
+                phase(JobPhase::Downloading { done, total: asset.size });
+            }
+            Some(turn)
+        };
+        download(release, asset, job)?
+    };
     log::info!("{}: download ready ({}), running installer...", app.name, file.display());
     // Past this point the install can't be interrupted halfway.
     (job.phase)(JobPhase::Installing);
@@ -750,6 +779,9 @@ pub fn failed_in_shared_call(error: &str) -> bool {
     cfg!(target_os = "linux") && error.ends_with(SHARED_CALL)
 }
 
+/// Apps download one at a time, in the order they are on screen; the others wait their turn here.
+pub static DOWNLOADS: crate::downloads::Downloads = crate::downloads::Downloads::new();
+
 /// The system's installer runs one job at a time; the others wait their turn here.
 #[cfg(any(target_os = "linux", windows))]
 static INSTALLER: crate::queue::Queue = crate::queue::Queue::new();
@@ -2031,6 +2063,7 @@ mod tests {
                 phase: &mut phase,
                 install_locations: &InstallLocations::default(),
                 replace: false,
+                place: None,
             };
             let err = install(app, &release, &asset, kind, None, None, &mut job).unwrap_err();
             assert!(err.contains("isn't supported"), "{err}");
@@ -2055,7 +2088,8 @@ mod tests {
         let http = Http::new().unwrap();
         let mut phases = vec![];
         let mut phase = |p| phases.push(p);
-        let mut job = Job { http: &http, paths: &paths, stop: &Stop::default(), phase: &mut phase, install_locations: &locations, replace: false };
+        let mut job =
+            Job { http: &http, paths: &paths, stop: &Stop::default(), phase: &mut phase, install_locations: &locations, replace: false, place: None };
         let error = install(app, &release, &asset, PackageKind::AppImage, None, None, &mut job).unwrap_err();
         assert!(error.contains("folder is unavailable") || (process::in_sandbox() && error.contains("aren't supported")), "{error}");
         assert!(requests.lock().unwrap().is_empty());
@@ -2085,6 +2119,7 @@ mod tests {
             phase: &mut phase,
             install_locations: &InstallLocations::default(),
             replace: false,
+            place: None,
         };
         let err = install(app, &release, &asset, kind, None, None, &mut job).unwrap_err();
         assert!(err.contains("publishes no checksum"), "{err}");
@@ -2574,7 +2609,7 @@ mod tests {
             package: None,
         };
         let stop = Stop::default();
-        let mut job = Job { http: &http, paths: &paths, stop: &stop, phase, install_locations: &locations, replace: false };
+        let mut job = Job { http: &http, paths: &paths, stop: &stop, phase, install_locations: &locations, replace: false, place: None };
         let result = install_at(app, &release, &asset, PackageKind::AppImage, Some(&installed), None, &mut job, None);
         assert!(result.is_err());
         assert!(result.unwrap_err().contains("installed version 0.5.0 is newer"));
@@ -2597,7 +2632,7 @@ mod tests {
             ..Asset::default()
         };
         let stop = Stop::default();
-        let mut job = Job { http: &http, paths: &paths, stop: &stop, phase, install_locations: &locations, replace: false };
+        let mut job = Job { http: &http, paths: &paths, stop: &stop, phase, install_locations: &locations, replace: false, place: None };
         let result = install_at(app, &release, &asset, PackageKind::AppImage, None, None, &mut job, None);
         assert!(result.is_err(), "mismatched artifact version must be rejected");
         assert!(result.unwrap_err().contains("does not match the advertised version"));
diff --git a/crates/native/src/lib.rs b/crates/native/src/lib.rs
--- a/crates/native/src/lib.rs
+++ b/crates/native/src/lib.rs
@@ -10,6 +10,7 @@
 pub mod backend;
 pub mod desktop_entry;
 pub mod detect;
+pub mod downloads;
 pub mod feed_throttle;
 pub mod http;
 pub mod install;
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -173,6 +173,8 @@ pub struct LauncherUi {
     /// Set when such a language comes into use, then kept for the session.
     pub(crate) cjk_font: Option<crate::i18n::Lang>,
     sent_options: Option<BackendOptions>,
+    /// The apps on screen in their order, as last told to the backend (queued downloads go in it).
+    sent_order: Vec<&'static str>,
     /// Unix seconds of the last look at what's installed (startup counts).
     last_detect: i64,
     /// Whether the window had focus last frame, to re-check when the user comes back to it.
@@ -262,6 +264,7 @@ impl LauncherUi {
             fonts_ready: false,
             cjk_font,
             sent_options: None,
+            sent_order: Vec::new(),
             last_detect: 0,
             was_focused: true,
             auto_update_attempted: std::collections::BTreeSet::new(),
@@ -415,16 +418,25 @@ impl LauncherUi {
         self.entries.iter_mut().find(|e| e.app.id == id)
     }
 
+    /// Sends the backend the settings it needs and, when it changed, the order of the apps on
+    /// screen. Runs every frame; only changes are sent.
     pub(crate) fn configure(&mut self) {
         let options = BackendOptions {
             kinds: self.settings.preferred_kinds.clone(),
             prereleases: self.settings.prereleases,
+            simultaneous_downloads: self.settings.simultaneous_downloads,
             install_locations: self.settings.install_locations.clone(),
         };
         if self.sent_options.as_ref() != Some(&options) {
             self.backend.send(Command::Configure(options.clone()));
             self.sent_options = Some(options);
         }
+        // Only the apps on screen: the backend puts any other app after them, in suite order.
+        let order = self.visible_ids();
+        if order != self.sent_order {
+            self.backend.send(Command::DownloadOrder(order.iter().map(|id| id.to_string()).collect()));
+            self.sent_order = order;
+        }
     }
 
     pub(crate) fn custom_install_folders(&self) -> bool {
@@ -1054,6 +1066,56 @@ mod tests {
         sent.borrow().iter().filter(|command| matches!(command, Command::Install { .. })).count()
     }
 
+    /// The last screen order sent to the backend for its download queue.
+    fn last_order(sent: &RefCell<Vec<Command>>) -> Option<Vec<String>> {
+        sent.borrow().iter().rev().find_map(|command| match command {
+            Command::DownloadOrder(order) => Some(order.clone()),
+            _ => None,
+        })
+    }
+
+    #[test]
+    /// Hidden apps are left out of the download order, so the backend puts them after the apps on
+    /// screen, in suite order.
+    fn the_download_order_leaves_out_hidden_apps() {
+        let (mut launcher, sent, _ctx) = launcher();
+        launcher.settings.hidden.extend(["wordcraft".to_string(), "filmcraft".to_string()]);
+        launcher.configure();
+        let order = last_order(&sent).expect("an order is sent");
+        assert_eq!(order, launcher.visible_ids());
+        assert_eq!(order.len(), SUITE.len() - 2);
+        assert!(!order.iter().any(|id| id == "wordcraft" || id == "filmcraft"), "{order:?}");
+    }
+
+    #[test]
+    /// Apps a search hides are left out of the download order, and come back once it is cleared.
+    fn the_download_order_follows_the_search() {
+        let (mut launcher, sent, _ctx) = launcher();
+        launcher.search = "photocraft".into();
+        launcher.configure();
+        assert_eq!(last_order(&sent).expect("an order is sent"), ["photocraft"]);
+        launcher.search.clear();
+        launcher.configure();
+        assert_eq!(last_order(&sent).expect("an order is sent"), launcher.visible_ids());
+        assert_eq!(launcher.visible_ids().len(), SUITE.len());
+    }
+
+    #[test]
+    /// The number of downloads allowed at once reaches the backend, and settings saved before it
+    /// existed allow one.
+    fn simultaneous_downloads_reach_the_backend() {
+        let old: Settings = serde_json::from_str(r#"{"prereleases":true}"#).unwrap();
+        assert_eq!(old.simultaneous_downloads, 1);
+        let (mut launcher, sent, _ctx) = launcher();
+        launcher.settings.simultaneous_downloads = 3;
+        launcher.configure();
+        let configured = sent.borrow().iter().rev().find_map(|command| match command {
+            Command::Configure(options) => Some(options.simultaneous_downloads),
+            _ => None,
+        });
+        assert_eq!(configured, Some(3));
+    }
+
     /// A CJK preference is drawable only while that language's own face is installed: having the
     /// system font cached, or another script's face installed, is not enough.
     #[test]
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -136,6 +136,9 @@
 	Retry update to {version}	Zkusit aktualizaci na {version} znovu
 	The download is still here. It's checked again, then installed.	Stažený soubor je stále k dispozici. Znovu se ověří a poté nainstaluje.
 	Waiting for another app…	Čekání na jinou aplikaci…
+	Waiting for another download…	Čekání na jiné stahování…
+	Simultaneous downloads	Souběžná stahování
+	Downloads running together share your connection, so each one is slower.	Souběžná stahování sdílejí vaše připojení, takže každé je pomalejší.
 	Waiting for other installs…	Čekání na dokončení ostatních instalací…
 	Installer (.msi)	Instalační program (.msi)
 	Portable (.zip)	Přenosná verze (.zip)
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -150,6 +150,9 @@
 	Retry update to {version}	Update auf {version} wiederholen
 	The download is still here. It's checked again, then installed.	Der Download ist noch da. Er wird erneut geprüft und dann installiert.
 	Waiting for another app…	Warten auf eine andere App…
+	Waiting for another download…	Warten auf einen anderen Download…
+	Simultaneous downloads	Gleichzeitige Downloads
+	Downloads running together share your connection, so each one is slower.	Gleichzeitige Downloads teilen sich Ihre Verbindung, daher ist jeder langsamer.
 	Waiting for other installs…	Warten auf andere Installationen…
 
 # Package formats offered for download (PackageKind::label)
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -149,6 +149,9 @@
 	Retry update to {version}	Reintentar la actualización a {version}
 	The download is still here. It's checked again, then installed.	La descarga sigue aquí. Se vuelve a comprobar y luego se instala.
 	Waiting for another app…	Esperando a otra aplicación…
+	Waiting for another download…	Esperando a otra descarga…
+	Simultaneous downloads	Descargas simultáneas
+	Downloads running together share your connection, so each one is slower.	Las descargas simultáneas comparten tu conexión, así que cada una es más lenta.
 	Waiting for other installs…	Esperando a otras instalaciones…
 	Installer (.msi)	Instalador (.msi)
 	Portable (.zip)	Portátil (.zip)
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -148,6 +148,9 @@
 	Retry update to {version}	Réessayer la mise à jour vers {version}
 	The download is still here. It's checked again, then installed.	Le téléchargement est toujours là. Il sera vérifié à nouveau, puis installé.
 	Waiting for another app…	En attente d'une autre application…
+	Waiting for another download…	En attente d'un autre téléchargement…
+	Simultaneous downloads	Téléchargements simultanés
+	Downloads running together share your connection, so each one is slower.	Les téléchargements simultanés partagent votre connexion, chacun est donc plus lent.
 	Waiting for other installs…	En attente d'autres installations…
 
 # Package formats offered for download (PackageKind::label)
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -150,6 +150,9 @@
 	Retry update to {version}	Frissítés újrapróbálása: {version}
 	The download is still here. It's checked again, then installed.	A letöltött fájl megmaradt. Újra ellenőrizzük, majd telepítjük.
 	Waiting for another app…	Várakozás egy másik alkalmazásra…
+	Waiting for another download…	Várakozás egy másik letöltésre…
+	Simultaneous downloads	Egyidejű letöltések
+	Downloads running together share your connection, so each one is slower.	Az egyidejű letöltések megosztják a kapcsolatot, így mindegyik lassabb.
 	Waiting for other installs…	Várakozás a többi telepítésre…
 
 # Package formats offered for download (PackageKind::label)
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -152,6 +152,9 @@
 	Retry update to {version}	{version} への更新を再試行
 	The download is still here. It's checked again, then installed.	ダウンロードしたファイルはそのまま残っています。もう一度検証してからインストールします。
 	Waiting for another app…	他のアプリを待機中…
+	Waiting for another download…	他のダウンロードを待機中…
+	Simultaneous downloads	同時ダウンロード数
+	Downloads running together share your connection, so each one is slower.	同時に行うダウンロードは接続を共有するため、それぞれが遅くなります。
 	Waiting for other installs…	他のインストールを待機中…
 
 # Package formats offered for download (PackageKind::label)
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -151,6 +151,9 @@
 	Retry update to {version}	Ponów aktualizację do {version}
 	The download is still here. It's checked again, then installed.	Pobrany plik wciąż tu jest. Zostanie sprawdzony ponownie, a następnie zainstalowany.
 	Waiting for another app…	Oczekiwanie na inną aplikację…
+	Waiting for another download…	Oczekiwanie na inne pobieranie…
+	Simultaneous downloads	Jednoczesne pobierania
+	Downloads running together share your connection, so each one is slower.	Jednoczesne pobierania dzielą połączenie, więc każde jest wolniejsze.
 	Waiting for other installs…	Oczekiwanie na inne instalacje…
 
 # Package formats offered for download (PackageKind::label)
diff --git a/crates/ui-egui/src/i18n/pt-BR.tsv b/crates/ui-egui/src/i18n/pt-BR.tsv
--- a/crates/ui-egui/src/i18n/pt-BR.tsv
+++ b/crates/ui-egui/src/i18n/pt-BR.tsv
@@ -138,6 +138,9 @@
 	Retry update to {version}	Tentar atualizar novamente para {version}
 	The download is still here. It's checked again, then installed.	O arquivo baixado ainda está aqui. Ele será verificado novamente e instalado.
 	Waiting for another app…	Aguardando outro aplicativo…
+	Waiting for another download…	Aguardando outro download…
+	Simultaneous downloads	Downloads simultâneos
+	Downloads running together share your connection, so each one is slower.	Downloads simultâneos compartilham sua conexão, então cada um fica mais lento.
 	Waiting for other installs…	Aguardando outras instalações…
 
 # Package formats offered for download (PackageKind::label)
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -148,6 +148,9 @@
 	Retry update to {version}	Повторить обновление до {version}
 	The download is still here. It's checked again, then installed.	Скачанный файл сохранён. Он будет проверен ещё раз, затем установлен.
 	Waiting for another app…	Ожидание другого приложения…
+	Waiting for another download…	Ожидание другой загрузки…
+	Simultaneous downloads	Одновременные загрузки
+	Downloads running together share your connection, so each one is slower.	Одновременные загрузки делят ваше подключение, поэтому каждая идёт медленнее.
 	Waiting for other installs…	Ожидание других установок…
 
 # Package formats offered for download (PackageKind::label)
diff --git a/crates/ui-egui/src/i18n/zh-CN.tsv b/crates/ui-egui/src/i18n/zh-CN.tsv
--- a/crates/ui-egui/src/i18n/zh-CN.tsv
+++ b/crates/ui-egui/src/i18n/zh-CN.tsv
@@ -283,6 +283,9 @@ figcraft	Interface design and prototyping	界面设计与原型制作
 	Retry update to {version}	重试更新至 {version}
 	The download is still here. It's checked again, then installed.	已下载的文件仍在。会先重新校验，然后安装。
 	Waiting for another app…	正在等待其他应用…
+	Waiting for another download…	正在等待其他下载…
+	Simultaneous downloads	同时下载数
+	Downloads running together share your connection, so each one is slower.	同时进行的下载会共享网络连接，因此每个下载都会变慢。
 	Waiting for other installs…	正在等待其他安装…
 
 # Package formats offered for download (PackageKind::label)
diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs
--- a/crates/ui-egui/src/settings.rs
+++ b/crates/ui-egui/src/settings.rs
@@ -108,6 +108,8 @@ pub struct Settings {
     /// Show a system notification when an installed app has an update.
     pub notify: bool,
     pub prereleases: bool,
+    /// Downloads that may run at once (1 to 4).
+    pub simultaneous_downloads: u8,
     /// Linux: the package format to prefer for new installs (empty: the system's own).
     pub preferred_kinds: Vec<PackageKind>,
     /// Announcements and alerts from the release feed the user dismissed (by notice id).
@@ -182,6 +184,7 @@ pub fn default_preferred_kinds() -> Vec<PackageKind> {
 }
 
 impl Default for Settings {
+    /// The settings a new install starts with.
     fn default() -> Self {
         Self {
             install_locations: Default::default(),
@@ -197,6 +200,7 @@ impl Default for Settings {
             auto_update: false,
             notify: true,
             prereleases: false,
+            simultaneous_downloads: 1,
             preferred_kinds: default_preferred_kinds(),
             dismissed_notices: std::collections::BTreeSet::new(),
             notified: BTreeMap::new(),
diff --git a/crates/ui-egui/src/settings_dialog.rs b/crates/ui-egui/src/settings_dialog.rs
--- a/crates/ui-egui/src/settings_dialog.rs
+++ b/crates/ui-egui/src/settings_dialog.rs
@@ -190,6 +190,23 @@ impl LauncherUi {
             {
                 self.trigger_auto_updates();
             }
+            if self.caps.manage_apps {
+                let mut at_once = self.settings.simultaneous_downloads.clamp(1, 4);
+                row(ui, tl!("Simultaneous downloads"), 150.0, &mut |ui, label| {
+                    egui::ComboBox::from_id_salt("at_once")
+                        .selected_text(at_once.to_string())
+                        .width(150.0)
+                        .show_ui(ui, |ui| {
+                            for n in 1..=4 {
+                                ui.selectable_value(&mut at_once, n, n.to_string());
+                            }
+                        })
+                        .response
+                        .labelled_by(label);
+                });
+                self.settings.simultaneous_downloads = at_once;
+                note(ui, tl!("Downloads running together share your connection, so each one is slower."));
+            }
             ui.checkbox(&mut self.settings.notify, tl!("Notify me when an installed app has an update"));
             ui.checkbox(&mut self.settings.prereleases, tl!("Include pre-releases (betas and release candidates)"));
         });
diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs
--- a/crates/ui-egui/src/views.rs
+++ b/crates/ui-egui/src/views.rs
@@ -150,7 +150,7 @@ impl LauncherUi {
                     ),
                     JobPhase::Downloading { .. } | JobPhase::Reconnecting { .. } => (None, tl!("Downloading…").to_string()),
                     JobPhase::Verifying => (None, tl!("Verifying…").to_string()),
-                    JobPhase::Waiting => (None, tl!("Waiting for other installs…").to_string()),
+                    JobPhase::Waiting | JobPhase::Queued => (None, tl!("Waiting for other installs…").to_string()),
                     JobPhase::Installing | JobPhase::Removing => (None, tl!("Installing, then restarting…").to_string()),
                 };
                 // Another app's install can hang on a password prompt: the wait can be called off.
@@ -871,13 +871,15 @@ impl LauncherUi {
                 (f, if short { tl!("Reconnecting…").into() } else { format!("{} {}", tl!("Reconnecting…"), amount(done, total)) }, t.warn)
             }
             JobPhase::Verifying => (None, tl!("Verifying checksum…").into(), rgb(entry.app.color)),
+            JobPhase::Queued => (None, tl!("Waiting for another download…").into(), rgb(entry.app.color)),
             JobPhase::Waiting => (None, tl!("Waiting for another app…").into(), rgb(entry.app.color)),
             JobPhase::Installing => (None, tl!("Installing…").into(), rgb(entry.app.color)),
             JobPhase::Removing => (None, tl!("Removing…").into(), rgb(entry.app.color)),
         };
         let downloading = matches!(phase, JobPhase::Downloading { .. } | JobPhase::Reconnecting { .. });
-        // A job waiting for the installer hasn't started it yet, so it can still be called off.
-        let waiting = phase == JobPhase::Waiting;
+        // A job waiting for its download or for the installer hasn't started it yet, so it can
+        // still be called off.
+        let waiting = matches!(phase, JobPhase::Queued | JobPhase::Waiting);
         // Waiting has one square button beside the bar, like the menu otherwise.
         widgets::progress(ui, fraction, &caption, color, if downloading { bar_w } else { main_w });
         if downloading && widgets::glyph_button(ui, widgets::Glyph::Pause, tl!("Pause download")).clicked() {
