Rate limit — fixed window
Variant: Fixed window
The naïve limiter, drawn honestly enough to indict itself. The counter fills with a 160ms pop per pass but empties in a single 16ms step at the window boundary, because the reset is bookkeeping and easing it would claim the count wound down through values it never held. The traffic is arranged as an ambush: three passes land late in one window, three more early in the next, and after the sixth a rust line fades in under the cluster — exactly one window-length of time holding double the limit the label promises. Ticks brighten while their window counts them and dim the moment its memory is discarded. Show it beside the sliding-window variant, which refuses the identical burst; the pair is the argument.
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 — fixed window</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;
}
.counter {
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;
}
/* The two 4s windows are printed on the axis itself: one tinted, one
bare, with a hard marker at the boundary. The zebra is what makes the
reset legible as a place rather than a moment. */
.zebra {
fill: var(--oat);
opacity: 0.3;
}
.bmark {
stroke: var(--_cap);
stroke-width: 1.5;
stroke-linecap: round;
}
.cap {
font-size: 10px;
letter-spacing: 0.08em;
fill: var(--_cap);
text-anchor: middle;
}
.pip {
fill: var(--clay);
opacity: 0;
transform-box: fill-box;
transform-origin: center;
}
.p1 {
animation: p1 var(--t) linear infinite;
}
.p2 {
animation: p2 var(--t) linear infinite;
}
.p3 {
animation: p3 var(--t) linear infinite;
}
/* Pips fill with a 160ms pop but empty in a single 16ms step — the reset
is bookkeeping, not motion, and easing it would claim the count wound
down through values it never held. */
@keyframes p1 {
0%,
35% {
opacity: 0;
transform: scale(0.72);
animation-timing-function: var(--ease-out);
}
37%,
49.8% {
opacity: 1;
transform: scale(1);
}
50%,
55% {
opacity: 0;
transform: scale(0.72);
animation-timing-function: var(--ease-out);
}
57%,
99.8% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.72);
}
}
@keyframes p2 {
0%,
40% {
opacity: 0;
transform: scale(0.72);
animation-timing-function: var(--ease-out);
}
42%,
49.8% {
opacity: 1;
transform: scale(1);
}
50%,
60% {
opacity: 0;
transform: scale(0.72);
animation-timing-function: var(--ease-out);
}
62%,
99.8% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.72);
}
}
@keyframes p3 {
0%,
45% {
opacity: 0;
transform: scale(0.72);
animation-timing-function: var(--ease-out);
}
47%,
49.8% {
opacity: 1;
transform: scale(1);
}
50%,
65% {
opacity: 0;
transform: scale(0.72);
animation-timing-function: var(--ease-out);
}
67%,
99.8% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.72);
}
}
.bflash {
stroke: var(--clay-d);
stroke-width: 2.5;
stroke-linecap: round;
opacity: 0;
animation: bflash var(--t) linear infinite;
}
@keyframes bflash {
0%,
49.8% {
opacity: 0;
}
50% {
opacity: 1;
}
54%,
100% {
opacity: 0;
}
}
.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;
}
.r6 {
animation: r6 var(--t) linear infinite;
}
.r7 {
animation: r7 var(--t) linear infinite;
}
@keyframes r1 {
0%,
30% {
transform: translateX(0);
opacity: 0;
}
30.5% {
opacity: 1;
}
37.9% {
opacity: 1;
}
38.75%,
100% {
transform: translateX(194px);
opacity: 0;
}
}
@keyframes r2 {
0%,
35% {
transform: translateX(0);
opacity: 0;
}
35.5% {
opacity: 1;
}
42.9% {
opacity: 1;
}
43.75%,
100% {
transform: translateX(194px);
opacity: 0;
}
}
@keyframes r3 {
0%,
40% {
transform: translateX(0);
opacity: 0;
}
40.5% {
opacity: 1;
}
47.9% {
opacity: 1;
}
48.75%,
100% {
transform: translateX(194px);
opacity: 0;
}
}
@keyframes r4 {
0%,
50% {
transform: translateX(0);
opacity: 0;
}
50.5% {
opacity: 1;
}
57.9% {
opacity: 1;
}
58.75%,
100% {
transform: translateX(194px);
opacity: 0;
}
}
@keyframes r5 {
0%,
55% {
transform: translateX(0);
opacity: 0;
}
55.5% {
opacity: 1;
}
62.9% {
opacity: 1;
}
63.75%,
100% {
transform: translateX(194px);
opacity: 0;
}
}
@keyframes r6 {
0%,
60% {
transform: translateX(0);
opacity: 0;
}
60.5% {
opacity: 1;
}
67.9% {
opacity: 1;
}
68.75%,
100% {
transform: translateX(194px);
opacity: 0;
}
}
/* The one refusal a fixed window does deliver: the second window's own
count is full by 5.2s, so the 6.4s arrival bounces. The fill change is
one 16ms step between var() colours — a discrete verdict, deliberately
not a blended transition. */
@keyframes r7 {
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);
}
}
.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;
}
}
/* Ticks persist across the loop at 35% weight — the axis is a record.
Each brightens on its event and dims on the frame its window's memory
is thrown away, so brightness always means "counted right now". */
.tick {
opacity: 0.35;
}
.tick.ok {
fill: var(--clay);
}
.tick.no {
fill: var(--rust);
}
.xA {
animation: xA var(--t) linear infinite;
}
.xB {
animation: xB var(--t) linear infinite;
}
.xC {
animation: xC var(--t) linear infinite;
}
.xD {
animation: xD var(--t) linear infinite;
}
.xE {
animation: xE var(--t) linear infinite;
}
.xF {
animation: xF var(--t) linear infinite;
}
.xG {
animation: xG var(--t) linear infinite;
}
@keyframes xA {
0%,
34.9% {
opacity: 0.35;
}
36.5%,
49.9% {
opacity: 1;
}
50.7%,
100% {
opacity: 0.35;
}
}
@keyframes xB {
0%,
39.9% {
opacity: 0.35;
}
41.5%,
49.9% {
opacity: 1;
}
50.7%,
100% {
opacity: 0.35;
}
}
@keyframes xC {
0%,
44.9% {
opacity: 0.35;
}
46.5%,
49.9% {
opacity: 1;
}
50.7%,
100% {
opacity: 0.35;
}
}
@keyframes xD {
0%,
54.9% {
opacity: 0.35;
}
56.5%,
99.9% {
opacity: 1;
}
100% {
opacity: 0.35;
}
}
@keyframes xE {
0%,
59.9% {
opacity: 0.35;
}
61.5%,
99.9% {
opacity: 1;
}
100% {
opacity: 0.35;
}
}
@keyframes xF {
0%,
64.9% {
opacity: 0.35;
}
66.5%,
99.9% {
opacity: 1;
}
100% {
opacity: 0.35;
}
}
@keyframes xG {
0%,
79.9% {
opacity: 0.35;
}
81.5%,
99.9% {
opacity: 1;
}
100% {
opacity: 0.35;
}
}
/* The tell: one window-length of time, centred on the boundary, holding
six passes — double the limit the algorithm claims to enforce. */
.flaw {
stroke: var(--rust);
stroke-width: 2;
stroke-linecap: round;
opacity: 0;
animation: flaw var(--t) linear infinite;
}
@keyframes flaw {
0%,
66% {
opacity: 0;
}
69%,
88% {
opacity: 0.7;
}
94%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.pip,
.bflash,
.req,
.progress,
.tick,
.flaw {
animation: none;
}
.req,
.bflash {
opacity: 0;
}
.progress {
stroke-dashoffset: 0;
}
.tick {
opacity: 1;
}
.flaw {
opacity: 0.7;
}
/* Reduced motion keeps the lesson: the full record, the rust span
marking six passes in one window-length of time. The counter breathes. */
.pip {
opacity: 1;
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 fixed window rate limiter: the counter resets at the window boundary, so a burst straddling the boundary gets six requests through where the limit says three"
>
<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" />
<circle class="req r6" cx="20" cy="29" r="4.5" />
<circle class="req r7" cx="20" cy="29" r="4.5" />
</g>
<g class="mech">
<rect class="counter" x="102" y="12" width="56" height="34" rx="7" />
<rect class="node" x="190" y="12" width="56" height="34" rx="7" />
<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="pip p1" cx="116" cy="36" r="4" />
<circle class="pip p2" cx="130" cy="36" r="4" />
<circle class="pip p3" cx="144" cy="36" r="4" />
</g>
<g class="timeline">
<rect class="zebra" x="16" y="93" width="112" height="7" />
<line class="axis" x1="16" y1="100" x2="240" y2="100" />
<line class="bmark" x1="128" y1="91" x2="128" y2="100" />
<line class="bflash" x1="128" y1="91" x2="128" y2="100" />
<line class="progress" x1="16" y1="100" x2="240" y2="100" />
<rect class="tick ok xA" x="93.15" y="86" width="2.5" height="14" />
<rect class="tick ok xB" x="104.35" y="86" width="2.5" height="14" />
<rect class="tick ok xC" x="115.55" y="86" width="2.5" height="14" />
<rect class="tick ok xD" x="137.95" y="86" width="2.5" height="14" />
<rect class="tick ok xE" x="149.15" y="86" width="2.5" height="14" />
<rect class="tick ok xF" x="160.35" y="86" width="2.5" height="14" />
<rect class="tick no xG" x="193.95" y="86" width="2.5" height="14" />
<line class="flaw" x1="72" y1="106" x2="184" y2="106" />
</g>
<g class="labels">
<text class="cap" x="130" y="57">3 PER WINDOW</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.