EP 16

Not Fast, but Feeling Fast (Part 2)


August 2026·6 min read·#performance#instrumentation#debugging

I ended EP 08 like this:

"Being fast and feeling fast are different things — and sometimes feeling fast matters more."

That was about Pepper's response time. This time the app itself was slow. And this time, I could only fix it by moving past the feeling and measuring it.


KakaoTalk shows up instantly

Danny (me) filed the report. Every time I open the app, every time I switch tabs, a spinner runs. KakaoTalk opens and the old conversation is just there.

The cause was that the caches were all memory-only. prefetch, session, room messages — all in memory, so a full kill-and-relaunch (cold start) wiped everything. Every time: blank screen → spinner → load.

The fix was three layers.

  1. Persistent disk cache — a cache that survives an app kill. From the second launch on, it paints the last state instantly, then refreshes behind it.
  2. prefetch — on app start, pull all five tabs' data in parallel, ahead of time.
  3. SWR (stale-while-revalidate) — enter a tab and it shows what it has immediately, then quietly freshens behind the scenes. No spinner.

One technical decision. What to use for the disk cache? MMKV is fast but adds a new native dependency. For a four-person app, that felt like overkill, so I built a thin KV (kvStore.ts) directly on expo-file-system. Zero new native dependencies. If I ever need more, I swap this one file.


But the chat room was still slow — and I didn't know why

The cache handled most of it, but entering a chat room and scrolling was oddly slow. No matter how long I stared at the code, I couldn't see where the problem was.

A non-developer's instinct here is to "stare at the code and guess." But I'd learned something in EP 11. Don't guess — look at what's actually happening.

So I instrumented it. Every time a component mounted/unmounted I logged its instance id, and every time it rendered I bumped a counter. Then I actually used the app.

The numbers revealed two things.

Cause 1 — the whole chat is reborn on every tab switch.

Family tab → id: h08axj
Direct tab → id: my9w86   ← instance id changed = it re-mounted

chat.tsx was a ternary render: tab === 'family' ? <ChatView/> : <FlatList/>. I believed "tabs are inline, so they don't die like a screen transition" — wrong. When the element type at the same position changes, React unmounts and remounts the whole thing. Every time you tapped the segment, the chat room was born anew.

Fix: drop the ternary, wrap both Family and Direct in always-mounted <View>s, and hide the inactive one with display:'none'.

Cause 2 — one scroll re-renders all 46 messages.

The render-count logs showed 46 counts spiking at once per scroll. renderItem was building a new array every time with reactions.filter(...) and passing it to MessageItem. MessageItem is memo'd, but [] !== [], so even messages with no reactions had their memo defeated.

Fix: pre-group reactions into a Map<message_id, Reaction[]> with useMemo, and return a module-level shared EMPTY_REACTIONS reference when there's no match. Passing the same empty array keeps memo alive.


Neither bug was visible from the code alone. The numbers in the instrumentation logs showed them: instance id h08axj → my9w86, 46 per scroll.

What was "look at the payload, look at the raw response" in EP 11 became "look at the mount log, look at the render count" here. Same principle. The way a non-developer fixes performance isn't by reading the code better — it's by turning a feeling into a number.

"Slow" can't be fixed. "46 re-renders per tab switch" can.