April 3, 2026   -   David Oyinbo

distkit

Distkit (DISTributed systems KIT) is a Rust library of the small building blocks you keep reaching for once an application outgrows a single server. It started as a distributed counter I needed for a realtime platform, and over time it grew into a handful of primitives that all share the same backend: Redis. As of the latest release it ships counters (strict and lax), instance-aware counters that clean up after dead servers on their own, distributed locks, and sliding-window rate limiting.

RustRedisDistributed SystemsLibraryAsync

The idea

Every primitive in distkit solves a problem that is easy on one machine and surprisingly fiddly the moment there are two. The whole library leans on one fact: Redis runs commands one at a time. That single-threadedness is what lets a counter increment, a lock acquisition, or a rate-limit check happen atomically across an entire fleet. The work each type does is wrapped in a Lua script so the read and the write travel together, leaving no gap for a second server to slip into. Everything is async, built on redis::aio::ConnectionManager, and the whole crate is #![forbid(unsafe_code)] with no panics in library paths.

The pieces are gated behind feature flags so you only pull in what you use. Counters are on by default; instance-aware counters, locks, and rate limiting are each opt-in.

[dependencies]
distkit = { version = "0.5", features = ["instance-aware-counter", "lock", "trypema"] }

Counters

The counters come in two flavours, and the choice between them is really a choice about what you are willing to trade. A StrictCounter does a Redis round-trip on every operation, so a read always reflects the latest write. It is the right tool when being wrong is expensive: billing, inventory, seat counts. A LaxCounter buffers increments in memory and flushes them to Redis on a short interval (around 20 ms by default). The hot path becomes an in-memory atomic add that finishes in well under a microsecond, at the cost of a small window where servers can disagree on the total. For analytics and high-volume metrics that trade is almost always worth it.

use distkit::{DistkitRedisKey, counter::{StrictCounter, CounterOptions, CounterTrait}};

let counter = StrictCounter::new(CounterOptions::new(prefix, conn));
let key = DistkitRedisKey::try_from("orders".to_string())?;
counter.inc(&key, 1).await?;
let total = counter.get(&key).await?;

Both also support conditional writes, where an increment or a set only goes through if the current value passes a comparison, and batched operations that preserve input order. Swapping StrictCounter for LaxCounter is a one-line change; the interface is the same.

Instance-aware counters

A plain global counter falls apart for one specific question: how many of something are alive across the cluster right now, like open WebSocket connections. Increment on connect, decrement on disconnect, and it works fine until a server crashes with five hundred connections still open. Nobody runs those five hundred decrements, and the total stays wrong forever.

The instance-aware counters fix this by giving every running process its own slice of the total. Each one is the sole writer of its slice, so there is nothing to coordinate, and the cumulative is just the sum of the live slices. Liveness rides along on ordinary work: every operation an instance runs also stamps a heartbeat. When a process goes quiet for longer than the dead-instance threshold (thirty seconds by default), the next instance to touch that counter subtracts the dead one's contribution and removes it. The cleanup is a side effect of normal traffic rather than a background job, so a crashed server's count simply evaporates the next time anyone looks. If the server was only briefly unreachable and comes back, its slice is recovered and resynchronised.

There is also a notion of an epoch per key, essentially a version number for the counter's current era. A global set or del bumps the epoch, which tells any stale instance to reset its stored slice before contributing again. That is what keeps a coordinated reset from double-counting. As with the plain counters, instance-aware comes in strict and lax variants. Reads return a pair: the cluster-wide cumulative and this instance's own slice.

StrictCounterLaxCounterStrictInstanceAwareLaxInstanceAware
ConsistencyImmediateEventualImmediateEventual
inc latencyRedis round-tripSub-microsecondRedis round-tripSub-microsecond
Redis I/OEvery callBatchedEvery callBatched
Per-instance trackingNoNoYesYes
Dead-instance cleanupNoNoYesYes
Feature flagcounter (default)counter (default)instance-aware-counterinstance-aware-counter
Use caseBilling, inventoryHigh-throughput analyticsLive connection countsHigh-frequency per-node metrics

Distributed locks

The newest addition is a pair of distributed locks, behind the lock feature. There is a Mutex for plain mutual exclusion and a writer-preferring RwLock for the many-readers-or-one-writer case. Both are deliberately modelled on tokio::sync::Mutex and tokio::sync::RwLock, so if you have used those, these will feel familiar. The one conceptual difference is that the guards hold no data; they are pure access tokens that say "you currently hold this lock."

A held lock keeps itself alive by refreshing its lease in the background (every third of the TTL), and it releases automatically when the guard is dropped. For callers who want to observe the final release rather than let it happen silently, there is an awaitable release(). Each lock offers three ways to acquire it: wait until it is free, try once and give up immediately, or wait up to a bounded timeout. TTL, maximum wait, retry interval, owner id, and namespace are all configurable.

use distkit::{DistkitRedisKey, lock::{Mutex, LockOptions}};

let key = DistkitRedisKey::try_from("invoice_42".to_string())?;
let mutex = Mutex::new(LockOptions::new(key, conn));

let guard = mutex.lock().await?; // waits until acquired
// ... critical section ...
guard.release().await?;

Rate limiting

The trypema feature re-exports my trypema crate under distkit::trypema, so distkit can also do sliding-window rate limiting without pulling in a separate dependency. There are three providers depending on how far the enforcement needs to reach: local for in-process limiting, Redis for distributed enforcement across servers, and hybrid for a local fast-path that periodically syncs with Redis. Each supports an absolute strategy (a straight allow/reject) and a suppressed one (a probabilistic ramp that starts shedding load before you hit the wall).

For the full surface there is the trypema documentation site and the Rust docs.

Highlights

What's in the box

  • Strict and lax distributed counters
  • Instance-aware counters with per-instance slices and epochs
  • Mutex and writer-preferring RwLock
  • Sliding-window rate limiting via trypema
  • Everything async over redis::aio::ConnectionManager

Consistency model

  • Strict types: immediately consistent, no local state to go stale
  • Lax types: eventual consistency, configurable lag (default ~20 ms)
  • Atomic Lua scripts keep every operation a single serialised step
  • Per-key epochs prevent double-counting after a global reset

Self-healing

  • Heartbeats ride along on ordinary operations, no separate ticker
  • Dead instances are cleaned up by surviving instances, not a background job
  • Briefly-unreachable instances recover and resync their slice
  • Locks refresh their lease automatically and release on drop

Built to be trusted

  • #![forbid(unsafe_code)]: no unsafe blocks anywhere in the library
  • No panics in library code; fallible paths return a typed error
  • Runnable doc-test examples, each isolated to its own Redis prefix
  • Criterion benchmarks across every counter type and the locks

Other Projects

May 13, 2024

Canvas Infinity & Circle Mouse

Pet project exploring HTML Canvas and JavaScript animations—drawing an infinity curve and a circle following the mouse.

JavaScriptHTML CanvasAnimation
June 1, 2022

iPay (Nuxt 3 Pet Project)

A simple Nuxt 3 site built to explore the Nuxt 3 ecosystem, routing, layouts, and component patterns.

Nuxt 3VuePet Project
May 13, 2026

Laye

Framework-agnostic RBAC library for Rust with composable AccessPolicy rules and plug-and-play middleware for actix-web and tower/axum.

RustRBACAccess Control

Let's build something together

Available for senior engineering roles, consulting, and architecture reviews.

© 2026 David Oyinbo