diff --git a/README.md b/README.md
index 3dd2c8f..48f8757 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,73 @@ idle for a configurable duration.
- **HTTP middleware example** — with accurate `Retry-After` and `RateLimit-*`
response headers.
+## Architecture
+
+`BucketLimiter` is a thin manager: it maps each key to its own `Limiter`,
+builds new ones on demand through a factory, persists them in a pluggable
+`Storage`, and runs a single background goroutine that evicts idle keys.
+
+```mermaid
+flowchart TD
+ subgraph caller["Your code"]
+ C["GetOrAdd(key)"]
+ end
+
+ subgraph manager["BucketLimiter[K]"]
+ direction TB
+ F["newLimiter func() Limiter
(factory)"]
+ A["access map
K → last-use time"]
+ S["sweepLoop goroutine
evicts idle keys"]
+ end
+
+ subgraph store["Storage[K, Limiter]"]
+ direction LR
+ K1["user-123 → bucket"]
+ K2["user-456 → bucket"]
+ K3["10.0.0.7 → bucket"]
+ end
+
+ C -->|"1. Load / LoadOrStore"| store
+ C -.->|"2. build on miss"| F
+ F -.->|"fresh *rate.Limiter"| store
+ C -->|"3. touch"| A
+ S -->|"Delete idle"| store
+ S -->|"Delete idle"| A
+
+ K1 & K2 & K3 -->|"independent
token buckets"| RL["golang.org/x/time/rate"]
+```
+
+Each key owns an **independent** token bucket, so one client draining its
+budget has no effect on any other. A typical `GetOrAdd(key).Allow()` call:
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant App as Your code
+ participant BL as BucketLimiter
+ participant St as Storage
+ participant Lim as Limiter (bucket)
+
+ App->>BL: GetOrAdd(key)
+ BL->>St: Load(key)
+ alt key exists
+ St-->>BL: existing Limiter
+ else first use of key
+ BL->>BL: newLimiter()
+ BL->>St: LoadOrStore(key, fresh)
+ Note over BL,St: atomic — racing callers
share one instance
+ St-->>BL: stored Limiter
+ end
+ BL->>BL: touch(key) — refresh idle timer
+ BL-->>App: Limiter
+ App->>Lim: Allow()
+ alt token available
+ Lim-->>App: true (consume 1 token)
+ else bucket empty
+ Lim-->>App: false (rate limited → 429)
+ end
+```
+
## Installation
```bash
diff --git a/docs/TOKEN_BUCKET.md b/docs/TOKEN_BUCKET.md
index 86f1f83..02bc4e8 100644
--- a/docs/TOKEN_BUCKET.md
+++ b/docs/TOKEN_BUCKET.md
@@ -53,6 +53,19 @@ Picture a bucket that holds tokens:
request limited (bucket empty)
```
+The same idea as a flow diagram:
+
+```mermaid
+flowchart TD
+ R["Refill: +r tokens / second"] -->|"drip"| B
+ B{"Bucket
capacity = burst b"}
+ B -->|"overflow above b"| X["discarded"]
+ Req(["Incoming request
needs 1 token"]) --> Q{"tokens ≥ 1 ?"}
+ Q -->|"yes"| Take["consume 1 token"] --> Allowed(["✅ allowed"])
+ Q -->|"no"| Limited(["⛔ limited — reject or wait"])
+ B -.->|"current level"| Q
+```
+
Rules:
1. Tokens are added to the bucket at a steady **rate** `r` (tokens per second).
@@ -208,6 +221,24 @@ full bucket. Choose `deleteAfter` comfortably longer than the window over which
you want the limit to hold (e.g. minutes, not milliseconds) so a client cannot
reset its own bucket by pausing briefly.
+The lifecycle of a single key:
+
+```mermaid
+stateDiagram-v2
+ [*] --> Absent
+ Absent --> Active: GetOrAdd(key)
newLimiter() builds a fresh bucket
+ Active --> Active: GetOrAdd(key)
refreshes idle timer
+ Active --> Idle: no access for deleteAfter
+ Idle --> Active: GetOrAdd(key)
before the sweep runs
+ Idle --> Absent: sweepLoop evicts
(state discarded)
+ Active --> Absent: Remove(key)
+ Absent --> [*]
+```
+
+The sweep is not instantaneous, so a key idle past `deleteAfter` lingers until
+the next tick — worst case ~1.5·`deleteAfter` after its last use with the
+default interval.
+
## HTTP response headers
Well-behaved HTTP clients can self-throttle if you tell them the state of their
@@ -227,6 +258,34 @@ the example also emits the legacy `X-RateLimit-*` variants for older clients.
[RFC 9110 §10.2.3](https://www.rfc-editor.org/rfc/rfc9110#section-10.2.3) and is
computed from the reservation delay so it is accurate rather than a guess.
+End to end, a request through the middleware:
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Client
+ participant MW as Middleware
+ participant BL as BucketLimiter
+ participant Lim as Limiter (per-IP bucket)
+ participant H as Handler
+
+ Client->>MW: HTTP request
+ MW->>MW: extract client IP
(net.SplitHostPort)
+ MW->>BL: GetOrAdd(ip)
+ BL-->>MW: Limiter
+ MW->>Lim: ReserveN(now, 1)
+ alt token available now (delay == 0)
+ Lim-->>MW: reservation, DelayFrom(now) = 0
+ MW->>H: serve
+ H-->>MW: response
+ MW-->>Client: 200 OK
RateLimit-Limit / Remaining / Reset
+ else bucket empty (delay > 0)
+ Lim-->>MW: reservation, DelayFrom(now) = d
+ MW->>Lim: reservation.Cancel()
(return the token)
+ MW-->>Client: 429 Too Many Requests
Retry-After: ⌈d⌉
+ end
+```
+
## Comparison with other algorithms
| Algorithm | Bursts | Memory/key | Notes |