05Duplicate-call guard — a list blocking its next request from a last-request timestamp value
Once the list's end is reached multiple times within a short span, the gap from the last request's timestamp value is computed, no new request goes out while that value stays under the threshold, and only once it crosses the threshold does the next request fire.
Best forA fast mouse-wheel scrolling environment, low-end devices
tryRequestCOOLDOWN_MSperformance.now

<main class="stage">
<div class="dgd is-demo" id="dgd">
<div class="dgd__meters">
<div class="dgd__meter">
<span class="dgd__meter-label">Since last request</span>
<span class="dgd__meter-value" id="dgdSince">0ms</span>
</div>
<div class="dgd__meter">
<span class="dgd__meter-label">Blocked calls</span>
<span class="dgd__meter-value" id="dgdBlocked">0 times</span>
</div>
</div>
<div class="dgd__view" id="dgdView">
<ul class="dgd__track" id="dgdTrack">
<li class="dgd__sentinel" id="dgdSentinel" aria-hidden="true"></li>
</ul>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 중복 호출 방지 — tryRequest() 가 마지막 요청 시각 값과의 차이를 계산해 대기 기준 안에서는
// 호출을 막고 막은 횟수만 센다. 센티넬 색이 붉게 깜빡이는 게 '막았다'는 계산의 증거다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-sky;
@include stage($stage-ink);
.dgd {
display: flex;
flex-direction: column;
align-items: stretch;
gap: $sp-3;
width: min(100%, 280px);
}
.dgd__meters {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $sp-2;
}
.dgd__meter {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-1;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba(255, 255, 255, .07);
}
.dgd__meter-label {
font-size: 10px;
color: rgba(255, 255, 255, .55);
}
.dgd__meter-value {
font-weight: 700;
font-size: 13px;
color: $stage-paper;
font-variant-numeric: tabular-nums;
}
.dgd__view {
box-sizing: border-box;
width: 100%;
height: 152px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .06);
padding: $sp-3;
scrollbar-width: thin;
}
.dgd__track {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.dgd__row {
box-sizing: border-box;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba(255, 255, 255, .08);
color: $stage-paper;
font-weight: 600;
font-size: 12px;
}
.dgd__sentinel {
height: 6px;
border-radius: $r-pill;
background: rgba(255, 255, 255, .18);
transition: background $duration $easing;
}
.dgd__sentinel--loading {
background: $color;
}
.dgd__sentinel--blocked {
background: $error;
}
@media (max-width: 340px) {
.dgd { gap: $sp-1; }
.dgd__meter { padding: $sp-1 $sp-2; }
.dgd__view { height: 80px; }
.dgd__row { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.dgd__sentinel { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dgd {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12px;
width: min(100%, 280px);
}
.dgd__meters {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.dgd__meter {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.07);
}
.dgd__meter-label {
font-size: 10px;
color: rgba(255, 255, 255, 0.55);
}
.dgd__meter-value {
font-weight: 700;
font-size: 13px;
color: #ffffff;
font-variant-numeric: tabular-nums;
}
.dgd__view {
box-sizing: border-box;
width: 100%;
height: 152px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
padding: 12px;
scrollbar-width: thin;
}
.dgd__track {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.dgd__row {
box-sizing: border-box;
padding: 8px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
font-weight: 600;
font-size: 12px;
}
.dgd__sentinel {
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
transition: background 220ms ease-out;
}
.dgd__sentinel--loading {
background: #6ec8ff;
}
.dgd__sentinel--blocked {
background: #c2361a;
}
@media (max-width: 340px) {
.dgd {
gap: 4px;
}
.dgd__meter {
padding: 4px 8px;
}
.dgd__view {
height: 80px;
}
.dgd__row {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.dgd__sentinel {
transition: none !important;
}
}(function () {
var view = document.getElementById('dgdView');
var track = document.getElementById('dgdTrack');
var sentinel = document.getElementById('dgdSentinel');
var sinceEl = document.getElementById('dgdSince');
var blockedEl = document.getElementById('dgdBlocked');
var root = document.getElementById('dgd');
var COOLDOWN_MS = 900; // 마지막 요청 시각과의 차이가 이 값보다 작으면 호출을 막는다
var LOAD_DELAY_MS = 540;
var MAX_BATCHES = 4;
var lastRequestAt = -99999;
var blockedCount = 0;
var loading = false;
var batches = 0;
var rowSeq = 0;
var dir = 1;
var autoplayTimer = null;
var uiTimer = null;
function addRow() {
var li = document.createElement('li');
li.className = 'dgd__row';
li.textContent = 'Inbox ' + String(rowSeq + 1).padStart(2, '0');
track.insertBefore(li, sentinel);
rowSeq += 1;
}
for (var i = 0; i < 6; i += 1) { addRow(); }
// 계산 겹: 호출 조건이 닿아도 마지막 요청 시각 값과 지금 시각 값의 차이를 계산해,
// 그 값이 대기 기준보다 작은 동안은 요청을 걸지 않고 막은 횟수만 센다.
function tryRequest() {
var now = performance.now();
var since = now - lastRequestAt;
if (since < COOLDOWN_MS) {
blockedCount += 1;
blockedEl.textContent = blockedCount + ' times';
sentinel.classList.add('dgd__sentinel--blocked');
setTimeout(function () { sentinel.classList.remove('dgd__sentinel--blocked'); }, 180);
return false;
}
lastRequestAt = now;
loading = true;
sentinel.classList.add('dgd__sentinel--loading');
setTimeout(function () {
for (var n = 0; n < 3; n += 1) { addRow(); }
loading = false;
batches += 1;
sentinel.classList.remove('dgd__sentinel--loading');
}, LOAD_DELAY_MS);
return true;
}
function recompute() {
var remaining = sentinel.getBoundingClientRect().top - view.getBoundingClientRect().bottom;
if (remaining <= 2 && !loading && batches < MAX_BATCHES) { tryRequest(); }
}
function uiTick() {
sinceEl.textContent = Math.round(performance.now() - lastRequestAt) + 'ms';
}
view.addEventListener('scroll', recompute);
function userTakesOver() {
if (!root.classList.contains('is-demo')) { return; }
root.classList.remove('is-demo');
stopAutoplay();
}
view.addEventListener('wheel', userTakesOver, { passive: true });
view.addEventListener('touchstart', userTakesOver, { passive: true });
function autoplayTick() {
var max = view.scrollHeight - view.clientHeight;
if (batches >= MAX_BATCHES && view.scrollTop >= max - 1 && dir !== 0) {
dir = 0;
setTimeout(function () {
while (track.firstChild !== sentinel) { track.removeChild(track.firstChild); }
rowSeq = 0; batches = 0; blockedCount = 0; lastRequestAt = -99999;
blockedEl.textContent = '0 times';
for (var i = 0; i < 6; i += 1) { addRow(); }
view.scrollTop = 0;
dir = 1;
}, 600);
return;
}
if (dir === 0) { return; }
view.scrollTop = Math.min(view.scrollTop + 4, max); // 끝에 붙어 있을 때 연속 이벤트 → 방어 동작 유발
recompute();
}
function stopAutoplay() {
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
if (uiTimer) { clearInterval(uiTimer); uiTimer = null; }
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
autoplayTimer = setInterval(autoplayTick, 60);
uiTimer = setInterval(uiTick, 100);
}
})();import { useEffect, useRef, useState, useCallback } from "react";
import styles from "./DuplicateGuard.module.scss";
// 중복 호출 방지 — tryRequest() 가 마지막 요청 시각 값과의 차이를 계산해 대기 기준 안에서는
// 호출을 막고 막은 횟수만 센다. 센티넬이 붉게 깜빡이는 게 '막았다'는 계산의 증거다.
const COOLDOWN_MS = 900;
const LOAD_DELAY_MS = 540;
const MAX_BATCHES = 4;
export default function DuplicateGuard() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLLIElement>(null);
const [rows, setRows] = useState(6);
const [since, setSince] = useState("0ms");
const [blocked, setBlocked] = useState(0);
const loadingRef = useRef(false);
const batchesRef = useRef(0);
const blockedRef = useRef(0);
const lastRequestAtRef = useRef(-99999);
const dirRef = useRef(1);
const autoplayRef = useRef<number | null>(null);
const uiTimerRef = useRef<number | null>(null);
// 계산 겹: 호출 조건이 닿아도 마지막 요청 시각 값과 지금 시각 값의 차이를 계산해,
// 그 값이 대기 기준보다 작은 동안은 요청을 걸지 않고 막은 횟수만 센다.
const tryRequest = useCallback(() => {
const sentinel = sentinelRef.current;
if (!sentinel) { return false; }
const now = performance.now();
const sinceValue = now - (lastRequestAtRef.current ?? now);
if (sinceValue < COOLDOWN_MS) {
blockedRef.current = (blockedRef.current ?? 0) + 1;
setBlocked(blockedRef.current ?? 0);
sentinel.classList.add(styles["dgd__sentinel--blocked"]);
window.setTimeout(() => sentinel.classList.remove(styles["dgd__sentinel--blocked"]), 180);
return false;
}
lastRequestAtRef.current = now;
loadingRef.current = true;
sentinel.classList.add(styles["dgd__sentinel--loading"]);
window.setTimeout(() => {
setRows((prev) => prev + 3);
loadingRef.current = false;
batchesRef.current = (batchesRef.current ?? 0) + 1;
sentinel.classList.remove(styles["dgd__sentinel--loading"]);
}, LOAD_DELAY_MS);
return true;
}, []);
const recompute = useCallback(() => {
const view = viewRef.current;
const sentinel = sentinelRef.current;
if (!view || !sentinel) { return; }
const remaining = sentinel.getBoundingClientRect().top - view.getBoundingClientRect().bottom;
if (remaining <= 2 && !loadingRef.current && (batchesRef.current ?? 0) < MAX_BATCHES) {
tryRequest();
}
}, [tryRequest]);
useEffect(() => {
const view = viewRef.current;
if (!view) { return; }
const onScroll = () => recompute();
view.addEventListener("scroll", onScroll);
const takeover = () => {
rootRef.current?.classList.remove(styles["is-demo"]);
if (autoplayRef.current !== null) { window.clearInterval(autoplayRef.current); }
if (uiTimerRef.current !== null) { window.clearInterval(uiTimerRef.current); }
};
view.addEventListener("wheel", takeover, { passive: true });
view.addEventListener("touchstart", takeover, { passive: true });
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
autoplayRef.current = window.setInterval(() => {
const v = viewRef.current;
if (!v) { return; }
const max = v.scrollHeight - v.clientHeight;
if ((batchesRef.current ?? 0) >= MAX_BATCHES && v.scrollTop >= max - 1 && dirRef.current !== 0) {
dirRef.current = 0;
window.setTimeout(() => {
setRows(6);
batchesRef.current = 0;
blockedRef.current = 0;
setBlocked(0);
lastRequestAtRef.current = -99999;
v.scrollTop = 0;
dirRef.current = 1;
}, 600);
return;
}
if (dirRef.current === 0) { return; }
v.scrollTop = Math.min(v.scrollTop + 4, max); // 끝에 붙어 연속 이벤트 → 방어 동작 유발
recompute();
}, 60);
uiTimerRef.current = window.setInterval(() => {
setSince(Math.round(performance.now() - (lastRequestAtRef.current ?? 0)) + "ms");
}, 100);
}
return () => {
view.removeEventListener("scroll", onScroll);
view.removeEventListener("wheel", takeover);
view.removeEventListener("touchstart", takeover);
if (autoplayRef.current !== null) { window.clearInterval(autoplayRef.current); }
if (uiTimerRef.current !== null) { window.clearInterval(uiTimerRef.current); }
};
}, [recompute]);
const rowEls = [];
for (let i = 0; i < rows; i += 1) {
rowEls.push(
<li key={i} className={styles["dgd__row"]}>수신함 {String(i + 1).padStart(2, "0")}</li>
);
}
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.dgd} ${styles["is-demo"]}`} id="dgd">
<div className={styles["dgd__meters"]}>
<div className={styles["dgd__meter"]}>
<span className={styles["dgd__meter-label"]}>마지막 요청 이후</span>
<span className={styles["dgd__meter-value"]}>{since}</span>
</div>
<div className={styles["dgd__meter"]}>
<span className={styles["dgd__meter-label"]}>막은 호출</span>
<span className={styles["dgd__meter-value"]}>{blocked}회</span>
</div>
</div>
<div ref={viewRef} className={styles["dgd__view"]}>
<ul className={styles["dgd__track"]}>
{rowEls}
<li ref={sentinelRef} className={styles["dgd__sentinel"]} aria-hidden="true" />
</ul>
</div>
</div>
</main>
);
}05 Duplicate-call guard — a list blocking its next request from a last-request timestamp value — Measured area 27.358% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Infinite Scroll Loading: 9 Essential UI Patterns
The post this demo lives in
Infinite Scroll Loading: 9 Essential UI PatternsOne indicator recomputes opacity from the remaining scroll distance, one offsets scroll position by the height added abo See all demos in this post
If you like this effect - similar demos
Loaders06 Footer loader that fetches the next page at the end of the listPage Loading Animation CSS: 9 Route-Change Demos
Loaders03 A speed label computed from elapsed-byte deltasFile Upload Progress Bar Loader: 9 UI Demos
Loaders09 Reduced-motion instant toggle — with reduced motion on, every animation from the prior eight bars snaps straight to its end valueCSS Progress Bar: 9 Game-Style Fill Patterns