[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-tab-leader-election-weblocks-broadcastchannel":44,"search-suggestions":60,"quiz-article-tab-leader-election-weblocks-broadcastchannel":108},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a11a22-5822-77e3-8be0-f7ac69d3d575","tab-leader-election-weblocks-broadcastchannel","PRACTICE_QUIZ","Tab leader election — navigator.locks & BroadcastChannel","Check what you took from the post: how navigator.locks elects and fails over a leader tab, and why a shared BroadcastChannel doesn't hear its own messages.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},7,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,77,81,85,89,93,97,101,105],{"slug":62,"name":63,"articles":64},"webdev","Webdev",137,{"slug":66,"name":67,"articles":68},"javascript","Javascript",112,{"slug":70,"name":71,"articles":72},"frontend","Frontend",83,{"slug":74,"name":75,"articles":76},"tutorial","Tutorial",53,{"slug":78,"name":79,"articles":80},"css","Css",42,{"slug":82,"name":83,"articles":84},"performance","Performance",20,{"slug":86,"name":87,"articles":88},"typescript","Typescript",18,{"slug":90,"name":91,"articles":92},"react","React",17,{"slug":94,"name":95,"articles":96},"browser","Browser",13,{"slug":98,"name":99,"articles":100},"node","Node",12,{"slug":102,"name":103,"articles":104},"programming","Programming",9,{"slug":106,"name":107,"articles":104},"accessibility","Accessibility",{"id":109,"slug":46,"title":110,"subtitle":52,"excerpt":111,"coverUrl":112,"locale":13,"readingMinutes":113,"publishedAt":114,"viewCount":115,"likeCount":19,"commentCount":19,"author":116,"vertical":121,"topic":122,"tags":124,"_count":129,"playground":131,"body":133,"bodyMd":291,"seo":292,"translationGroupId":295,"series":52,"podcastUrl":52,"verticalId":5,"thread":296,"assessments":298,"translations":301,"quiz":303},"01a11a22-57e3-710c-8787-8b86669fb1c9","Your Tabs Each Open Their Own WebSocket. navigator.locks Elects One.","Six open tabs means six live connections to your server, six duplicate pushes, six timers draining the battery. navigator.locks elects a single leader tab to hold the real connection — the rest just listen.","\u002Fmedia\u002Fcovers\u002Ftab-leader-election-weblocks-broadcastchannel.png",6,"2026-10-13T06:11:00.980Z",30,{"id":117,"name":118,"username":119,"avatarUrl":52,"headline":120},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":66,"name":123},"JavaScript",[125,126,127,128],{"slug":66,"name":67,"color":52},{"slug":62,"name":63,"color":52},{"slug":70,"name":71,"color":52},{"slug":82,"name":83,"color":52},{"assessments":130},1,{"slug":46,"title":132},"Tab leader election — interactive playground",{"blocks":134,"version":130},[135,139,142,146,149,152,156,159,165,168,171,174,177,180,183,187,190,193,196,199,202,205,208,212,215,218,221,224,227,230,233,236,239,242,245,248,251,254,257,260,263,266,273,276,279,282,285],{"id":136,"html":137,"type":138},"b1","\u003Cp>Open your app in six tabs — most people have at least that many of something open right now — and check your server&#39;s connection count. If you&#39;re using WebSockets or Server-Sent Events for live updates, it just went up by six. Six sockets, six keep-alives, six copies of every push notification landing in the same browser window.\u003C\u002Fp>","paragraph",{"id":140,"html":141,"type":138},"b2","\u003Cp>Nobody asked for six connections. They asked to see their dashboard update live. You gave them that, and also a server bill and a battery drain that scale with how many tabs a bookmark-happy user happens to have open.\u003C\u002Fp>",{"id":143,"html":144,"text":144,"type":145,"level":31},"b3","The bug you can see in your own network tab","heading",{"id":147,"html":148,"type":138},"b4","\u003Cp>Open devtools, go to the Network tab, filter by WS, and open the same page twice. You&#39;ll see two independent socket connections, each sending its own ping, each receiving its own copy of every broadcast the server sends.\u003C\u002Fp>",{"id":150,"html":151,"type":138},"b5","\u003Cp>Now open six. Your server — which probably caps concurrent connections per deploy, or bills per connection-minute — just got six times the load from one human being. And when the server pushes a notification, that human sees it pop up six times, once per tab, because each tab&#39;s JavaScript has no idea the other five exist.\u003C\u002Fp>",{"id":153,"html":154,"text":155,"type":145,"level":31},"b6","The fix that doesn&#39;t fix it","The fix that doesn't fix it",{"id":157,"html":158,"type":138},"b7","\u003Cp>The obvious instinct is the Page Visibility API: pause the connection in background tabs, since the user is only looking at one of them anyway.\u003C\u002Fp>",{"id":160,"code":161,"type":162,"language":163,"highlight":164},"b8","document.addEventListener('visibilitychange', () => {\n  if (document.hidden) socket.close();\n  else socket = new WebSocket(url);\n});","code","js",[],{"id":166,"html":167,"type":138},"b9","\u003Cp>This helps a little and breaks something else. A user with the dashboard open on a second monitor, in a visible-but-not-active tab, stops getting updates. Worse, &quot;hidden&quot; and &quot;pointless&quot; aren&#39;t the same thing — a background tab doing the actual relaying for a visible one is exactly what you want, not what you&#39;re trying to kill.\u003C\u002Fp>",{"id":169,"html":170,"type":138},"b10","\u003Cp>The real problem was never &quot;which tabs should connect.&quot; It&#39;s that \u003Cstrong>six tabs are duplicating work that only needs to happen once.\u003C\u002Fstrong> You don&#39;t want fewer connections scattered unpredictably across whichever tabs happen to be visible — you want exactly \u003Cstrong>one\u003C\u002Fstrong> connection, owned by exactly one tab, with the other five just listening in.\u003C\u002Fp>",{"id":172,"html":173,"type":138},"b11","\u003Cp>That&#39;s leader election, and the browser already ships the primitive for it.\u003C\u002Fp>",{"id":175,"html":176,"text":176,"type":145,"level":31},"b12","navigator.locks, held for the life of a tab",{"id":178,"html":179,"type":138},"b13","\u003Cp>\u003Ccode>navigator.locks.request()\u003C\u002Fcode> normally looks like a mutex: acquire a named lock, do some work, let it resolve, the lock releases. But nothing requires the callback to resolve quickly. Give it a promise that never resolves on its own, and the lock stays held for as long as that tab is open — the moment the tab closes, crashes, or navigates away, the browser releases the lock for you, automatically, no cleanup code required.\u003C\u002Fp>",{"id":181,"html":182,"type":138},"b14","\u003Cp>That&#39;s the whole mechanism. Whichever tab&#39;s \u003Ccode>request()\u003C\u002Fcode> call wins the (exclusive, FIFO) queue becomes the leader. Every other tab&#39;s request just waits — and the instant the leader&#39;s tab closes, the next one in line is granted the lock and takes over.\u003C\u002Fp>",{"id":184,"code":185,"type":162,"language":163,"highlight":186},"b15","const LEADER_LOCK = 'app-connection-leader';\nlet socket;\n\nnavigator.locks.request(LEADER_LOCK, () => {\n  \u002F\u002F Only one tab's callback ever runs this at a time.\n  socket = new WebSocket('wss:\u002F\u002Fexample.com\u002Flive');\n  socket.onmessage = (e) => {\n    channel.postMessage({ tick: e.data }); \u002F\u002F fan out to every other tab\n    renderUpdate(e.data);                  \u002F\u002F and render here too — see why below\n  };\n\n  return new Promise(() => {}); \u002F\u002F never resolves — holds the lock until this tab dies\n});",[],{"id":188,"html":189,"type":138},"b16","\u003C!-- playground:start -->",{"id":191,"html":192,"text":192,"type":145,"level":31},"b17","🎮 Try it yourself",{"id":194,"html":195,"type":138},"b18","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ftab-leader-election-weblocks-broadcastchannel\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":197,"html":198,"type":138},"b19","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":200,"html":201,"type":138},"b20","\u003C!-- playground:end -->",{"id":203,"html":204,"text":204,"type":145,"level":31},"b21","Relaying to the followers",{"id":206,"html":207,"type":138},"b22","\u003Cp>Electing a leader only solves half the problem — the other five tabs still need the updates. That&#39;s what \u003Ccode>BroadcastChannel\u003C\u002Fcode> is for: the leader relays every message it receives onto a channel that every \u003Cem>other\u003C\u002Fem> tab is already listening to.\u003C\u002Fp>",{"id":209,"code":210,"type":162,"language":163,"highlight":211},"b23","const channel = new BroadcastChannel('app-updates');\n\n\u002F\u002F Every follower tab runs this:\nchannel.onmessage = (e) => renderUpdate(e.data.tick);",[],{"id":213,"html":214,"type":138},"b24","\u003Cp>Here&#39;s the gotcha worth knowing before you build this: a \u003Ccode>BroadcastChannel\u003C\u002Fcode> never delivers a message back to the instance that posted it — that&#39;s by design, not a bug. So the leader can&#39;t just post to the channel and expect its \u003Cem>own\u003C\u002Fem> \u003Ccode>onmessage\u003C\u002Fcode> to fire; that&#39;s why the leader&#39;s \u003Ccode>socket.onmessage\u003C\u002Fcode> above renders directly instead of waiting for an echo that will never arrive. Followers never touch the real connection — they only ever get updates through the channel. If a follower needs to \u003Cem>send\u003C\u002Fem> something back (a user action, a form submit), it posts that onto the channel too, and the leader is the only one that forwards it to the server. One tab talks to your backend. The rest just listen.\u003C\u002Fp>",{"id":216,"html":217,"text":217,"type":145,"level":31},"b25","What happens when the leader closes",{"id":219,"html":220,"type":138},"b26","\u003Cp>This is the part worth testing yourself, because it&#39;s the part that makes this worth doing at all: close the leader tab, and one of the waiting tabs is granted the lock as soon as the browser notices that tab&#39;s context is gone — no polling, no &quot;is anyone still there&quot; timeout, no stale leader lingering for thirty seconds before a new one takes over. The browser&#39;s lock queue \u003Cem>is\u003C\u002Fem> the failover mechanism — you don&#39;t write one.\u003C\u002Fp>",{"id":222,"html":223,"type":138},"b27","\u003Cp>There&#39;s a brief gap you should know about: the new leader has to open its own \u003Ccode>WebSocket\u003C\u002Fcode> connection after acquiring the lock, so there&#39;s a reconnect delay (normal connection-establishment time) before live updates resume. For a dashboard or notification feed, that&#39;s invisible. For something that can&#39;t tolerate a gap at all, keep that in mind rather than assuming the handoff is instant.\u003C\u002Fp>",{"id":225,"html":226,"type":138},"b28","\u003Cp>One more option worth knowing: \u003Ccode>navigator.locks.request(name, { ifAvailable: true }, callback)\u003C\u002Fcode> checks leadership without joining the queue — \u003Ccode>callback\u003C\u002Fcode> runs immediately with \u003Ccode>null\u003C\u002Fcode> if someone else already holds the lock, instead of waiting. Useful for a tab that wants to know &quot;am I the leader?&quot; without volunteering to become one.\u003C\u002Fp>",{"id":228,"html":229,"text":229,"type":145,"level":31},"b29","Try it with real tabs, not a mental model",{"id":231,"html":232,"type":138},"b30","\u003Cp>Everything above is easy to agree with and easy to get wrong in practice — the FIFO ordering, the automatic release, the reconnect gap. The playground above runs the exact same \u003Ccode>navigator.locks\u003C\u002Fcode> + \u003Ccode>BroadcastChannel\u003C\u002Fcode> code in three simulated tabs on one page. Open each one, watch the lock queue hand off leadership the moment you close the leader, and try the &quot;steal&quot; option to see what happens when a tab forces its way to the front instead of waiting in line.\u003C\u002Fp>",{"id":234,"html":235,"type":138},"b31","\u003C!-- quiz:start -->",{"id":237,"html":238,"text":238,"type":145,"level":31},"b32","🧠 Test yourself",{"id":240,"html":241,"type":138},"b33","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ftab-leader-election-weblocks-broadcastchannel\u002Fquiz\">Take the 7-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":243,"html":244,"type":138},"b34","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":246,"html":247,"type":138},"b35","\u003C!-- quiz:end -->",{"id":249,"html":250,"text":250,"type":145,"level":31},"b36","Browser support, honestly",{"id":252,"html":253,"type":138},"b37","\u003Cp>\u003Ccode>navigator.locks\u003C\u002Fcode> is Baseline widely available — Chrome 69+, Firefox 96+, Safari 15.4+ — so this works in every current browser. The one footgun is the \u003Ccode>steal\u003C\u002Fcode> option: it forcibly yanks the lock away from whoever holds it, skipping the queue entirely. That&#39;s occasionally the right tool (a user force-reloading a stuck tab), but use it knowing the previous leader never gets a chance to finish cleanly — it just loses the lock mid-flight.\u003C\u002Fp>",{"id":255,"html":256,"type":138},"b38","\u003Cp>You don&#39;t need six tabs each holding their own connection to do the job of one. You need one tab doing the job, and a browser API that already knows how to pick which one — and hand the job to the next tab the moment the first one leaves.\u003C\u002Fp>",{"id":258,"html":259,"type":138},"b39","\u003Cp>Does your app open a connection per tab right now? Go count it in the Network panel — I&#39;ll bet it&#39;s more than you think.\u003C\u002Fp>",{"id":261,"html":262,"type":138},"b40","\u003C!-- related:start -->",{"id":264,"html":265,"text":265,"type":145,"level":31},"b41","📚 Read next",{"id":267,"type":268,"items":269,"ordered":18},"b42","list",[270,271,272],"\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpage-visibility-api-tab-aware-timers\">Your Timer Doesn&#39;t Know You Tabbed Away\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fbroadcast-channel-cross-tab-messaging\">Stop using the localStorage hack to sync browser tabs. BroadcastChannel does it natively.\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fweb-locks-api-tab-coordination\">Five tabs open, one refresh token — the race nobody noticed\u003C\u002Fa>",{"id":274,"html":275,"type":138},"b43","\u003C!-- related:end -->",{"id":277,"type":278},"b44","divider",{"id":280,"html":281,"type":138},"b45","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":283,"html":284,"type":138},"b46","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":286,"type":268,"items":287,"ordered":18},"b47",[288,289,290],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","Open your app in six tabs — most people have at least that many of something open right now — and check your server's connection count. If you're using WebSockets or Server-Sent Events for live updates, it just went up by six. Six sockets, six keep-alives, six copies of every push notification landing in the same browser window.\n\nNobody asked for six connections. They asked to see their dashboard update live. You gave them that, and also a server bill and a battery drain that scale with how many tabs a bookmark-happy user happens to have open.\n\n## The bug you can see in your own network tab\n\nOpen devtools, go to the Network tab, filter by WS, and open the same page twice. You'll see two independent socket connections, each sending its own ping, each receiving its own copy of every broadcast the server sends.\n\nNow open six. Your server — which probably caps concurrent connections per deploy, or bills per connection-minute — just got six times the load from one human being. And when the server pushes a notification, that human sees it pop up six times, once per tab, because each tab's JavaScript has no idea the other five exist.\n\n## The fix that doesn't fix it\n\nThe obvious instinct is the Page Visibility API: pause the connection in background tabs, since the user is only looking at one of them anyway.\n\n```js\ndocument.addEventListener('visibilitychange', () => {\n  if (document.hidden) socket.close();\n  else socket = new WebSocket(url);\n});\n```\n\nThis helps a little and breaks something else. A user with the dashboard open on a second monitor, in a visible-but-not-active tab, stops getting updates. Worse, \"hidden\" and \"pointless\" aren't the same thing — a background tab doing the actual relaying for a visible one is exactly what you want, not what you're trying to kill.\n\nThe real problem was never \"which tabs should connect.\" It's that **six tabs are duplicating work that only needs to happen once.** You don't want fewer connections scattered unpredictably across whichever tabs happen to be visible — you want exactly **one** connection, owned by exactly one tab, with the other five just listening in.\n\nThat's leader election, and the browser already ships the primitive for it.\n\n## navigator.locks, held for the life of a tab\n\n`navigator.locks.request()` normally looks like a mutex: acquire a named lock, do some work, let it resolve, the lock releases. But nothing requires the callback to resolve quickly. Give it a promise that never resolves on its own, and the lock stays held for as long as that tab is open — the moment the tab closes, crashes, or navigates away, the browser releases the lock for you, automatically, no cleanup code required.\n\nThat's the whole mechanism. Whichever tab's `request()` call wins the (exclusive, FIFO) queue becomes the leader. Every other tab's request just waits — and the instant the leader's tab closes, the next one in line is granted the lock and takes over.\n\n```js\nconst LEADER_LOCK = 'app-connection-leader';\nlet socket;\n\nnavigator.locks.request(LEADER_LOCK, () => {\n  \u002F\u002F Only one tab's callback ever runs this at a time.\n  socket = new WebSocket('wss:\u002F\u002Fexample.com\u002Flive');\n  socket.onmessage = (e) => {\n    channel.postMessage({ tick: e.data }); \u002F\u002F fan out to every other tab\n    renderUpdate(e.data);                  \u002F\u002F and render here too — see why below\n  };\n\n  return new Promise(() => {}); \u002F\u002F never resolves — holds the lock until this tab dies\n});\n```\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ftab-leader-election-weblocks-broadcastchannel\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## Relaying to the followers\n\nElecting a leader only solves half the problem — the other five tabs still need the updates. That's what `BroadcastChannel` is for: the leader relays every message it receives onto a channel that every *other* tab is already listening to.\n\n```js\nconst channel = new BroadcastChannel('app-updates');\n\n\u002F\u002F Every follower tab runs this:\nchannel.onmessage = (e) => renderUpdate(e.data.tick);\n```\n\nHere's the gotcha worth knowing before you build this: a `BroadcastChannel` never delivers a message back to the instance that posted it — that's by design, not a bug. So the leader can't just post to the channel and expect its *own* `onmessage` to fire; that's why the leader's `socket.onmessage` above renders directly instead of waiting for an echo that will never arrive. Followers never touch the real connection — they only ever get updates through the channel. If a follower needs to *send* something back (a user action, a form submit), it posts that onto the channel too, and the leader is the only one that forwards it to the server. One tab talks to your backend. The rest just listen.\n\n## What happens when the leader closes\n\nThis is the part worth testing yourself, because it's the part that makes this worth doing at all: close the leader tab, and one of the waiting tabs is granted the lock as soon as the browser notices that tab's context is gone — no polling, no \"is anyone still there\" timeout, no stale leader lingering for thirty seconds before a new one takes over. The browser's lock queue *is* the failover mechanism — you don't write one.\n\nThere's a brief gap you should know about: the new leader has to open its own `WebSocket` connection after acquiring the lock, so there's a reconnect delay (normal connection-establishment time) before live updates resume. For a dashboard or notification feed, that's invisible. For something that can't tolerate a gap at all, keep that in mind rather than assuming the handoff is instant.\n\nOne more option worth knowing: `navigator.locks.request(name, { ifAvailable: true }, callback)` checks leadership without joining the queue — `callback` runs immediately with `null` if someone else already holds the lock, instead of waiting. Useful for a tab that wants to know \"am I the leader?\" without volunteering to become one.\n\n## Try it with real tabs, not a mental model\n\nEverything above is easy to agree with and easy to get wrong in practice — the FIFO ordering, the automatic release, the reconnect gap. The playground above runs the exact same `navigator.locks` + `BroadcastChannel` code in three simulated tabs on one page. Open each one, watch the lock queue hand off leadership the moment you close the leader, and try the \"steal\" option to see what happens when a tab forces its way to the front instead of waiting in line.\n\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 7-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ftab-leader-election-weblocks-broadcastchannel\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n## Browser support, honestly\n\n`navigator.locks` is Baseline widely available — Chrome 69+, Firefox 96+, Safari 15.4+ — so this works in every current browser. The one footgun is the `steal` option: it forcibly yanks the lock away from whoever holds it, skipping the queue entirely. That's occasionally the right tool (a user force-reloading a stuck tab), but use it knowing the previous leader never gets a chance to finish cleanly — it just loses the lock mid-flight.\n\nYou don't need six tabs each holding their own connection to do the job of one. You need one tab doing the job, and a browser API that already knows how to pick which one — and hand the job to the next tab the moment the first one leaves.\n\nDoes your app open a connection per tab right now? Go count it in the Network panel — I'll bet it's more than you think.\n\n\u003C!-- related:start -->\n\n## 📚 Read next\n\n- [Your Timer Doesn't Know You Tabbed Away](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpage-visibility-api-tab-aware-timers)\n- [Stop using the localStorage hack to sync browser tabs. BroadcastChannel does it natively.](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fbroadcast-channel-cross-tab-messaging)\n- [Five tabs open, one refresh token — the race nobody noticed](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fweb-locks-api-tab-coordination)\n\n\u003C!-- related:end -->\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":110,"canonical":293,"description":294},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ftab-leader-election-weblocks-broadcastchannel","Six open tabs means six live connections to your server, six duplicate pushes, six timers draining the battery. navigator.locks elects a single leader tab to hold the real connecti","01a11a22-57e3-710c-8787-8f2bcafa16ff",{"id":297,"locked":18},"01a11a22-5805-7736-8e0f-488b02eec44c",[299],{"id":45,"slug":46,"title":48,"_count":300},{"questions":51},[302],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":304,"questionCount":51},{"questions":51}]