How to stop a re-rendering React component from flooding your logging endpoint
I wrote this bug myself. I added one debug log inside a component that tracked a drag position, dragged a box around the screen for about three seconds to test it, and watched my terminal scroll faster than I could read. Then I opened the Network tab. Over 40 requests in three seconds. All from one component. All saying almost the same thing.
This is an easy trap to fall into, and it has a fix that is simple once you see it clearly. But there is also a one-word mistake in that fix that will quietly undo the whole thing — I found it while checking my own code for this post, and I will show you exactly what it costs.
Why this happens
Put a log call directly in a component body — not in an effect, not wrapped in anything — and it runs on every render.
React renders a lot more often than most people expect. Dragging, scrolling, resizing, animating, or any state that updates quickly can cause dozens of renders per second. If every render sends a log over the network, you have built an accidental flood.
The usual first idea is “move the log into useEffect so it only runs when something changes.” That helps a little, but it does not solve it. An effect still runs on every render where its dependencies changed — and for a drag position, the dependency is changing on every frame. That is the whole point of a drag. You still need to control the rate. Moving the call just moves where you have to deal with it.
So the real question is not where you call log. It is:
How often does this kind of log actually need to leave the browser?
And the honest answer is different for a debug line than for an error.
First, clear up what “throttling” actually does here
This is the part I had wrong in my head for a while, so let me be blunt about it, because getting it wrong leads to bad decisions.
Throttling your logs does not throw logs away.
In @developerehsan/nextjs-logger, every log call you make goes into a queue in memory. The throttle controls how often that queue is emptied and sent — not which entries survive. When a send finally happens, it drains everything waiting and ships it in one request.
You can see it directly in the library’s queue code:
async flushOnce() {
const entries = this.buffer.drain(); // takes EVERYTHING waiting
if (entries.length === 0) return;
await relayEntries(entries, this.transportOpts);
}drain() takes the whole buffer. So 60 debug calls in one second do not become 60 requests, and they do not become 2 surviving logs either. They become 60 logs delivered in about 2 requests.
That distinction matters. It means turning the throttle up is cheap — you are trading a little delay for far fewer requests, not trading away information. If you actually do want to keep only a sample of your logs, that is a different setting (sampleRate), and it is a separate decision.
The one case where logs really are lost: the queue has a size limit (maxQueueSize, default 500 entries). If you overflow it before a send happens, the oldest entries get pushed out to protect your memory. In normal use you will not get near that.
Different levels deserve different treatment
A single setting for all logs is the wrong shape, because the levels do not have the same needs:
debugis loud and rarely urgent. Batch it hard. Nobody is reading it in real time.infomatters a bit more, so batch it a little less.warntends to arrive in clusters. It is nicer to wait for the cluster to finish, then send one batch, than to send during the noise.errormust arrive quickly and must not be dropped — but it also must not be allowed to hammer your server. A failing API call stuck in a retry loop can produce errors forever.fatalis rare by definition, so a hard cap costs you nothing normally and saves you in a bad moment.
That maps to three genuinely different tools.
Throttle — send at most once per time window. The first call goes immediately, and the newest call in the window goes out at the end of it. Good for a firehose you want smoothed.
Debounce — wait for silence. Every new call resets the timer. Nothing is sent until things go quiet. Good for bursts you want consolidated.
Rate limit — allow at most N sends per window, and refuse extras. This is a hard ceiling that does not care about the shape of the burst. Good for errors.
What throttle actually does with your calls
“Send at most once per window” is often explained as “the first call goes through and the rest are ignored.” That is not right, and the difference is worth knowing.
The throttle here runs on both the leading and trailing edge. I checked by calling a throttled function six times, 20ms apart, with a 300ms window, and recording which arguments actually made it through:
throttle wait=300, called with 1,2,3,4,5,6 at 20ms spacing
fired with: [1, 6]Call 1 fired instantly. Calls 2 through 5 did not fire on their own — but call 6, the newest one, fired at the end of the window. The latest value always lands. Nothing goes stale on you.
And in the logger, remember that the thing being throttled is the send, not the log. So calls 2 through 5 are still sitting in the queue, and they ride along in the request that call 6 triggers.
Here is the rate over a realistic burst — a throttle set to 500ms, called at roughly 60 times per second for 3 seconds:
executions: 7Seven requests in three seconds, instead of 180. That matches the promise: roughly two per second, no matter how hard the component re-renders.
The config — and the typo that silently breaks it
Here is a full per-level setup:
import { configureLogger } from '@developerehsan/nextjs-logger'
configureLogger({
pacerPolicies: {
debug: { strategy: 'throttle', windowMs: 500 },
info: { strategy: 'throttle', windowMs: 300 },
warn: { strategy: 'debounce', waitMs: 200 },
error: { strategy: 'rateLimit', limit: 10, windowMs: 5000, windowType: 'sliding' },
fatal: { strategy: 'rateLimit', limit: 3, windowMs: 10000, windowType: 'sliding' },
},
})Look closely at the warn line. It is waitMs, not windowMs.
Throttle and rate limit take windowMs. Debounce takes waitMs. They are different words because they mean different things — a window you are dividing time into, versus an amount of silence you are waiting for.
I originally wrote windowMs there out of habit, because the three lines around it all say windowMs. So I measured what that costs. Same burst both ways — five calls, 50ms apart, then quiet:
with windowMs: 200 (wrong) -> 5 sends
with waitMs: 200 (right) -> 1 sendFive times the requests. And here is why it is nasty: the wrong key does not crash and does not warn. The library reads policy.waitMs, finds undefined, and passes that straight through as the wait time. A debounce with no wait time fires almost instantly:
broken debounce fired after ~2ms of silence (expected 200ms)So your warn level goes back to one request per call — the exact flood you added the config to prevent — and nothing anywhere tells you. It just looks like the throttling “isn’t working.”
If you use TypeScript, this is caught for you at compile time, because each strategy is its own type with its own required fields. That is the strongest argument I have for running tsc over your logger config instead of trusting it by eye. If you are in plain JavaScript, this line is worth double-checking by hand.
The good news: the library’s built-in defaults already use the correct keys, so if you never write a pacerPolicies block at all, you get sensible behaviour for free:
| Level | Default | Why |
|---|---|---|
debug | throttle, 500ms | Loud, rarely urgent |
info | throttle, 300ms | Frequent but more useful |
warn | debounce, 200ms | Consolidate clusters |
error | rate limit, 10 per 5s | Prompt, but capped |
fatal | rate limit, 3 per 10s | Rare — hard cap |
Only override these when you have a specific reason.
About that windowType: 'sliding' option
You will see windowType: 'sliding' on the rate-limit lines, usually explained like this: a fixed window resets its counter on a clock tick, so a burst sitting right on the boundary can sneak through twice the limit; a sliding window looks at real timestamps and avoids that.
That is a real problem in general, and it is a good reason to prefer sliding windows. But I wanted to see it happen, so I ran the classic boundary burst against both settings in TanStack Pacer 0.5.0 — filling the window, waiting a gap, then bursting again — across thirteen different gaps, including exactly on the boundary:
gap= 950ms fixed=3 sliding=3
gap= 990ms fixed=3 sliding=3
gap= 1000ms fixed=6 sliding=6
gap= 1010ms fixed=6 sliding=6
total divergences across 13 timings: 0Identical. Every time.
The reason is in the source. Before deciding anything, the limiter runs a cleanup step that drops every timestamp older than the window — and it does this on both paths, not just the sliding one:
cleanupOldExecutions() {
const windowStart = Date.now() - this.getWindow();
this._executionTimes = this._executionTimes.filter((t) => t > windowStart);
}Once that has run, the extra “is this a new window?” check on the fixed path can never be true, because anything old enough to trigger it was just removed. So in this version both settings behave as a sliding window regardless of what you pass.
Two takeaways. First: you are protected from the boundary problem here either way — that is genuinely good. Second, and more useful: do not assume an option does something because its name says so. I would have repeated the usual explanation and been wrong about the mechanism. Ten minutes of measuring beat a confident guess. That habit is worth more than this particular detail.
(Worth saying: this is what Pacer 0.5.0 does today. It could change. The logger passes 'sliding' by default anyway, which is the behaviour you want either way.)
Checking it yourself
Open the Network tab, filter to your relay endpoint, and repeat whatever triggers the logs — drag the thing, resize the panel. Count requests over 5 seconds and compare against what your config predicts. A 500ms throttle should sit near 2 per second.
If you see many more than that, check the level first. It is very easy to log at info while picturing debug’s looser setting. Each level has its own policy and they do not inherit from each other.
One more thing worth knowing: because a send drains the entire queue, a flush triggered by any level carries pending entries from all levels. So when an error fires during a debug-heavy burst, that request delivers the error and the debug lines leading up to it. That is a nice accident — you get the error promptly, with its context attached, in one request.
What this does not fix
Throttling controls how often logs leave the browser. It does nothing about how often your component renders.
If the real problem is a render loop — a useEffect with a missing dependency, or a context value rebuilt fresh on every render — then throttling hides the symptom while the component keeps burning CPU and battery on the user’s device. Your endpoint stops drowning; the waste stays.
Throttling is right for “logging shouldn’t turn a reasonable render rate into a network flood.” It is wrong for “this component renders far more than it should.” That second one is a real bug, and React DevTools’ Profiler will find it faster than any log setting will.
The same idea shows up everywhere
Once this clicked for logging, I started seeing it in every place a UI event triggers a network call:
- Autosave while typing — debounce, so you save once typing pauses.
- Search-as-you-type — debounce if you only want the final query, throttle if you want live results while typing.
- Scroll-depth analytics — throttle, since you want occasional samples, not one event per pixel.
The numbers change every time. The question does not:
How often does this event actually need to leave the browser to still be useful — regardless of how often the thing that triggers it fires?
Answer that per call site and the flood never starts. The library is just the mechanism. The decision is yours.
The short version
- Throttling batches your logs, it does not delete them. A flush sends everything waiting.
- Throttle fires on the first call and the newest one at the end of the window — the latest value always lands.
- Debounce takes
waitMs. Throttle and rate limit takewindowMs. Mixing them up fails silently and restores the flood. TypeScript catches it; your eyes might not. - The defaults are already correct — only override with a reason.
- A flush drains every level, so errors arrive with their surrounding context for free.
- None of this fixes a component that renders too much. That is a separate bug. Go find it with the Profiler.
Measured against @developerehsan/nextjs-logger 1.0.1 and @tanstack/pacer 0.5.0 on Node 22.