<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Byunghee Kim</title>
    <description>The latest articles on DEV Community by Byunghee Kim (@tacckim).</description>
    <link>https://dev.to/tacckim</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4093342%2Fb47d7c98-7863-41e4-9c6c-1ed304d817af.png</url>
      <title>DEV Community: Byunghee Kim</title>
      <link>https://dev.to/tacckim</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tacckim"/>
    <language>en</language>
    <item>
      <title>Unread badges that would not clear: 59 of my 64 messages were junk</title>
      <dc:creator>Byunghee Kim</dc:creator>
      <pubDate>Tue, 25 Aug 2026 06:11:22 +0000</pubDate>
      <link>https://dev.to/tacckim/unread-badges-that-would-not-clear-59-of-my-64-messages-were-junk-4fi1</link>
      <guid>https://dev.to/tacckim/unread-badges-that-would-not-clear-59-of-my-64-messages-were-junk-4fi1</guid>
      <description>&lt;p&gt;I am building a small chat feature for an HR system. Shared hosting, PHP, no WebSocket, polling every few seconds. Nothing exotic.&lt;/p&gt;

&lt;p&gt;Then a bug report landed. This is what I actually wrote, translated from Korean:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"If I open room A, it gets read. So if I close it and open another room, it should stay read. But that doesn't happen — the badge on the room I already opened is still there."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The original had two typos in it. That tells you how many times I had already retyped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Open room A. Badge clears.&lt;/li&gt;
&lt;li&gt;Open room B. &lt;strong&gt;Room A has a badge again.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Nobody sent anything. I was the only person on the server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last line is what made it worth writing about. Unread messages were appearing in rooms that nobody was in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong theory #1: the read-state cookie
&lt;/h2&gt;

&lt;p&gt;The engine stores "which messages you have already seen" client side. My first guess was an encoding problem — the value round-tripping badly, so the comparison silently failed.&lt;/p&gt;

&lt;p&gt;I checked. The cookie was fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong theory #2: a stale high-water mark
&lt;/h2&gt;

&lt;p&gt;Second guess: the "last message ID I have seen" was being captured before the room finished loading, so it lagged one step behind. That would explain a badge reappearing exactly one room-switch later, which is what it looked like.&lt;/p&gt;

&lt;p&gt;Also wrong.&lt;/p&gt;

&lt;p&gt;Two theories, both plausible, both about &lt;strong&gt;why the counter was reading the data wrong&lt;/strong&gt;. Neither of them questioned the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually found it
&lt;/h2&gt;

&lt;p&gt;I stopped guessing and counted rows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;LEFT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chat_messages&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/channelEnter    31
/channelLeave    28
real messages     5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;64 rows in the table. &lt;strong&gt;59 of them were not written by a human.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The cause
&lt;/h2&gt;

&lt;p&gt;The chat engine writes a system message every time you enter or leave a room. There is a config flag that is supposed to turn this off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'showChannelMessages'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The name is exactly right, and that is the trap. It controls &lt;strong&gt;showing&lt;/strong&gt;. It does not control &lt;strong&gt;storing&lt;/strong&gt;. The rows are still inserted, still owned by a channel, still newer than your last-seen marker — so the unread counter counts them.&lt;/p&gt;

&lt;p&gt;Which produces this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Open room A  -&amp;gt;  system stores "entered" in room A
Open room B  -&amp;gt;  system stores "left"    in room A
             -&amp;gt;  room A now has 2 unread messages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The act of switching rooms was generating the unread messages.&lt;/strong&gt; I was the one creating them, by clicking around looking for the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The engine is a third-party library and I did not want to patch it, so I overrode one method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;insertChatBotMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$channelID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$messageText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'showChannelMessages'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$messageText&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;strpos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/channelEnter'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;strpos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/channelLeave'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                          &lt;span class="c1"&gt;// not shown -&amp;gt; not stored&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;insertChatBotMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$channelID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$messageText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$mode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine lines. The rule it encodes is one sentence: &lt;strong&gt;if we are never going to display it, do not write it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two follow-ups were needed, and I would have missed both if I had stopped at the first one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Exclude system rows from the unread count&lt;/strong&gt; — the junk already in the table does not delete itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclude them from search too.&lt;/strong&gt; Otherwise searching for a username returns a wall of &lt;code&gt;/channelEnter username&lt;/code&gt;. Same rows, second symptom, different screen.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The effect
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;unread per room&lt;/td&gt;
&lt;td&gt;16 · 5 · 8 · 3 · 6 · 6 · 4 · 2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2 · 1 · 0 · 0 · 0 · 0 · 0 · 0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;junk share of table&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;92%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0 new rows&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second row mattered more than the first. On shared hosting the messages table is the only thing that grows without a ceiling, and I had been sizing a weekly archive job around it. I was designing storage policy for a table that was 92% garbage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would keep
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A flag named after the UI can quietly own the database.&lt;/strong&gt; &lt;code&gt;showChannelMessages&lt;/code&gt; reads as a display setting and behaves as one — and still writes rows forever. If a boolean controls both display and persistence, the name will only ever describe one of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When a counter is wrong, count the rows before theorizing.&lt;/strong&gt; My two wrong theories were both about the reading side, because that is where the visible code was. The query took thirty seconds and ended the argument.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A bug that only shows up when you go looking for it is not rare.&lt;/strong&gt; Switching rooms was both the diagnostic action and the cause. That is worth remembering the next time something only reproduces while you are watching.&lt;/p&gt;

</description>
      <category>php</category>
      <category>debugging</category>
      <category>webdev</category>
      <category>database</category>
    </item>
  </channel>
</rss>
