Skip to main content
← Blog

The Calendar Wouldn't Stop

VauDium ·

How a tiny filter persistence change almost flooded the server.

The Calendar Wouldn’t Stop

We added a filter to the desktop calendar not long ago. Narrow tasks down by keyword, color, status. Useful, but with one annoyance: navigate to another tab and back, and the filter resets. You had to reapply it every time.

“Let’s just persist the filter.” A two-line change, we thought.

Two Lines

There’s a standard way to make state survive across mounts: don’t keep it inside the component, keep it in a shared store outside. We use Jotai. Define an atom, have the component read and write through it, done. The atom outlives the page, so when you come back the filter is still there.

Two lines changed. Type check passed. The screen reloaded.

The calendar wouldn’t stop.

Tasks Were Flickering

On screen, tasks kept blinking — appearing, disappearing, reappearing. Server logs showed the same request firing over and over. Same date range, same filter, same response. Several times per second.

The cause was clear in shape if not in detail: somewhere, a fetch was looping infinitely. The calendar already had a guard against this: “only clear the cache and refetch when the filter actually changes.” If the filter is unchanged, do nothing.

For the guard to work, you need a way to tell whether the filter changed. The usual trick is to compare object identity. Same object, no change. Different object, change. The new code was written to return the same object every time the filter value was unchanged — a stable reference. By the rules, this should have been fine.

But it kept looping. Which meant somehow, every render was being seen as a new filter.

I Don’t Know

I had a few hypotheses. The atom subscription might have shaken some effect chain in a way I didn’t understand. A circular type import might have nudged module evaluation order in some subtle way. The guard itself might have been too fragile, falling into a domino chain after the first false trigger.

I couldn’t prove any of them. Just guesses, no verification.

This is the moment where, if you start patching based on guesses dressed up as facts, you get into trouble. You might luck into a fix, but next time a similar symptom shows up, you’re back at the same square. So I changed direction. Instead of pinning down the exact cause, I rewrote it using a pattern already proven to work.

The first version had the atom acting directly as component state. The new version uses the atom only as a passive store. The component reads the atom once on mount, holds the value as ordinary local state inside, and on changes writes both to local state and back to the atom. No subscription. The mechanisms that worked perfectly before stay perfectly intact.

Reload. It stopped. Switch tabs and come back — filter still there. Both goals met.

The Real Problem Was Elsewhere

Right after the relief of “fixed it,” another thought crept in.

The bigger problem was that the server accepted all those requests in the first place.

If the same client sends the same request dozens of times per second, no system should treat that as normal usage. Today it was an accidental client bug. Tomorrow it might be someone deliberately stressing the server. The day after, a similar guard mistake might appear in some other code path.

If the server always sits there receiving anything thrown at it, every small client mistake becomes server load. From one user it’s just one user’s load. From a bug shipped in production code, it’s every user simultaneously creating the same load.

Limits on the Server

We added rate limiting to the API. If a single IP sends more than 180 requests in 10 seconds or 1200 in a minute, further requests get rejected with 429. Normal page loads and rapid tab-switching pass comfortably; today’s runaway loop would eventually be cut off.

The numbers are guesses. We’ve never measured what real usage looks like at the upper end. Set them too tight and real users hit the wall. Set them too loose and runaways slip through. So we paired the limits with one more thing.

Every time a 429 fires, we log the IP and the path. To a separate file, errors only, nothing else cluttering it. After running for a while, we’ll be able to see where real users hit the limit, if at all. Then we can tune the numbers based on what we actually see, not what we guessed.

Starting from a guess but laying down the path to measurement. That was the point.

Two Layers of Defense

The real outcome of today’s work wasn’t filter persistence, and it wasn’t rate limiting. It was that the system now has two layers of defense.

The client tries not to flood the server. The server tries not to accept a flood. Neither layer can be perfect on its own. The client can ship a new bug at any time, and the server’s numbers start as guesses. But as long as both layers don’t fail at the same time, the system as a whole holds.

One more thing I learned today. When you don’t know the cause, say you don’t know. Then choose the path that demonstrably works. That always finishes faster than treating a guess as a fact.