Moka Integration with Tokio: A Complete Guide to Async Caching

Moka integrates with Tokio through its runtime-agnostic moka::future::Cache type, which implements standard Future traits that Tokio can poll directly without requiring any Tokio-specific dependencies in the crate.

The moka-rs/moka crate provides high-performance concurrent caching for Rust, with dedicated async support designed to work naturally with Tokio's task model. Because Moka's async implementation relies solely on the standard Future trait and lock-free data structures, it composes seamlessly with Tokio executors while maintaining compatibility with other async runtimes.

Core Architecture of the Async Cache

Moka's async functionality centers on the future::Cache type defined in src/future/cache.rs, which wraps a lock-free hash table with async-friendly APIs.

The future::Cache Struct

At src/future/cache.rs#L33-L43, the Cache struct defines the main async cache type. It stores entries in a lock-free hash table and performs maintenance operations—including eviction and expiration—lazily when cache methods are called. The cache is runtime-agnostic by design, meaning Tokio is only required to drive the async tasks that interact with the cache, not to operate the cache internals themselves.

Async Constructors and Methods

The cache provides standard constructors that work immediately with Tokio tasks:

  • Cache::new and Cache::builder – Create caches with specified capacities and policies. Documentation comments at src/future/cache.rs#L77-L86 demonstrate Tokio-specific usage patterns.
  • Cache::get, Cache::insert, Cache::invalidate – These async methods return Future types that Tokio can await. The signature pub async fn get appears at src/future/cache.rs#L74-L86.

Entry Selectors for Advanced Patterns

For richer cache manipulation, Moka provides Future-based entry selectors defined in src/future/entry_selector.rs#L17-L30. The OwnedKeyEntrySelector and RefKeyEntrySelector types offer methods like or_insert and or_insert_with_if that return futures, enabling complex conditional insertion logic within Tokio tasks.

Explicit Maintenance with run_pending_tasks

When using time-based expiration (TTL/TTI), the run_pending_tasks method forces immediate processing of pending maintenance work. Located around src/future/cache.rs#L540-L547, this method drains bounded channels and cleans up expired entries. Tokio tasks often invoke this after sleeping to ensure expirations are applied promptly.

Why Moka Requires No Tokio-Specific Code

Moka intentionally depends only on the standard Future trait and core async primitives (Arc, Pin, etc.). This architectural decision means the crate contains no Tokio imports, allowing the same binary to run on async-std, smol, or other executors without modification. The repository includes Tokio-focused examples and tests to demonstrate typical usage, but the library itself remains runtime-agnostic.

How Tokio Drives Moka's Async Operations

When you annotate a function with #[tokio::main] or #[tokio::test], the Tokio runtime polls the futures returned by Moka's methods. Each call—such as cache.insert(key, value).await—schedules work on the runtime's thread pool, but the underlying cache operations remain non-blocking and lock-free.

Cloning the cache is cheap because it only increments an internal Arc reference count, making it safe to share across multiple Tokio tasks. The async version of the cache is effectively a thin async façade over the same lock-free core used by the synchronous moka::sync::Cache.

Practical Tokio Integration Examples

The following patterns demonstrate common Moka integration scenarios with Tokio.

1. Basic Shared Cache Across Tokio Tasks

This example shows cheap cache cloning and concurrent access from multiple tasks:

use moka::future::Cache;

#[tokio::main]
async fn main() {
    const NUM_TASKS: usize = 8;
    const KEYS_PER_TASK: usize = 100;

    // Create a cache that can hold up to 20,000 entries.
    let cache = Cache::new(20_000);

    // Spawn a bunch of concurrent Tokio tasks.
    let handles: Vec<_> = (0..NUM_TASKS)
        .map(|task_id| {
            let my_cache = cache.clone();
            tokio::spawn(async move {
                let start = task_id * KEYS_PER_TASK;
                let end = start + KEYS_PER_TASK;

                // Insert and immediately read each key.
                for key in start..end {
                    my_cache.insert(key, format!("value-{key}")).await;
                    assert_eq!(my_cache.get(&key).await, Some(format!("value-{key}")));
                }

                // Invalidate every third key.
                for key in (start..end).step_by(3) {
                    my_cache.invalidate(&key).await;
                }
            })
        })
        .collect();

    // Wait for all tasks to finish.
    futures_util::future::join_all(handles).await;

    // Verify the final state.
    for key in 0..(NUM_TASKS * KEYS_PER_TASK) {
        if key % 3 == 0 {
            assert!(cache.get(&key).await.is_none());
        } else {
            assert_eq!(cache.get(&key).await, Some(format!("value-{key}")));
        }
    }
}

2. Using get_with to Coalesce Expensive Initialization

The get_with method ensures that concurrent tasks share a single initialization future, preventing duplicate work:

use moka::future::Cache;
use std::sync::Arc;

#[tokio::main]
async fn main() {
    // A cache that holds large binary blobs.
    let cache: Cache<u32, Arc<Vec<u8>>> = Cache::new(100);

    // Simulate four tasks that need the same heavy data.
    let handles: Vec<_> = (0..4)
        .map(|id| {
            let c = cache.clone();
            tokio::spawn(async move {
                let data = c
                    .get_with(42, async {
                        // This block runs once even though four tasks call it.
                        println!("Task {id} creating the blob…");
                        Arc::new(vec![0u8; 10 * 1024 * 1024]) // 10 MiB
                    })
                    .await;
                println!("Task {id} got {} bytes", data.len());
            })
        })
        .collect();

    futures_util::future::join_all(handles).await;
}

3. Time-to-Live (TTL) Expiration with Explicit Housekeeping

When using TTL policies, call run_pending_tasks after delays to force expiration processing:

use moka::future::Cache;
use std::time::Duration;

#[tokio::main]
async fn main() {
    // TTL of 5 seconds, no idle timeout.
    let cache = Cache::builder()
        .time_to_live(Duration::from_secs(5))
        .max_capacity(10)
        .build();

    cache.insert("temp", "data".to_string()).await;
    assert!(cache.contains_key(&"temp"));

    // Wait longer than the TTL.
    tokio::time::sleep(Duration::from_secs(6)).await;

    // Force the cache to process pending expirations.
    cache.run_pending_tasks().await;
    assert!(!cache.contains_key(&"temp"));
}

Key Source Files in moka-rs/moka

Understanding these implementation files helps when debugging Moka integration with Tokio:

  • src/future/cache.rs – Contains the core async cache implementation, including method signatures for get, insert, and run_pending_tasks, plus doc examples showing Tokio usage.
  • src/future/entry_selector.rs – Implements async entry selectors (OwnedKeyEntrySelector, RefKeyEntrySelector) that provide the or_insert API.
  • tests/runtime_tokio.rs – Test harness spawning Tokio tasks to validate thread-safety and runtime integration.
  • tests/entry_api_tokio.rs – Additional Tokio-based tests covering the entry API methods.
  • examples/basics_async.rs – Minimal example demonstrating cache creation and basic operations in an async context.

Summary

  • Moka's async cache is runtime-agnostic, relying only on the standard Future trait rather than Tokio-specific APIs.
  • moka::future::Cache provides lock-free concurrent access suitable for Tokio's multi-threaded scheduler.
  • Cheap cloning via Arc allows efficient sharing across Tokio tasks without blocking.
  • get_with enables request coalescing, ensuring expensive initialization runs only once across concurrent tasks.
  • run_pending_tasks forces immediate expiration processing when TTL policies require deterministic cleanup.

Frequently Asked Questions

Does Moka require Tokio as a dependency?

No. According to the moka-rs/moka source code, the crate depends only on the standard Future trait and async primitives. It works with any async executor including Tokio, async-std, and smol.

How do I share a Moka cache across multiple Tokio tasks?

Clone the cache handle before spawning tasks. The cache uses Arc internally, making clones inexpensive and safe for concurrent access across Tokio's thread pool.

When should I call run_pending_tasks in a Tokio application?

Call run_pending_tasks().await after tokio::time::sleep or other delays when you need to guarantee that TTL-based expirations have been processed immediately, rather than waiting for the next cache operation to trigger maintenance.

Can I use the synchronous Moka cache with Tokio?

Yes, but you should use moka::future::Cache instead. The sync cache blocks threads, which can starve Tokio's runtime. The async version provides the same lock-free core with non-blocking APIs designed for async/await contexts.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →