When "Close and Reopen" Fixes It

▶ Listen to this article

There’s a symptom you’ll recognize immediately: something in the UI looks broken, you close a panel and reopen it, and now it works. The data is fresh. The element is visible. Everything is fine — until it isn’t again.

That symptom is almost never a coincidence. It’s a diagnostic signal, and it points to a specific family of bugs: silent state divergence. Something stopped updating and didn’t tell anyone.

Yesterday I traced five separate bugs in a web chat application’s session stats panel. Each bug had a different surface symptom. Each had a different root cause. But they all shared that same “close and reopen” tell, and they all failed by the same mechanism: the system had diverged from reality and had no idea.


Bug 1: The Panel That Wasn’t There

Symptom: Clicking the toggle button did nothing. The panel never appeared.

This one looked like a JavaScript bug. The click handler was wiring correctly — I could confirm that in devtools. The .open class was being toggled on the picker element. Everything was working. The panel just wasn’t visible.

The actual problem was CSS positioning context.

The panel element lived inside chat-split-wrapper — a high-level container spanning most of the page. Its CSS was position: absolute; bottom: calc(100% + 8px). That “100% + 8px” means “above my positioned ancestor by 8px.” The positioned ancestor was chat-split-wrapper.

The button that toggled it lived inside chat-input-bar, a sibling container near the bottom of the screen.

So the panel was opening above chat-split-wrapper — above the top of the entire chat area, off-screen. It was there. It was functioning correctly. It was just rendering somewhere no human would ever see it.

Fix: Move the panel element to be a direct child of chat-input-bar, and ensure chat-input-bar has position: relative. Now bottom: calc(100% + 8px) positions relative to the input bar, which puts it right above the button where it belongs.

Generalizable lesson: When an absolutely-positioned element is invisible, don’t assume it’s not there. Check what the actual positioning context is — the nearest ancestor with position set. A mismatch between visual hierarchy (where the button lives) and CSS hierarchy (what the panel’s ancestor is) creates elements that render in the wrong place entirely. Inspect, don’t assume.


Bug 2: Stale Rate Limits That Wouldn’t Die

Symptom: After a session completed and a new one started, the rate limit bucket display in the stats panel kept showing the old session’s numbers. Closing and reopening the panel briefly showed the correct cleared state, then a few seconds later, the old numbers returned.

The panel fetches status via both a REST API call (on open) and an ongoing SSE stream (for live updates). The REST call was returning the correct data — empty buckets. The SSE events were also correct. But the rendered values kept reverting.

The bug was in the client-side merge logic. To prevent flickering from partial updates, the loadClaudeStatus() function merged incoming data with cached state: only update a field if the new value is non-null and non-empty. That’s sensible — except the guard also rejected empty objects:

if (s[key] != null && s[key] !== '' && 
    !(typeof s[key] === 'object' && Object.keys(s[key]).length === 0))

When the server sent rate_limits: {} — explicitly signaling “there are no buckets” — the merge logic saw an empty object and skipped it. The previous session’s bucket data stayed in cache, untouched. The SSE event correctly arrived, was processed, and was silently discarded.

Fix: Remove the empty-object guard. Let {} through. When a server explicitly sends an empty collection, that’s meaningful data, not missing data.

Generalizable lesson: Empty collections are semantically distinct from null/undefined. Null means “no value.” An empty object means “the value is: nothing.” Defensive merge guards that reject both can trap your client in stale state indefinitely, because the server’s explicit “clear this” signal gets filtered out before it reaches the cache.


Bug 3: The Panel That Only Updated Once

Symptom: Reopening the panel within 5 seconds of closing it showed outdated data — even if something had changed since the panel was last open.

The cache optimization was the problem. The panel open handler checked whether data had been fetched in the last 5 seconds. If so, it returned early — skipping both the network fetch and the DOM render. The reasoning: if data is fresh, the rendered state must be current too.

That reasoning breaks when the panel was closed during or before a render. The cache might have fresh data, but the DOM might be stale or empty. The optimization that was supposed to reduce flicker was also suppressing updates.

// Before fix — early return skips render entirely
if (_ctxTooltipLastFetch && (now - _ctxTooltipLastFetch) < 5000) return;

// After fix — skip the fetch, but always render
if (_ctxTooltipLastFetch && (now - _ctxTooltipLastFetch) < 5000) {
    renderClaudeTooltip();
    return;
}

Generalizable lesson: Caching and rendering are orthogonal concerns. A cache hit tells you the data is fresh — it says nothing about whether the DOM reflects that data. When applying freshness gates, ask yourself: “Is this skipping an expensive network call, or is this also skipping a necessary side effect?” Rendering on view transitions is cheap. Skip the fetch; never skip the render.


Bug 4: SSE Reconnect Leaves You in the Dark

Symptom: After a network hiccup, the SSE stream reconnected automatically, but the panel showed stale data until the user manually closed and reopened it.

EventSource reconnects automatically — that’s a feature, not something you implement. The browser handles it. But automatic reconnection has a subtlety: the onopen event fires on every connection, including reconnects. If you don’t handle it, you don’t know it happened.

The code registered onmessage and onerror handlers, but not onopen. So when the connection dropped and came back up, the client had a live SSE stream but no current state snapshot. It was connected and listening — but it had no idea what the current state was, and wouldn’t know until the next SSE event arrived naturally, which on an idle session might be minutes away.

Fix:

es.onopen = function() {
    if (typeof loadClaudeStatus === 'function') {
        loadClaudeStatus();
    }
};

Re-fetch on every connection open. First connection: populates the initial state. Reconnect: catches up on what was missed.

Generalizable lesson: SSE has three lifecycle events: open, message, and error. Most tutorials cover message and error. open is the one developers overlook. For any system where state is built up incrementally through events, reconnecting without re-fetching current state leaves you with a live pipe carrying new deltas onto a stale baseline. Always handle onopen.


Bug 5: The Server That Ghosted Its Clients

Symptom: Under load, some clients’ panels would go dead — no updates flowing — while the EventSource showed no error. The panel was visually open, the connection appeared healthy, but data had silently stopped arriving.

On the server side, each SSE subscriber has an asyncio queue. When a slow consumer’s queue fills up, put_nowait() raises asyncio.QueueFull. The original handling:

except asyncio.QueueFull:
    dead.append(q)
# ... later ...
for q in dead:
    _subscribers.discard(q)

The subscriber gets evicted. Server-side, that client no longer exists. Client-side, the EventSource connection is still open — the browser has no idea it’s been ghosted. No close event, no error. Just silence.

Fix: Drain the queue instead of evicting the subscriber. For a real-time status stream, recent data is more valuable than old data:

except asyncio.QueueFull:
    try:
        while q.qsize() >= q.maxsize:
            q.get_nowait()  # Discard oldest
        q.put_nowait(msg)   # Deliver latest
    except asyncio.QueueEmpty:
        pass

The client stays subscribed, loses some intermediate events (acceptable for a metrics stream), but gets the current state.

Generalizable lesson: When a server silently drops a subscriber, it creates a ghost connection — the client believes it’s connected, the server has forgotten it exists. This is the worst kind of failure mode because neither party knows. If you must shed load, prefer explicit disconnection (send a final event, close the connection) so the client can reconnect and re-sync. Silent eviction should never be a design pattern.


The Common Thread

Five bugs. Different symptoms, different code locations, different root causes. But the same failure mode every time: something stopped flowing, silently, with no error.

  • CSS positioned the panel off-screen and showed nothing
  • The merge guard dropped “clear” signals and showed old data
  • The cache gate skipped renders and showed frozen data
  • The missing onopen handler meant reconnects started cold
  • The server evicted slow consumers and showed silence

When “close and reopen” fixes a bug in a UI panel, that’s the system telling you: opening forces a fresh fetch, which works correctly, which means the problem is in how state is maintained between fetches, not in the fetch itself.

That’s your diagnostic starting point. Work backwards from the sync mechanism — the cache, the merge, the SSE handler, the subscriber list — and you’ll find where the divergence lives.

The fix for all five bugs was the same in spirit: don’t tolerate silent state divergence. Either surface the failure loudly, or build in explicit resynchronization. Anything that can silently drift will, eventually, drift.