Skip to content
Select theme

Showing progress for slow requests

This step finishes: the silence. Since the error handling went in, hit answers at a glance — but while a request is in flight it says nothing at all. Against a slow server you sit at a prompt wondering whether the network hung or the program did. This step adds a spinner that appears only when the wait is real: nothing at all for requests that answer inside 200 ms, a spinning line after that, erased the moment the response lands — and none of it piped into someone else’s output.

You need the project as the previous article left it. If you are starting from scratch, that state is tagged cli-ch06 in the examples repository:

Terminal window
git clone https://github.com/hackingwithrust/examples.git
cd examples/cli-tool
git checkout cli-ch06
Terminal window
cargo add indicatif
Cargo.toml
[dependencies]
clap = { version = "4", features = ["derive"] }
comfy-table = "8.0.1"
indicatif = "0.18.6"
owo-colors = { version = "4.4.0", features = ["supports-colors"] }
reqwest = { version = "0.13", features = ["blocking"] }
thiserror = "2"

indicatif draws progress bars and spinners. It needs no feature flags for this job, and it has one default worth knowing before you write a line: it draws to stderr, and it hides itself when stderr is not a terminal. Run this:

use indicatif::{ProgressBar, ProgressStyle};
use std::thread;
use std::time::Duration;
fn main() {
let spinner = ProgressBar::new_spinner();
spinner.set_style(ProgressStyle::with_template("{spinner:.green} {msg}").unwrap());
spinner.set_message("waiting for the server");
spinner.enable_steady_tick(Duration::from_millis(100));
thread::sleep(Duration::from_millis(1500)); // pretend request
spinner.finish_and_clear();
println!("done");
}
done

On a terminal that is a green braille dot animating beside waiting for the server for a second and a half, then an empty line where the spinner was, then done. The capture shows only done because the harness’s stderr is a pipe, and indicatif refused to draw into it. Last step needed if_supports_color and a feature flag to get that restraint; indicatif does it unprompted. It is also why hit get ... | jq never receives spinner bytes, even mid-spin: the spinner lives on stderr, and a pipe only carries stdout. finish_and_clear() erases the line instead of leaving a frozen spinner above your output.

ProgressBar::new_spinner() starts drawing the moment it has a message. Most requests answer in a fraction of a second, and a spinner that flashes on every fast one trains the eye to ignore it. The rule this step implements: the spinner appears only if the request is still in flight 200 ms after it started.

indicatif has no “appear after a delay” switch. The 200 ms decision needs a second thread, because request.send() blocks the main one. Here is the decision on its own, with the request faked as a sleep so the outcome can be printed:

use indicatif::ProgressBar;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
const SLOW_AFTER: Duration = Duration::from_millis(200);
fn main() {
for ms in [50, 600] {
let spinner = ProgressBar::new_spinner();
let shown = Arc::new(AtomicBool::new(false));
let watch_shown = Arc::clone(&shown);
let watchdog = spinner.clone();
thread::spawn(move || {
thread::sleep(SLOW_AFTER);
if !watchdog.is_finished() {
watchdog.enable_steady_tick(Duration::from_millis(100));
watch_shown.store(true, Ordering::Relaxed);
}
});
thread::sleep(Duration::from_millis(ms)); // the fake request
spinner.finish_and_clear();
println!("{ms} ms request, spinner shown: {}", shown.load(Ordering::Relaxed));
}
}
50 ms request, spinner shown: false
600 ms request, spinner shown: true

The watchdog sleeps 200 ms, wakes, and asks one question: has the bar been finished? A fast request has — finish_and_clear() ran long before the watchdog woke — so the watchdog leaves without drawing anything, and a fast request leaves no trace on the terminal. A slow one has not, so the watchdog switches the spinner on and it animates every 100 ms until the main thread finishes it. The shown flag exists only so this demo can print what the watchdog decided; the real code below doesn’t need it.

One honest caveat: a request that lands within a few milliseconds of the threshold can get either treatment, because the watchdog’s 200 ms sleep and the request’s completion race each other. 200 ms is a heuristic, not a contract, and being wrong by one frame costs nothing.

send() is the only place in hit that can take seconds, so it is the only place the spinner goes. The whole file, with the new SLOW_AFTER constant, the two new imports, and send() plus a start_spinner() helper:

src/main.rs
use std::error::Error;
use std::process::ExitCode;
use std::thread;
use std::time::Duration;
use clap::{Parser, Subcommand};
use comfy_table::presets::UTF8_FULL;
use comfy_table::Table;
use indicatif::{ProgressBar, ProgressStyle};
use owo_colors::{OwoColorize, Stream};
use reqwest::blocking::{Client, RequestBuilder, Response};
use thiserror::Error;
/// Requests that answer inside this window never show a spinner.
const SLOW_AFTER: Duration = Duration::from_millis(200);
#[derive(Parser)]
#[command(name = "hit", version, about = "A tiny command-line HTTP client")]
struct Cli {
/// Print what we are about to do
#[arg(short, long, global = true)]
verbose: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Send a GET request
Get {
/// The URL to request
url: String,
/// Send this header, for example -H "Accept: application/json"
#[arg(short = 'H', long = "header")]
headers: Vec<String>,
},
/// Send a POST request with a body
Post {
/// The URL to request
url: String,
/// Request body
#[arg(short, long)]
body: Option<String>,
},
}
#[derive(Debug, Error)]
enum HitError {
#[error("{url} did not answer within 10 seconds")]
Timeout {
url: String,
#[source]
source: reqwest::Error,
},
#[error("could not reach {url}")]
Connect {
url: String,
#[source]
source: reqwest::Error,
},
#[error("invalid --header {header:?}: expected \"Name: value\"")]
InvalidHeader { header: String },
#[error(transparent)]
Request(#[from] reqwest::Error),
}
fn main() -> ExitCode {
let cli = Cli::parse();
match run(cli) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("hit: {e}");
let mut source = e.source();
while let Some(err) = source {
eprintln!(" because: {err}");
source = err.source();
}
ExitCode::FAILURE
}
}
}
fn run(cli: Cli) -> Result<(), HitError> {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
match cli.command {
Command::Get { url, headers } => {
if cli.verbose {
eprintln!("GET {url}");
for header in &headers {
eprintln!(" header: {header}");
}
}
let mut request = client.get(&url);
for header in &headers {
let (name, value) = header
.split_once(':')
.ok_or_else(|| HitError::InvalidHeader { header: header.clone() })?;
request = request.header(name.trim(), value.trim());
}
let response = send(request, &url)?;
print_response(response)?;
}
Command::Post { url, body } => {
if cli.verbose {
eprintln!("POST {url}");
}
let mut request = client.post(&url);
if let Some(body) = body {
request = request.body(body);
}
let response = send(request, &url)?;
print_response(response)?;
}
}
Ok(())
}
fn send(request: RequestBuilder, url: &str) -> Result<Response, HitError> {
let spinner = start_spinner(url);
let result = request.send().map_err(|error| match error {
e if e.is_timeout() => HitError::Timeout {
url: url.to_string(),
source: e,
},
e if e.is_connect() => HitError::Connect {
url: url.to_string(),
source: e,
},
e => HitError::Request(e),
});
spinner.finish_and_clear();
result
}
fn start_spinner(url: &str) -> ProgressBar {
let spinner = ProgressBar::new_spinner();
spinner.set_style(ProgressStyle::with_template("{spinner:.green} {msg}").unwrap());
// The request blocks this thread, so a watchdog thread decides whether
// the wait is long enough to be worth showing. Nothing is drawn until
// the watchdog speaks: a fast request leaves no trace on the terminal.
let url = url.to_string();
let watchdog_spinner = spinner.clone();
thread::spawn(move || {
thread::sleep(SLOW_AFTER);
if !watchdog_spinner.is_finished() {
watchdog_spinner.set_message(format!("waiting for {url}"));
watchdog_spinner.enable_steady_tick(Duration::from_millis(100));
}
});
spinner
}
fn print_response(response: Response) -> Result<(), HitError> {
println!("{}", paint_status(response.status()));
let mut table = Table::new();
table.load_style(UTF8_FULL);
table.set_header(vec!["Header", "Value"]);
for (name, value) in response.headers() {
table.add_row(vec![name.as_str(), value.to_str().unwrap_or("<binary>")]);
}
println!("{table}");
println!();
let body = response.text()?;
println!("{body}");
Ok(())
}
fn paint_status(status: reqwest::StatusCode) -> String {
let text = status.to_string();
if status.is_success() {
text.if_supports_color(Stream::Stdout, |t| t.green()).to_string()
} else if status.is_redirection() {
text.if_supports_color(Stream::Stdout, |t| t.cyan()).to_string()
} else if status.is_client_error() {
text.if_supports_color(Stream::Stdout, |t| t.yellow()).to_string()
} else if status.is_server_error() {
text.if_supports_color(Stream::Stdout, |t| t.red()).to_string()
} else {
text
}
}

Three things worth noticing in send(). The spinner starts before the request and finish_and_clear() runs before the result is returned — error or not — so a failed request never leaves a spinner hanging above its error message. The watchdog closure gets its own clone of the ProgressBar, which is a cheap handle around shared state, not a second spinner. And nothing in print_response moved: the spinner draws on stderr, the response prints on stdout, and the two never interleave in a pipe.

Run it:

Terminal window
cargo run -- get https://example.org
200 OK
┌─────────────────┬───────────────────────────────┐
│ Header ┆ Value │
╞═════════════════╪═══════════════════════════════╡
│ date ┆ Fri, 16 Oct 2026 18:56:43 GMT │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ content-type ┆ text/html; charset=utf-8 │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ server ┆ cloudflare │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ last-modified ┆ Tue, 13 Oct 2026 22:05:48 GMT │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ allow ┆ GET, HEAD │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ accept-ranges ┆ bytes │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ age ┆ 8199 │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ cache-control ┆ max-age=14400 │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ cf-cache-status ┆ HIT │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ cf-ray ┆ a4b949026849e5da-FRA │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ alt-svc ┆ h3=":443"; ma=86400 │
└─────────────────┴───────────────────────────────┘
<!doctype html><html lang=en><head><meta charset=utf-8><link rel=icon href=data:,><meta name=viewport content="width=device-width,initial-scale=1"><title>Example Domain</title><style>html{color-scheme:light dark;background:light-dark(#eee,#222)}body{font:16px/1.6 system-ui,sans-serif;max-width:26em;margin:auto;padding:25vh 2em 2em;text-align:center}</style></head><body><p>This domain is for use in documentation examples without needing permission. This is not a service; avoid relying on it for testing and monitoring purposes.</p><script src=/s.js></script></body></html>

No spinner above the 200 OK — that capture ran with stderr as a pipe, where indicatif hides itself, and example.org answered in well under a second on this machine. On your terminal, whether the waiting line flashes past depends on how fast your connection answers: under 200 ms and you see nothing, over it and you get the spinner.

The point of the feature needs a request that is genuinely slow:

Terminal window
cargo run -- get https://httpbin.org/delay/2

httpbin’s /delay/2 endpoint answers after two seconds. On a terminal, about 200 ms after you press Enter the line ⠁ waiting for https://httpbin.org/delay/2 appears, the braille dot spinning; when the response lands the line is erased and the familiar output starts at the top of it:

200 OK
┌──────────────────────────────────┬───────────────────────────────┐
│ Header ┆ Value │
╞══════════════════════════════════╪═══════════════════════════════╡
│ date ┆ Fri, 16 Oct 2026 18:53:23 GMT │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ content-type ┆ application/json │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ content-length ┆ 273 │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ server ┆ gunicorn/19.9.0 │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ access-control-allow-origin ┆ * │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ access-control-allow-credentials ┆ true │
└──────────────────────────────────┴───────────────────────────────┘
{
"args": {},
"data": "",
"files": {},
"form": {},
"headers": {
"Accept": "*/*",
"Host": "httpbin.org",
"X-Amzn-Trace-Id": "Root=1-6ad272a1-58c850885f54e2ba6ce10e77"
},
"origin": "171.113.217.182",
"url": "https://httpbin.org/delay/2"
}

Your own run will differ in the date, X-Amzn-Trace-Id and origin rows. Piped, the same command produces this output with no escape codes anywhere in it — spinner and colour both stay out of the pipe.

A request that never answers clears the spinner too. hit’s timeout is ten seconds, so against /delay/10 the spinner spins for the full wait and then:

Terminal window
cargo run -- get https://httpbin.org/delay/10
hit: https://httpbin.org/delay/10 did not answer within 10 seconds
because: error sending request for url (https://httpbin.org/delay/10)
because: operation timed out

The line vanishes first, the error prints after it, and the exit code is 1. That ordering is not luck: finish_and_clear() sits between send() and the return, so every path out of send() — response, timeout, connection refused — starts from a clean terminal.

This step’s end state is tagged cli-ch07 in the examples repository — the project exactly as this article leaves it:

Terminal window
git clone https://github.com/hackingwithrust/examples.git
cd examples/cli-tool
git checkout cli-ch07

If your terminal shows something different from what this article describes, checking out the tag and diffing it against your own tree shows which line drifted.

hit now behaves the same at every speed: quiet when fast, honest when slow, clean when piped. Every value in it is still hard-coded, though — the ten-second timeout, the 200 ms threshold, the output shape. Next it learns to read defaults from a config file, so ~/.hit.toml can carry the settings you keep retyping.

← Back to the track overview