How to Provide Both Async and Sync APIs in a Rust Library Without Duplicating Code

This article walks through the challenges of offering both asynchronous and synchronous interfaces for a Rust API client, evaluates naive copy‑paste solutions, explores runtime‑based wrappers and the maybe_async procedural macro, and finally presents a clean, feature‑flagged design that avoids code duplication and compile‑time overhead.

BirdNest Tech Talk
BirdNest Tech Talk
BirdNest Tech Talk
How to Provide Both Async and Sync APIs in a Rust Library Without Duplicating Code

Problem

A Rust library that wraps a public API (e.g., Spotify or ArangoDB) is implemented asynchronously using tokio. Users request a synchronous API similar to reqwest::blocking without sprinkling block_on(...) throughout their code.

First Method – Duplicate Code

Copy the entire client into a new blocking module, replace reqwest with reqwest::blocking, and remove async / .await. This yields a rspotify::blocking::Client alongside the async client.

Every endpoint is written twice.

Maintaining parity is error‑prone and slows development.

Async‑only users incur a larger binary because the blocking module is compiled in.

Human copy‑paste errors increase with more contributors.

Second Method – Runtime‑Based Wrapper

Keep a single async implementation and expose a synchronous wrapper that calls block_on on a runtime.

mod blocking {
    struct Spotify(super::Spotify);
    impl Spotify {
        fn endpoint(&self, param: String) -> SpotifyResult<String> {
            runtime.block_on(async move { self.0.endpoint(param).await })
        }
    }
}

Creating a new tokio runtime per call is expensive:

let mut runtime = tokio::runtime::Builder::new()
    .basic_scheduler()
    .enable_all()
    .build()
    .unwrap();

A shared runtime can be stored in a global lazy_static or accessed via tokio::runtime::Handle:

use tokio::runtime::Runtime;

lazy_static! {
    static ref RT: Runtime = Runtime::new().unwrap();
}

fn endpoint(&self, param: String) -> SpotifyResult<String> {
    RT.handle().block_on(async move { self.0.endpoint(param).await })
}

This reduces overhead but still requires a runtime dependency for the blocking client and does not eliminate duplicated method signatures.

Feature‑Based Crate Split

Creating separate crates (e.g., rspotify-sync and rspotify-async) runs into Cargo’s feature unification: enabling both sync and async features simultaneously can cause conflicts, as demonstrated by a minimal reproducer repository.

Third Method – maybe_async Procedural Macro

The maybe_async crate can automatically generate sync or async versions of functions, traits, and impl blocks based on a feature flag ( is_sync).

#[maybe_async::maybe_async]
async fn endpoint() { /* ... */ }

When the is_sync feature is disabled, the function remains async; when enabled, the macro strips async and .await and emits a synchronous version.

#[cfg(not(feature = "is_sync"))]
async fn endpoint() { /* ... */ }

#[cfg(feature = "is_sync")]
fn endpoint() { /* ... with .await removed */ }

Using this macro, the library can expose a single code base and let users select the desired client via Cargo features (e.g., client-ureq for a synchronous ureq client or client-reqwest for an async reqwest client). No extra runtime or heavy dependencies are pulled in for the unused path.

Benefits

No duplicated endpoint implementations.

Binary size stays minimal; sync users can choose a lightweight client like ureq without pulling tokio.

Feature flags are simple to configure in Cargo.toml.

Trait‑Based HTTP‑Client Abstraction

Define a trait for the HTTP client and implement it for both async ( reqwest) and sync ( ureq) backends. The macro can generate the appropriate impls.

#[maybe_async]
trait HttpClient {
    async fn get(&self) -> String;
}

#[sync_impl]
impl HttpClient for UreqClient {
    fn get(&self) -> String { ureq::get(/* ... */) }
}

#[async_impl]
impl HttpClient for ReqwestClient {
    async fn get(&self) -> String { reqwest::get(/* ... */).await }
}

struct SpotifyClient<Http: HttpClient> { http: Http }

#[maybe_async]
impl<Http: HttpClient> SpotifyClient<Http> {
    async fn endpoint(&self) { self.http.get().await }
}

Users enable the desired client feature in Cargo.toml; the macro then selects the matching implementation.

Feature‑Unification Conflict

Rust’s Cargo requires features to be additive. When a crate appears multiple times in a dependency graph, Cargo may unify its features, causing mutually exclusive features (e.g., maybe_async/is_sync and client-reqwest) to clash. This makes it impossible for a single binary to depend on both sync and async versions of the same crate.

References explaining feature unification: https://doc.rust-lang.org/cargo/reference/features.html#feature-unification and the feature‑resolver‑v2 documentation: https://doc.rust-lang.org/cargo/reference/features.html#feature-resolver-version-2.

A minimal reproducer repository ( https://github.com/marioortizmanero/resolver-v2-conflict) shows the conflict under any resolver version.

Other Crates Affected

arangors

and aragog (ArangoDB wrappers) use maybe_async and hit the same conflict. inkwell (LLVM wrapper) suffers similar issues. k8s-openapi also encounters the conflict.

Fixing maybe_async

The crate was updated to provide two exclusive feature flags: is_sync and is_async. Functions are generated with suffixes ( _sync / _async) to avoid name collisions:

#[maybe_async::maybe_async]
async fn endpoint() { /* ... */ }
#[cfg(feature = "is_async")]
async fn endpoint_async() { /* ... */ }

#[cfg(feature = "is_sync")]
fn endpoint_sync() { /* ... with .await removed */ }

Although the suffixes add noise, they resolve the mutual‑exclusion problem without requiring Cargo to support mutually exclusive features.

Conclusion

Three viable strategies were explored:

Duplicate code in a separate blocking module – simple but unsustainable.

Wrap async calls with a shared runtime – reduces duplication but still incurs runtime overhead and feature‑unification issues.

Use the maybe_async procedural macro – eliminates duplication, keeps binary size low, and leverages Cargo feature flags, at the cost of handling mutually exclusive features via suffixes.

Given the trade‑offs, the macro‑based solution is recommended for libraries that need both async and sync APIs while preserving maintainability.

References

Spotify Web API – https://developer.spotify.com/documentation/web-api/

ArangoDB – https://www.arangodb.com/

reqwest crate – https://crates.io/crates/reqwest

reqwest blocking module – https://docs.rs/reqwest/0.11.4/reqwest/blocking/index.html

tokio runtime – https://crates.io/crates/tokio

maybe_async crate – https://crates.io/crates/maybe_async

Cargo feature unification – https://doc.rust-lang.org/cargo/reference/features.html#feature-unification

Feature resolver v2 – https://doc.rust-lang.org/cargo/reference/features.html#feature-resolver-version-2

Sans I/O – https://sans-io.readthedocs.io/

tame-oidc example – https://github.com/EmbarkStudios/tame-oidc

Conflict reproducer – https://github.com/marioortizmanero/resolver-v2-conflict

maybe_async issue – https://github.com/fMeow/maybe-async-rs/issues/6

Rust RFC on mutually exclusive features – https://github.com/rust-lang/rfcs/pull/2962

cargo‑hack for testing feature combinations – https://github.com/taiki-e/cargo-hack

Keyword‑generics initiative – https://blog.rust-lang.org/inside-rust/2023/02/23/keyword-generics-progress-report-feb-2023.html

Diagram
Diagram
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

RustAsyncsynclibrary-designcargocratemaybe_async
BirdNest Tech Talk
Written by

BirdNest Tech Talk

Author of the rpcx microservice framework, original book author, and chair of Baidu's Go CMC committee.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.