TL;DR
Tokio refuses to nest runtimes. Call block_on() from inside an async function already running on Tokio's threads, and it panics immediately. The right fix depends on who owns the problematic code:
- Own the async code? Replace
block_on(future)withfuture.await. Done. - Calling a sync library that wraps block_on internally? Move the call into
tokio::task::spawn_blockingortokio::task::block_in_place. - Need full runtime isolation? Spawn a real OS thread and give it its own
Runtime::new().
What Actually Happened
Tokio's thread pool threads are owned by the runtime. Call Handle::block_on() or Runtime::block_on() from one of those threads and Tokio panics on purpose — blocking a thread that's already polling futures would deadlock the executor instantly.
Three situations cause this more than anything else:
- A third-party library calls
block_oninternally. SDK wrappers around async HTTP clients are the usual suspect. - A sync helper builds a local
Runtimeand callsblock_on, then you call that helper from inside anasync fn. - You put
#[tokio::main]onmain(), then also triedtokio::runtime::Builder::new_current_thread().block_on()somewhere in the body.
// Panics — block_on inside a #[tokio::main] context
#[tokio::main]
async fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(some_async_fn()); // PANIC here
println!("{:?}", result);
}
Fix 1: Use .await (The Right Default)
You control the code? Drop block_on entirely. Let .await do its job.
// Before (panics)
fn fetch_data() -> String {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async_fetch()) // panics inside async context
}
// After (correct)
async fn fetch_data() -> String {
async_fetch().await
}
Then your caller becomes:
#[tokio::main]
async fn main() {
let data = fetch_data().await;
println!("{}", data);
}
This is almost always the right answer. Only reach for the other fixes when a library forces your hand.
Fix 2: spawn_blocking for Sync or CPU-Bound Work
Got a sync function that has to block — a legacy SDK, a CPU-heavy computation, synchronous file I/O? Move it onto Tokio's dedicated blocking thread pool with spawn_blocking. These threads are managed separately from the async executor. No panic, no deadlock.
#[tokio::main]
async fn main() {
let result = tokio::task::spawn_blocking(|| {
// Safe to block here — runs on a dedicated blocking thread
legacy_sync_client::fetch()
})
.await
.unwrap();
println!("{:?}", result);
}
Tokio caps blocking threads at 512 by default. That's generous for occasional calls. If you're spawning hundreds under sustained load, tune the ceiling with Builder::max_blocking_threads() before it bites you in production.
Fix 3: block_in_place for Inline Blocking
Rather than offloading to another thread, block_in_place migrates other tasks away from the current thread so you can block right where you are. Simpler to wire up than spawn_blocking — but it only works on the multi-thread runtime.
#[tokio::main] // default = multi_thread
async fn main() {
let result = tokio::task::block_in_place(|| {
// Blocking call inline — runtime migrates other tasks off this thread
some_blocking_sdk::run()
});
println!("{:?}", result);
}
Running on current_thread? Use spawn_blocking instead. block_in_place panics on single-threaded runtimes because there are no other threads to migrate tasks to.
Fix 4: Separate OS Thread with Its Own Runtime
Some libraries call block_on internally and you genuinely cannot change them. The workaround: don't run them on Tokio's threads at all. Spawn a real OS thread — completely outside the executor — and build a fresh runtime there.
#[tokio::main]
async fn main() {
// A true OS thread, not a Tokio task
let handle = std::thread::spawn(|| {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
// Safe — fresh runtime on a thread Tokio doesn't own
some_library_with_internal_block_on()
})
});
let result = handle.join().unwrap();
println!("{:?}", result);
}
Heavy-handed? A bit. But when a dependency hard-codes block_on and you can't touch it, this is your most reliable exit.
Diagnosing Which Fix You Need
Check the panic's stack trace. It names the culprit directly:
thread 'tokio-runtime-worker' panicked at 'Cannot start a runtime from within a runtime.
...
stack backtrace:
0: tokio::runtime::scheduler::block_on
1: some_sdk::client::Client::send <-- the culprit
2: my_crate::handler::process
3: tokio::runtime::task::harness
Library code in frame 1? You need Fix 2 or Fix 4. Your own code in frame 1? Fix 1 or Fix 3.
Writing a library that must handle both sync and async callers? Use Handle::try_current() to detect the situation at runtime:
pub fn do_work() {
match tokio::runtime::Handle::try_current() {
Ok(_handle) => {
// Already inside Tokio — caller must use do_work_async().await instead
panic!("do_work() cannot be called from an async context; use do_work_async().await");
}
Err(_) => {
// No runtime present — safe to create one
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(internal_async_work());
}
}
}
Verifying the Fix
Run the binary and confirm the panic is gone:
cargo run 2>&1 | grep -i "cannot start a runtime"
# No output = fixed
Add an integration test to catch regressions early:
#[tokio::test]
async fn test_no_nested_runtime_panic() {
// Must not panic
let result = fetch_data().await;
assert!(!result.is_empty());
}
Using spawn_blocking under real load? Watch your thread count. The default 512-thread cap sounds generous, but sustained blocking calls accumulate fast. Check utilization with Builder::max_blocking_threads() before you hit the ceiling in production.
Quick Reference
.await— first choice when you own the codespawn_blocking— sync code on a blocking thread; works on both multi-thread and current_thread runtimesblock_in_place— inline blocking; multi-thread runtime onlystd::thread::spawn+ new Runtime — when a dependency forcesblock_onand modification isn't an option

