Fix Rust Tokio 'Cannot start a runtime from within a runtime' Error When Calling block_on in Async Context

intermediate🦀 Rust2026-07-10| Rust 1.60+, Tokio 1.x, Linux/macOS/Windows — any platform running Tokio async runtime

Error Message

thread 'main' panicked at 'Cannot start a runtime from within a runtime. This happens because a function (like `block_on`) attempted to block the current thread while the thread is being used to drive asynchronous tasks.'
#tokio#async#runtime#block_on#nested-runtime

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) with future.await. Done.
  • Calling a sync library that wraps block_on internally? Move the call into tokio::task::spawn_blocking or tokio::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_on internally. SDK wrappers around async HTTP clients are the usual suspect.
  • A sync helper builds a local Runtime and calls block_on, then you call that helper from inside an async fn.
  • You put #[tokio::main] on main(), then also tried tokio::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 code
  • spawn_blocking — sync code on a blocking thread; works on both multi-thread and current_thread runtimes
  • block_in_place — inline blocking; multi-thread runtime only
  • std::thread::spawn + new Runtime — when a dependency forces block_on and modification isn't an option

Related Error Notes