Rate limit — token bucket
Variant: Token bucket
The limiter as a savings account: a token drips in every 1.6s and the balance caps at three, so most of the loop is patience quietly accruing — and one drip arriving against a full bucket deflects off the rim and is wasted, which is the frame that separates a bucket from a plain counter. Then a burst of three requests spends the whole balance in 400ms and sails through, the fourth arrival snaps rust against an empty bucket, and the next drip buys exactly one more pass. Each spend lands on the exact frame its request crosses under the bucket, because the token is the request’s fare, not a nearby decoration, and the axis keeps the ledger both ways — olive deposits below the line, verdicts above it. Reach for it when documenting burst-tolerant limits — API quotas that forgive a spike but not a habit.
The source
A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Rate limit — token bucket</title>
<style>
:root {
/* Neutrals — fixed, never remixed. */
--ivory: #faf9f5;
--paper: #ffffff;
--oat: #e3dacc;
--slate: #141413;
/* Knobs — the remix API. A knob exists iff declared here and used below. */
--hue: 38.8;
--lift: 0;
--chroma: 1;
--speed: 1;
/* Accents — derived from the knobs with the family's offsets baked in,
so one hue roll moves them together. Defaults reproduce the original hex. */
--clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
--clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
--olive: oklch(
calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
);
--rust: oklch(
calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
);
/* The caption gray sits between slate and oat and belongs to neither
family, so it stays a fixed neutral rather than riding the hue. */
--_cap: #87867f;
/* Clock — every duration in the document runs off --t. The reduced-motion
breath is its own period and divides by the same --speed. */
--cycle: 8s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
/* The one characterful curve: the drip's gravity. A falling drop
accelerates, so this is the library's rare justified ease-in. */
--ease-accent: cubic-bezier(0.55, 0, 1, 0.45);
--_calm: calc(2.6s / var(--speed));
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: var(--slate);
}
.diagram {
display: block;
width: min(400px, 100%);
height: auto;
}
.node {
fill: none;
stroke: var(--oat);
stroke-width: 1.5;
}
/* The bucket is filled ivory so request dots visibly disappear into the
mechanism and re-emerge, rather than sliding across its face. */
.bucket {
fill: var(--ivory);
stroke: var(--oat);
stroke-width: 1.5;
}
.link,
.axis {
stroke: var(--oat);
stroke-width: 1.5;
stroke-linecap: round;
}
.slot-mark {
fill: none;
stroke: var(--oat);
stroke-width: 1.2;
}
.tap {
fill: var(--oat);
}
.cap {
font-size: 10px;
letter-spacing: 0.08em;
fill: var(--_cap);
text-anchor: middle;
}
/* One drip every 1.6s — the sub-clock is the master cycle over five, so
a speed roll keeps the refill rate locked to the story. */
.drip {
fill: var(--clay);
opacity: 0;
animation: drip calc(var(--t) / 5) linear infinite;
}
@keyframes drip {
0%,
12.5% {
transform: translateY(0);
opacity: 0;
animation-timing-function: var(--ease-accent);
}
15% {
opacity: 1;
}
24% {
opacity: 1;
}
25%,
100% {
transform: translateY(7px);
opacity: 0;
}
}
/* The one drip that arrives against a full bucket: it deflects off the
rim and is gone. A capped resource wastes its refill — that beat is
what separates a token bucket from a plain counter. */
.spill {
fill: var(--clay);
opacity: 0;
animation: spill var(--t) linear infinite;
}
@keyframes spill {
0%,
65% {
transform: translate(0, 0);
opacity: 0;
}
65.3% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
68.5%,
100% {
transform: translate(11px, -5px);
opacity: 0;
}
}
.token {
fill: var(--clay);
opacity: 0;
transform-box: fill-box;
transform-origin: center;
}
.tk1 {
animation: tk1 var(--t) linear infinite;
}
.tk2 {
animation: tk2 var(--t) linear infinite;
}
.tk3 {
animation: tk3 var(--t) linear infinite;
}
/* Tokens land left to right and are spent right to left; each spend frame
coincides with a request dot crossing under the bucket. */
@keyframes tk1 {
0%,
5% {
opacity: 0;
transform: scale(0.72);
animation-timing-function: var(--ease-out);
}
7%,
77.5% {
opacity: 1;
transform: scale(1);
animation-timing-function: var(--ease-out);
}
78.8%,
85% {
opacity: 0;
transform: scale(0.72);
animation-timing-function: var(--ease-out);
}
87%,
90% {
opacity: 1;
transform: scale(1);
animation-timing-function: var(--ease-out);
}
91.3%,
100% {
opacity: 0;
transform: scale(0.72);
}
}
@keyframes tk2 {
0%,
25% {
opacity: 0;
transform: scale(0.72);
animation-timing-function: var(--ease-out);
}
27%,
75% {
opacity: 1;
transform: scale(1);
animation-timing-function: var(--ease-out);
}
76.3%,
100% {
opacity: 0;
transform: scale(0.72);
}
}
@keyframes tk3 {
0%,
45% {
opacity: 0;
transform: scale(0.72);
animation-timing-function: var(--ease-out);
}
47%,
72.5% {
opacity: 1;
transform: scale(1);
animation-timing-function: var(--ease-out);
}
73.8%,
100% {
opacity: 0;
transform: scale(0.72);
}
}
.req {
fill: var(--clay);
opacity: 0;
}
.r1 {
animation: r1 var(--t) linear infinite;
}
.r2 {
animation: r2 var(--t) linear infinite;
}
.r3 {
animation: r3 var(--t) linear infinite;
}
.r4 {
animation: r4 var(--t) linear infinite;
}
.r5 {
animation: r5 var(--t) linear infinite;
}
/* Flights are linear — a request in transit is constant motion. Each dot
crosses the bucket's centre on the exact frame its token blinks out. */
@keyframes r1 {
0%,
67.5% {
transform: translateX(0);
opacity: 0;
}
68% {
opacity: 1;
}
75.4% {
opacity: 1;
}
76.25%,
100% {
transform: translateX(194px);
opacity: 0;
}
}
@keyframes r2 {
0%,
70% {
transform: translateX(0);
opacity: 0;
}
70.5% {
opacity: 1;
}
77.9% {
opacity: 1;
}
78.75%,
100% {
transform: translateX(194px);
opacity: 0;
}
}
@keyframes r3 {
0%,
72.5% {
transform: translateX(0);
opacity: 0;
}
73% {
opacity: 1;
}
80.4% {
opacity: 1;
}
81.25%,
100% {
transform: translateX(194px);
opacity: 0;
}
}
@keyframes r5 {
0%,
85% {
transform: translateX(0);
opacity: 0;
}
85.5% {
opacity: 1;
}
92.9% {
opacity: 1;
}
93.75%,
100% {
transform: translateX(194px);
opacity: 0;
}
}
/* The refusal: arrives at an empty bucket, turns rust on contact, and is
pushed back the way it came. The colour snaps rather than blends —
a verdict, not a mood. */
@keyframes r4 {
0%,
76.5% {
transform: translateX(0);
opacity: 0;
fill: var(--clay);
}
77% {
opacity: 1;
}
79.9% {
fill: var(--clay);
}
80%,
82% {
transform: translateX(78px);
opacity: 1;
fill: var(--rust);
animation-timing-function: var(--ease-out);
}
84.5%,
100% {
transform: translateX(66px);
opacity: 0;
fill: var(--rust);
}
}
.deny {
fill: none;
stroke: var(--rust);
stroke-width: 1.5;
opacity: 0;
animation: deny var(--t) linear infinite;
}
@keyframes deny {
0%,
80% {
opacity: 0;
}
81% {
opacity: 1;
}
84.5%,
100% {
opacity: 0;
}
}
.progress {
stroke: var(--clay);
stroke-width: 2.5;
stroke-dasharray: 224;
stroke-dashoffset: 224;
animation: sweep var(--t) linear infinite;
}
@keyframes sweep {
0% {
stroke-dashoffset: 224;
}
100% {
stroke-dashoffset: 0;
}
}
.tick {
transform-box: fill-box;
transform-origin: bottom center;
opacity: 0;
}
.tick.ok {
fill: var(--clay);
}
.tick.no {
fill: var(--rust);
}
.x1 {
animation: x1 var(--t) linear infinite;
}
.x2 {
animation: x2 var(--t) linear infinite;
}
.x3 {
animation: x3 var(--t) linear infinite;
}
.x4 {
animation: x4 var(--t) linear infinite;
}
.x5 {
animation: x5 var(--t) linear infinite;
}
@keyframes x1 {
0%,
72.5% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
74.3%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes x2 {
0%,
75% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
76.8%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes x3 {
0%,
77.5% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
79.3%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes x4 {
0%,
80% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
81.8%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes x5 {
0%,
90% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
91.8%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
/* The axis keeps the ledger both ways: olive drip marks hang below the
line as deposits, verdict ticks stand above it as spends. The spilled
drip leaves no mark — it never entered the ledger. */
.ledger {
fill: var(--olive);
transform-box: fill-box;
transform-origin: top center;
opacity: 0;
}
.dp1 {
animation: dp1 var(--t) linear infinite;
}
.dp2 {
animation: dp2 var(--t) linear infinite;
}
.dp3 {
animation: dp3 var(--t) linear infinite;
}
.dp4 {
animation: dp4 var(--t) linear infinite;
}
@keyframes dp1 {
0%,
5% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
6.8%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes dp2 {
0%,
25% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
26.8%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes dp3 {
0%,
45% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
46.8%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes dp4 {
0%,
85% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
86.8%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
.drip,
.spill,
.token,
.req,
.deny,
.progress,
.tick,
.ledger {
animation: none;
}
.drip,
.spill,
.req,
.deny {
opacity: 0;
}
.progress {
stroke-dashoffset: 0;
}
.tick,
.ledger {
opacity: 1;
transform: none;
}
/* Reduced motion keeps the lesson: a bucket holding its three saved
tokens against the record of the burst they paid for. The savings
breathe; nothing else moves. */
.token {
transform: none;
animation: settle var(--_calm) ease-in-out infinite;
}
@keyframes settle {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
}
</style>
</head>
<body>
<svg
class="diagram"
viewBox="0 0 260 112"
role="img"
aria-label="A token bucket rate limiter: tokens drip in slowly and cap at three, a burst of three requests spends the saved balance in 400 milliseconds, the next request is refused, and one more drip buys one more pass"
>
<g class="under">
<line class="link" x1="14" y1="29" x2="102" y2="29" />
<line class="link" x1="158" y1="29" x2="190" y2="29" />
<circle class="req r1" cx="20" cy="29" r="4.5" />
<circle class="req r2" cx="20" cy="29" r="4.5" />
<circle class="req r3" cx="20" cy="29" r="4.5" />
<circle class="req r4" cx="20" cy="29" r="4.5" />
<circle class="req r5" cx="20" cy="29" r="4.5" />
</g>
<g class="mech">
<rect class="bucket" x="102" y="12" width="56" height="34" rx="7" />
<rect class="node" x="190" y="12" width="56" height="34" rx="7" />
<rect class="tap" x="126.5" y="1.5" width="7" height="3" rx="1.5" />
<circle class="slot-mark" cx="116" cy="36" r="4" />
<circle class="slot-mark" cx="130" cy="36" r="4" />
<circle class="slot-mark" cx="144" cy="36" r="4" />
<circle class="token tk1" cx="116" cy="36" r="4" />
<circle class="token tk2" cx="130" cy="36" r="4" />
<circle class="token tk3" cx="144" cy="36" r="4" />
<circle class="drip" cx="130" cy="4" r="2.5" />
<circle class="spill" cx="130" cy="11" r="2.5" />
<rect class="deny" x="102" y="12" width="56" height="34" rx="7" />
</g>
<g class="timeline">
<line class="axis" x1="16" y1="100" x2="240" y2="100" />
<line class="progress" x1="16" y1="100" x2="240" y2="100" />
<rect class="tick ok x1" x="177.15" y="86" width="2.5" height="14" />
<rect class="tick ok x2" x="182.75" y="86" width="2.5" height="14" />
<rect class="tick ok x3" x="188.35" y="86" width="2.5" height="14" />
<rect class="tick no x4" x="193.95" y="86" width="2.5" height="14" />
<rect class="tick ok x5" x="216.35" y="86" width="2.5" height="14" />
<rect class="ledger dp1" x="25.95" y="102" width="2.5" height="5" />
<rect class="ledger dp2" x="70.75" y="102" width="2.5" height="5" />
<rect class="ledger dp3" x="115.55" y="102" width="2.5" height="5" />
<rect class="ledger dp4" x="205.15" y="102" width="2.5" height="5" />
</g>
<g class="labels">
<text class="cap" x="130" y="57">BUCKET</text>
<text class="cap" x="218" y="32.5">API</text>
</g>
</svg>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.