PWA closes the Supabase Realtime socket when switching tabs — solution with “event listener + auto-resubscribe” in WeWeb

Context
For anyone using Realtime features with the official WeWeb Supabase plugin and who wants the socket to automatically reconnect when the browser tab regains focus (a common situation with PWAs or inactive tabs), here’s the solution I implemented in my project — it might help you too.


Solution idea

When the tab regains focus, a small listener script triggers a WeWeb workflow. This workflow checks whether the Realtime channel is still joined. If it’s not, it runs Unsubscribe → Subscribe (WeWeb’s native Realtime actions) and optionally refetches the data to keep everything in sync.

:memo: Important note: I noticed that if you run only Subscribe directly, the channel won’t reopen. The only reliable way I found is to first Unsubscribe and then Subscribe again.


Step 1 — Listener script (Custom Code → “Run code on page load”)

Replace <YOUR_WORKFLOW_ID> and the channel name (e.g., realtime_task_1).

(() => {
  // ============================================================
  // 📡 SCRIPT: Simple listener to trigger a workflow on page events
  // ============================================================
  // This script:
  // - Listens only to the following events: focus, pageshow, visibilitychange('visible'), and resume
  // - Uses debounce and cooldown to prevent repeated executions
  // - Triggers a single workflow with a static payload
  // - Allows enabling/disabling listeners by channel
  // ============================================================

  const workflowId = ‘<YOUR_WORKFLOW_ID>’;  // e.g., “wflow_123”
  const _expectedKey = ‘realtime_task_1’;   // e.g., your logical channel name

  // Prevents duplicate listeners per channel
  if (!_expectedKey) {
    console.warn('[INIT] Empty channel — listeners not registered.');
    return;
  }
  window.__rtListenerRegistry = window.__rtListenerRegistry || new Set();
  if (window.__rtListenerRegistry.has(_expectedKey)) {
    console.log(`[INIT] Listeners already active for "${_expectedKey}".`);
    return;
  }
  window.__rtListenerRegistry.add(_expectedKey);

  // ------------------------------------------------------------
  // 🕒 Utilities
  // ------------------------------------------------------------
  const now = () => Date.now();

  function debounce(fn, wait = 300) {
    let t;
    return (...args) => {
      clearTimeout(t);
      t = setTimeout(() => fn(...args), wait);
    };
  }

  function withCooldown(fn, cooldownMs = 5000) {
    let last = 0;
    return async (eventName) => {
      const t = now();
      if (t - last < cooldownMs) {
        console.log(`[SKIP] ${eventName} ignored (cooldown active)`);
        return;
      }
      last = t;
      return fn(eventName);
    };
  }

  // ------------------------------------------------------------
  // 🚀 Simplified trigger function
  // ------------------------------------------------------------
  async function triggerWorkflow(triggerEvent) {
    wwLib.executeWorkflow(workflowId, {
      debug: {
        type: 'event_trigger',
        trigger: triggerEvent,
        channel: _expectedKey,
        timestamp: new Date().toISOString(),
      }
    });
  }

  // Combine debounce + cooldown
  const runTrigger = debounce(withCooldown(triggerWorkflow, 5000), 300);

  // ------------------------------------------------------------
  // 🪝 Event listeners
  // ------------------------------------------------------------
  const onFocus = () => runTrigger('focus');
  const onPageShow = () => runTrigger('pageshow');
  const onVisibilityChange = () => {
    if (document.visibilityState === 'visible') runTrigger("visibilitychange('visible')");
  };
  const onResume = () => runTrigger('resume');

  window.addEventListener('focus', onFocus);
  window.addEventListener('pageshow', onPageShow);
  document.addEventListener('visibilitychange', onVisibilityChange);
  document.addEventListener('resume', onResume);

  // ------------------------------------------------------------
  // 🛑 Disable listeners by channel
  // ------------------------------------------------------------
  window[`disableRealtimeListeners_${_expectedKey}`] = () => {
    window.removeEventListener('focus', onFocus);
    window.removeEventListener('pageshow', onPageShow);
    document.removeEventListener('visibilitychange', onVisibilityChange);
    document.removeEventListener('resume', onResume);
    window.__rtListenerRegistry?.delete(_expectedKey);
    console.log(`🛑 Realtime listeners disabled for "${_expectedKey}"`);
  };

  // ------------------------------------------------------------
  // 📝 Initialization log
  // ------------------------------------------------------------
  console.log(`✅ Listeners active for "${_expectedKey}" → focus | pageshow | visibility | resume`);
  console.log(`ℹ️ To disable: window.disableRealtimeListeners_${_expectedKey}()`);
})();


Step 2 — Workflow triggered by the script

Create a new Workflow in WeWeb — this is the workflow whose ID you’ll use in the listener script above. Whenever the script is triggered, it will call this workflow automatically.

Inside the workflow:

  1. Run JavaScript → get the list of active channels.
const supabase = wwLib.wwPlugins.supabase.instance;

// get active channels
const channels = (supabase.getChannels?.() || [])
  .filter(c => c?.state === 'joined' && typeof c?.topic === 'string');

// transform into [{ realtime: '...' }]
const result = channels.map(c => {
  const t = c.topic;
  const i = t.indexOf(':');
  // get everything after the first ":"; if there’s none, use the topic itself
  const val = i >= 0 ? t.slice(i + 1) : t;
  return { realtime: val.trim() };
});

// optional: remove duplicates
const unique = Array.from(new Map(result.map(o => [o.realtime, o])).values());

// if it's “Return a value” in WeWeb:
return unique;
  1. Check if your channel is active
  • If active → do nothing.

  • If inactive → execute the reconnection flow:

  1. Realtime → Unsubscribe from channel
    Use the same channel name you used in the original subscribe (e.g., realtime_task_1).

  2. Realtime → Subscribe to channel

  3. (Optional) Refetch data
    After reopening the channel, reload your data in case any changes happened while the tab was inactive.


I’d like to ask the WeWeb dev team if it would be possible to make this improvement natively. I managed to solve the issue with a custom script, but I believe that everyone using the Realtime feature will eventually face the same problem and have to build their own workaround. Having a native auto-reconnect option for Realtime would make the experience more stable and easier to implement for all users.

Hi Artur :waving_hand:

Thanks for sharing your solution, I’m going to pass your feedback to our dev team.

Any updates on this? I’m having the same problem.

Hello ! Any advances ?
As i said here : Realtime subscription works in editor, not in live version - #9 by Philibert

Even the fist subscription to the channel is not working (yet works in editor in weweb)
(Browser console : WebSocket connection to ‘ws://localhost:XXXX/’ failed:)
Any clues on that ?
Thank you !

I could be wrong as I’ve developed something to deal with this issue as well and from what I can see this would still fail if left too long. At some point weweb still tells you, that you are joined to the channel but what it doesn’t tell you is the websocket connection is actually disconnected so realtime still won’t work.

I could be wrong but those are the issues I remember encountering doing this. @Agustin_Carozo This has been an issue with realtime forever, we’ve just let users know that this happens and to refresh page. As I said above I don’t even believe this issue fixes it fully. Weweb needs to actually check if the websocket connection is active and reconnect/subscribe, I spent like 3 days having design something that works only 95% of the time it still can fail if its left off the tab for too long. I guess the main issues is weweb still works like nothing is wrong. If you need any proof I’m pretty sure I could go as far as back as sentry will allow us and show a bunch of unsubscribing to channel issues we get daily cause of this.

Hi everyone :waving_hand:

We investigated this, and indeed this is a limitation of the Supabase SDK. While Supabase SDK supports re-establishing a socket connection, it doesn’t re-subscribe to the channels. So it’s up to the developer to re-build all the subscriptions.

Adding on to that, WeWeb doesn’t currently offer native ways to detect if the app/tab is back in focus, and hence your only choice is to construct that detection yourself (via custom code).

We’ve taken note of this limitation and will start exploring possible solutions in the coming weeks so that this type of use case is much easier to implement in the future.