FeatureOS is where teams collect product feedback, plan a roadmap and publish a changelog. When someone votes on a post or a teammate changes its status, everyone looking at that board should see it without reloading. For a long time we thought that was mostly true. It wasn't.

This is how we found the gaps, what we changed on the server and in the browser, and two bugs that only showed up once events started flowing.

How the pieces fit

The app is a Next.js 16 and React 19 frontend using TanStack Query, talking to a Rails API. Real-time runs over ActionCable on a single websocket. Each organization has two streams. The team stream is for admins. The customer stream is for people browsing the public portal.

The frontend side was in decent shape. We had 13 listeners that patched the React Query cache when a post, comment, roadmap item or form changed. So when I asked why the public board didn't update live, I expected to find a bug in a listener. The listeners were fine. Most of them were waiting for messages that never came.

One change, end to end

  1. Rails modelafter_commit
  2. Redispublish
  3. ActionCableone websocket
Team stream Admins Every create, update and delete.
Customer stream Public portal Only what passes the public visibility check. Thin payload.
React Query cache Shallow-merge into the cached row, or refetch when the payload can't render alone.

What the audit turned up

I stopped reading the listeners and went through every place the Rails app broadcasts. That list was much shorter than it should have been.

Posts broadcast only when their status or assignee changed. Creating, editing, voting, tagging, merging and deleting a post sent nothing.

The vote notifier was a method called notify_sync_engine!, and its body was empty. The module that defined it wasn't included in any model. It had been notifying nobody since the day it was written, and nothing ever complained.

Comment creation broadcast twice. The customer stream carried only roadmap items, so the public board never updated live at all.

The reverse problem existed too. The backend already broadcast knowledge-base articles and FAQs, but the frontend had no mapping for them and dropped them with a console warning that said "No model found".

Interactive

What the server actually sent

EventTeamCustomer
Post creatednonenone
Post editednonenone
Status or assigneesentnone
Votenonenone
Taggednonenone
Merged or deletednonenone
Commenttwice—
Changelognonenone
Article or FAQdropped—

8 of 9 events broken. The portal saw roadmap items only.

On the client, the QueryClient had no defaults, so staleTime was 0. The changelog pages had refetch-on-focus turned off on purpose, which meant they never updated until you reloaded the page.

We also had three collaboration packages installed and unused: yjs, y-protocols and the TipTap collaboration extension. We kept them, because a drag-handle extension we use lists them as peer dependencies.

Server changes

Posts now have an after_commit callback that broadcasts creation, update and deletion on the team stream. It respects the existing _skip_sync_engine_callbacks flag, so importers and bulk jobs don't flood the socket with thousands of messages.

The customer stream is stricter. A post goes out only if it passes the same visibility checks the public listing uses. It has to be approved, not merged, not deleted, not hidden, not private and not spam. Its board has to be public, not archived, and not set to show people only their own submissions. When a post becomes visible, the portal gets a creation event. When it stops being visible, the portal gets a deletion with just the id.

Interactive

Does the portal hear about it?

Flip a post or its board. The server runs the public listing's rules at broadcast time.

Team streampost update

Customer streampost update

{
 "id": 4182,
 "slug": "dark-mode",
 "title": "Dark mode",
 "status": {
  "title": "Planned",
  "value": "planned"
 },
 "comments_count": 12,
 "board_id": 7,
 "upvotes_count": 48
}

The customer payload is small on purpose. It has the id, slug, title, status, comment count, board id and upvote count, and the upvote count is left out when the board hides counts. Nothing goes out that the portal doesn't render.

We filled in the empty vote notifier, so every vote now broadcasts its parent post. Tags work the same way through an after_commit on the join table. Changelogs go to the team stream on every change, and to the customer stream only when they are published and not limited to a segment. Unpublishing sends a deletion. We also removed the duplicate comment broadcast.

Then we changed the payload keys to match the JSON our REST serializers already return. Status is {title, value}, tags are an array of {id, name, color}, and comment counts follow the same member-aware rules. Because the shapes match, the client can shallow-merge a broadcast straight into a cached row.

We wrote eight specs with ActionCable's have_broadcasted_to matcher. They check that creating a post reaches the team stream, that a post on a private board never reaches customers, and that moving a post to a private board sends customers a deletion. Others cover a vote sending an update, a published changelog reaching customers while a draft doesn't, a comment producing exactly one broadcast, and a hidden post that becomes visible arriving as a creation.

Broadcasts run inline after the commit. Each one is a Redis publish and a small serialization, a few milliseconds of work. We had no existing job pattern for this, and adding a queue for that cost wasn't worth it.

Client changes

The QueryClient now defaults to a 30-second staleTime and refetches on reconnect. We turned refetch-on-focus back on for changelog lists and portal pages. We left it off in the admin changelog editor, because a refetch in the middle of editing would overwrite what you were typing.

Article, FAQ and changelog broadcasts now map to models and invalidate their list queries. We never invalidate the admin editor's detail queries, for the same reason.

Post events also refresh the post counts and the detail query for that one post, matched by slug. Matching by key prefix would have refetched every open post page in the browser on every single vote.

On the portal list, a creation event triggers a refetch instead of inserting the payload. The thin payload doesn't have enough fields to render a row, so inserting it would show a broken card. While in there, we found that the delete handler compared string ids to numbers. It had never removed anything.

Last, reconnects. If the websocket drops, any events sent while it was down are gone. Now, when the socket reconnects, we invalidate the whole query cache and let the active queries refetch. It's five lines.

The bug we almost shipped

One roadmap listener handled post creation by inserting the new post as a roadmap item. That code had never run, because posts never sent creation events. Once they did, every open roadmap would have gained a new item for every new post on any board.

Nothing was wrong with that listener in the world it was written for. Turning on events that never existed changed the world under it. The delete handler with mismatched id types was the same kind of bug, latent until the events showed up.

What I'd tell another team

Real-time breaks without any errors. A broadcast that never fires doesn't throw, log or fail a test. If you want to know what your app actually sends, read the broadcast call sites on the server, not the listeners.

When you add an event type, go and read every listener that could receive it. Code written for a world without that event may be wrong in a world with it.

Send payloads in the same shape as your REST responses and shallow-merge them on the client. That's less data than sending whole records, and the cache stays consistent with what the API returns.

Check visibility on the server, at the moment you broadcast, with the same rules your public API uses. Our channel is org-wide, so the client can never be the filter. Anything it receives, a curious user can read in the network tab.

Invalidate narrowly. Match the one record that changed by its key, or a single vote turns into a refetch on every open page.

If a payload can't render on its own, refetch instead of inserting it. One extra request is cheaper than a broken row.