
We will try to figure out how to reduce the load on server hardware, while ensuring maximum performance of the Web application.
In the development of large high-load projects with a huge online, you often have to think about how to reduce the load on the server, especially when working in webSockets and dynamically modified interfaces. 100500 users come to us and we have 100500 open connections by sockets. And if each of them opens 2 tabs - this is * 201000 connections. And if five?
Consider a trivial example. We have, let's say Twitch.tv which for each user raises a WS connection. Online for such a project is huge, so every detail is important. We cannot afford to open a new WS-connection on each tab, keeping the old one intact, for iron needs a lot to do this.
An idea is born - and what if the WS connections are raised only in one tab and always keep it open, and in the new ones you do not initialize the connection, but just listen from the next tab? It is about the implementation of this idea that I want to tell.
Browser tab logic behavior
- Open the first tab, mark it as Primary
- We start the check - if the tab is_primary, then raise the WS-connection
- We work ...
- Open the second tab (duplicate the window, enter the address manually, open in a new tab, it does not matter)
- From the new tab we are looking at whether there is somewhere a Primary tab. If "yes", then mark the current Secondary and wait for what will happen.
- Open another 10 tabs. And everyone is waiting.
- At some point, the Primary tab closes. Before her death, she screams to everyone about her doom. All in shock.
- And here all the tabs are trying to instantly become Primary. The reaction of everyone is different (random) and who had time, that and sneakers. As soon as one of the tabs has managed to become is_primary, it shouts to everyone that the place is occupied. After this, the WS connection is raised again. We work. The rest are waiting.
- Etc. Scavengers are waiting for Primary-tabs to die in order to take their place.
The technical side of the issue
For communication between tabs, we will use what binds them within one domain - localStorage. Appeals to it are not costly for the user's iron resources and the response from them is very fast. The whole idea is being built around him.
There is a library that has not been supported by the creator for a long time, but you can make it a local fork, as I did. From it we get the file:
/intercom.js
The essence of the library is that it allows you to communicate with emit / on between tabs using localStorage.
After that, we need a tool that allows us to “lock” ( block changes ) a certain key in localStorage, not allowing it to be changed by anyone without the necessary permissions. To do this, was written a small library " locableStorage ", the essence of which lies in the trySyncLock () function
Library code locableStorage (function () { function now() { return new Date().getTime(); } function someNumber() { return Math.random() * 1000000000 | 0; } let myId = now() + ":" + someNumber(); function getter(lskey) { return function () { let value = localStorage[lskey]; if (!value) return null; let splitted = value.split(/\|/); if (parseInt(splitted[1]) < now()) { return null; } return splitted[0]; } } function _mutexTransaction(key, callback, synchronous) { let xKey = key + "__MUTEX_x", yKey = key + "__MUTEX_y", getY = getter(yKey); function criticalSection() { try { callback(); } finally { localStorage.removeItem(yKey); } } localStorage[xKey] = myId; if (getY()) { if (!synchronous) setTimeout(function () { _mutexTransaction(key, callback); }, 0); return false; } localStorage[yKey] = myId + "|" + (now() + 40); if (localStorage[xKey] !== myId) { if (!synchronous) { setTimeout(function () { if (getY() !== myId) { setTimeout(function () { _mutexTransaction(key, callback); }, 0); } else { criticalSection(); } }, 50) } return false; } else { criticalSection(); return true; } } function lockImpl(key, callback, maxDuration, synchronous) { maxDuration = maxDuration || 5000; let mutexKey = key + "__MUTEX", getMutex = getter(mutexKey), mutexValue = myId + ":" + someNumber() + "|" + (now() + maxDuration); function restart() { setTimeout(function () { lockImpl(key, callback, maxDuration); }, 10); } if (getMutex()) { if (!synchronous) restart(); return false; } let aquiredSynchronously = _mutexTransaction(key, function () { if (getMutex()) { if (!synchronous) restart(); return false; } localStorage[mutexKey] = mutexValue; if (!synchronous) setTimeout(mutexAquired, 0) }, synchronous); if (synchronous && aquiredSynchronously) { mutexAquired(); return true; } return false; function mutexAquired() { try { callback(); } finally { _mutexTransaction(key, function () { if (localStorage[mutexKey] !== mutexValue) throw key + " was locked by a different process while I held the lock" localStorage.removeItem(mutexKey); }); } } } window.LockableStorage = { lock: function (key, callback, maxDuration) { lockImpl(key, callback, maxDuration, false) }, trySyncLock: function (key, callback, maxDuration) { return lockImpl(key, callback, maxDuration, true) } }; })();
Now you need to combine everything into a single mechanism, which will allow to realize our plans.
Implementation code if (Intercom.supported) { let intercom = Intercom.getInstance(),
Now on the fingers I will explain what is happening here.
GitHub demo project
Step 1. Open the first tab.
This example implements a timer that works in several contributions, but which is calculated only in one. The timer code can be replaced by anything, for example, by initializing a WS connection. when launched, webSocketInit () is executed immediately, which in the first tab will lead us to start the counter ( open a socket ), as well as to start the startHeartBitInterval () timer to update the value of the " wsLU " in localStorage. This key is responsible for the creation and maintenance of the Primary tab. This is a key element of the whole structure. At the same time, the key " wsOpen " is created, which is responsible for the status of the counter (or opening a WS connection) and the variable " primaryStatus ", which makes the current tab main, becomes true. The receipt of any event from the counter (WS-connection) will be issued to Intercom, with the construction:
intercom.emit('incoming', {data: count});
Step 2. Opening the second tab
Opening the second, third and any other tabs will call webSocketInit () , after which the key " wsLU " and " forceOpen " enter the battle. If code:
if (wsLU) { let diff = Date.now() - parseInt(wsLU); forceOpen = diff > period_heart_bit * 5 * 1000; }
... will cause the " forceOpen " to become true , then the counter will stop and start anew, but this will not happen, because diff will not be greater than the specified value, because the wsLU key is supported by the current Primary tab. All Secondary tabs will listen to the events that the Primary tab gives them through Intercom, by construction:
intercom.on('incoming', data => { document.getElementById('counter').innerHTML = data.data; document.getElementById('socketStatus').innerHTML = primaryStatus.toString(); return false; });
Step 3. Closing the tab
Closing tabs causes the onbeforeunload event in modern browsers. We process it as follows:
window.onbeforeunload = function () { if (primaryStatus) { localStorage.setItem('wsOpen', false); clearInterval(refreshIntervalId); intercom.emit('TAB_CLOSED', {count: count}); } };
It should be noted that the call to all methods will occur only in the Primary tab. When closing any Secondary tabs, nothing will happen to the counter. You only need to remove the wiretap of events to free up memory. But if we closed the Primary tab, then we set wsOpen to false and cancel the TAB_CLOSED event. All open tabs will immediately respond to it:
intercom.on('TAB_CLOSED', function (data) { if (localStorage.wsId !== wsId) { count = data.count; setTimeout(() => { webSocketInit() }, parseInt(getRandomArbitary(1, 1000), 10));
This is where the magic begins. Function...
getRandomArbitary (1, 1000) function getRandomArbitary(min, max) { return Math.random() * (max - min) + min; }
... allows you to call the socket initialization (in our case, the counter) at different intervals, which makes it possible for some of the Secondary tabs to have time to become Primary and record information about this in localStorage. Scratch in numbers (1, 1000) you can achieve the fastest response tabs. The remaining Secondary tabs remain listening to events and responding to them, waiting for Primary to die.
Total
We received a design that allows you to keep only one webSocket-connection for the entire application, no matter how many tabs it has, which will significantly reduce the load on the hardware of our servers and, as a result, will allow us to keep more online.