---
title: 'Rate Limiting Windows'
source: 'https://academia.sh/en/courses/runtime-security/rate-limiting-windows'
course: 'Runtime Security and Secrets Management'
language: en
updated: '2026-10-07T04:31:06+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Runs three window shapes over the same load series and counts caught, missed, and falsely blocked requests separately'
    - 'Measures the attack volume let through by the limit that saves the legitimate peak load'
    - 'Separates which part of the item a rate limit cuts would be closed at an earlier stage'
---

# Rate Limiting Windows

Running the same five-minute load through three different rate limit windows and how the window shape shifts the three counts: the window boundary effect of the fixed window, the request the sliding window cuts from legitimate peak load, the token bucket saving the peak share while also letting the attack peak through, how many bad requests the limit that saves the legitimate peak lets go, and which part of the cut volume could have been closed at an earlier stage.

The previous lesson had a rule set look at requests one by one, and it showed that the 1,200
missed requests were missed for exactly this reason: none of them was wrong on its own. The
wrongness was not inside the request but in **the number and timing of requests** from the same
client. Rate limiting is the defense that looks at this axis, and its measure is not the bad
requests it stops but what it cuts from legitimate peak load.

Throttling algorithms were measured earlier and are not repeated here. This lesson asks a single
question: when the same nominal limit is applied in three different window shapes, where do the
three counts shift. The window shape looks like an implementation detail; once measured, it turns
out to be the main decision that determines whether legitimate work is cut.

## The Same Load, Three Window Shapes

A five-minute slice is taken from the regional measurement network's production environment.
Everything below is a model built with `node`; no real traffic is generated, and no request body
is written.

- **AD6.** The seven client classes, their arrival series, and their requests per second belong
  to the fictional measurement network. Two of the legitimate classes have a peak load that is
  the work itself: the sessions the field application opens at shift start, and the bulk query
  that runs in the billing period.
- **AD7.** The rate limit is tied to a single key per client, and in this lesson the key is
  fixed. Whether the key is an account or an address is a separate axis; the next lesson
  measures it.
- **AD8.** Arrival rate is spread evenly within a second. In reality clustering within a second
  raises the amount the sliding window cuts somewhat, without changing its direction.
- **AD9.** The three shapes carry **the same nominal limit**: 600 requests per minute. The token
  bucket adds 10 tokens per second and fills at 900 tokens; the average rate is the same, the
  peak share differs.
- **AD10.** The "earlier closing stage" column was marked by hand for each bad client.

```js
// load.mjs — five-minute arrival series and three window shapes; a model, not real traffic
// [client, true class, [[t0, t1, requests per second], ...], earlier closing stage]
export const DURATION = 300;
export const CLIENTS = [
  ['subscriber portal, steady', 'legit', [[0, 300, 4]], '-'],
  ['field app, shift start', 'legit', [[0, 110, 2], [110, 150, 30], [150, 300, 2]], '-'],
  ['billing period bulk query', 'legit', [[0, 45, 1], [45, 65, 40], [65, 300, 1]], '-'],
  ['internal monitoring poll', 'legit', [[0, 300, 1]], '-'],
  ['out-of-workflow bulk download', 'bad', [[0, 300, 9]], 'design'],
  ['page crawling scraper', 'bad', [[0, 300, 26]], 'none'],
  ['login attempt series', 'bad', [[40, 100, 18], [160, 220, 18]], 'code'],
];

export const series = (profile) => {
  const a = new Array(DURATION).fill(0);
  for (const [t0, t1, r] of profile) for (let t = t0; t < t1; t++) a[t] = r;
  return a;
};

// fixed window: the counter resets at the window boundary
export function fixed(a, limit, win) {
  let passed = 0, counter = 0;
  for (let t = 0; t < a.length; t++) {
    if (t % win === 0) counter = 0;
    const allow = Math.min(a[t], Math.max(0, limit - counter));
    counter += allow; passed += allow;
  }
  return passed;
}

// sliding window: requests accepted in the last `win` seconds are counted
export function sliding(a, limit, win) {
  let passed = 0; const accepted = new Array(a.length).fill(0);
  for (let t = 0; t < a.length; t++) {
    let recent = 0;
    for (let u = Math.max(0, t - win + 1); u < t; u++) recent += accepted[u];
    accepted[t] = Math.min(a[t], Math.max(0, limit - recent));
    passed += accepted[t];
  }
  return passed;
}

// token bucket: `rate` tokens are added per second, the bucket fills at `cap` tokens
export function bucket(a, rate, cap) {
  let passed = 0, tokens = cap;
  for (let t = 0; t < a.length; t++) {
    tokens = Math.min(cap, tokens + rate);
    const allow = Math.min(a[t], tokens);
    tokens -= allow; passed += allow;
  }
  return passed;
}

export const SHAPES = [
  ['fixed window 60 s', (a) => fixed(a, 600, 60)],
  ['sliding window 60 s', (a) => sliding(a, 600, 60)],
  ['token bucket 10/s', (a) => bucket(a, 10, 900)],
];

export function measure(shape) {
  let caught = 0, missed = 0, falsely = 0, legit = 0;
  for (const [, cls, profile] of CLIENTS) {
    const a = series(profile);
    const total = a.reduce((x, y) => x + y, 0);
    const cut = total - shape(a);
    if (cls === 'bad') { caught += cut; missed += total - cut; }
    else { falsely += cut; legit += total; }
  }
  return { caught, missed, falsely, legit };
}
```

The only difference between the three shapes is how the counter keeps its history. The fixed
window resets the counter at the start of the minute; it remembers no request from the previous
minute. The sliding window remembers every request it accepted in the last sixty seconds. The
token bucket keeps no window at all: it accumulates the unused share and allows it to be spent
in one go.

## Requests Cut from Legitimate Peak Load

```js
// run-windows.mjs — the same five-minute load; the only thing that changes is the window shape
import { CLIENTS, SHAPES, series, measure } from './load.mjs';

const s = (x, n) => String(x).padStart(n);
const T = (f) => CLIENTS.filter(f).reduce((t, k) => t + series(k[2]).reduce((x, y) => x + y, 0), 0);
console.log(`${CLIENTS.length} clients, 300 seconds; total ${T(() => true)} requests` +
  ` (${T((k) => k[1] === 'bad')} bad / ${T((k) => k[1] === 'legit')} legit)\n`);

console.log(`${'client'.padEnd(36)}${s('class', 6)}${s('total', 7)}` +
  SHAPES.map(([name]) => s(name.split(' ')[0], 9)).join('') + s('earlier stage', 15));
for (const [name, cls, profile, stage] of CLIENTS) {
  const a = series(profile), total = a.reduce((x, y) => x + y, 0);
  console.log(`${name.padEnd(36)}${s(cls, 6)}${s(total, 7)}` +
    SHAPES.map(([, f]) => s(total - f(a), 9)).join('') + s(stage, 15));
}

console.log(`\n(three columns: requests cut by that shape)\n`);
console.log(`${'window shape'.padEnd(20)}${s('caught', 11)}${s('missed', 8)}` +
  `${s('falsely blocked', 16)}${s('legit cut %', 15)}`);
for (const [name, f] of SHAPES) {
  const r = measure(f);
  console.log(`${name.padEnd(20)}${s(r.caught, 11)}${s(r.missed, 8)}${s(r.falsely, 16)}` +
    `${s((100 * r.falsely / r.legit).toFixed(1), 15)}`);
}
```

```
7 clients, 300 seconds; total 16960 requests (12660 bad / 4300 legit)

client                               class  total    fixed  sliding    token  earlier stage
subscriber portal, steady            legit   1200        0        0        0              -
field app, shift start               legit   1720      360      640        0              -
billing period bulk query            legit   1080       45      240        0              -
internal monitoring poll             legit    300        0        0        0              -
out-of-workflow bulk download          bad   2700        0        0        0         design
page crawling scraper                  bad   7800     4800     4800     3910           none
login attempt series                   bad   2160      240      960        0           code

(three columns: requests cut by that shape)

window shape             caught  missed falsely blocked    legit cut %
fixed window 60 s          5040    7620             405            9.4
sliding window 60 s        5760    6900             880           20.5
token bucket 10/s          3910    8750               0            0.0
```

The three rows carry the same nominal limit, and in all three the three counts differ. The
sliding window stops the most bad requests: 5,760. In the same row **20.5%** of legitimate load is
cut, and what is cut is not random requests but the 640 sessions the field application opens at
shift start and the 240 queries of the billing period. The fixed window cuts less because it
resets the counter at the minute boundary; the shift peak is split across two windows, so it
escapes with 360 requests. The same reset works on the bad side too: only 240 requests are cut
from the login attempt series, because both sixty-second peaks fall on the two sides of the
boundary. The window boundary effect does not choose whom it will favor.

The token bucket row is the most striking one. **Nothing** is cut from legitimate requests: the
accumulation of 900 tokens covers both the shift peak and the billing peak in full. The same
accumulation covers the login attempt series in full too, all 2,160 requests getting through.
The bucket does not ask whether a peak is legitimate; it looks only at whether there is
accumulated share. A defense that wants to recognize peak share has to recognize the peak-shaped
attack as well.

The out-of-workflow bulk download of 2,700 requests is not cut in any of the three shapes. Nine
requests per second is below the limit of 600 per minute; as long as the client works at a steady
rate just under the limit, no window shape sees it. The item that slipped past the previous
lesson's rule set slips past the rate limit as well.

## The Price of Raising the Limit

The 880 legitimate requests the sliding window cuts mean a disruption in production: part of the
field crew cannot get into the application at shift start. The first answer given is to raise the
limit.

```js
// limit.mjs — the price of raising the limit to save the legitimate peak load
import { CLIENTS, series, sliding, bucket } from './load.mjs';

const s = (x, n) => String(x).padStart(n);
const BAD = CLIENTS.filter((k) => k[1] === 'bad');
const LEGIT = CLIENTS.filter((k) => k[1] === 'legit');
const attempts = CLIENTS.find((k) => k[0].startsWith('login'));

const cut = (list, f) => list.reduce((t, k) => {
  const a = series(k[2]); return t + a.reduce((x, y) => x + y, 0) - f(a);
}, 0);

console.log(`sliding window, 60 s; only the limit changes`);
console.log(`${'limit'.padEnd(7)}${s('falsely blocked', 16)}${s('caught', 11)}${s('missed', 8)}` +
  `${s('login attempts passed', 23)}`);
for (const L of [600, 800, 1000, 1240, 1500]) {
  const f = (a) => sliding(a, L, 60);
  console.log(`${String(L).padEnd(7)}${s(cut(LEGIT, f), 16)}${s(cut(BAD, f), 11)}` +
    `${s(12660 - cut(BAD, f), 8)}${s(f(series(attempts[2])), 23)}`);
}

console.log(`\ntoken bucket, 10/s; only the bucket capacity changes`);
console.log(`${'capacity'.padEnd(10)}${s('falsely blocked', 16)}${s('caught', 11)}${s('missed', 8)}` +
  `${s('login attempts passed', 23)}`);
for (const K of [200, 400, 600, 900, 1200]) {
  const f = (a) => bucket(a, 10, K);
  console.log(`${String(K).padEnd(10)}${s(cut(LEGIT, f), 16)}${s(cut(BAD, f), 11)}` +
    `${s(12660 - cut(BAD, f), 8)}${s(f(series(attempts[2])), 23)}`);
}
```

```
sliding window, 60 s; only the limit changes
limit   falsely blocked     caught  missed  login attempts passed
600                 880       5760    6900                   1200
800                 480       4360    8300                   1600
1000                240       2960    9700                   2000
1240                  0       1600   11060                   2160
1500                  0        300   12360                   2160

token bucket, 10/s; only the bucket capacity changes
capacity   falsely blocked     caught  missed  login attempts passed
200                   1020       5190    7470                   1580
400                    620       4590    8070                   1980
600                    220       4210    8450                   2160
900                      0       3910    8750                   2160
1200                     0       3610    9050                   2160
```

The two tables say the same thing through two different settings. In the sliding window, falsely
blocked requests fall to zero only when the limit reaches 1,240; the busiest sixty-second slice of the
shift peak is exactly that much. In the same row, caught requests have dropped from 5,760 to
1,600, and missed requests have risen from 6,900 to 11,060. The last column writes the real price
of the decision: **all** of the login attempt series starts to pass.

The token bucket table repeats the same curve on the capacity axis. The first capacity at which
the legitimate peak passes with zero cuts is 900; the first capacity at which the whole login
series passes is 600. That is, the bucket has already saved the attack peak **before** it is
loosened enough to save the legitimate peak. The order cannot be reversed, because the two peaks
are the same thing: a short-lived high rate.

What comes out of this is not a setting recommendation but a limit: in a system whose peak load is
the work itself, a single-threshold rate limit tied to a single key has to let the attack peak
through in order to let the legitimate peak through. The distinction is not in the rate but in
**what** the request is made to; a rate counter does not see that.

## Where What Is Cut Belongs

```js
// stage-share.mjs — how much of the cut bad requests could have been closed at a cheaper stage
import { CLIENTS, SHAPES, series, sliding } from './load.mjs';

const s = (x, n) => String(x).padStart(n);
const BAD = CLIENTS.filter((k) => k[1] === 'bad');
const LEGIT = CLIENTS.filter((k) => k[1] === 'legit');
const STAGES = ['design', 'code', 'none'];
const cut = (k, f) => { const a = series(k[2]); return a.reduce((x, y) => x + y, 0) - f(a); };

console.log(`${'window shape'.padEnd(20)}${STAGES.map((a) => s(a, 10)).join('')}${s('only here %', 17)}`);
for (const [name, f] of SHAPES) {
  const share = STAGES.map((a) => BAD.filter((k) => k[3] === a).reduce((x, k) => x + cut(k, f), 0));
  const t = share.reduce((x, y) => x + y, 0);
  console.log(`${name.padEnd(20)}${share.map((p) => s(p, 10)).join('')}${s((100 * share[2] / t).toFixed(1), 17)}`);
}

console.log(`\nsliding window; limit needed to catch the out-of-workflow bulk download (9/s)`);
console.log(`${'limit'.padEnd(7)}${s('cut from bulk download', 26)}${s('legit cut', 15)}${s('legit cut %', 15)}`);
const download = BAD.find((k) => k[3] === 'design');
for (const L of [600, 540, 480, 360]) {
  const f = (a) => sliding(a, L, 60);
  const m = LEGIT.reduce((x, k) => x + cut(k, f), 0);
  console.log(`${String(L).padEnd(7)}${s(cut(download, f), 26)}${s(m, 15)}${s((100 * m / 4300).toFixed(1), 15)}`);
}
```

```
window shape            design      code      none      only here %
fixed window 60 s            0       240      4800             95.2
sliding window 60 s          0       960      4800             83.3
token bucket 10/s            0         0      3910            100.0

sliding window; limit needed to catch the out-of-workflow bulk download (9/s)
limit      cut from bulk download      legit cut    legit cut %
600                             0            880           20.5
540                             0           1000           23.3
480                           300           1120           26.0
360                           900           1360           31.6
```

The upper table gives the layer's real share. The page crawling scraper cannot be closed at any
earlier stage: its requests are individually legitimate, authenticated, and authorized, and only
their number per unit of time is meaningful. The 4,800 requests cut in all three shapes (3,910 in
the bucket) belong to this class, and this is exactly the reason this layer exists. The login
attempt series belongs somewhere else: once a second factor is required at the code stage, the
series loses its value, because even if the right password is found, no session is opened. The
240 or 960 requests the rate limit cuts from that series are the repeated catching, every day, of
an item that can be closed.

The lower table shows the item that belongs to the design stage. To see the out-of-workflow bulk
download with a rate limit, the limit has to be lowered to 480; at that setting 300 requests are
cut from this client and 1,120 from legitimate load, which is **a quarter** of the legitimate
work. When the limit drops to 360, the bad requests cut rise to 900 and the legitimate cut to
31.6%. This item can be closed with a rate limit only by making the system unusable; its right
place is design. Once the bulk download is moved into a queue job, there is no room left to work
at a steady rate just under the limit.

## Summary

- The same nominal limit (600 requests per minute) gave three different results in three window
  shapes: fixed window 5,040 / 7,620 / 405, sliding window 5,760 / 6,900 / 880, token bucket
  3,910 / 8,750 / 0 (caught / missed / falsely blocked).
- The sliding window cut 20.5% of legitimate load, and what it cut was the shift start and
  billing period peaks. The fixed window cutting less is not an advantage but a by-product of the
  window boundary effect: the same reset let the login series off with 240 requests cut instead of 960.
- The token bucket cut nothing from the legitimate peak but let all 2,160 requests of the login
  attempt series through. By the time the bucket capacity was opened enough to save the
  legitimate peak (900), the attack peak had already escaped at a capacity of 600.
- The limit that zeroes falsely blocked requests in the sliding window is 1,240; at that setting
  caught requests fall from 5,760 to 1,600 and missed requests rise to 11,060. In a system whose
  peak load is the work itself, a single-threshold rate limit lets the attack peak through in
  order to let the legitimate peak through.
- The only part of the cut volume that can be closed solely here is the scraper (4,800 / 3,910).
  The login series closes with a second factor at the code stage, the out-of-workflow bulk
  download with a queue job in design; seeing the latter with a rate limit requires lowering the
  limit to 480, that is, cutting a quarter of legitimate work.

## Next Step

This lesson's counter was tied to a single key, and what that key was never came up. The part of
the login attempt series that was cut or missed stayed uncertain for exactly this reason: the
series' rate per second was known, but **how many accounts** and **how many addresses** the
attempts were spread across was not. The same total rate is one kind of attack when loaded onto a
single account and a very different kind when distributed as a single attempt each across 10,000
accounts, and the second is invisible in every setting of this lesson. The next lesson moves the
threshold off the rate axis and onto the account and address axes, separates which threshold
stops which attack shape, and asks the same three counts there, especially of the legitimate
account that gets locked out.
