Replacing my revm ForkDB background thread with SharedBackend

I have been refactoring the RPC client that backs my revm ForkDB. The old version, called Client, was built around a background worker thread. It handled batching, caching, deduplication, rate limiting, and retries. It worked, but it was hard to reason about and kept growing more complex every time I fixed a bug.

I replaced it with SharedBackend. The new version deletes four modules and roughly 1,100 lines of code. More importantly, it needs no background thread at all. The fuzzer threads themselves coordinate batches through a single mutex and a condition variable. Here is how I got there and why it is simpler.

The old architecture

The old Client spawned a background Batcher thread at startup. The batcher sat in a loop reading requests from a crossbeam channel, grouping them into JSON-RPC batches, and dispatching responses back through one-shot channels.

Rust
pub struct ClientInner {
    pub request_tx: RwLock<Sender<PendingRequest>>,
    pub cache: Option<Arc<Cache>>,
    pub dedup: Arc<DedupTable>,
}

When a fuzzer thread needed data, it called Client::request. That method had to:

  1. Check the in-memory cache (DashMap).
  2. Check the deduplication table (DashMap + more crossbeam channels) to see if another thread was already fetching the same key.
  3. Send the request to the batcher via a bounded crossbeam channel.
  4. Block on a one-shot crossbeam channel for the response.
  5. Handle self-deduplication if the same key appeared twice in one request slice.

On top of that, there was a supervisor thread wrapped around the batcher in catch_unwind. If the batcher panicked (for example, because ureq threw on malformed JSON), the supervisor had to respawn a fresh worker and swap out the request_tx channel so that pending requests did not get lost. This meant the ClientInner stored the sender inside an RwLock so it could be replaced at runtime.

The code was spread across four files:

That is almost 2,000 lines just to batch and cache RPC requests. And the crossbeam channels were everywhere: one bounded channel from clients to the batcher, one bounded channel per request from the batcher back to the client, and one bounded channel per in-flight dedup entry for waiters.

Because the batcher could panic and restart, ForkDB (the revm DatabaseRef implementation) had to wrap every call in a with_retry helper that retried BatcherRestarted errors up to three times. That retry logic lived in the database layer, which felt wrong. The database should not know that the RPC client has a background thread that might crash.

The new architecture

SharedBackend removes the background thread entirely. Instead, the fuzzer threads themselves collect pending requests into batches. The core idea is:

Here is the new SharedBackend inner state:

Rust
struct SharedBackendInner {
    global_cache: PapayaMap<String, Value>,
    batch_state: Mutex<BatchState>,
    batch_condvar: Condvar,
    batch_size: usize,
    batch_timeout: Duration,
    transport: Arc<dyn Transport>,
    url: String,
    retries: u32,
    backoff: Duration,
    limiter: Option<Arc<RateLimiter>>,
    cache_dir: Option<PathBuf>,
}

The BatchState is a simple struct:

Rust
struct BatchState {
    pending: Vec<Request>,
    keys: HashSet<String>,
    deadline: Option<Instant>,
    errors: HashMap<String, Arc<Error>>,
    fetcher_in_flight: bool,
}

The fetch_or_wait method has two paths.

  1. Fast path: check global_cache without locking. If every request is cached, return immediately.
  2. Slow path: acquire the mutex, re-check the cache under the lock (in case another thread filled it while we were waiting), add missing requests to the pending batch, and decide whether to become the fetcher or wait.

If the pending batch reaches batch_size or the deadline expires, the current thread becomes the fetcher. It takes ownership of the pending requests, drops the mutex while performing the HTTP call, then re-acquires the lock to publish results and wake all waiters with notify_all.

If another thread is already fetching, the current thread simply waits on the condvar. When it wakes up, it loops back and re-checks the cache.

Because there is no background thread that can panic and restart, there is no BatcherRestarted error. That let me delete the with_retry wrapper from ForkDB entirely. The database layer is now a thin mapping from revm operations to typed RPC requests:

Rust
impl DatabaseRef for ForkDB {
    fn basic_ref(&self, address: Address) -> Result<Option<AccountInfo>, Self::Error> {
        let responses = self.backend.fetch_or_wait(&[
            Request::GetBalance { /* ... */ },
            Request::GetTransactionCount { /* ... */ },
            Request::GetCode { /* ... */ },
        ])?;
        self.parse_basic_responses(responses)
    }
}

No retries, no channel juggling, no supervisor. Just fetch_or_wait.

What I learned

Background threads are seductive. They feel like the “right” way to handle I/O in a multithreaded system. But every thread boundary introduces channels, serialization, restart logic, and subtle race conditions. In this case, the batcher was not doing anything that the caller threads could not do themselves. It was just a middleman.

Lock-free data structures like papaya::HashMap are a great fit for read-heavy shared caches. The fast path never touches a lock, which means the common case (cached data) is essentially free.

Finally, I learned that shrinking the system is often better than optimizing it. The old Client had DashMap for the cache and another DashMap for dedup, plus all those crossbeam channels, because each piece was trying to solve a problem created by the background thread. Once I removed the thread, I realized I did not need most of the infrastructure. A plain Mutex and a HashMap were enough.

The fuzzer does not care how the RPCs are batched. It just wants the data. SharedBackend gives it that without the complex stuff.