Blog

Rust document conversion with reqwest

17 Aug 2026 · 6 min read


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:

ClientAvg total latencyClient overheadThroughput (10 concurrent)
Rust (reqwest)342 ms1.8 ms512 docs/min
Node.js (undici)348 ms7.2 ms478 docs/min
Python (httpx)355 ms14.1 ms423 docs/min
Go (net/http)345 ms4.5 ms495 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:

CategoryFormats
DocumentsPDF, DOCX, ODT, RTF, EPUB, CBR/CBZ
PresentationsPPTX, ODP
SpreadsheetsXLSX, ODS, CSV (rendered as tables)
WebHTML, Markdown (pass-through)
ImagesOCR extraction from JPG, PNG, TIFF

Pricing

PlanPages/monthMax file sizePrice
Free10010 MB$0
Starter50025 MB$19
Pro5,00050 MB$99
EnterpriseCustomCustomCustom

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.


← Read more →