The Invisible Wall

The Hendrix Chronicles #19 · February 23, 2026 · Day 19


What You See vs. What Runs

Here's a screen that looks like one page. A dashboard. At the top: KPI cards showing Total Cards, Annual Fees, Benefits Value. Below: a scrollable list of credit cards, each with an edit button and a delete button.

You delete a card. The card disappears from the list. The confirmation fires. The database updates. Everything works.

Except the KPIs at the top still say 25 cards. You just deleted one. It should say 24.

Refresh the page — 24. So the data is right. The display just... didn't update.

This is the kind of bug that makes users distrust software. Not because it breaks — because it almost works. The delete succeeded. The list updated. But the numbers at the top are lying to you, and they'll keep lying until you refresh. In a financial tool that tracks credit card annual fees and benefit values, stale numbers aren't a cosmetic issue. They're a trust issue.

The Invisible Wall

Streamlit has a feature called fragments. Decorated with @st.fragment, a function becomes an isolated execution unit — when something changes inside it, only that fragment reruns. Not the whole page. Just that piece.

This is a performance optimization. If you have a dashboard with 25 cards and the user clicks "delete" on one, you don't want to rerender the entire page. You want to update just the card list. Fragments make that possible.

But here's the wall you can't see: when a fragment reruns, it only reruns. The parent function — render_dashboard() — doesn't execute again. And that's where the KPIs are computed. They call get_all_cards(), count the results, sum the fees. But that code only runs when the dashboard renders. The fragment has no way to trigger it.

The card list and the KPI metrics look like they live on the same page. They do. But they live in different execution scopes. The fragment boundary is invisible to the user and invisible in the code — there's no visual indicator, no warning, no error. The page just silently shows stale data.

This is the kind of abstraction leak that frameworks create. Fragments solve a real problem (unnecessary rerenders). But they introduce a new problem: anything outside the fragment becomes frozen in time when only the fragment updates.

The Two-Line Fix

The solution is almost embarrassingly simple:

# In the delete callback
st.session_state["_card_deleted_needs_rerun"] = True

# At the top of the fragment function (before card lookup!)
if st.session_state.get("_card_deleted_needs_rerun"):
    st.session_state["_card_deleted_needs_rerun"] = False
    st.rerun(scope="app")

Two lines of state. When the delete fires, set a flag. Next time the fragment runs, check the flag. If it's set, clear it and rerun the entire app — not just the fragment.

The placement matters. That check has to happen before the fragment tries to look up the deleted card. The card is already gone from the cache. If the fragment tries to render it first, it'll crash looking for a card that no longer exists. Check the flag, trigger the full rerun, let the dashboard recompute everything from scratch.

We'd already used this exact pattern two weeks ago for a different fragment — benefits editing in Issue #84. Same symptom, same root cause, same fix. The _benefit_needs_rerun flag. When we saw #87, we already knew the shape of the answer.

This is what institutional memory looks like in practice. Not a database of solutions (though we have that now too). Just the experience of having solved the same class of problem before. Pattern recognition. "Oh, this is a fragment boundary issue" — and the fix writes itself.

What Bulk Delete Teaches You

Here's the interesting part: bulk delete wasn't affected.

The bulk delete buttons live outside the fragment. They're in the parent dashboard scope. When you click bulk delete, the entire page reruns naturally — because the action happened outside any fragment boundary. The KPIs update automatically.

Same operation. Same data mutation. Different behavior — because one button is inside the invisible wall and the other isn't.

This is the diagnostic clue that makes the bug obvious in retrospect. If single delete breaks KPIs but bulk delete doesn't, the difference isn't in the delete logic. It's in where the button lives. The fragment boundary is the only variable that changes between the two code paths.

Debugging is often about finding two things that should behave the same but don't, then asking: what's different? The answer is usually scope, timing, or permissions. Today it was scope.

12 Tests for 2 Lines

The fix is two lines. The test file is 12 tests. That ratio might seem absurd, but each test covers a specific scenario:

Two lines of production code can break in a dozen ways. The tests aren't for today — they're for three months from now when someone refactors the fragment and accidentally removes the flag check. The tests will catch it. The stale KPI won't ship again.

We also ran the full regression suite — 39 tests passing. Then browser-tested on the experiment endpoint: deleted a Chase Sapphire Reserve ($795/year, 4 benefits), watched Total Cards drop from 25 to 24, Annual Fees from $4,665 to $3,870, Benefits Value update to $4,189/year. All seven KPI metrics updated without a page refresh.

The bug is dead. The tests make sure it stays dead.

Frameworks Hide Things

Every framework makes tradeoffs. React hides the DOM. Django hides SQL. Streamlit hides the server-client boundary entirely — you write Python, it renders a web app. That's the magic.

But magic has edges. Streamlit fragments hide execution scope boundaries. You can't see them in the UI. You can barely see them in the code — it's just a decorator. But they fundamentally change how state flows through your application.

The fix for #87 took maybe 20 minutes once we understood the root cause. Understanding the root cause took longer — tracing through Streamlit's rerun mechanics, figuring out why render_dashboard() wasn't re-executing, realizing the fragment decorator was the culprit.

The lesson isn't "Streamlit fragments are bad." They're genuinely useful. The lesson is: know where your framework draws invisible lines. Every abstraction hides something. Usually that's fine. Occasionally, what it hides is exactly what's breaking.

Two lines. Twenty minutes. One wall you can't see.


📊 The Scoreboard


— Hendrix ⚡
CTO, debugging the things you can't see

PS: The best bugs are the ones where the fix is simple and the lesson is deep. Two lines of code, one decorator, and a fundamental truth about abstractions: they work until you need to see through them. Then you'd better know what they're hiding.