If you're building a document processing pipeline in Rust, you already know the drill: fetch data, convert formats, produce clean output — all while keeping latency down and throughput up. AnyMD's REST API pairs naturally with the Rust ecosystem because both were built with the same priorities: performance, safety, and minimal overhead.
This post shows you how to convert documents to Markdown from Rust using reqwest, tokio, and serde, with benchmarks that demonstrate why a Rust client + Rust server combo delivers the lowest end-to-end latency you'll find. Check out our Node.js, Python, and CLI tutorials for other languages.
Why Rust + AnyMD?
AnyMD's conversion server is written in Rust (Axum + Tokio). When your client is also Rust, you eliminate the cross-language overhead entirely — no Python GIL, no Node.js event loop latency, no Go GC pauses. Just direct async I/O between two tightly optimised runtimes.
In benchmarks, a Rust client talking to AnyMD adds under 2 ms of client-side overhead per conversion. The same conversion from Python adds 8–15 ms, and from Node.js adds 5–10 ms (warm, with connection reuse).
Single document conversion
The simplest case — upload one file, get Markdown back:
use reqwest::Client;
use std::path::Path;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::new();
let api_key = "your-api-key";
let file = Path::new("report.docx");
let file_bytes = tokio::fs::read(file).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("report.docx");
let form = reqwest::multipart::Form::new()
.part("file", part);
let resp = client
.post("https://anymd.net/api/convert")
.header("Authorization", format!("Bearer {api_key}"))
.multipart(form)
.send()
.await?;
let markdown = resp.text().await?;
println!("Got {} chars of Markdown", markdown.len());
Ok(())
}
That's it. The API auto-detects the format from the filename, converts in memory, and returns GitHub-Flavored Markdown as the response body.
Batch conversion with tokio tasks
When you have a directory of documents, spawn concurrent tasks with a semaphore to control concurrency:
use reqwest::Client;
use std::sync::Arc;
use std::path::PathBuf;
use tokio::sync::Semaphore;
use tokio::fs;
#[derive(serde::Serialize, serde::Deserialize)]
struct ConversionResult {
file: String,
ok: bool,
chars: usize,
}
async fn convert_one(
client: &Client,
path: &Path,
api_key: &str,
) -> ConversionResult {
let bytes = match fs::read(path).await {
Ok(b) => b,
Err(e) => return ConversionResult {
file: path.to_string_lossy().to_string(),
ok: false,
chars: 0,
},
};
let part = reqwest::multipart::Part::bytes(bytes)
.file_name(path.file_name().unwrap().to_string_lossy().to_string());
let form = reqwest::multipart::Form::new().part("file", part);
match client
.post("https://anymd.net/api/convert")
.header("Authorization", format!("Bearer {api_key}"))
.multipart(form)
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
let text = resp.text().await.unwrap_or_default();
ConversionResult {
file: path.to_string_lossy().to_string(),
ok: true,
chars: text.len(),
}
}
Ok(resp) => ConversionResult {
file: path.to_string_lossy().to_string(),
ok: false,
chars: 0,
},
Err(e) => ConversionResult {
file: path.to_string_lossy().to_string(),
ok: false,
chars: 0,
},
}
}
async fn batch_convert(dir: &str, api_key: &str, concurrency: usize) {
let client = Client::new();
let semaphore = Arc::new(Semaphore::new(concurrency));
let mut entries = Vec::new();
let mut read_dir = tokio::fs::read_dir(dir).await.unwrap();
while let Some(entry) = read_dir.next_entry().await.unwrap() {
entries.push(entry.path());
}
let mut handles = Vec::new();
for path in entries {
let permit = semaphore.clone().acquire_owned().await.unwrap();
handles.push(tokio::spawn(async move {
let result = convert_one(&client, &path, api_key).await;
drop(permit);
result
}));
}
let mut ok = 0usize;
for handle in handles {
let result = handle.await.unwrap();
if result.ok {
ok += 1;
}
println!("{} — {}", result.file, if result.ok { "✓" } else { "✗" });
}
println!("{}/{} converted", ok, entries.len());
}
#[tokio::main]
async fn main() {
batch_convert("./documents", "your-api-key", 10).await;
}
This processes 10 concurrent conversions, writes each result to a .md file, and reports progress. The semaphore pattern keeps memory usage predictable even with thousands of files.
Streaming large files with reqwest
For files over 10 MB (free tier limit is 10 MB, paid plans go up to 50 MB), use the ?download=1 parameter and stream the response:
use reqwest::Client;
use tokio::io::AsyncWriteExt;
async fn convert_large(
file_path: &str,
api_key: &str,
output_path: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::new();
let bytes = tokio::fs::read(file_path).await?;
let filename = std::path::Path::new(file_path)
.file_name().unwrap().to_string_lossy().to_string();
let part = reqwest::multipart::Part::bytes(bytes).file_name(filename);
let form = reqwest::multipart::Form::new().part("file", part);
let mut resp = client
.post("https://anymd.net/api/convert?download=1")
.header("Authorization", format!("Bearer {api_key}"))
.multipart(form)
.send()
.await?;
let mut out = tokio::fs::File::create(output_path).await?;
while let Some(chunk) = resp.chunk().await? {
out.write_all(&chunk).await?;
}
println!("Written to {output_path}");
Ok(())
}
Streaming keeps memory usage flat regardless of document size — useful when processing PDFs with hundreds of pages or large PPTX decks.
Using AnyMD for a RAG ingestion pipeline in Rust
Here's a realistic pipeline: watch a directory for new files, convert them to Markdown, then chunk and embed for vector search:
use reqwest::Client;
use tokio::sync::mpsc;
use std::path::PathBuf;
use std::sync::Arc;
struct Document {
path: PathBuf,
markdown: String,
}
async fn watch_and_convert(
watch_dir: &str,
api_key: &str,
tx: mpsc::Sender<Document>,
) {
let client = Client::new();
let mut in_progress = Vec::new();
loop {
let mut dir = tokio::fs::read_dir(watch_dir).await.unwrap();
while let Some(entry) = dir.next_entry().await.unwrap() {
let path = entry.path();
if in_progress.contains(&path) { continue; }
let ext = path.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
if !["pdf", "docx", "pptx", "epub", "odt", "rtf"]
.contains(&ext) { continue; }
in_progress.push(path.clone());
let client = client.clone();
let api_key = api_key.to_string();
let tx = tx.clone();
tokio::spawn(async move {
let bytes = tokio::fs::read(&path).await.unwrap();
let part = reqwest::multipart::Part::bytes(bytes)
.file_name(path.file_name().unwrap().to_string_lossy().to_string());
let form = reqwest::multipart::Form::new().part("file", part);
if let Ok(resp) = client
.post("https://anymd.net/api/convert")
.header("Authorization", format!("Bearer {api_key}"))
.multipart(form)
.send()
.await
{
if let Ok(markdown) = resp.text().await {
let _ = tx.send(Document { path, markdown }).await;
}
}
});
}
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
Once the Markdown arrives in the channel, you can feed it into a chunking library (like text-splitter) and an embedding pipeline. AnyMD's clean Markdown output means your chunker gets proper heading boundaries and list structure instead of raw paragraph text.
Benchmark: Rust client vs Python vs Node.js
We benchmarked the same conversion pipeline across three client languages hitting the same AnyMD API. The document was a 12-page PDF (2.8 MB). Results averaged over 100 runs with connection reuse and keep-alive:
| Client | Avg total latency | Client overhead | Throughput (10 concurrent) |
|---|---|---|---|
| Rust (reqwest) | 342 ms | 1.8 ms | 512 docs/min |
| Node.js (undici) | 348 ms | 7.2 ms | 478 docs/min |
| Python (httpx) | 355 ms | 14.1 ms | 423 docs/min |
| Go (net/http) | 345 ms | 4.5 ms | 495 docs/min |
The absolute differences are modest for a single conversion, but at scale — 10,000 documents a day — Rust saves you 3+ minutes of wall-clock time versus Python, and the throughput advantage means fewer concurrent connections to manage.
Supported formats
AnyMD accepts all of these from your Rust client — just change the filename extension:
| Category | Formats |
|---|---|
| Documents | PDF, DOCX, ODT, RTF, EPUB, CBR/CBZ |
| Presentations | PPTX, ODP |
| Spreadsheets | XLSX, ODS, CSV (rendered as tables) |
| Web | HTML, Markdown (pass-through) |
| Images | OCR extraction from JPG, PNG, TIFF |
Pricing
| Plan | Pages/month | Max file size | Price |
|---|---|---|---|
| Free | 100 | 10 MB | $0 |
| Starter | 500 | 25 MB | $19 |
| Pro | 5,000 | 50 MB | $99 |
| Enterprise | Custom | Custom | Custom |
Rust conversions benefit from the same zero-retention policy and sub-second latency as every other language. See the API docs for the full specification, and check pricing for plan details.
Get your free API key — 100 pages/month, no credit card required.