SevyDevy

Guide · Frontend

In-App Notifications Explained: Badges, Toasts and Cross-Tab Read State

27 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • In-app notifications are messages your web app shows inside its own screens. They come as a badge (the small unread count on a bell icon), a toast (a short pop-up message) and a notification centre (the list of past notifications).
  • Badges go wrong when each tab adds and subtracts on its own. Let the server send the full unread count with a version number, a counter that only goes up, and show only newer versions.
  • Toasts go wrong when every event becomes a pop-up. Merge toasts about the same thing, show at most a few, skip them when the tab is hidden, and keep everything in the notification centre.
  • Screen reader users need the same news without losing their place. A live region, an area the screen reader watches, with role status announces calmly. The role attribute tells the screen reader what an element is; role alert interrupts and is for urgent problems.
  • Beginners can stop after the accessibility section. Engineers then get tested cross-tab sync, comparing BroadcastChannel, a message channel between tabs, with the storage event, the older signal sent when saved browser data changes. They also get one leader tab, chosen with the Web Locks API, a browser lock, and what hidden tabs do to timers.

What goes wrong when in-app notifications drift out of sync?

The user stops trusting your in-app notifications. The bell says 3 unread, the panel shows nothing new, and another tab still says 5. After a few of these, people ignore the bell, and real alerts get missed.

Picture a common setup. A project-tracking web app shows a bell with a red count, a toast in the corner when someone comments, and a panel listing past notifications. One person keeps the app open in three tabs on a laptop and also on a phone. The setup is made up, but the bugs are real ones we reproduced in our own test runs.

Four things break in setups like this. Tabs show different counts after the user reads something in one of them. A busy thread fires twelve toasts in a minute. A tab left in the background shows a stale count when the user comes back. A screen reader user hears every toast as an interruption, or hears nothing at all.

This guide covers the browser side. You will keep the badge honest, queue toasts without nagging, make all of it accessible, and sync read state across tabs and devices. It does not cover the delivery servers behind them. The SevyDevy guide on notification system design covers that part.

Which words do you need before building in-app notifications?

Eight words carry the whole guide. Each one maps to a piece of the project-tracking app.

  • Notification: one record saying something happened, such as a new comment, with an ID, a time and a read flag.
  • Badge: the small number on the bell. It is a summary, not the notifications themselves.
  • Toast: a short message that slides in and usually leaves on its own after a few seconds.
  • Notification centre: the panel or page listing past notifications. It is the permanent record the toasts point to.
  • Read state: whether the user has dealt with a notification. Seen means it was on screen; read means it was opened or marked read.
  • Tab: one open page of your app. Each tab runs its own copy of your JavaScript and its own memory.
  • Source of truth: the one place whose answer wins when copies disagree. For read state, that is your server.
  • Live region: a part of the page that screen readers watch, so changes there are read aloud without moving focus. Focus is the spot where your keyboard typing goes.

What are in-app notifications and how do they work?

In-app notifications are messages your app shows inside its own interface while the user is using it. System notifications are different. They appear in the operating system's own notification area, can arrive through a service worker even after your page closes, and need the user's permission first.

Under the hood there are three layers. The server stores each notification and its read state. A connection pushes changes to the browser. It may be a WebSocket, a two-way connection, or Server-Sent Events, a one-way stream from server to browser. The browser then updates the badge, maybe shows a toast, and adds the item to the notification centre.

The SevyDevy guide comparing WebSockets, SSE and long polling explains those connections. Here we start where the message lands in the browser, because that is where users decide whether to trust it.

The four places a notification can show up, and what each one is for.
SurfaceWhat it is forDoes it go away?Accessibility default
Badge on the bellA count that says something is waitingChanges when the count changesPut the count in the button's name, such as Notifications, 3 unread
ToastA brief heads-up about one eventUsually after a few secondsrole status for news, role alert only for urgent problems
Notification centreThe full, permanent list with read stateNo, it is the recordA normal list the user can reach with the keyboard
System notificationReach the user when the page is not openControlled by the operating systemNeeds permission through the Notifications API

MDN's Notifications API guide says the user must grant permission before a site can show system notifications. It also advises asking only after a click, and says Firefox has enforced that since version 72. So in-app surfaces work from the very first visit, while system notifications wait for a yes.

Where an in-app notification travels

Notice that only one tab holds the server connection. The other tabs hear about changes from it, and every surface reads the same count.

Diagram connections:

  • Notification server to Leader tab (unread count + version)
  • Leader tab to BroadcastChannel (post the update)
  • BroadcastChannel to Other tabs (deliver to each tab)
  • Leader tab to Badge and live region (render)
  • Leader tab to Toast queue (toast if this tab is visible)
  • Other tabs to Toast queue (toast if visible)
  • Notification server to Phone app (same update)
  • Notification server: Stores items, read state and version
  • Leader tab: Holds the one open connection
  • BroadcastChannel: Copies updates to same-origin tabs
  • Other tabs: Apply the update, no connection
  • Badge and live region: Count on the bell, spoken status
  • Toast queue: Merges, caps and skips hidden tabs
  • Phone app: Its own connection to the server

Why does the notification badge count go wrong?

Usually because each tab keeps its own running total. It adds 1 for every new notification and subtracts 1 when the user reads one. Any message that is lost, repeated or late then leaves the total wrong for good.

This is the naive version, and it is wrong. It feels natural because a count looks like simple arithmetic. But the browser does not see every change. A reconnect can re-send a message, a retry can arrive after newer news, and a read in another tab may never reach this one.

Pause and predict: feed the same five events to a plus-one badge and to a badge that copies the server's count. What will each show at the end?

The same event stream fed to two badges, from our run of naive_vs_versioned.test.js. The server's true answer at the end is 1 unread.
EventVersionPlus-one badgeServer count with version
New notification n1111
New notification n2222
n2 re-sent after a reconnect232 (same version, ignored)
n1 read in another tab33 (never heard of it)1
A slow retry of n2 arrives late241 (older version, ignored)

What just happened? The plus-one badge ended at 4 while the truth was 1. The second badge stayed right because it never did arithmetic. It copied the server's full count and ignored any update whose version was not newer than the one it already showed.

That gives two rules. First, the server sends the absolute unread count, not a change of plus or minus one. Second, every update carries a version number that only goes up, such as a per-user change counter, so old or repeated updates can be dropped.

An everyday picture helps here. Keeping your own tally of a cricket score from radio commentary is the plus-one badge. Miss one boundary or hear a replay twice, and your total is wrong until the end. Glancing at the scoreboard is the absolute count, and the over number tells you which glance is newer.

Where the picture breaks: a stadium scoreboard is updated live, but your server's count can be a little stale when it arrives. The version does not make it fresh. It only makes sure you never replace a newer count with an older one.

How should the badge show large numbers?

Cap what you draw, not what you store. Show 99+ beyond 99, but keep the real number for the button's accessible name. MDN's Badging API page notes that browsers may show a count like 4000 as 99+ on an installed app's icon anyway.

That Badging API, navigator.setAppBadge, sets a badge on an installed web app's icon. As of 29 October 2026, MDN marks it limited availability, not Baseline, meaning some major browsers lack it. Treat it as an extra, never your only badge.

What should the badge count actually count?

Decide this before writing code, because unseen and unread are different numbers. Many apps clear the badge when the panel opens, which counts unseen items. Others keep it until each item is opened, which counts unread items.

Either works if every device uses the same rule. The bug is mixing them: the phone clears on open while the laptop waits for reads, so the two badges disagree forever. Write the rule down, store it on the server, and let every client copy the result.

Mark all as read needs one more detail. Send the newest notification ID the user actually saw, not a bare command. Anything that arrived after that ID stays unread, so a notification landing mid-click is not silently swallowed.

Make the mark-read call idempotent, meaning repeating it changes nothing further. Marking notification n1 read twice should leave the same state as once. Then retries after a flaky network are harmless.

How do toast notifications avoid over-alerting?

By treating a toast as a rare interruption, not as the delivery channel. Every notification goes to the notification centre and the badge. Only a few become toasts, and toasts about the same thing are merged.

Here is a small queue we ran with Node 22. It merges toasts that share a key, such as a post ID, shows at most three, queues the rest, and skips toasts when the page is hidden.

javascript
// toast-queue.js: show at most MAX toasts, merge toasts about the same thing
const MAX = 3;
const visible = []; // toasts on screen, oldest first
const waiting = []; // toasts queued for later

function addToast(n, pageHidden) {
  if (pageHidden) return 'badge only'; // nobody is looking
  const same = visible.concat(waiting).find(function (t) { return t.key === n.key; });
  if (same) { same.count += 1; return 'merged'; } // "3 new replies"
  const toast = { key: n.key, text: n.text, count: 1 };
  if (visible.length === MAX) { waiting.push(toast); return 'queued'; }
  visible.push(toast);
  return 'shown';
}

function dismiss(key) {
  const i = visible.findIndex(function (t) { return t.key === key; });
  if (i !== -1) visible.splice(i, 1);
  if (waiting.length && visible.length !== MAX) visible.push(waiting.shift());
}

Our test fed it seven events. Three replies on the same post became one toast with a count of 3. The fourth new topic waited in the queue and appeared when a toast was closed. A reply arriving while the tab was hidden returned badge only.

  1. Send everything to the centre

    Every notification is stored and listed, so a missed toast never means lost information.

  2. Merge by subject

    Group by a key such as the post or order ID. Show one toast saying 3 new replies, not three toasts.

  3. Cap what is on screen

    Keep a small maximum, such as three, and queue the rest or drop them to the centre.

  4. Skip toasts nobody can see

    If the tab is hidden, or the user is already looking at that thread, update the badge only.

  5. Let urgent items stay

    A failed payment or a security warning should stay until the user acts, not vanish after five seconds.

  6. Give users the off switch

    Offer per-type settings and a mute for a while, so people can postpone interruptions.

Accessibility guidance backs these rules. The W3C's Understanding page for WCAG 2.2 success criterion 2.2.1, Timing Adjustable, discusses a toast that disappears after 5 seconds. It says that is not a time limit, because users can still find the new mail in their inbox.

The same page adds that if there is no other way to find the information, each such message would have to meet the criterion. So the notification centre is what keeps disappearing toasts acceptable. Without it, your toasts are a timing problem.

The ARIA Authoring Practices Guide, the W3C's pattern guide, goes further. Its alert pattern says to avoid alerts that disappear automatically, and that frequent interruptions hurt usability for people with visual and cognitive disabilities. WCAG 2.2 success criterion 2.2.4, at the strictest level AAA, asks that users can postpone or suppress interruptions.

How do you make in-app notifications accessible to screen reader users?

Announce them through a live region with role status, and do not move keyboard focus to them. WCAG 2.2 success criterion 4.1.3, Status Messages, is level AA. It asks that status messages are exposed through a role or property, so assistive technology can present them without focus moving.

A screen reader reads the page aloud for blind and low-vision users. If a toast appears silently in a corner, they never learn about it. If your code moves focus to the toast, they lose their place in the form they were typing in.

We read Chromium's computed accessibility tree, the structure screen readers receive, for four kinds of region. The results matched MDN and the WAI-ARIA 1.2 specification.

Live-region settings Chromium 153 computed for each markup choice, from our run of aria.test.js.
MarkupComputed rolearia-livearia-atomicUse it for
A div with role statusstatuspolitetrueNew comment, saved, 3 new replies
The HTML output elementstatuspolitetrueSame as role status
A div with role alertalertassertivetruePayment failed, session about to expire
A div with role loglogpolitefalseA running feed where new lines add up

Polite means the screen reader waits for a pause before speaking. Assertive means it usually interrupts whatever it is reading. Atomic true means it reads the whole region each time, not just the changed words. MDN warns that assertive can be extremely annoying and disruptive, so use it sparingly. The Understanding page for 4.1.3 lists role alert on unimportant content as a failure.

Create the live region before you need it

MDN's live regions guide says assistive technology generally announces changes to a live region, not its first contents. It advises putting an empty region in the initial page markup and then changing its text. Results vary across browser and screen reader pairs, so test with a real screen reader.

MDN adds two compatibility notes. It suggests a redundant aria-live polite on a role status region. It also warns that combining aria-live with role alert causes double speaking in VoiceOver on iOS.

For the project-tracking app, that means one empty status region exists from page load. When a toast appears, your code also writes a short sentence into that region, such as 3 new replies on your post.

Give the badge a real name

A red 3 on a bell is visual only. We checked what a screen reader would hear as the button's name, called its accessible name, in three versions of the same button.

  • Digit hidden with aria-hidden, the attribute that hides an element from screen readers: the name was just Notifications, so the count was lost.
  • Digit left visible inside the button text: the name was Notifications3, with no space and no meaning.
  • Button given an aria-label, the attribute that sets the spoken name, of Notifications, 3 unread: the name was exactly that.

Update that label whenever the count changes, and hide the drawn digit from screen readers to avoid reading it twice. Our test page did this in its render function. The SevyDevy guide on frontend accessibility covers the wider basics, such as names, roles and keyboard use.

How do you sync notification read state across browser tabs?

Send the server's update to every open tab of your site with BroadcastChannel. It is a browser feature that passes messages between tabs of the same origin, meaning the same scheme, host and port. Each tab then applies the update with the same version check as before.

Tabs share no memory. Reading a notification in tab 3 changes tab 3's variables only. Unless something tells tabs 1 and 2, their badges stay stale until the next server push or page reload.

BroadcastChannel sends messages between tabs

Every tab creates a channel with the same name, such as notifications, and listens for messages. The HTML standard sends a posted message to every other channel object with that name and the same storage key. A storage key is the origin, plus the top-level site where the browser partitions storage. The sending object itself is skipped.

We confirmed this with two tabs in one browser profile. Tab B received tab A's message. Tab A's own channel object received nothing, but a second channel object in tab A did. A tab in a separate browser context, like another profile, received nothing.

Messages are copied with the structured clone algorithm, the browser's built-in deep copy. In our run, a Date and a Map arrived intact. Posting a function failed with DataCloneError, and posting on a closed channel failed with InvalidStateError. MDN lists BroadcastChannel as Baseline, working across major browsers, since March 2022.

The storage event rides on localStorage

The older trick writes to localStorage, the browser's small key-value store that survives reloads. Other same-origin tabs then get a storage event. It works, but it has sharp edges we confirmed in our runs.

BroadcastChannel and the storage event side by side, run on Chromium 153 on 29 October 2026.
BehaviourBroadcastChannellocalStorage storage event
Does the sending tab hear itself?Only through a second channel object; the sender is skippedNo, the writing tab gets no event
What can you send?Structured-cloned objects, such as a Map or DateStrings only; the number 5 came back as the string 5
Sending the same thing twiceTwo messages arrivedNo second event; the spec returns early
Leaves data behind?NoYes, the value stays until removed
Removal and clearNot applicableremoveItem gives newValue null; clear gives key null
sessionStorage, the per-tab storeNot applicableNo event reached the other tab

The same-value row bites notification code. If two updates in a row write the same unread count, the second write fires no event. That is fine for a count, but wrong if you use writes as a signal that something new arrived. The web storage section of the HTML standard also warns that localStorage has no locking.

Use BroadcastChannel for messages and the server for truth. Reach for localStorage only to keep a value across reloads.

Marking a notification read in one tab

Notice that tab 3 does not tell the other tabs directly. The server's versioned update is the only message that changes any badge, so all tabs agree.

Diagram connections:

  • Tab 3: user opens item to Server saves read state (1. mark read)
  • Server saves read state to Leader tab applies it (2. push new count)
  • Leader tab applies it to Channel delivers (3. post)
  • Channel delivers to Version newer? (4. each tab)
  • Version newer? to Badge shows 2 (Yes)
  • Version newer? to Ignore update (No)
  • Tab 3: user opens item: Tab 3 → Server: mark n1 read
  • Server saves read state: Server → Leader tab: unread 2, version 9
  • Leader tab applies it: Leader tab → BroadcastChannel: post
  • Channel delivers: BroadcastChannel → Tabs 2 and 3
  • Version newer?: Each tab checks before drawing
  • Badge shows 2: Tab → Screen: same count everywhere
  • Ignore update: Tab → Nothing: stale or repeated

How do you keep only one tab connected to the notification server?

Elect a leader tab with the Web Locks API, a browser feature that lets only one tab or worker hold a named lock at a time. The tab holding the lock opens the server connection. The others wait in line and listen on BroadcastChannel.

Without this, ten open tabs mean ten server connections per user, all carrying the same updates. The Web Locks specification describes this exact case: a primary tab does the work, and if it crashes or closes, another tab gets the lock.

Here is the whole client piece we ran. Your app supplies two functions: render, which draws the badge and updates the button's label, and connectToServer, which opens your WebSocket or Server-Sent Events stream.

javascript
// notif-sync.js: one tab talks to the server, every tab shows the same badge
const channel = new BroadcastChannel('notifications');
let state = { unread: 0, version: 0 };

function apply(update) {
  // update = { unread, version }, both decided by the server
  const newest = Math.max(update.version, state.version);
  if (newest === state.version) return false; // old or duplicate
  state = { unread: update.unread, version: update.version };
  render(state.unread);
  return true;
}

channel.onmessage = function (event) { apply(event.data); };

function fromServer(update) {
  if (apply(update)) channel.postMessage(update); // tell the other tabs
}

navigator.locks.request('notifications-leader', function () {
  connectToServer(fromServer); // only the leader tab opens a connection
  return new Promise(function () {}); // keep the lock until this tab closes
});

async function refresh() {
  const res = await fetch('/notifications/unread-count');
  fromServer(await res.json()); // same { unread, version } shape
}
refresh(); // a tab opened later starts with the real count
document.addEventListener('visibilitychange', function () {
  if (document.visibilityState === 'visible') refresh(); // catch up after sleep
});

Toasts are decided in each tab, not by the leader. A real update would also carry the new notification's text, and each visible tab runs its own toast queue.

We loaded this file into three tabs against a small test server that streamed unread counts with Server-Sent Events. The results were these.

  • Only one server connection was open at any moment, with three tabs.
  • A new notification, and a mark-read sent from a follower tab, reached all three badges with the same count.
  • A stale version 8 and a repeated version 9 sent to one tab both returned false and changed nothing.
  • With 150 unread, every badge drew 99+ while the button label said Notifications, 150 unread.
  • Closing the leader tab released its lock. Another tab became leader and opened the second connection of the whole run.

Our first version had no refresh function, and it had a bug. Tabs opened after the leader showed an empty badge until the next change arrived. The refresh call on load fixed it, and the test showed one count request per tab.

In our run the waiting tab became leader within 54 milliseconds of the close, measured with polling. The Web Locks specification says remaining locks are released when a document unloads. It also marks worker termination as vaguely defined, so test shared and service workers separately.

Three limits matter. Web Locks needs a secure context, meaning HTTPS or localhost. MDN lists it as Baseline since March 2022. The specification itself is still an Editor's Draft, dated 24 September 2025 when we checked on 29 October 2026.

What happens to in-app notifications in a background tab?

The page keeps running, but in Chrome its timers slow down, so anything that polls on a timer falls behind. When the user comes back, the visibilitychange event fires, and that is your cue to fetch the real count.

The HTML standard gives every document a visibility state, visible or hidden, and fires visibilitychange when it changes. The browser decides when, such as on a tab switch or when the window is minimised.

We switched between two real tabs in Chromium 153, started without Playwright. The tab sent to the back fired visibilitychange with hidden, and fired visible when we switched back. A setInterval, a repeating timer, asking for every 100 milliseconds ran 3 times in 3 seconds while hidden, against 30 times while visible.

That matches Chrome's own description from Chrome 88, released in January 2021. Hidden pages that are not playing sound have timers checked once per second. Stricter rules start after 5 minutes hidden, with chains of 5 or more timers and 30 seconds of silence. A chain means timers set by other timers, and a repeating timer counts. If no WebRTC call, meaning live audio or video, is running, checks drop to once per minute.

Messages still arrive in a hidden tab. Our hidden tab received a BroadcastChannel message within our 300 millisecond check. So a follower tab's badge stays current, while the leader's own timers slow down.

The refresh function in the code above handles the rest. In our test, we stopped the server pushes, raised the count from 4 to 9, then switched back to the hidden tab. It fetched the count, drew 9 and broadcast it, and the other tab drew 9 too.

So test background behaviour in a plain browser, or on a real device. Otherwise a passing test can hide a stale badge.

How do you sync read state across devices?

Through the server only. Browsers have no channel between your laptop and your phone, so the server stores read state per notification and pushes the new count to every connection the user has open.

The rules from earlier carry over unchanged. The server owns the count and the version. Mark-read is idempotent. Mark all as read sends the newest ID the user saw. Each device copies the count and ignores older versions.

A phone with the app closed has no open connection at all. Reaching it needs a push service and system notifications, which belong to the delivery side. The SevyDevy guide on notification system design covers that infrastructure.

One small habit helps across devices. When a device reconnects after being offline, it should ask for the current count before trusting any pushed update. That is the same refresh as the background tab, triggered by the connection's reconnect instead of visibility.

How do you test in-app notifications before shipping?

Open two or more real tabs, drive them with a script, and check that every badge, label and live region agrees after each change. Our tests folder did this with Playwright and with plain Chromium.

  1. Start a fake server

    Serve a page plus a stream of unread counts with versions, and an endpoint that returns the current count.

  2. Open three tabs in one profile

    Check that exactly one server connection is open. Expect open connections: 1.

  3. Send stale and repeated updates

    Call your apply function with an old version. Expect false and no change on screen.

  4. Close the leader

    Expect another tab to open the connection and every badge to keep the same count.

  5. Hide a tab in a plain browser

    Switch tabs, change the count while pushes are blocked, switch back, and expect the fresh count.

  6. Read the accessibility tree

    Check that the bell's name includes the count and the toast region has role status.

Then listen with a real screen reader at least once. The accessibility tree tells you what is exposed, not how it sounds.

How do you explain in-app notifications in a system design interview?

This comes up naturally in frontend system design rounds, such as designing a chat app or a social feed with a bell icon. A strong answer shows that you separate truth from display and think about many tabs and devices.

  1. Name the surfaces

    Badge, toast and notification centre, plus system notifications when the page is closed.

  2. Put truth on the server

    The server stores read state and sends absolute unread counts with a rising version number.

  3. Make the client copy, not count

    Apply an update only if its version is newer. Refetch the count on load, reconnect and when the tab becomes visible.

  4. Coordinate tabs

    One leader tab chosen with Web Locks holds the connection. BroadcastChannel copies updates to the other tabs.

  5. Control noise

    Merge toasts by subject, cap them, skip hidden tabs, and let users mute types.

  6. Cover accessibility

    A role status live region for news, role alert only for urgent problems, and the count in the bell's accessible name.

  7. Mention the trade-offs

    A leader tab adds hand-over delay, Web Locks needs a secure context such as HTTPS, and background timers are throttled.

Questions people ask

What is the difference between in-app notifications and push notifications?
In-app notifications appear inside your app while it is open; push notifications reach the device when it is not. In-app ones are badges, toasts and a notification centre drawn by your own code. Push or system notifications appear in the operating system's tray and need the user's permission through the Notifications API. Most apps use both, with the server deciding which to send.
Should the unread count come from the server or be calculated in the browser?
From the server. A browser that adds and subtracts on its own drifts when messages are repeated, lost or late. In our test, a plus-one badge showed 4 while the truth was 1. Send the full count with a version number that only goes up, and let the browser ignore anything older than what it shows.
Is BroadcastChannel better than the localStorage storage event for syncing tabs?
For messages, yes. BroadcastChannel sends structured objects and leaves nothing behind. The storage event sends only strings, leaves the value in storage, and fires nothing when the same value is written twice. Neither delivers the change back to the object that sent it, and both stay within the same origin and browser profile. Use the server for truth either way.
Should toast notifications disappear automatically?
Routine ones can, if the same information stays in a notification centre. The W3C's Understanding page for WCAG 2.2.1 says a disappearing toast is not a time limit when users can find the information elsewhere. Urgent messages, such as a failed payment, should stay until the user acts. The ARIA pattern guide advises against alerts that vanish automatically.
Should a toast use role alert or role status?
Use role status for most toasts and role alert only for urgent problems. Status waits politely for a pause before the screen reader speaks. Alert interrupts whatever is being read. The Understanding page for WCAG 4.1.3 lists role alert on unimportant content as a failure. Neither role should move keyboard focus to the toast.
How do you stop every tab from opening its own WebSocket?
Elect one leader tab with the Web Locks API. Each tab requests the same named lock, only one gets it, and that tab opens the connection. The others wait and receive updates over BroadcastChannel. When the leader tab closes, the browser releases its lock and a waiting tab takes over; in our run that took about 54 milliseconds.
Why does my notification badge go stale when I come back to a tab?
Because hidden tabs have their timers slowed, and connections may have dropped while the device slept. In our Chromium run, a 100 millisecond timer fired once per second in a hidden tab. Listen for visibilitychange and fetch the real count when the page becomes visible again, then share it with the other tabs.

Sources and further reading

Practise this with Lala

Practise