Back to the library

Rate limit — sliding window

Variant: Sliding window

The same seven arrivals the fixed window faces, against a limiter whose memory is a 4s band welded to the clock instead of a counter with an amnesty scheduled. The band’s leading edge is now and its tail is the horizon: a pass stops counting on the exact frame the tail slides over its tick, which is why the counter drains pip by pip rather than snapping — a sliding window has no reset to snap on. The burst the fixed variant waved through bounces here four times in a row, because three passes still sit inside the band every time it arrives. Reach for it when documenting why the stricter limiter is the one that actually keeps its promise, and run it next to the fixed variant — identical traffic, opposite verdicts.

diagraminfrastructuresvgloop

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 — sliding 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;
      }

      .cap {
        font-size: 10px;
        letter-spacing: 0.08em;
        fill: var(--_cap);
        text-anchor: middle;
      }

      /* The window is a 4s band welded to the clock: its leading edge is "now"
         and its tail is the horizon of memory. Two copies 224 units apart ride
         one transform, so the wrap hands off without a seam. */
      .band {
        animation: slide var(--t) linear infinite;
      }

      .band-fill {
        fill: var(--clay);
        opacity: 0.18;
      }

      .now {
        stroke: var(--clay);
        stroke-width: 1.5;
        stroke-linecap: round;
      }

      /* The tail gets its own hairline at half the now-line's weight: it is
         the edge that does the forgetting, and every pip drain reads off it. */
      .tail {
        stroke: var(--clay);
        stroke-width: 1.5;
        stroke-linecap: round;
        opacity: 0.45;
      }

      @keyframes slide {
        0% {
          transform: translateX(0);
        }
        100% {
          transform: translateX(224px);
        }
      }

      .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 as passes are counted and drain one by one as the band's
         tail crosses each tick — never all at once, because a sliding window
         has no reset to snap on. */
      @keyframes p1 {
        0%,
        35% {
          opacity: 0;
          transform: scale(0.72);
          animation-timing-function: var(--ease-out);
        }
        37%,
        94.9% {
          opacity: 1;
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        95.9%,
        100% {
          opacity: 0;
          transform: scale(0.72);
        }
      }

      @keyframes p2 {
        0%,
        40% {
          opacity: 0;
          transform: scale(0.72);
          animation-timing-function: var(--ease-out);
        }
        42%,
        89.9% {
          opacity: 1;
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        90.9%,
        100% {
          opacity: 0;
          transform: scale(0.72);
        }
      }

      @keyframes p3 {
        0%,
        45% {
          opacity: 0;
          transform: scale(0.72);
          animation-timing-function: var(--ease-out);
        }
        47%,
        84.9% {
          opacity: 1;
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        85.9%,
        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;
      }
      .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;
        }
      }

      /* Four refusals in a row: the same burst the fixed window waved through.
         Each arrives while three passes still sit inside the band. The fill
         change is one 16ms step between var() colours — a discrete verdict,
         deliberately not a blended transition. */
      @keyframes r4 {
        0%,
        51.5% {
          transform: translateX(0);
          opacity: 0;
          fill: var(--clay);
        }
        52% {
          opacity: 1;
        }
        54.9% {
          fill: var(--clay);
        }
        55%,
        57% {
          transform: translateX(78px);
          opacity: 1;
          fill: var(--rust);
          animation-timing-function: var(--ease-out);
        }
        59.5%,
        100% {
          transform: translateX(66px);
          opacity: 0;
          fill: var(--rust);
        }
      }

      @keyframes r5 {
        0%,
        56.5% {
          transform: translateX(0);
          opacity: 0;
          fill: var(--clay);
        }
        57% {
          opacity: 1;
        }
        59.9% {
          fill: var(--clay);
        }
        60%,
        62% {
          transform: translateX(78px);
          opacity: 1;
          fill: var(--rust);
          animation-timing-function: var(--ease-out);
        }
        64.5%,
        100% {
          transform: translateX(66px);
          opacity: 0;
          fill: var(--rust);
        }
      }

      @keyframes r6 {
        0%,
        61.5% {
          transform: translateX(0);
          opacity: 0;
          fill: var(--clay);
        }
        62% {
          opacity: 1;
        }
        64.9% {
          fill: var(--clay);
        }
        65%,
        67% {
          transform: translateX(78px);
          opacity: 1;
          fill: var(--rust);
          animation-timing-function: var(--ease-out);
        }
        69.5%,
        100% {
          transform: translateX(66px);
          opacity: 0;
          fill: var(--rust);
        }
      }

      @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);
        }
      }

      /* Ticks persist at 35% weight as history. A tick brightens while it sits
         inside the band's lookback and dims on the exact frame the tail slides
         past it. Only the clay ones count toward the limit — the log stores
         passes, not refusals — but both fade with the same horizon. */
      .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%,
        84.9% {
          opacity: 1;
        }
        85.9%,
        100% {
          opacity: 0.35;
        }
      }

      @keyframes xB {
        0%,
        39.9% {
          opacity: 0.35;
        }
        41.5%,
        89.9% {
          opacity: 1;
        }
        90.9%,
        100% {
          opacity: 0.35;
        }
      }

      @keyframes xC {
        0%,
        44.9% {
          opacity: 0.35;
        }
        46.5%,
        94.9% {
          opacity: 1;
        }
        95.9%,
        100% {
          opacity: 0.35;
        }
      }

      @keyframes xD {
        0%,
        4.9% {
          opacity: 1;
        }
        5.9%,
        54.9% {
          opacity: 0.35;
        }
        56.5%,
        100% {
          opacity: 1;
        }
      }

      @keyframes xE {
        0%,
        9.9% {
          opacity: 1;
        }
        10.9%,
        59.9% {
          opacity: 0.35;
        }
        61.5%,
        100% {
          opacity: 1;
        }
      }

      @keyframes xF {
        0%,
        14.9% {
          opacity: 1;
        }
        15.9%,
        64.9% {
          opacity: 0.35;
        }
        66.5%,
        100% {
          opacity: 1;
        }
      }

      @keyframes xG {
        0%,
        29.9% {
          opacity: 1;
        }
        30.9%,
        79.9% {
          opacity: 0.35;
        }
        81.5%,
        100% {
          opacity: 1;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        .band,
        .pip,
        .req,
        .tick {
          animation: none;
        }

        .req {
          opacity: 0;
        }

        .tick {
          opacity: 1;
        }

        /* Reduced motion parks the band over the burst, mid-refusal: three
           counted passes inside the window and the count at full. The counter
           breathes; nothing travels. */
        .band {
          transform: translateX(156.8px);
        }

        .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 sliding window rate limiter: a 4-second band trails the clock, requests are refused while three passes sit inside it, and each pass stops counting the moment the band's tail slides past it"
    >
      <defs>
        <clipPath id="band-clip">
          <rect x="16" y="88" width="224" height="16" />
        </clipPath>
      </defs>

      <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">
        <line class="axis" x1="16" y1="100" x2="240" y2="100" />

        <g clip-path="url(#band-clip)">
          <g class="band">
            <rect class="band-fill" x="-96" y="90.5" width="112" height="11" rx="2" />
            <rect class="band-fill" x="128" y="90.5" width="112" height="11" rx="2" />
            <line class="tail" x1="-96" y1="90.5" x2="-96" y2="101.5" />
            <line class="tail" x1="128" y1="90.5" x2="128" y2="101.5" />
            <line class="now" x1="16" y1="90.5" x2="16" y2="101.5" />
          </g>
        </g>

        <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 no xD" x="137.95" y="86" width="2.5" height="14" />
        <rect class="tick no xE" x="149.15" y="86" width="2.5" height="14" />
        <rect class="tick no 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" />
      </g>

      <g class="labels">
        <text class="cap" x="130" y="57">3 IN ANY 4s</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.