Rate limit — leaky bucket
Variant: Leaky bucket
The one that shapes instead of refusing: nothing bounces, but nothing leaves off the beat either. Bursts pour in and stand as a liquid level — each jump an 80ms settle, because the queue is bookkeeping, not weather — while departures leave strictly every 0.8s, and only while the bucket holds anything, so an empty queue is a silent beat rather than a stretched interval. The record is kept twice on one axis: olive arrival ticks above the line in bursts, clay departure ticks below it on a metronome, and reading the two rows against each other is the whole diagram. Use it to document traffic shaping, write coalescing, and any consumer that must be fed at one rate no matter how the producer behaves.
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 — leaky 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);
--_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;
}
.bucket {
fill: var(--ivory);
stroke: var(--oat);
stroke-width: 1.5;
}
.link,
.axis {
stroke: var(--oat);
stroke-width: 1.5;
stroke-linecap: round;
}
.cap {
font-size: 10px;
letter-spacing: 0.08em;
fill: var(--_cap);
text-anchor: middle;
}
/* The queue is a liquid level: it jumps when a request lands, drops on
each 0.8s leak, and is the only state the algorithm keeps. Each move
is a quick 80ms settle — the level is bookkeeping, not weather. */
.level {
fill: var(--clay);
opacity: 0.35;
transform-box: fill-box;
transform-origin: bottom center;
transform: scaleY(0);
animation: level var(--t) var(--ease-out) infinite;
}
@keyframes level {
0%,
3.75% {
transform: scaleY(0);
}
4.75%,
6.25% {
transform: scaleY(0.25);
}
7.25%,
8.75% {
transform: scaleY(0.5);
}
9.75%,
11.25% {
transform: scaleY(0.75);
}
12.25%,
12.5% {
transform: scaleY(1);
}
13.5%,
22.5% {
transform: scaleY(0.75);
}
23.5%,
32.5% {
transform: scaleY(0.5);
}
33.5%,
36.25% {
transform: scaleY(0.25);
}
37.25%,
38.75% {
transform: scaleY(0.5);
}
39.75%,
42.5% {
transform: scaleY(0.75);
}
43.5%,
52.5% {
transform: scaleY(0.5);
}
53.5%,
62.5% {
transform: scaleY(0.25);
}
63.5%,
76.25% {
transform: scaleY(0);
}
77.25%,
82.5% {
transform: scaleY(0.25);
}
83.5%,
100% {
transform: scaleY(0);
}
}
.req {
fill: var(--olive);
opacity: 0;
}
.out {
fill: var(--clay);
opacity: 0;
}
.a1 {
animation: a1 var(--t) linear infinite;
}
.a2 {
animation: a2 var(--t) linear infinite;
}
.a3 {
animation: a3 var(--t) linear infinite;
}
.a4 {
animation: a4 var(--t) linear infinite;
}
.a5 {
animation: a5 var(--t) linear infinite;
}
.a6 {
animation: a6 var(--t) linear infinite;
}
.a7 {
animation: a7 var(--t) linear infinite;
}
/* Arrivals come in two bursts and a straggler; each dot vanishes into
the bucket on the frame the level jumps. */
@keyframes a1 {
0%,
0.15% {
transform: translateX(0);
opacity: 0;
}
0.65% {
opacity: 1;
}
3.45% {
opacity: 1;
}
3.75%,
100% {
transform: translateX(80px);
opacity: 0;
}
}
@keyframes a2 {
0%,
2.65% {
transform: translateX(0);
opacity: 0;
}
3.15% {
opacity: 1;
}
5.95% {
opacity: 1;
}
6.25%,
100% {
transform: translateX(80px);
opacity: 0;
}
}
@keyframes a3 {
0%,
5.15% {
transform: translateX(0);
opacity: 0;
}
5.65% {
opacity: 1;
}
8.45% {
opacity: 1;
}
8.75%,
100% {
transform: translateX(80px);
opacity: 0;
}
}
@keyframes a4 {
0%,
7.65% {
transform: translateX(0);
opacity: 0;
}
8.15% {
opacity: 1;
}
10.95% {
opacity: 1;
}
11.25%,
100% {
transform: translateX(80px);
opacity: 0;
}
}
@keyframes a5 {
0%,
32.65% {
transform: translateX(0);
opacity: 0;
}
33.15% {
opacity: 1;
}
35.95% {
opacity: 1;
}
36.25%,
100% {
transform: translateX(80px);
opacity: 0;
}
}
@keyframes a6 {
0%,
35.15% {
transform: translateX(0);
opacity: 0;
}
35.65% {
opacity: 1;
}
38.45% {
opacity: 1;
}
38.75%,
100% {
transform: translateX(80px);
opacity: 0;
}
}
@keyframes a7 {
0%,
72.65% {
transform: translateX(0);
opacity: 0;
}
73.15% {
opacity: 1;
}
75.95% {
opacity: 1;
}
76.25%,
100% {
transform: translateX(80px);
opacity: 0;
}
}
.d1 {
animation: d1 var(--t) linear infinite;
}
.d2 {
animation: d2 var(--t) linear infinite;
}
.d3 {
animation: d3 var(--t) linear infinite;
}
.d4 {
animation: d4 var(--t) linear infinite;
}
.d5 {
animation: d5 var(--t) linear infinite;
}
.d6 {
animation: d6 var(--t) linear infinite;
}
.d7 {
animation: d7 var(--t) linear infinite;
}
/* Departures leave only on the 0.8s beat, and only while the bucket
holds anything — the two silent beats are the queue standing empty. */
@keyframes d1 {
0%,
12.5% {
transform: translateX(0);
opacity: 0;
}
12.8% {
opacity: 1;
}
14.6% {
opacity: 1;
}
14.9%,
100% {
transform: translateX(54px);
opacity: 0;
}
}
@keyframes d2 {
0%,
22.5% {
transform: translateX(0);
opacity: 0;
}
22.8% {
opacity: 1;
}
24.6% {
opacity: 1;
}
24.9%,
100% {
transform: translateX(54px);
opacity: 0;
}
}
@keyframes d3 {
0%,
32.5% {
transform: translateX(0);
opacity: 0;
}
32.8% {
opacity: 1;
}
34.6% {
opacity: 1;
}
34.9%,
100% {
transform: translateX(54px);
opacity: 0;
}
}
@keyframes d4 {
0%,
42.5% {
transform: translateX(0);
opacity: 0;
}
42.8% {
opacity: 1;
}
44.6% {
opacity: 1;
}
44.9%,
100% {
transform: translateX(54px);
opacity: 0;
}
}
@keyframes d5 {
0%,
52.5% {
transform: translateX(0);
opacity: 0;
}
52.8% {
opacity: 1;
}
54.6% {
opacity: 1;
}
54.9%,
100% {
transform: translateX(54px);
opacity: 0;
}
}
@keyframes d6 {
0%,
62.5% {
transform: translateX(0);
opacity: 0;
}
62.8% {
opacity: 1;
}
64.6% {
opacity: 1;
}
64.9%,
100% {
transform: translateX(54px);
opacity: 0;
}
}
@keyframes d7 {
0%,
82.5% {
transform: translateX(0);
opacity: 0;
}
82.8% {
opacity: 1;
}
84.6% {
opacity: 1;
}
84.9%,
100% {
transform: translateX(54px);
opacity: 0;
}
}
.progress {
stroke: var(--clay);
stroke-width: 2.5;
stroke-dasharray: 204;
stroke-dashoffset: 204;
animation: sweep var(--t) linear infinite;
}
@keyframes sweep {
0% {
stroke-dashoffset: 204;
}
100% {
stroke-dashoffset: 0;
}
}
/* Two records of the same traffic: olive arrivals above the line, clay
departures below it. Bursty in, metronome out — reading the two rows
against each other is the whole diagram. */
.tick {
transform-box: fill-box;
opacity: 0;
}
.tick.in {
fill: var(--olive);
transform-origin: bottom center;
}
.tick.outp {
fill: var(--clay);
transform-origin: top center;
}
.i1 {
animation: i1 var(--t) linear infinite;
}
.i2 {
animation: i2 var(--t) linear infinite;
}
.i3 {
animation: i3 var(--t) linear infinite;
}
.i4 {
animation: i4 var(--t) linear infinite;
}
.i5 {
animation: i5 var(--t) linear infinite;
}
.i6 {
animation: i6 var(--t) linear infinite;
}
.i7 {
animation: i7 var(--t) linear infinite;
}
.o1 {
animation: o1 var(--t) linear infinite;
}
.o2 {
animation: o2 var(--t) linear infinite;
}
.o3 {
animation: o3 var(--t) linear infinite;
}
.o4 {
animation: o4 var(--t) linear infinite;
}
.o5 {
animation: o5 var(--t) linear infinite;
}
.o6 {
animation: o6 var(--t) linear infinite;
}
.o7 {
animation: o7 var(--t) linear infinite;
}
@keyframes i1 {
0%,
3.75% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
5.55%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes i2 {
0%,
6.25% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
8.05%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes i3 {
0%,
8.75% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
10.55%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes i4 {
0%,
11.25% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
13.05%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes i5 {
0%,
36.25% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
38.05%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes i6 {
0%,
38.75% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
40.55%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes i7 {
0%,
76.25% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
78.05%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes o1 {
0%,
12.5% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
14.3%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes o2 {
0%,
22.5% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
24.3%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes o3 {
0%,
32.5% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
34.3%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes o4 {
0%,
42.5% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
44.3%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes o5 {
0%,
52.5% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
54.3%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes o6 {
0%,
62.5% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
64.3%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@keyframes o7 {
0%,
82.5% {
opacity: 0;
transform: scaleY(0.72);
animation-timing-function: var(--ease-out);
}
84.3%,
96.9% {
opacity: 1;
transform: scaleY(1);
}
99.4%,
100% {
opacity: 0;
transform: scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
.level,
.req,
.out,
.progress,
.tick {
animation: none;
}
.req,
.out {
opacity: 0;
}
.progress {
stroke-dashoffset: 0;
}
.tick {
opacity: 1;
transform: none;
}
/* Reduced motion keeps the lesson: the bursty row against the even
row, with a half-full queue breathing between them. */
.level {
transform: scaleY(0.5);
animation: settle var(--_calm) ease-in-out infinite;
}
@keyframes settle {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 0.18;
}
}
}
</style>
</head>
<body>
<svg
class="diagram"
viewBox="0 0 260 112"
role="img"
aria-label="A leaky bucket rate limiter: bursts of requests queue in a bucket and drain out at one fixed rate, turning an uneven arrival record into an even departure record"
>
<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 a1" cx="20" cy="29" r="4.5" />
<circle class="req a2" cx="20" cy="29" r="4.5" />
<circle class="req a3" cx="20" cy="29" r="4.5" />
<circle class="req a4" cx="20" cy="29" r="4.5" />
<circle class="req a5" cx="20" cy="29" r="4.5" />
<circle class="req a6" cx="20" cy="29" r="4.5" />
<circle class="req a7" cx="20" cy="29" r="4.5" />
<circle class="out d1" cx="160" cy="29" r="4.5" />
<circle class="out d2" cx="160" cy="29" r="4.5" />
<circle class="out d3" cx="160" cy="29" r="4.5" />
<circle class="out d4" cx="160" cy="29" r="4.5" />
<circle class="out d5" cx="160" cy="29" r="4.5" />
<circle class="out d6" cx="160" cy="29" r="4.5" />
<circle class="out d7" cx="160" cy="29" r="4.5" />
</g>
<g class="mech">
<rect class="bucket" x="102" y="12" width="56" height="34" rx="7" />
<rect class="level" x="106" y="16" width="48" height="26" rx="3" />
<rect class="node" x="190" y="12" width="56" height="34" rx="7" />
</g>
<!-- This axis runs 25.5 units/s from x=36 rather than the trio's 28
units/s from x=16: the IN/OUT gutter labels need the left margin.
A deliberate trade — this is the one variant whose axis is compared
against itself (top row vs bottom row), not against its siblings. -->
<g class="timeline">
<line class="axis" x1="36" y1="98" x2="240" y2="98" />
<line class="progress" x1="36" y1="98" x2="240" y2="98" />
<rect class="tick in i1" x="42.4" y="90" width="2.5" height="7" />
<rect class="tick in i2" x="47.5" y="90" width="2.5" height="7" />
<rect class="tick in i3" x="52.6" y="90" width="2.5" height="7" />
<rect class="tick in i4" x="57.7" y="90" width="2.5" height="7" />
<rect class="tick in i5" x="108.7" y="90" width="2.5" height="7" />
<rect class="tick in i6" x="113.8" y="90" width="2.5" height="7" />
<rect class="tick in i7" x="190.3" y="90" width="2.5" height="7" />
<rect class="tick outp o1" x="60.25" y="99" width="2.5" height="7" />
<rect class="tick outp o2" x="80.65" y="99" width="2.5" height="7" />
<rect class="tick outp o3" x="101.05" y="99" width="2.5" height="7" />
<rect class="tick outp o4" x="121.45" y="99" width="2.5" height="7" />
<rect class="tick outp o5" x="141.85" y="99" width="2.5" height="7" />
<rect class="tick outp o6" x="162.25" y="99" width="2.5" height="7" />
<rect class="tick outp o7" x="203.05" y="99" width="2.5" height="7" />
</g>
<g class="labels">
<text class="cap" x="130" y="57">STEADY LEAK</text>
<text class="cap" x="218" y="32.5">API</text>
<text class="cap" x="22" y="96">IN</text>
<text class="cap" x="22" y="107">OUT</text>
</g>
</svg>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.