Rust Std Fs vs Tokio Fs
Synchronous std::fs versus Tokio's async fs wrappers for file I/O in Rust. One is the real filesystem API; the other is a thread-pool costume worn over it.
The short answer
Rust Std Fs over Tokio Fs for most cases. tokio::fs is std::fs running on a blocking thread pool with extra allocation and await overhead.
- Pick Rust Std Fs if reading config, loading assets at startup, processing files in a batch, or anywhere the I/O isn't on a hot concurrent path. This is almost everyone
- Pick Tokio Fs if inside an async server, doing many file ops concurrently, and profiling proves blocking the runtime threads actually hurts you — not because you assumed it would
- Also consider: spawn_blocking with std::fs if you need to bridge sync I/O into async without paying for tokio::fs's per-call wrapper everywhere.
— Nice Pick, opinionated tool recommendations
What you're actually choosing between
This is not async-vs-sync magic. There is no async filesystem syscall on Linux that tokio::fs uses by default — it dispatches std::fs calls onto a blocking thread pool and awaits the result. So tokio::fs IS std::fs, plus a channel round-trip, plus an allocation, plus scheduler overhead, on every single read and write. People reach for it because their function is already async and std::fs::read feels illegal in an async fn. That instinct is the entire reason this comparison exists, and it's mostly wrong. The blocking call to read a 4KB config file completes in microseconds; shipping it to another thread to 'not block' costs more than the block you were avoiding. Know what you bought before you buy it.
Performance: the costume isn't free
std::fs makes the syscall on the calling thread. Done. tokio::fs takes your path, hands the operation to a thread pool, parks the task, wakes it on completion, and allocates along the way. For one small file, that overhead dwarfs the I/O. For a tight loop of thousands of tiny files, tokio::fs will lose to std::fs running on a dedicated blocking thread every time, because you're paying the dispatch tax per call instead of once. tokio::fs only earns its keep when individual operations are large or slow enough that keeping the async runtime's worker threads free to schedule other tasks is worth the per-call cost. If you can't articulate which of your file ops is slow enough to starve the runtime, you don't have that problem, and you're paying for a solution to it anyway.
When Tokio Fs is genuinely the right call
I'm not religious about this. Inside an async server handling many connections, a slow NFS mount or a multi-megabyte upload streamed to disk CAN block a runtime worker thread long enough to stall unrelated requests. There, tokio::fs (or better, an explicit spawn_blocking around std::fs) is correct — you trade per-call overhead for not freezing your event loop. tokio::fs also composes cleanly with the rest of an async pipeline: tokio::io::copy, async readers, .await ergonomics, cancellation. If your file work is already braided into streams and select! loops, fighting to keep it synchronous is its own kind of ugly. The rule: reach for it when concurrency and runtime liveness are real constraints you've measured — not because .await looks tidier than a plain function call.
The verdict nobody wants to hear
Default to std::fs. It's the actual filesystem API; tokio::fs is a convenience wrapper that quietly costs more than it looks. Most file I/O in most programs — config, assets, logs, batch jobs, CLI tools — is not on a hot concurrent path and never needed to be async at all. If you're in async land and the op is genuinely heavy or contended, use spawn_blocking with std::fs so the cost is explicit and one-time, or reach for tokio::fs when its streaming ergonomics actually buy you something. What you should not do is sprinkle tokio::fs everywhere because a linter-shaped feeling told you blocking calls in async functions are sinful. That feeling is selling you overhead. Measure first; the runtime is faster than your assumptions about it.
Quick Comparison
| Factor | Rust Std Fs | Tokio Fs |
|---|---|---|
| Per-call overhead | Direct syscall on calling thread, zero dispatch cost | Thread-pool dispatch + allocation + task park/wake per op |
| Async runtime ergonomics | Plain blocking fn, awkward inside async; needs spawn_blocking | Native .await, composes with streams and select! |
| Avoiding runtime-thread starvation | Blocks the calling thread unless you wrap it yourself | Offloads to blocking pool, keeps workers free |
| Simplicity / honesty about cost | Does exactly what it says, no hidden machinery | Looks async, is actually std::fs on a thread pool |
| Right default for typical I/O | Correct for config/assets/batch/CLI — the common case | Overkill unless concurrency is a measured constraint |
The Verdict
Use Rust Std Fs if: You are reading config, loading assets at startup, processing files in a batch, or anywhere the I/O isn't on a hot concurrent path. This is almost everyone.
Use Tokio Fs if: You are inside an async server, doing many file ops concurrently, and profiling proves blocking the runtime threads actually hurts you — not because you assumed it would.
Consider: spawn_blocking with std::fs if you need to bridge sync I/O into async without paying for tokio::fs's per-call wrapper everywhere.
Rust Std Fs vs Tokio Fs: FAQ
Is Rust Std Fs or Tokio Fs better?
Rust Std Fs is the Nice Pick. tokio::fs is std::fs running on a blocking thread pool with extra allocation and await overhead. Unless you are doing genuinely concurrent, latency-sensitive file work inside an async runtime, std::fs is faster, simpler, and honest about what it does.
When should you use Rust Std Fs?
You are reading config, loading assets at startup, processing files in a batch, or anywhere the I/O isn't on a hot concurrent path. This is almost everyone.
When should you use Tokio Fs?
You are inside an async server, doing many file ops concurrently, and profiling proves blocking the runtime threads actually hurts you — not because you assumed it would.
What's the main difference between Rust Std Fs and Tokio Fs?
Synchronous std::fs versus Tokio's async fs wrappers for file I/O in Rust. One is the real filesystem API; the other is a thread-pool costume worn over it.
How do Rust Std Fs and Tokio Fs compare on per-call overhead?
Rust Std Fs: Direct syscall on calling thread, zero dispatch cost. Tokio Fs: Thread-pool dispatch + allocation + task park/wake per op. Rust Std Fs wins here.
Are there alternatives to consider beyond Rust Std Fs and Tokio Fs?
spawn_blocking with std::fs if you need to bridge sync I/O into async without paying for tokio::fs's per-call wrapper everywhere.
tokio::fs is std::fs running on a blocking thread pool with extra allocation and await overhead. Unless you are doing genuinely concurrent, latency-sensitive file work inside an async runtime, std::fs is faster, simpler, and honest about what it does.
Related Comparisons
Disagree? nice@nicepick.dev