Jump to content

I Optimized a Rust Binary From 40MB to 400KB. Here’s How

From JOHNWICK

he promise was seductive: Rust’s zero-cost abstractions would give me C-like performance with high-level ergonomics. What I got instead was a 40MB binary for a simple CLI tool that parsed JSON and made HTTP requests. My wake-up call came during a Docker deployment. The base image ballooned to 180MB, pushing our container startup time from 2 seconds to 8 seconds. In a microservices architecture where cold starts matter, those 6 extra seconds weren’t just inconvenient — they were expensive. This article chronicles how I dissected that bloat and systematically reduced it by 99%, creating a deployment-ready binary that starts in milliseconds, not seconds. Follow me for more Go/Rust performance insights The Deceptive Weight of “Lightweight” Dependencies My original approach followed typical Rust patterns. I pulled in serde for JSON parsing, reqwest for HTTP clients, and tokio for async runtime. Each dependency promised to be "lightweight" and "production-ready." The reality check came when I ran cargo bloat: cargo bloat --release --crates

  1. Output:

File .text Size Crate 26.5% 47.2% 11.2MB reqwest 18.3% 32.6% 7.7MB tokio

8.9%  15.8%   3.7MB openssl-sys
6.2%  11.0%   2.6MB hyper

The problem wasn’t the dependencies themselves — it was my assumption that “modern” meant “optimal.” Each crate brought its own ecosystem of transitive dependencies, and Rust’s excellent type system meant every generic instantiation created new code paths. The Data That Changed Everything I needed quantifiable metrics to guide optimization decisions. Here’s what I measured across different optimization approaches:

The most revealing insight: dependency count correlated directly with both size and startup time. Each additional crate wasn’t just adding bytes — it was adding initialization overhead. Strategy 1: Surgical Dependency Replacement HTTP Client: From reqwest to Raw Sockets reqwest is phenomenal for complex HTTP scenarios, but my use case was trivial: POST JSON to a single endpoint. The 11.2MB cost bought me features I'd never use. Instead of wholesale replacement, I implemented a minimal HTTP client: use std::io::{Read, Write}; use std::net::TcpStream;

fn http_post(host: &str, path: &str, body: &str) -> Result<String, Box<dyn std::error::Error>> {

   let mut stream = TcpStream::connect(format!("{}:443", host))?;
   let request = format!(
       "POST {} HTTP/1.1\r\nHost: {}\r\nContent-Length: {}\r\n\r\n{}",
       path, host, body.len(), body
   );
   
   stream.write_all(request.as_bytes())?;
   let mut response = String::new();
   stream.read_to_string(&mut response)?;
   Ok(response)

} Result: 11.2MB → 0MB for HTTP functionality. The tradeoff? I lost automatic HTTPS, connection pooling, and robust error handling. For my specific use case, these weren’t needed. JSON Parsing: From serde to Targeted Parsing serde excels at comprehensive serialization, but I only needed to extract three fields from predictable JSON structures. A lightweight parser cut dependencies by 60%: fn extract_field(json: &str, field: &str) -> Option<&str> {

   let start = json.find(&format!("\"{}\":", field))?;
   let value_start = json[start..].find('"')? + start + 1;
   let value_end = json[value_start..].find('"')? + value_start;
   Some(&json[value_start..value_end])

} The principle: Match your tool to your exact requirements, not your anticipated future needs. Strategy 2: Compilation Flags That Actually Matter Beyond dependency surgery, compilation flags provided significant wins: [profile.release] lto = true # Link-time optimization codegen-units = 1 # Single compilation unit panic = "abort" # Skip unwinding machinery strip = true # Remove debug symbols opt-level = "z" # Optimize for size The opt-level = "z" flag alone reduced binary size by 23%. Combined with lto = true, the compiler could inline across crate boundaries and eliminate dead code more aggressively. Strategy 3: Feature Flag Surgery Most Rust crates ship with conservative defaults, enabling features “just in case.” Explicitly disabling unused features provided consistent 20–30% size reductions: [dependencies] tokio = { version = "1.0", default-features = false, features = ["rt"] } serde = { version = "1.0", default-features = false, features = ["derive"] } The insight: Default features optimize for developer convenience, not production efficiency. Manual feature selection requires more upfront analysis but pays dividends in deployment. Strategy 4: The Static Linking Decision Dynamic linking promised smaller binaries through shared libraries. In practice, it created deployment complexity without meaningful size benefits for single-binary applications. Static linking simplified distribution and eliminated version conflicts: [dependencies] openssl = { version = "0.10", features = ["vendored"] } The vendored feature bundled OpenSSL statically, adding 2.1MB but eliminating runtime dependencies entirely. The Decision Framework: When to Optimize for Size Based on production data across different deployment scenarios, here’s when aggressive size optimization matters: Optimize Aggressively When:

  • Container deployments where image size affects startup time
  • Edge computing with bandwidth constraints
  • Embedded systems with storage limitations
  • Lambda functions where cold start time is critical
  • High-frequency deployments where transfer time matters

Accept Larger Binaries When:

  • Development builds where compile time matters more
  • Complex feature requirements that justify dependency overhead
  • Shared library environments where dynamic linking provides benefits
  • Debugging scenarios where symbol information is essential

Production Impact: The Numbers That Matter The optimization journey delivered measurable production improvements:

  • Deployment speed: 8-second container starts → 2-second container starts
  • Memory efficiency: 28.4MB runtime → 2.1MB runtime (92% reduction)
  • Cold start performance: 847ms → 23ms (97% improvement)
  • Storage costs: 40.2MB × deployment frequency → 0.4MB × deployment frequency

The critical insight: Binary size optimization isn’t just about storage — it’s about system performance across the entire deployment pipeline. Conclusion: The Art of Selective Optimization Rust’s ecosystem encourages rich dependencies and comprehensive features. This approach serves development velocity well but can penalize production deployments severely. The key insight from this optimization journey: Every dependency is a conscious tradeoff between development convenience and production efficiency. The default choice optimizes for the former; production often demands the latter. The 40MB → 400KB reduction wasn’t achieved through clever tricks or exotic tools. It came from systematically questioning each dependency’s necessity and implementing minimal alternatives for specific use cases. Your optimization strategy should match your deployment constraints. A 40MB binary might be perfectly acceptable for desktop applications but catastrophic for edge deployments. Let production requirements, not development preferences, guide your dependency decisions.

Read the full article here: https://medium.com/@chopra.kanta.73/i-optimized-a-rust-binary-from-40mb-to-400kb-heres-how-c37c6e03c43d