[{"data":1,"prerenderedAt":23057},["ShallowReactive",2],{"blog-list":3},[4,994,1840,2291,2907,3506,4568,5129,5911,7902,9789,12006,12856,14044,14733,16236,16948,17694,18533,19433,20101,21137,22076],{"id":5,"title":6,"author":7,"body":8,"category":983,"date":984,"description":985,"excerpt":986,"extension":987,"meta":988,"navigation":175,"path":989,"readTime":990,"seo":991,"stem":992,"__hash__":993},"blog/blog/one-connection-many-realtime-features.md","Stop Buying Realtime One Scenario at a Time","Henco Burger",{"type":9,"value":10,"toc":972},"minimark",[11,15,18,26,34,39,131,134,263,274,280,284,287,290,412,415,418,422,425,428,438,445,449,452,463,466,469,473,476,479,663,666,699,716,719,723,729,854,874,878,881,903,906,910,913,940,943,947,968],[12,13,14],"p",{},"Realtime tends to arrive in a product one scenario at a time, and each one gets bought or built separately. Chat comes from one vendor. Push notifications from another. Live dashboard updates get hand-rolled on a raw WebSocket. Device telemetry goes over MQTT because that is what the hardware speaks. Then agents arrive and need task dispatch, so a job queue appears too.",[12,16,17],{},"Every one of those is a different SDK, a different auth model, a different reconnect strategy, a different bill and a different mental model. None of it is your product. All of it is yours to keep working.",[12,19,20,21,25],{},"The alternative is not \"one vendor who can technically do all of it\". Any pub/sub primitive can technically do all of it, which is exactly the trap: you end up with one connection and five bespoke implementations on top. The alternative that is actually worth having is one platform where each of those scenarios is ",[22,23,24],"em",{},"already shaped",", so you get the consolidation without paying for it in bespoke plumbing.",[12,27,28,29,33],{},"That is what this post is about. NoLag is realtime messaging infrastructure and a coordination layer for AI agents: pub/sub topics, rooms, presence and per-message QoS, with a Blueprint per scenario so chat behaves like chat rather than like topics you wire yourself. The architecture below is a multi-tenant SaaS with a browser dashboard, an admin console, a backend API and an AI agent runtime, running ",[30,31,32],"strong",{},"one WebSocket per tenant"," carrying all of it.",[35,36,38],"h2",{"id":37},"the-shape","The shape",[40,41,42,58],"table",{},[43,44,45],"thead",{},[46,47,48,52,55],"tr",{},[49,50,51],"th",{},"Piece",[49,53,54],{},"What it carries",[49,56,57],{},"How",[59,60,61,73,91,102],"tbody",{},[46,62,63,67,70],{},[64,65,66],"td",{},"Dashboard",[64,68,69],{},"Chat, notifications, live dashboard updates",[64,71,72],{},"One core client, three wrapper SDKs attached",[46,74,75,78,81],{},[64,76,77],{},"Admin console",[64,79,80],{},"Device telemetry, agent approvals",[64,82,83,87,88],{},[84,85,86],"code",{},"@nolag/iot",", ",[84,89,90],{},"@nolag/agents",[46,92,93,96,99],{},[64,94,95],{},"Backend API",[64,97,98],{},"Token minting, provisioning, tenant scoping",[64,100,101],{},"Control-plane API plus one client per tenant",[46,103,104,107,110],{},[64,105,106],{},"Agent runtime",[64,108,109],{},"Orchestrator and a pool of sub-agents",[64,111,112,114,115,87,118,87,121,87,124,87,127,130],{},[84,113,90],{}," (",[84,116,117],{},"Handoff",[84,119,120],{},"Observe",[84,122,123],{},"Tools",[84,125,126],{},"Approve",[84,128,129],{},"Blackboard",")",[12,132,133],{},"The dashboard is the clearest illustration. It used to open three sockets, one per feature store. It now opens one:",[135,136,137],"code-tabs",{},[138,139,145],"pre",{"className":140,"code":141,"filename":142,"language":143,"meta":144,"style":144},"language-typescript shiki shiki-themes github-light github-dark","import type { NoLagSocket } from '@nolag/js-sdk'\n\n// One core client per tenant carries ALL realtime apps.\n// Feature stores no longer own sockets; they attach a wrapper to this client.\n// The app names are the suffixed slugs returned when each app was created.\nexport interface RealtimeHandle {\n  client: NoLagSocket\n  chatAppName: string\n  notifyAppName: string\n  dashboardAppName: string\n}\n","TypeScript","typescript","",[84,146,147,170,177,184,190,196,212,225,237,247,257],{"__ignoreMap":144},[148,149,152,156,159,163,166],"span",{"class":150,"line":151},"line",1,[148,153,155],{"class":154},"szBVR","import",[148,157,158],{"class":154}," type",[148,160,162],{"class":161},"sVt8B"," { NoLagSocket } ",[148,164,165],{"class":154},"from",[148,167,169],{"class":168},"sZZnC"," '@nolag/js-sdk'\n",[148,171,173],{"class":150,"line":172},2,[148,174,176],{"emptyLinePlaceholder":175},true,"\n",[148,178,180],{"class":150,"line":179},3,[148,181,183],{"class":182},"sJ8bj","// One core client per tenant carries ALL realtime apps.\n",[148,185,187],{"class":150,"line":186},4,[148,188,189],{"class":182},"// Feature stores no longer own sockets; they attach a wrapper to this client.\n",[148,191,193],{"class":150,"line":192},5,[148,194,195],{"class":182},"// The app names are the suffixed slugs returned when each app was created.\n",[148,197,199,202,205,209],{"class":150,"line":198},6,[148,200,201],{"class":154},"export",[148,203,204],{"class":154}," interface",[148,206,208],{"class":207},"sScJk"," RealtimeHandle",[148,210,211],{"class":161}," {\n",[148,213,215,219,222],{"class":150,"line":214},7,[148,216,218],{"class":217},"s4XuR","  client",[148,220,221],{"class":154},":",[148,223,224],{"class":207}," NoLagSocket\n",[148,226,228,231,233],{"class":150,"line":227},8,[148,229,230],{"class":217},"  chatAppName",[148,232,221],{"class":154},[148,234,236],{"class":235},"sj4cs"," string\n",[148,238,240,243,245],{"class":150,"line":239},9,[148,241,242],{"class":217},"  notifyAppName",[148,244,221],{"class":154},[148,246,236],{"class":235},[148,248,250,253,255],{"class":150,"line":249},10,[148,251,252],{"class":217},"  dashboardAppName",[148,254,221],{"class":154},[148,256,236],{"class":235},[148,258,260],{"class":150,"line":259},11,[148,261,262],{"class":161},"}\n",[12,264,265,266,269,270,273],{},"The feature stores did not get simpler because we were clever. They got simpler because the connection stopped being their problem. Each one attaches a wrapper SDK, uses it, and detaches. It never calls ",[84,267,268],{},"connect()"," and never calls ",[84,271,272],{},"disconnect()",", so there is exactly one place that owns the socket lifecycle.",[12,275,276,277],{},"That is worth stating as a rule, because it is the thing that makes the whole pattern hold together: ",[30,278,279],{},"one core client, many wrappers, and only the owner touches the socket.",[35,281,283],{"id":282},"why-this-is-not-just-one-connection","Why this is not just \"one connection\"",[12,285,286],{},"Collapsing five vendors into one socket is only a win if the scenarios stay easy. Otherwise you have traded five well-shaped SDKs for one primitive and a lot of homework.",[12,288,289],{},"That is what Blueprints are for. Each one is a use case that has already been thought through: an app blueprint that seeds the rooms and topics the scenario needs, a presence shape, and an SDK whose methods are the domain rather than the transport.",[135,291,292,391],{},[138,293,296],{"className":140,"code":294,"filename":295,"language":143,"meta":144,"style":144},"import { NoLagChat } from '@nolag/chat'\n\n// `client` is the tenant's core client; the owner has already connected it\nconst chat = new NoLagChat({ client, appName: chatAppName, username })\nawait chat.ready()\nconst room = chat.joinRoom('general')\nroom.sendMessage('Hello!')\n","With a Blueprint",[84,297,298,310,314,319,339,353,376],{"__ignoreMap":144},[148,299,300,302,305,307],{"class":150,"line":151},[148,301,155],{"class":154},[148,303,304],{"class":161}," { NoLagChat } ",[148,306,165],{"class":154},[148,308,309],{"class":168}," '@nolag/chat'\n",[148,311,312],{"class":150,"line":172},[148,313,176],{"emptyLinePlaceholder":175},[148,315,316],{"class":150,"line":179},[148,317,318],{"class":182},"// `client` is the tenant's core client; the owner has already connected it\n",[148,320,321,324,327,330,333,336],{"class":150,"line":186},[148,322,323],{"class":154},"const",[148,325,326],{"class":235}," chat",[148,328,329],{"class":154}," =",[148,331,332],{"class":154}," new",[148,334,335],{"class":207}," NoLagChat",[148,337,338],{"class":161},"({ client, appName: chatAppName, username })\n",[148,340,341,344,347,350],{"class":150,"line":192},[148,342,343],{"class":154},"await",[148,345,346],{"class":161}," chat.",[148,348,349],{"class":207},"ready",[148,351,352],{"class":161},"()\n",[148,354,355,357,360,362,364,367,370,373],{"class":150,"line":198},[148,356,323],{"class":154},[148,358,359],{"class":235}," room",[148,361,329],{"class":154},[148,363,346],{"class":161},[148,365,366],{"class":207},"joinRoom",[148,368,369],{"class":161},"(",[148,371,372],{"class":168},"'general'",[148,374,375],{"class":161},")\n",[148,377,378,381,384,386,389],{"class":150,"line":214},[148,379,380],{"class":161},"room.",[148,382,383],{"class":207},"sendMessage",[148,385,369],{"class":161},[148,387,388],{"class":168},"'Hello!'",[148,390,375],{"class":161},[138,392,395],{"className":140,"code":393,"filename":394,"language":143,"meta":144,"style":144},"// pick topic names, decide a room convention, design a presence payload,\n// define a message envelope, handle typing and streamed replies, and write\n// all of it again for notifications\n","Without one",[84,396,397,402,407],{"__ignoreMap":144},[148,398,399],{"class":150,"line":151},[148,400,401],{"class":182},"// pick topic names, decide a room convention, design a presence payload,\n",[148,403,404],{"class":150,"line":172},[148,405,406],{"class":182},"// define a message envelope, handle typing and streamed replies, and write\n",[148,408,409],{"class":150,"line":179},[148,410,411],{"class":182},"// all of it again for notifications\n",[12,413,414],{},"The dashboard in this architecture attaches three of them (chat, notifications, dashboard) to a single client, and the agent runtime attaches a fourth. Nobody on the team picked topic names or designed a presence payload. The consolidation is real because the ease of use survived it.",[12,416,417],{},"That is the actual claim worth making: not one connection instead of three, but one place to get realtime, where each scenario still arrives ready to use.",[35,419,421],{"id":420},"scoping-to-a-tenant","Scoping to a tenant",[12,423,424],{},"Every connection is scoped to one tenant, and the browser never holds a long-lived credential. The backend mints a short-lived tenant-scoped client token, the browser connects with it, and the broker enforces the scope. A user with access to twelve tenants still only ever has a connection to one.",[12,426,427],{},"Here is the first thing that cost us real time.",[12,429,430,433,434,437],{},[30,431,432],{},"Never re-scope a live connection."," When a user switches tenant, it is tempting to keep the socket and change what it is subscribed to. Do not. On reconnect the broker restores the connection's previous subscriptions, so a re-scoped client quietly ends up subscribed to the tenant the user just left. A tenant switch has to be a ",[22,435,436],{},"new"," client: tear the old one down, mint a token for the new tenant, connect fresh.",[12,439,440,441,444],{},"Feature stores detect the identity change and re-attach. In Vue that is a ",[84,442,443],{},"shallowRef"," holding the client, watched by each store; the equivalent in any framework is \"treat the client as a value that can be replaced, not as a singleton that lives forever\".",[35,446,448],{"id":447},"the-second-thing-that-cost-us-time","The second thing that cost us time",[12,450,451],{},"There is a subtle tenancy bug worth describing, because the shape of it generalises well beyond our domain.",[12,453,454,455,458,459,462],{},"An endpoint took a tenant id in the URL and used it for two different things: deciding ",[22,456,457],{},"which tenant's data"," the caller was allowed to touch, and deciding ",[22,460,461],{},"whose conversation"," to create. Those look like the same question and are not. The tenant scopes the data. The caller scopes the conversation.",[12,464,465],{},"Conflating them meant a support thread got created under the tenant's account while the browser was listening in the viewer's, so the chat opened and simply hung, with nobody on the other end. Nothing errored. The fix was to separate the two explicitly: the URL scopes the tenant, the authenticated actor scopes the conversation.",[12,467,468],{},"If you take one thing from this section: when a request carries a tenant id, be precise about whether it is answering \"what may I see\" or \"who am I\". Those diverge exactly when it matters most.",[35,470,472],{"id":471},"where-the-agents-come-in","Where the agents come in",[12,474,475],{},"The agent runtime is a separate process, which is the honest shape of it: it holds model credentials and database access the browser tier does not need, and it scales on a different curve.",[12,477,478],{},"An orchestrator handles a request that needs analysis across many sub-areas of a site. Rather than doing it in one enormous context, it fans the work out to a pool of sub-agents, each with an isolated context, and collects the results.",[135,480,481],{},[138,482,484],{"className":140,"code":483,"filename":142,"language":143,"meta":144,"style":144},"import { Handoff } from '@nolag/agents'\n\n// Fan work out to a worker pool and settle each result independently.\n// Per-task failures are isolated: one bad task never rejects the batch.\nexport async function dispatchSubAgents(\n  handoff: Handoff,\n  tasks: { capability: string; payload: Record\u003Cstring, unknown> }[],\n  timeout = 120_000,\n) {\n  return Promise.allSettled(\n    tasks.map((t) => handoff.dispatch(t.capability, t.payload, { waitForResult: true, timeout })),\n  )\n}\n",[84,485,486,498,502,507,512,528,541,584,596,601,617,652,658],{"__ignoreMap":144},[148,487,488,490,493,495],{"class":150,"line":151},[148,489,155],{"class":154},[148,491,492],{"class":161}," { Handoff } ",[148,494,165],{"class":154},[148,496,497],{"class":168}," '@nolag/agents'\n",[148,499,500],{"class":150,"line":172},[148,501,176],{"emptyLinePlaceholder":175},[148,503,504],{"class":150,"line":179},[148,505,506],{"class":182},"// Fan work out to a worker pool and settle each result independently.\n",[148,508,509],{"class":150,"line":186},[148,510,511],{"class":182},"// Per-task failures are isolated: one bad task never rejects the batch.\n",[148,513,514,516,519,522,525],{"class":150,"line":192},[148,515,201],{"class":154},[148,517,518],{"class":154}," async",[148,520,521],{"class":154}," function",[148,523,524],{"class":207}," dispatchSubAgents",[148,526,527],{"class":161},"(\n",[148,529,530,533,535,538],{"class":150,"line":198},[148,531,532],{"class":217},"  handoff",[148,534,221],{"class":154},[148,536,537],{"class":207}," Handoff",[148,539,540],{"class":161},",\n",[148,542,543,546,548,551,554,556,559,562,565,567,570,573,576,578,581],{"class":150,"line":214},[148,544,545],{"class":217},"  tasks",[148,547,221],{"class":154},[148,549,550],{"class":161}," { ",[148,552,553],{"class":217},"capability",[148,555,221],{"class":154},[148,557,558],{"class":235}," string",[148,560,561],{"class":161},"; ",[148,563,564],{"class":217},"payload",[148,566,221],{"class":154},[148,568,569],{"class":207}," Record",[148,571,572],{"class":161},"\u003C",[148,574,575],{"class":235},"string",[148,577,87],{"class":161},[148,579,580],{"class":235},"unknown",[148,582,583],{"class":161},"> }[],\n",[148,585,586,589,591,594],{"class":150,"line":227},[148,587,588],{"class":217},"  timeout",[148,590,329],{"class":154},[148,592,593],{"class":235}," 120_000",[148,595,540],{"class":161},[148,597,598],{"class":150,"line":239},[148,599,600],{"class":161},") {\n",[148,602,603,606,609,612,615],{"class":150,"line":249},[148,604,605],{"class":154},"  return",[148,607,608],{"class":235}," Promise",[148,610,611],{"class":161},".",[148,613,614],{"class":207},"allSettled",[148,616,527],{"class":161},[148,618,619,622,625,628,631,634,637,640,643,646,649],{"class":150,"line":259},[148,620,621],{"class":161},"    tasks.",[148,623,624],{"class":207},"map",[148,626,627],{"class":161},"((",[148,629,630],{"class":217},"t",[148,632,633],{"class":161},") ",[148,635,636],{"class":154},"=>",[148,638,639],{"class":161}," handoff.",[148,641,642],{"class":207},"dispatch",[148,644,645],{"class":161},"(t.capability, t.payload, { waitForResult: ",[148,647,648],{"class":235},"true",[148,650,651],{"class":161},", timeout })),\n",[148,653,655],{"class":150,"line":654},12,[148,656,657],{"class":161},"  )\n",[148,659,661],{"class":150,"line":660},13,[148,662,262],{"class":161},[12,664,665],{},"Two details in there are the whole lesson.",[12,667,668,671,672,675,676,679,680,683,684,687,688,87,691,694,695,698],{},[30,669,670],{},"Per-task failure isolation."," One sub-agent failing must not reject the batch. If you await a plain ",[84,673,674],{},"Promise.all"," over model calls, a single timeout throws away eleven good answers. ",[84,677,678],{},"Promise.allSettled"," lets each task settle on its own, and the caller is told which ones came back. A settled result is a ",[84,681,682],{},"ResultEnvelope"," with a ",[84,685,686],{},"status"," of ",[84,689,690],{},"success",[84,692,693],{},"error"," or ",[84,696,697],{},"partial",", so check it rather than treating fulfilment as success.",[12,700,701,707,708,711,712,715],{},[30,702,703,704,706],{},"One long-lived ",[84,705,117],{}," per room."," Constructing a new one per call leaks a ",[84,709,710],{},"result"," listener every time, which is invisible until a long-running process gets slow for no apparent reason. Build it once, pass it in, and call ",[84,713,714],{},"dispose()"," on it when the room goes away.",[12,717,718],{},"Neither of those is in a getting-started guide. Both are the kind of thing you learn by running something for a while.",[35,720,722],{"id":721},"humans-in-the-loop","Humans in the loop",[12,724,725,726,728],{},"Some agent actions should not happen without a person saying yes. The admin console attaches ",[84,727,126],{}," to the relevant room and renders pending requests:",[135,730,731],{},[138,732,734],{"className":140,"code":733,"filename":142,"language":143,"meta":144,"style":144},"import { Approve } from '@nolag/agents'\n\n// `room` is agents.room('...') on the admin console's own agents wrapper\nconst approve = new Approve(room, agents.agentId)\napprove.onRequest((request, respond) => {\n  renderPending(request)   // action, context, urgency, requestedBy\n  onDecision(request.requestId, (ok: boolean) => respond(ok ? 'approved' : 'rejected'))\n})\n",[84,735,736,747,751,756,773,797,808,849],{"__ignoreMap":144},[148,737,738,740,743,745],{"class":150,"line":151},[148,739,155],{"class":154},[148,741,742],{"class":161}," { Approve } ",[148,744,165],{"class":154},[148,746,497],{"class":168},[148,748,749],{"class":150,"line":172},[148,750,176],{"emptyLinePlaceholder":175},[148,752,753],{"class":150,"line":179},[148,754,755],{"class":182},"// `room` is agents.room('...') on the admin console's own agents wrapper\n",[148,757,758,760,763,765,767,770],{"class":150,"line":186},[148,759,323],{"class":154},[148,761,762],{"class":235}," approve",[148,764,329],{"class":154},[148,766,332],{"class":154},[148,768,769],{"class":207}," Approve",[148,771,772],{"class":161},"(room, agents.agentId)\n",[148,774,775,778,781,783,786,788,791,793,795],{"class":150,"line":192},[148,776,777],{"class":161},"approve.",[148,779,780],{"class":207},"onRequest",[148,782,627],{"class":161},[148,784,785],{"class":217},"request",[148,787,87],{"class":161},[148,789,790],{"class":217},"respond",[148,792,633],{"class":161},[148,794,636],{"class":154},[148,796,211],{"class":161},[148,798,799,802,805],{"class":150,"line":198},[148,800,801],{"class":207},"  renderPending",[148,803,804],{"class":161},"(request)   ",[148,806,807],{"class":182},"// action, context, urgency, requestedBy\n",[148,809,810,813,816,819,821,824,826,828,831,834,837,840,843,846],{"class":150,"line":214},[148,811,812],{"class":207},"  onDecision",[148,814,815],{"class":161},"(request.requestId, (",[148,817,818],{"class":217},"ok",[148,820,221],{"class":154},[148,822,823],{"class":235}," boolean",[148,825,633],{"class":161},[148,827,636],{"class":154},[148,829,830],{"class":207}," respond",[148,832,833],{"class":161},"(ok ",[148,835,836],{"class":154},"?",[148,838,839],{"class":168}," 'approved'",[148,841,842],{"class":154}," :",[148,844,845],{"class":168}," 'rejected'",[148,847,848],{"class":161},"))\n",[148,850,851],{"class":150,"line":227},[148,852,853],{"class":161},"})\n",[12,855,856,857,860,861,687,864,87,867,694,870,873],{},"The agent calls ",[84,858,859],{},"approve.request(action, context)"," and awaits a ",[84,862,863],{},"decision",[84,865,866],{},"approved",[84,868,869],{},"rejected",[84,871,872],{},"deferred","; the request appears in a human's queue, and the agent waits. What makes this workable is that it is the same connection and the same room as everything else. The approval is not a separate integration with its own webhook and its own state to reconcile. It is a coordination pattern on infrastructure that is already there.",[35,875,877],{"id":876},"what-this-collapses","What this collapses",[12,879,880],{},"Counted against buying realtime a scenario at a time, one tenant-scoped connection on one platform replaces:",[882,883,884,888,891,894,897,900],"ul",{},[885,886,887],"li",{},"Three sockets in the dashboard, each with its own reconnect and backoff behaviour",[885,889,890],{},"Three vendors, three SDKs, three auth models and three bills",[885,892,893],{},"Three places that had to be told about a tenant switch, and three chances to get it wrong",[885,895,896],{},"A separate queue or job system for agent task dispatch",[885,898,899],{},"A separate mechanism for human approval gates",[885,901,902],{},"A separate transport for device telemetry",[12,904,905],{},"None of that is exotic infrastructure. It is all things a team builds because their realtime layer only does one thing, and it is all undifferentiated work.",[35,907,909],{"id":908},"if-you-are-building-something-similar","If you are building something similar",[12,911,912],{},"Four things we would tell ourselves at the start:",[914,915,916,922,928,934],"ol",{},[885,917,918,921],{},[30,919,920],{},"Put the connection behind one owner."," A store, a service, a module, whatever your framework calls it. Features attach and detach; they never connect.",[885,923,924,927],{},[30,925,926],{},"Make a tenant switch a new client."," Never re-scope a live one.",[885,929,930,933],{},[30,931,932],{},"Mint short-lived scoped tokens on your backend."," The browser should never hold a credential that outlives the session or reaches beyond the current tenant.",[885,935,936,939],{},[30,937,938],{},"Be precise about what a tenant id in a request means."," Data scope and identity scope are different questions that happen to share a parameter.",[12,941,942],{},"The architecture above is not the only way to use this. It is the one that fell out of actually building on it, including the parts we got wrong first.",[35,944,946],{"id":945},"read-next","Read next",[882,948,949,956,962],{},[885,950,951],{},[952,953,955],"a",{"href":954},"/blog/multi-agent-coordination-layer","Why multi-agent systems need a coordination layer",[885,957,958],{},[952,959,961],{"href":960},"/blog/blueprint-sdks-vs-raw-pubsub","Blueprint SDKs vs raw pub/sub",[885,963,964],{},[952,965,967],{"href":966},"/docs/agents","The agents SDK and its coordination patterns",[969,970,971],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":144,"searchDepth":172,"depth":172,"links":973},[974,975,976,977,978,979,980,981,982],{"id":37,"depth":172,"text":38},{"id":282,"depth":172,"text":283},{"id":420,"depth":172,"text":421},{"id":447,"depth":172,"text":448},{"id":471,"depth":172,"text":472},{"id":721,"depth":172,"text":722},{"id":876,"depth":172,"text":877},{"id":908,"depth":172,"text":909},{"id":945,"depth":172,"text":946},"Architecture","2026-08-31","Chat from one vendor, notifications from another, dashboards hand-rolled, telemetry over MQTT, a queue for agent tasks. NoLag is realtime messaging infrastructure and a coordination layer for AI agents, with a Blueprint per scenario, so all of it runs on one connection per tenant.",null,"md",{},"/blog/one-connection-many-realtime-features","10 min read",{"title":6,"description":985},"blog/one-connection-many-realtime-features","VCaPXWGjcO45wCmOv8gL8uyZPz_uobuoCUXajavrl-Y",{"id":995,"title":996,"author":7,"body":997,"category":1832,"date":984,"description":1833,"excerpt":986,"extension":987,"meta":1834,"navigation":175,"path":1835,"readTime":1836,"seo":1837,"stem":1838,"__hash__":1839},"blog/blog/voice-agent-with-tools-twilio.md","Building a Voice AI Agent That Can Actually Do Things",{"type":9,"value":998,"toc":1821},[999,1002,1005,1009,1012,1015,1018,1021,1025,1028,1031,1090,1093,1096,1100,1103,1106,1117,1120,1123,1161,1164,1168,1171,1174,1531,1538,1542,1548,1551,1554,1558,1561,1606,1609,1616,1619,1622,1626,1629,1760,1768,1771,1775,1778,1781,1785,1815,1818],[12,1000,1001],{},"There is a specific failure mode in voice AI that anyone who has shipped one will recognise. The agent sounds great. It is warm, it is fast, it handles interruptions. And then the caller asks it to change a booking, and it says \"I have updated that for you,\" and nothing has been updated, because the model on the phone has no tools and no database and no idea whether that was even possible.",[12,1003,1004],{},"This is not a prompting problem. It is a budget problem, and the budget is time.",[35,1006,1008],{"id":1007},"why-the-model-on-the-phone-is-small","Why the model on the phone is small",[12,1010,1011],{},"A phone call has a rhythm. Somewhere past about a second of silence, a caller starts to think the line has dropped. That gives you roughly one second, total, to transcribe what they said, decide what to say back, synthesise it, and get the first audio byte onto the wire.",[12,1013,1014],{},"That budget picks your model for you. It has to be small and fast, which is exactly right for conversation and exactly wrong for judgement. A small model is a poor choice for deciding whether a booking can be moved, what a refund policy allows, or whether this caller is who they claim to be.",[12,1016,1017],{},"The work that matters runs on a completely different clock. Looking a customer up, changing a record, waiting for a human to approve something: five to thirty seconds, sometimes minutes. None of that fits inside a one second budget, so it cannot live inside the call.",[12,1019,1020],{},"It has to be something the call talks to. And talking to it has to be asynchronous, because the caller is still on the line while it happens.",[35,1022,1024],{"id":1023},"the-split","The split",[12,1026,1027],{},"NoLag is realtime messaging infrastructure and a coordination layer for AI agents: pub/sub topics, rooms, presence and per-message QoS, with higher-level SDKs for the patterns agents actually need. For voice, that means a live phone call can become a room that other software joins.",[12,1029,1030],{},"Three pieces, each doing one job:",[40,1032,1033,1045],{},[43,1034,1035],{},[46,1036,1037,1039,1042],{},[49,1038,51],{},[49,1040,1041],{},"Job",[49,1043,1044],{},"Clock",[59,1046,1047,1063,1079],{},[46,1048,1049,1057,1060],{},[64,1050,1051],{},[952,1052,1054],{"href":1053},"/docs/sdks/voice-engine",[84,1055,1056],{},"@nolag/voice-engine",[64,1058,1059],{},"Turn taking, interruption, voice activity detection, filler speech",[64,1061,1062],{},"Milliseconds",[46,1064,1065,1073,1076],{},[64,1066,1067],{},[952,1068,1070],{"href":1069},"/docs/high-level-sdks/voice",[84,1071,1072],{},"@nolag/voice",[64,1074,1075],{},"Puts the call in a NoLag room, carries the ask and the answer",[64,1077,1078],{},"Per turn",[46,1080,1081,1084,1087],{},[64,1082,1083],{},"Your orchestrator",[64,1085,1086],{},"Bigger model, real tools, database credentials",[64,1088,1089],{},"Seconds to minutes",[12,1091,1092],{},"The voice engine has no NoLag dependency and no opinion about which telephony provider carries the call or which models do the thinking. Those are interfaces. What it owns is the part that no prompt can fix: deciding when someone has stopped talking, who holds the channel, and what the caller actually hears.",[12,1094,1095],{},"The orchestrator is a separate process because that is the honest shape of it. It holds database credentials the phone-facing server does not need, it scales on a different curve, and it can be restarted without dropping a call.",[35,1097,1099],{"id":1098},"waiting-out-loud-is-the-feature","Waiting out loud is the feature",[12,1101,1102],{},"Here is the part that took the longest to get right, and it is a design decision rather than an implementation detail.",[12,1104,1105],{},"When the model on the phone needs help, it asks the orchestrator. Most of the time that answer will not come back inside a turn. So the turn ends, the agent says something honest, and the answer is spoken whenever it arrives.",[135,1107,1108],{},[138,1109,1115],{"className":1110,"code":1112,"filename":1113,"language":1114,"meta":144},[1111],"language-text","CALLER  Hi, it's Alex. My flight landed early, can you move my pickup?\nAGENT   Let me have a look.\nAGENT   Let me check that for you, one moment.\nAGENT   Still checking on that.\nAGENT   Yes, I can move your airport pickup earlier. You're currently\n        booked for three forty pm, and I have one fifteen pm or one\n        forty-five pm available.\n","What the caller hears","text",[84,1116,1112],{"__ignoreMap":144},[12,1118,1119],{},"That transcript is from a single real call we made against our example app, transcribed back from the audio the caller would actually have heard. In that run, the two tool calls behind it, looking the booking up and checking availability, took about eight seconds (our own measurement, one call, not a benchmark). Nothing survives being nested inside a turn for eight seconds, so it was not nested inside one.",[12,1121,1122],{},"The behaviour has three tiers, and only the first one is invisible to the caller:",[40,1124,1125,1135],{},[43,1126,1127],{},[46,1128,1129,1132],{},[49,1130,1131],{},"Answer arrives",[49,1133,1134],{},"What the caller experiences",[59,1136,1137,1145,1153],{},[46,1138,1139,1142],{},[64,1140,1141],{},"Within the grace window (1200ms by default)",[64,1143,1144],{},"Folded into the reply. They never learn anything was asked",[46,1146,1147,1150],{},[64,1148,1149],{},"Later",[64,1151,1152],{},"An acknowledgement now, the answer spoken in the next gap",[46,1154,1155,1158],{},[64,1156,1157],{},"Never",[64,1159,1160],{},"A spoken apology, never silence",[12,1162,1163],{},"An answer that arrives late is never spliced into audio that is already playing, and never spoken over the caller. There is one audio buffer to the far end, so two things speaking at once are not heard as two voices. They are heard as alternating fragments of two sentences. If the floor never frees, the answer is dropped rather than said late, because talking over someone is worse than staying quiet and the next ordinary turn can usually carry the point anyway.",[35,1165,1167],{"id":1166},"one-tool-not-twenty","One tool, not twenty",[12,1169,1170],{},"The agent on the phone gets exactly one tool: ask the orchestrator. That is deliberate.",[12,1172,1173],{},"A small model handed twenty tools has to choose between them, and choosing badly is where small models fail. Handed one, its only judgement is \"do I need help, and how do I phrase it\". Which of the orchestrator's tools to actually reach for is decided by the orchestrator, which is large enough to decide well.",[135,1175,1176,1379],{},[138,1177,1180],{"className":140,"code":1178,"filename":1179,"language":143,"meta":144,"style":144},"import { NoLagVoice, orchestratedModel, ORCHESTRATOR_ROOM } from '@nolag/voice'\n\n// Once, at startup. The orchestrator room is not seeded by the Agents\n// blueprint and rooms never exist implicitly, so create it before the\n// process-wide connection authenticates. `processAgents` is one NoLagAgents\n// for the whole server, not the per-call instance.\nawait provisioner.ensureRoom(ORCHESTRATOR_ROOM)\nconst bridge = new NoLagVoice({ agents: processAgents }).orchestrator()\nawait bridge.ready()\n\n// Per call. The wrapper needs the session as its floor and the session needs\n// its providers, so build the providers object first and swap the model in\n// after the session exists. The session reads `llm` on every turn, and no\n// turn can happen before the call has started.\nconst callProviders = { ...providers }\nconst session = new VoiceSession({ transport, providers: callProviders, systemPrompt })\ncallProviders.llm = orchestratedModel({\n  model: providers.llm,   // the fast model that carries the conversation\n  bridge,\n  floor: session,         // so an answer can be spoken outside a turn\n  callId\n})\n","On the call server",[84,1181,1182,1194,1198,1203,1208,1213,1218,1235,1257,1268,1272,1277,1282,1287,1293,1311,1329,1344,1353,1359,1368,1374],{"__ignoreMap":144},[148,1183,1184,1186,1189,1191],{"class":150,"line":151},[148,1185,155],{"class":154},[148,1187,1188],{"class":161}," { NoLagVoice, orchestratedModel, ORCHESTRATOR_ROOM } ",[148,1190,165],{"class":154},[148,1192,1193],{"class":168}," '@nolag/voice'\n",[148,1195,1196],{"class":150,"line":172},[148,1197,176],{"emptyLinePlaceholder":175},[148,1199,1200],{"class":150,"line":179},[148,1201,1202],{"class":182},"// Once, at startup. The orchestrator room is not seeded by the Agents\n",[148,1204,1205],{"class":150,"line":186},[148,1206,1207],{"class":182},"// blueprint and rooms never exist implicitly, so create it before the\n",[148,1209,1210],{"class":150,"line":192},[148,1211,1212],{"class":182},"// process-wide connection authenticates. `processAgents` is one NoLagAgents\n",[148,1214,1215],{"class":150,"line":198},[148,1216,1217],{"class":182},"// for the whole server, not the per-call instance.\n",[148,1219,1220,1222,1225,1228,1230,1233],{"class":150,"line":214},[148,1221,343],{"class":154},[148,1223,1224],{"class":161}," provisioner.",[148,1226,1227],{"class":207},"ensureRoom",[148,1229,369],{"class":161},[148,1231,1232],{"class":235},"ORCHESTRATOR_ROOM",[148,1234,375],{"class":161},[148,1236,1237,1239,1242,1244,1246,1249,1252,1255],{"class":150,"line":227},[148,1238,323],{"class":154},[148,1240,1241],{"class":235}," bridge",[148,1243,329],{"class":154},[148,1245,332],{"class":154},[148,1247,1248],{"class":207}," NoLagVoice",[148,1250,1251],{"class":161},"({ agents: processAgents }).",[148,1253,1254],{"class":207},"orchestrator",[148,1256,352],{"class":161},[148,1258,1259,1261,1264,1266],{"class":150,"line":239},[148,1260,343],{"class":154},[148,1262,1263],{"class":161}," bridge.",[148,1265,349],{"class":207},[148,1267,352],{"class":161},[148,1269,1270],{"class":150,"line":249},[148,1271,176],{"emptyLinePlaceholder":175},[148,1273,1274],{"class":150,"line":259},[148,1275,1276],{"class":182},"// Per call. The wrapper needs the session as its floor and the session needs\n",[148,1278,1279],{"class":150,"line":654},[148,1280,1281],{"class":182},"// its providers, so build the providers object first and swap the model in\n",[148,1283,1284],{"class":150,"line":660},[148,1285,1286],{"class":182},"// after the session exists. The session reads `llm` on every turn, and no\n",[148,1288,1290],{"class":150,"line":1289},14,[148,1291,1292],{"class":182},"// turn can happen before the call has started.\n",[148,1294,1296,1298,1301,1303,1305,1308],{"class":150,"line":1295},15,[148,1297,323],{"class":154},[148,1299,1300],{"class":235}," callProviders",[148,1302,329],{"class":154},[148,1304,550],{"class":161},[148,1306,1307],{"class":154},"...",[148,1309,1310],{"class":161},"providers }\n",[148,1312,1314,1316,1319,1321,1323,1326],{"class":150,"line":1313},16,[148,1315,323],{"class":154},[148,1317,1318],{"class":235}," session",[148,1320,329],{"class":154},[148,1322,332],{"class":154},[148,1324,1325],{"class":207}," VoiceSession",[148,1327,1328],{"class":161},"({ transport, providers: callProviders, systemPrompt })\n",[148,1330,1332,1335,1338,1341],{"class":150,"line":1331},17,[148,1333,1334],{"class":161},"callProviders.llm ",[148,1336,1337],{"class":154},"=",[148,1339,1340],{"class":207}," orchestratedModel",[148,1342,1343],{"class":161},"({\n",[148,1345,1347,1350],{"class":150,"line":1346},18,[148,1348,1349],{"class":161},"  model: providers.llm,   ",[148,1351,1352],{"class":182},"// the fast model that carries the conversation\n",[148,1354,1356],{"class":150,"line":1355},19,[148,1357,1358],{"class":161},"  bridge,\n",[148,1360,1362,1365],{"class":150,"line":1361},20,[148,1363,1364],{"class":161},"  floor: session,         ",[148,1366,1367],{"class":182},"// so an answer can be spoken outside a turn\n",[148,1369,1371],{"class":150,"line":1370},21,[148,1372,1373],{"class":161},"  callId\n",[148,1375,1377],{"class":150,"line":1376},22,[148,1378,853],{"class":161},[138,1380,1383],{"className":140,"code":1381,"filename":1382,"language":143,"meta":144,"style":144},"import { Handoff } from '@nolag/agents'\nimport { ORCHESTRATOR_CAPABILITY, ORCHESTRATOR_ROOM } from '@nolag/voice'\n\nnew Handoff(agents.room(ORCHESTRATOR_ROOM))\n  .onTask([ORCHESTRATOR_CAPABILITY], async (task, respond) => {\n    const { question, context, callId } = task.payload\n    const answer = await yourBigModelWithTools(question, context)\n\n    respond('success', { speech: answer.speech, detail: answer.detail })\n  })\n","On the orchestrator",[84,1384,1385,1395,1406,1410,1428,1463,1491,1509,1513,1526],{"__ignoreMap":144},[148,1386,1387,1389,1391,1393],{"class":150,"line":151},[148,1388,155],{"class":154},[148,1390,492],{"class":161},[148,1392,165],{"class":154},[148,1394,497],{"class":168},[148,1396,1397,1399,1402,1404],{"class":150,"line":172},[148,1398,155],{"class":154},[148,1400,1401],{"class":161}," { ORCHESTRATOR_CAPABILITY, ORCHESTRATOR_ROOM } ",[148,1403,165],{"class":154},[148,1405,1193],{"class":168},[148,1407,1408],{"class":150,"line":179},[148,1409,176],{"emptyLinePlaceholder":175},[148,1411,1412,1414,1416,1419,1422,1424,1426],{"class":150,"line":186},[148,1413,436],{"class":154},[148,1415,537],{"class":207},[148,1417,1418],{"class":161},"(agents.",[148,1420,1421],{"class":207},"room",[148,1423,369],{"class":161},[148,1425,1232],{"class":235},[148,1427,848],{"class":161},[148,1429,1430,1433,1436,1439,1442,1445,1448,1450,1453,1455,1457,1459,1461],{"class":150,"line":192},[148,1431,1432],{"class":161},"  .",[148,1434,1435],{"class":207},"onTask",[148,1437,1438],{"class":161},"([",[148,1440,1441],{"class":235},"ORCHESTRATOR_CAPABILITY",[148,1443,1444],{"class":161},"], ",[148,1446,1447],{"class":154},"async",[148,1449,114],{"class":161},[148,1451,1452],{"class":217},"task",[148,1454,87],{"class":161},[148,1456,790],{"class":217},[148,1458,633],{"class":161},[148,1460,636],{"class":154},[148,1462,211],{"class":161},[148,1464,1465,1468,1470,1473,1475,1478,1480,1483,1486,1488],{"class":150,"line":198},[148,1466,1467],{"class":154},"    const",[148,1469,550],{"class":161},[148,1471,1472],{"class":235},"question",[148,1474,87],{"class":161},[148,1476,1477],{"class":235},"context",[148,1479,87],{"class":161},[148,1481,1482],{"class":235},"callId",[148,1484,1485],{"class":161}," } ",[148,1487,1337],{"class":154},[148,1489,1490],{"class":161}," task.payload\n",[148,1492,1493,1495,1498,1500,1503,1506],{"class":150,"line":214},[148,1494,1467],{"class":154},[148,1496,1497],{"class":235}," answer",[148,1499,329],{"class":154},[148,1501,1502],{"class":154}," await",[148,1504,1505],{"class":207}," yourBigModelWithTools",[148,1507,1508],{"class":161},"(question, context)\n",[148,1510,1511],{"class":150,"line":227},[148,1512,176],{"emptyLinePlaceholder":175},[148,1514,1515,1518,1520,1523],{"class":150,"line":239},[148,1516,1517],{"class":207},"    respond",[148,1519,369],{"class":161},[148,1521,1522],{"class":168},"'success'",[148,1524,1525],{"class":161},", { speech: answer.speech, detail: answer.detail })\n",[148,1527,1528],{"class":150,"line":249},[148,1529,1530],{"class":161},"  })\n",[12,1532,1533,1534,1537],{},"The ",[84,1535,1536],{},"speech"," field is read to the caller and may be heard a turn or two after the question, so it has to stand on its own. \"The 1:15 is free\" is not enough. \"About that pickup, the 1:15 is free\" is.",[35,1539,1541],{"id":1540},"why-the-ask-is-text-and-not-a-function-call","Why the ask is text and not a function call",[12,1543,1544,1547],{},[84,1545,1546],{},"orchestratedModel"," has the conversational model ask in-band, by replying with a marker that gets filtered out of what is spoken, rather than through a function-calling API. That looks like a shortcut until you look at the clock again.",[12,1549,1550],{},"The voice engine streams. Synthesis of the first finished sentence starts while the model is still writing, and that head start is most of what makes an agent feel responsive. A function-calling round trip has to complete before you know whether it was a tool call or an answer, which throws that head start away on every single turn, including the overwhelming majority that never need help at all.",[12,1552,1553],{},"In-band, a turn that does not ask costs exactly nothing. It also works with providers that have no tool-calling API. The cost is that a small model occasionally garbles the marker, which fails safe: an unrecognised marker is stripped from speech rather than read aloud.",[35,1555,1557],{"id":1556},"scaling-the-orchestrator","Scaling the orchestrator",[12,1559,1560],{},"Orchestrators are the expensive half, so they are the half you scale. NoLag distributes work across a pool with load-balanced subscriptions, where each task reaches exactly one member of the group.",[135,1562,1563],{},[138,1564,1566],{"className":140,"code":1565,"filename":142,"language":143,"meta":144,"style":144},"import { orchestratorPoolOptions } from '@nolag/voice'\n\nconst client = NoLag(token, { url, ...orchestratorPoolOptions() })\n",[84,1567,1568,1579,1583],{"__ignoreMap":144},[148,1569,1570,1572,1575,1577],{"class":150,"line":151},[148,1571,155],{"class":154},[148,1573,1574],{"class":161}," { orchestratorPoolOptions } ",[148,1576,165],{"class":154},[148,1578,1193],{"class":168},[148,1580,1581],{"class":150,"line":172},[148,1582,176],{"emptyLinePlaceholder":175},[148,1584,1585,1587,1590,1592,1595,1598,1600,1603],{"class":150,"line":179},[148,1586,323],{"class":154},[148,1588,1589],{"class":235}," client",[148,1591,329],{"class":154},[148,1593,1594],{"class":207}," NoLag",[148,1596,1597],{"class":161},"(token, { url, ",[148,1599,1307],{"class":154},[148,1601,1602],{"class":207},"orchestratorPoolOptions",[148,1604,1605],{"class":161},"() })\n",[12,1607,1608],{},"Two details are worth knowing, because both fail quietly rather than loudly.",[12,1610,1611,1612,1615],{},"The load-balance group defaults to the actor token id, so replicas holding different tokens each form a group of one and each still receive a copy. That means every replica runs the same expensive inference and several answers race back to the same call. The only symptom is the bill, which is why ",[84,1613,1614],{},"orchestratorPoolOptions()"," exists: it sets both halves.",[12,1617,1618],{},"And every process needs its own actor token. The broker never delivers a message back to the actor that published it, so an orchestrator sharing a token with the call server never sees a task at all. Everything else looks healthy: both connect, both appear in presence, the capability is discovered exactly as it should be. The asks simply time out.",[12,1620,1621],{},"In our own testing, a single run rather than a benchmark, a two replica pool split eight tasks four and four with no duplicates, and when one replica was taken away the survivor picked up all of the next batch.",[35,1623,1625],{"id":1624},"watching-a-call-while-it-happens","Watching a call while it happens",[12,1627,1628],{},"Because the call is a room, anything with access can join it. A supervisor dashboard can stream the transcript turn by turn, with per-leg latency, and send a line back that the agent speaks to the caller seconds later.",[135,1630,1631],{},[138,1632,1634],{"className":140,"code":1633,"filename":142,"language":143,"meta":144,"style":144},"const watcher = voice.watchCall(callId, {\n  onTranscript: (line) => render(line.role, line.text),\n  onEvent: (event) => {\n    if (event.event === 'turn-complete') showLatency(event.totalMs)\n  }\n})\n\nwatcher.say('Let them know we can hold the car until two.')\nwatcher.instruct('Only discuss today\\'s booking.')\n",[84,1635,1636,1654,1674,1690,1712,1717,1721,1725,1740],{"__ignoreMap":144},[148,1637,1638,1640,1643,1645,1648,1651],{"class":150,"line":151},[148,1639,323],{"class":154},[148,1641,1642],{"class":235}," watcher",[148,1644,329],{"class":154},[148,1646,1647],{"class":161}," voice.",[148,1649,1650],{"class":207},"watchCall",[148,1652,1653],{"class":161},"(callId, {\n",[148,1655,1656,1659,1662,1664,1666,1668,1671],{"class":150,"line":172},[148,1657,1658],{"class":207},"  onTranscript",[148,1660,1661],{"class":161},": (",[148,1663,150],{"class":217},[148,1665,633],{"class":161},[148,1667,636],{"class":154},[148,1669,1670],{"class":207}," render",[148,1672,1673],{"class":161},"(line.role, line.text),\n",[148,1675,1676,1679,1681,1684,1686,1688],{"class":150,"line":179},[148,1677,1678],{"class":207},"  onEvent",[148,1680,1661],{"class":161},[148,1682,1683],{"class":217},"event",[148,1685,633],{"class":161},[148,1687,636],{"class":154},[148,1689,211],{"class":161},[148,1691,1692,1695,1698,1701,1704,1706,1709],{"class":150,"line":186},[148,1693,1694],{"class":154},"    if",[148,1696,1697],{"class":161}," (event.event ",[148,1699,1700],{"class":154},"===",[148,1702,1703],{"class":168}," 'turn-complete'",[148,1705,633],{"class":161},[148,1707,1708],{"class":207},"showLatency",[148,1710,1711],{"class":161},"(event.totalMs)\n",[148,1713,1714],{"class":150,"line":192},[148,1715,1716],{"class":161},"  }\n",[148,1718,1719],{"class":150,"line":198},[148,1720,853],{"class":161},[148,1722,1723],{"class":150,"line":214},[148,1724,176],{"emptyLinePlaceholder":175},[148,1726,1727,1730,1733,1735,1738],{"class":150,"line":227},[148,1728,1729],{"class":161},"watcher.",[148,1731,1732],{"class":207},"say",[148,1734,369],{"class":161},[148,1736,1737],{"class":168},"'Let them know we can hold the car until two.'",[148,1739,375],{"class":161},[148,1741,1742,1744,1747,1749,1752,1755,1758],{"class":150,"line":239},[148,1743,1729],{"class":161},[148,1745,1746],{"class":207},"instruct",[148,1748,369],{"class":161},[148,1750,1751],{"class":168},"'Only discuss today",[148,1753,1754],{"class":235},"\\'",[148,1756,1757],{"class":168},"s booking.'",[148,1759,375],{"class":161},[12,1761,1762,1764,1765,1767],{},[84,1763,1732],{}," is spoken to the caller immediately. ",[84,1766,1746],{}," is never spoken: it is guidance the model sees from its next turn onward, which is what you want for \"keep it brief\" or \"stop offering refunds\".",[12,1769,1770],{},"There is one rule that catches everyone here. A watcher needs its own actor token, because the broker never delivers a message back to the actor that published it. A dashboard sharing the call's token connects perfectly happily and then displays nothing at all.",[35,1772,1774],{"id":1773},"building-without-a-phone-number","Building without a phone number",[12,1776,1777],{},"Iterating on turn taking through real phone calls is slow and costs money every attempt. The voice engine ships a browser simulator that speaks the same Media Streams protocol a carrier does, at the same endpoint, so the session cannot tell it apart from a real call.",[12,1779,1780],{},"That matters more than convenience. Barge-in and turn taking cannot be checked from a unit test, because they are timing behaviour between a person and a stream. Being able to talk over your agent in a browser tab, twenty times in a row, is how you find out that it stops when you interrupt it.",[35,1782,1784],{"id":1783},"what-to-read-next","What to read next",[882,1786,1787,1794,1801],{},[885,1788,1789,1793],{},[952,1790,1791],{"href":1053},[84,1792,1056],{}," for the real-time half: turn taking, barge-in, filler speech, call screener and voicemail handling, recording",[885,1795,1796,1800],{},[952,1797,1798],{"href":1069},[84,1799,1072],{}," for the coordination half: the room, the orchestrator bridge, supervisor steering",[885,1802,1803,1807,1808,87,1810,1812,1813],{},[952,1804,1805],{"href":966},[84,1806,90],{}," for the six coordination patterns the orchestrator side is built from, including ",[84,1809,117],{},[84,1811,123],{}," and ",[84,1814,126],{},[12,1816,1817],{},"Together they are a complete voice agent: fast reflexes on the phone, real capability behind it, and a caller who is told the truth about how long something is taking.",[969,1819,1820],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":144,"searchDepth":172,"depth":172,"links":1822},[1823,1824,1825,1826,1827,1828,1829,1830,1831],{"id":1007,"depth":172,"text":1008},{"id":1023,"depth":172,"text":1024},{"id":1098,"depth":172,"text":1099},{"id":1166,"depth":172,"text":1167},{"id":1540,"depth":172,"text":1541},{"id":1556,"depth":172,"text":1557},{"id":1624,"depth":172,"text":1625},{"id":1773,"depth":172,"text":1774},{"id":1783,"depth":172,"text":1784},"AI Agents","A voice agent has about a second to reply, so the model answering the phone is small and has no tools. Here is how to give it real capability over NoLag: a Twilio call becomes a coordination room, and a larger orchestrator answers asynchronously while the caller keeps talking.",{},"/blog/voice-agent-with-tools-twilio","12 min read",{"title":996,"description":1833},"blog/voice-agent-with-tools-twilio","v86xl0GX6MuNgaoNJEbHIPynkD2RrfmJugxwi791yU0",{"id":1841,"title":1842,"author":7,"body":1843,"category":2282,"date":2283,"description":2284,"excerpt":986,"extension":987,"meta":2285,"navigation":175,"path":2286,"readTime":2287,"seo":2288,"stem":2289,"__hash__":2290},"blog/blog/coordinate-langchain-agents-with-nolag.md","Coordinate LangChain Agents with NoLag",{"type":9,"value":1844,"toc":2273},[1845,1848,1850,1882,1885,1889,1913,1917,2129,2140,2144,2147,2213,2219,2223,2238,2242,2245,2249,2270],[12,1846,1847],{},"LangChain is excellent at building a single agent: prompts, tools, memory, and chains. What it does not give you is the layer above a single agent, where several agents and humans coordinate: dispatching work, sharing state, and gating actions. That is what NoLag provides. This guide connects a LangChain worker to a NoLag room so you can fan work out and collect results in realtime.",[35,1849,38],{"id":37},[882,1851,1852,1863,1876],{},[885,1853,1854,1855,1858,1859,1862],{},"A ",[30,1856,1857],{},"dispatcher"," publishes tasks to a ",[84,1860,1861],{},"tasks"," topic.",[885,1864,1865,1866,1869,1870,1872,1873,611],{},"One or more ",[30,1867,1868],{},"workers",", each wrapping a LangChain chain, subscribe to ",[84,1871,1861],{},", run the chain, and publish to ",[84,1874,1875],{},"results",[885,1877,1878,1879,1881],{},"Anything can watch ",[84,1880,1875],{},": a UI, a supervisor agent, or a human.",[12,1883,1884],{},"Two platform rules shape the code below. A publishing actor never receives its own messages, so the dispatcher and each worker connect with their own actor token. And every subscriber to a topic receives every message unless it opts into load balancing, so workers that should share a queue join a load-balance group.",[35,1886,1888],{"id":1887},"_1-install","1. Install",[138,1890,1894],{"className":1891,"code":1892,"language":1893,"meta":144,"style":144},"language-bash shiki shiki-themes github-light github-dark","pip install nolag langchain langchain-openai\n","bash",[84,1895,1896],{"__ignoreMap":144},[148,1897,1898,1901,1904,1907,1910],{"class":150,"line":151},[148,1899,1900],{"class":207},"pip",[148,1902,1903],{"class":168}," install",[148,1905,1906],{"class":168}," nolag",[148,1908,1909],{"class":168}," langchain",[148,1911,1912],{"class":168}," langchain-openai\n",[35,1914,1916],{"id":1915},"_2-a-langchain-worker-on-a-nolag-room","2. A LangChain worker on a NoLag room",[138,1918,1922],{"className":1919,"code":1920,"language":1921,"meta":144,"style":144},"language-python shiki shiki-themes github-light github-dark","import asyncio\nfrom nolag import NoLag, NoLagOptions\nfrom langchain_openai import ChatOpenAI\nfrom langchain_core.prompts import ChatPromptTemplate\n\n# Build the LangChain agent (a simple summariser here)\nllm = ChatOpenAI(model=\"gpt-4o-mini\")\nprompt = ChatPromptTemplate.from_template(\"Summarise this in one sentence:\\n\\n{input}\")\nchain = prompt | llm\n\nasync def main():\n    # Every worker in the \"summarisers\" group shares the queue: each task\n    # goes to one of them instead of all of them.\n    client = NoLag(WORKER_TOKEN, NoLagOptions(\n        load_balance=True,\n        load_balance_group=\"summarisers\",\n    ))\n    await client.connect()\n\n    # Create the app and the \"workflow\" room first; the slug you get back\n    # has a random suffix and that suffixed slug is what set_app() takes.\n    room = client.set_app(APP_SLUG).set_room(\"workflow\")\n    await room.subscribe(\"tasks\")\n\n    def on_task(data, meta):\n        async def run():\n            result = await chain.ainvoke({\"input\": data[\"input\"]})\n            await room.emit(\"results\", {\n                \"task_id\": data[\"task_id\"],\n                \"output\": result.content,\n            })\n        asyncio.create_task(run())\n\n    room.on(\"tasks\", on_task)\n\n    # Keep the worker alive\n    await asyncio.Event().wait()\n\nasyncio.run(main())\n","python",[84,1923,1924,1929,1934,1939,1944,1948,1953,1958,1963,1968,1972,1977,1982,1987,1992,1997,2002,2007,2012,2016,2021,2026,2031,2037,2042,2048,2054,2060,2066,2072,2078,2084,2090,2095,2101,2106,2112,2118,2123],{"__ignoreMap":144},[148,1925,1926],{"class":150,"line":151},[148,1927,1928],{},"import asyncio\n",[148,1930,1931],{"class":150,"line":172},[148,1932,1933],{},"from nolag import NoLag, NoLagOptions\n",[148,1935,1936],{"class":150,"line":179},[148,1937,1938],{},"from langchain_openai import ChatOpenAI\n",[148,1940,1941],{"class":150,"line":186},[148,1942,1943],{},"from langchain_core.prompts import ChatPromptTemplate\n",[148,1945,1946],{"class":150,"line":192},[148,1947,176],{"emptyLinePlaceholder":175},[148,1949,1950],{"class":150,"line":198},[148,1951,1952],{},"# Build the LangChain agent (a simple summariser here)\n",[148,1954,1955],{"class":150,"line":214},[148,1956,1957],{},"llm = ChatOpenAI(model=\"gpt-4o-mini\")\n",[148,1959,1960],{"class":150,"line":227},[148,1961,1962],{},"prompt = ChatPromptTemplate.from_template(\"Summarise this in one sentence:\\n\\n{input}\")\n",[148,1964,1965],{"class":150,"line":239},[148,1966,1967],{},"chain = prompt | llm\n",[148,1969,1970],{"class":150,"line":249},[148,1971,176],{"emptyLinePlaceholder":175},[148,1973,1974],{"class":150,"line":259},[148,1975,1976],{},"async def main():\n",[148,1978,1979],{"class":150,"line":654},[148,1980,1981],{},"    # Every worker in the \"summarisers\" group shares the queue: each task\n",[148,1983,1984],{"class":150,"line":660},[148,1985,1986],{},"    # goes to one of them instead of all of them.\n",[148,1988,1989],{"class":150,"line":1289},[148,1990,1991],{},"    client = NoLag(WORKER_TOKEN, NoLagOptions(\n",[148,1993,1994],{"class":150,"line":1295},[148,1995,1996],{},"        load_balance=True,\n",[148,1998,1999],{"class":150,"line":1313},[148,2000,2001],{},"        load_balance_group=\"summarisers\",\n",[148,2003,2004],{"class":150,"line":1331},[148,2005,2006],{},"    ))\n",[148,2008,2009],{"class":150,"line":1346},[148,2010,2011],{},"    await client.connect()\n",[148,2013,2014],{"class":150,"line":1355},[148,2015,176],{"emptyLinePlaceholder":175},[148,2017,2018],{"class":150,"line":1361},[148,2019,2020],{},"    # Create the app and the \"workflow\" room first; the slug you get back\n",[148,2022,2023],{"class":150,"line":1370},[148,2024,2025],{},"    # has a random suffix and that suffixed slug is what set_app() takes.\n",[148,2027,2028],{"class":150,"line":1376},[148,2029,2030],{},"    room = client.set_app(APP_SLUG).set_room(\"workflow\")\n",[148,2032,2034],{"class":150,"line":2033},23,[148,2035,2036],{},"    await room.subscribe(\"tasks\")\n",[148,2038,2040],{"class":150,"line":2039},24,[148,2041,176],{"emptyLinePlaceholder":175},[148,2043,2045],{"class":150,"line":2044},25,[148,2046,2047],{},"    def on_task(data, meta):\n",[148,2049,2051],{"class":150,"line":2050},26,[148,2052,2053],{},"        async def run():\n",[148,2055,2057],{"class":150,"line":2056},27,[148,2058,2059],{},"            result = await chain.ainvoke({\"input\": data[\"input\"]})\n",[148,2061,2063],{"class":150,"line":2062},28,[148,2064,2065],{},"            await room.emit(\"results\", {\n",[148,2067,2069],{"class":150,"line":2068},29,[148,2070,2071],{},"                \"task_id\": data[\"task_id\"],\n",[148,2073,2075],{"class":150,"line":2074},30,[148,2076,2077],{},"                \"output\": result.content,\n",[148,2079,2081],{"class":150,"line":2080},31,[148,2082,2083],{},"            })\n",[148,2085,2087],{"class":150,"line":2086},32,[148,2088,2089],{},"        asyncio.create_task(run())\n",[148,2091,2093],{"class":150,"line":2092},33,[148,2094,176],{"emptyLinePlaceholder":175},[148,2096,2098],{"class":150,"line":2097},34,[148,2099,2100],{},"    room.on(\"tasks\", on_task)\n",[148,2102,2104],{"class":150,"line":2103},35,[148,2105,176],{"emptyLinePlaceholder":175},[148,2107,2109],{"class":150,"line":2108},36,[148,2110,2111],{},"    # Keep the worker alive\n",[148,2113,2115],{"class":150,"line":2114},37,[148,2116,2117],{},"    await asyncio.Event().wait()\n",[148,2119,2121],{"class":150,"line":2120},38,[148,2122,176],{"emptyLinePlaceholder":175},[148,2124,2126],{"class":150,"line":2125},39,[148,2127,2128],{},"asyncio.run(main())\n",[12,2130,2131,2132,2135,2136,2139],{},"Start this process on as many machines as you like. Each instance is another member of the ",[84,2133,2134],{},"summarisers"," group, and the broker hands each task to one of them. Leave ",[84,2137,2138],{},"load_balance"," off and every instance would run every task.",[35,2141,2143],{"id":2142},"_3-dispatch-work","3. Dispatch work",[12,2145,2146],{},"Any authorised client can publish a task. Use a different actor token from the workers, or the dispatcher will never see the results it is waiting for:",[138,2148,2150],{"className":1919,"code":2149,"language":1921,"meta":144,"style":144},"from nolag import NoLag\n\nclient = NoLag(DISPATCHER_TOKEN)\nawait client.connect()\nroom = client.set_app(APP_SLUG).set_room(\"workflow\")\n\nawait room.subscribe(\"results\")\nroom.on(\"results\", lambda data, meta: print(\"done:\", data[\"task_id\"], data[\"output\"]))\n\nawait room.emit(\"tasks\", {\n    \"task_id\": \"t-001\",\n    \"input\": \"NoLag is realtime messaging infrastructure with a coordination layer for agents.\",\n})\n",[84,2151,2152,2157,2161,2166,2171,2176,2180,2185,2190,2194,2199,2204,2209],{"__ignoreMap":144},[148,2153,2154],{"class":150,"line":151},[148,2155,2156],{},"from nolag import NoLag\n",[148,2158,2159],{"class":150,"line":172},[148,2160,176],{"emptyLinePlaceholder":175},[148,2162,2163],{"class":150,"line":179},[148,2164,2165],{},"client = NoLag(DISPATCHER_TOKEN)\n",[148,2167,2168],{"class":150,"line":186},[148,2169,2170],{},"await client.connect()\n",[148,2172,2173],{"class":150,"line":192},[148,2174,2175],{},"room = client.set_app(APP_SLUG).set_room(\"workflow\")\n",[148,2177,2178],{"class":150,"line":198},[148,2179,176],{"emptyLinePlaceholder":175},[148,2181,2182],{"class":150,"line":214},[148,2183,2184],{},"await room.subscribe(\"results\")\n",[148,2186,2187],{"class":150,"line":227},[148,2188,2189],{},"room.on(\"results\", lambda data, meta: print(\"done:\", data[\"task_id\"], data[\"output\"]))\n",[148,2191,2192],{"class":150,"line":239},[148,2193,176],{"emptyLinePlaceholder":175},[148,2195,2196],{"class":150,"line":249},[148,2197,2198],{},"await room.emit(\"tasks\", {\n",[148,2200,2201],{"class":150,"line":259},[148,2202,2203],{},"    \"task_id\": \"t-001\",\n",[148,2205,2206],{"class":150,"line":654},[148,2207,2208],{},"    \"input\": \"NoLag is realtime messaging infrastructure with a coordination layer for agents.\",\n",[148,2210,2211],{"class":150,"line":660},[148,2212,853],{},[12,2214,2215,2216,2218],{},"Results arrive on the ",[84,2217,1875],{}," topic for anyone subscribed, so a dashboard or a supervising agent can react as they land.",[35,2220,2222],{"id":2221},"keeping-a-human-in-the-loop","Keeping a human in the loop",[12,2224,2225,2226,2229,2230,2233,2234,2237],{},"For steps that need sign-off, NoLag's coordination patterns include ",[30,2227,2228],{},"approval gates",": an agent publishes a proposed action, a human approves or rejects it from a UI, and the agent proceeds only on approval. The ",[952,2231,2232],{"href":966},"AI agents guide"," covers approval gates, shared blackboard state, and observability in depth. For higher-level Python ergonomics, the ",[84,2235,2236],{},"nolag-agents"," package wraps these patterns so you do not hand-roll the topics.",[35,2239,2241],{"id":2240},"why-coordinate-over-pubsub","Why coordinate over pub/sub",[12,2243,2244],{},"Direct orchestration, where one process calls each agent in sequence, is easy to start and hard to scale: it couples your agents together and hides what is happening. A realtime coordination layer decouples dispatch from execution, lets you add workers freely, and gives you a single stream to observe every decision. LangChain builds the agent; NoLag coordinates the system.",[35,2246,2248],{"id":2247},"next-steps","Next steps",[882,2250,2251,2257,2264],{},[885,2252,2253,2254,611],{},"Read ",[952,2255,2256],{"href":954},"why multi-agent systems need a coordination layer",[885,2258,2259,2260,611],{},"Start with the ",[952,2261,2263],{"href":2262},"/docs/getting-started","5-minute quick start",[885,2265,2266,2267,611],{},"Go deeper on the six ",[952,2268,2269],{"href":966},"coordination patterns",[969,2271,2272],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":144,"searchDepth":172,"depth":172,"links":2274},[2275,2276,2277,2278,2279,2280,2281],{"id":37,"depth":172,"text":38},{"id":1887,"depth":172,"text":1888},{"id":1915,"depth":172,"text":1916},{"id":2142,"depth":172,"text":2143},{"id":2221,"depth":172,"text":2222},{"id":2240,"depth":172,"text":2241},{"id":2247,"depth":172,"text":2248},"Integration","2026-08-02","Use NoLag as the coordination layer for LangChain agents. Dispatch tasks to workers, collect results, and keep a human in the loop, over realtime pub/sub.",{},"/blog/coordinate-langchain-agents-with-nolag","8 min read",{"title":1842,"description":2284},"blog/coordinate-langchain-agents-with-nolag","qiR81OP9FrEABwnnEFtjRIAy8xNO4ZTIQo8gRA5_Xlk",{"id":2292,"title":2293,"author":7,"body":2294,"category":2898,"date":2899,"description":2900,"excerpt":986,"extension":987,"meta":2901,"navigation":175,"path":2902,"readTime":2903,"seo":2904,"stem":2905,"__hash__":2906},"blog/blog/migrate-from-firebase-to-nolag.md","Migrate from Firebase to NoLag",{"type":9,"value":2295,"toc":2889},[2296,2299,2303,2314,2322,2326,2352,2356,2429,2433,2558,2562,2823,2826,2830,2866,2868,2886],[12,2297,2298],{},"Firebase Realtime Database and Firestore give you realtime by syncing stored documents and firing client listeners when the data changes. NoLag takes a different approach: explicit publish and subscribe over topics. That difference matters for migration, so let us be clear up front about what to move and what to keep.",[35,2300,2302],{"id":2301},"what-to-migrate-and-what-not-to","What to migrate, and what not to",[12,2304,2305,2306,2309,2310,2313],{},"NoLag is a ",[30,2307,2308],{},"messaging layer, not a database",". If you use Firebase purely as a realtime signal, presence, or message bus, NoLag replaces that cleanly and gives you more control over delivery. If you use Firebase as your ",[30,2311,2312],{},"system of record",", keep a database. Migrate the realtime and pub/sub usage to NoLag, and let your data live wherever suits you. Many teams end up with NoLag for the live layer and a database of their choice behind it.",[12,2315,2316,2317,2321],{},"This also answers the question every Firebase team asks first: \"what does a client see when it reconnects?\" In Firebase, the current document. In NoLag, its subscriptions are restored and it receives what is published from then on; nothing is replayed and a fresh subscription gets no history. The database you keep is where \"current state\" lives, and the client reads it on connect. (The broker does replay for load-balanced worker groups with persistent sessions, which is a different job; see ",[952,2318,2320],{"href":2319},"/docs/concepts/replay","Replay and Durable Delivery",".)",[35,2323,2325],{"id":2324},"why-teams-move-the-realtime-layer","Why teams move the realtime layer",[882,2327,2328,2334,2340,2346],{},[885,2329,2330,2333],{},[30,2331,2332],{},"Explicit pub/sub"," instead of modelling every realtime feature as a change on a stored document.",[885,2335,2336,2339],{},[30,2337,2338],{},"Database-agnostic",": add realtime to any stack rather than standardising on Firestore.",[885,2341,2342,2345],{},[30,2343,2344],{},"Blueprint SDKs"," for chat, notifications, dashboards, and tracking.",[885,2347,2348,2351],{},[30,2349,2350],{},"A coordination layer for AI agents"," on the same platform.",[35,2353,2355],{"id":2354},"concept-map","Concept map",[40,2357,2358,2368],{},[43,2359,2360],{},[46,2361,2362,2365],{},[49,2363,2364],{},"Firebase",[49,2366,2367],{},"NoLag",[59,2369,2370,2378,2395,2413,2421],{},[46,2371,2372,2375],{},[64,2373,2374],{},"Realtime Database ref / Firestore collection",[64,2376,2377],{},"Room + topic",[46,2379,2380,2390],{},[64,2381,2382,2385,2386,2389],{},[84,2383,2384],{},"onValue"," / ",[84,2387,2388],{},"onSnapshot"," listener",[64,2391,2392],{},[84,2393,2394],{},"room.on('topic', cb)",[46,2396,2397,2408],{},[64,2398,2399,2385,2402,2385,2405],{},[84,2400,2401],{},"set",[84,2403,2404],{},"update",[84,2406,2407],{},"push",[64,2409,2410],{},[84,2411,2412],{},"room.emit('topic', data)",[46,2414,2415,2418],{},[64,2416,2417],{},"Security rules",[64,2419,2420],{},"Actors, access tokens, per-topic ACL",[46,2422,2423,2426],{},[64,2424,2425],{},"Presence via connection state",[64,2427,2428],{},"Presence (per room)",[35,2430,2432],{"id":2431},"before-firebase-realtime-database","Before: Firebase Realtime Database",[138,2434,2438],{"className":2435,"code":2436,"language":2437,"meta":144,"style":144},"language-js shiki shiki-themes github-light github-dark","import { getDatabase, ref, onValue, push } from 'firebase/database'\n\nconst db = getDatabase()\nconst messagesRef = ref(db, 'chat/general/messages')\n\nonValue(messagesRef, (snapshot) => {\n  console.log('Data changed:', snapshot.val())\n})\n\npush(messagesRef, { text: 'Hello!', sender: 'user-123' })\n","js",[84,2439,2440,2452,2456,2470,2490,2494,2510,2532,2536,2540],{"__ignoreMap":144},[148,2441,2442,2444,2447,2449],{"class":150,"line":151},[148,2443,155],{"class":154},[148,2445,2446],{"class":161}," { getDatabase, ref, onValue, push } ",[148,2448,165],{"class":154},[148,2450,2451],{"class":168}," 'firebase/database'\n",[148,2453,2454],{"class":150,"line":172},[148,2455,176],{"emptyLinePlaceholder":175},[148,2457,2458,2460,2463,2465,2468],{"class":150,"line":179},[148,2459,323],{"class":154},[148,2461,2462],{"class":235}," db",[148,2464,329],{"class":154},[148,2466,2467],{"class":207}," getDatabase",[148,2469,352],{"class":161},[148,2471,2472,2474,2477,2479,2482,2485,2488],{"class":150,"line":186},[148,2473,323],{"class":154},[148,2475,2476],{"class":235}," messagesRef",[148,2478,329],{"class":154},[148,2480,2481],{"class":207}," ref",[148,2483,2484],{"class":161},"(db, ",[148,2486,2487],{"class":168},"'chat/general/messages'",[148,2489,375],{"class":161},[148,2491,2492],{"class":150,"line":192},[148,2493,176],{"emptyLinePlaceholder":175},[148,2495,2496,2498,2501,2504,2506,2508],{"class":150,"line":198},[148,2497,2384],{"class":207},[148,2499,2500],{"class":161},"(messagesRef, (",[148,2502,2503],{"class":217},"snapshot",[148,2505,633],{"class":161},[148,2507,636],{"class":154},[148,2509,211],{"class":161},[148,2511,2512,2515,2518,2520,2523,2526,2529],{"class":150,"line":214},[148,2513,2514],{"class":161},"  console.",[148,2516,2517],{"class":207},"log",[148,2519,369],{"class":161},[148,2521,2522],{"class":168},"'Data changed:'",[148,2524,2525],{"class":161},", snapshot.",[148,2527,2528],{"class":207},"val",[148,2530,2531],{"class":161},"())\n",[148,2533,2534],{"class":150,"line":227},[148,2535,853],{"class":161},[148,2537,2538],{"class":150,"line":239},[148,2539,176],{"emptyLinePlaceholder":175},[148,2541,2542,2544,2547,2549,2552,2555],{"class":150,"line":249},[148,2543,2407],{"class":207},[148,2545,2546],{"class":161},"(messagesRef, { text: ",[148,2548,388],{"class":168},[148,2550,2551],{"class":161},", sender: ",[148,2553,2554],{"class":168},"'user-123'",[148,2556,2557],{"class":161}," })\n",[35,2559,2561],{"id":2560},"after-nolag","After: NoLag",[138,2563,2567],{"className":2564,"code":2565,"language":2566,"meta":144,"style":144},"language-ts shiki shiki-themes github-light github-dark","import { NoLag } from '@nolag/js-sdk'\n\n// Create the app and its `general` room in the control plane first. The app\n// slug you get back has a random suffix (for example `chat-a3f9`): that is\n// what you pass to setApp(). Room slugs are kept verbatim.\nconst APP_SLUG = 'chat-a3f9'\n\n// One actor listens...\nconst receiver = NoLag('receiver_access_token')\nawait receiver.connect()\n\nconst inbox = receiver.setApp(APP_SLUG).setRoom('general')\ninbox.subscribe('messages')\ninbox.on('messages', (data) => {\n  console.log('Message received:', data)\n})\n\n// ...and another publishes. Publishers never receive their own messages,\n// so the sender appends its own message to the UI locally.\nconst sender = NoLag('sender_access_token')\nawait sender.connect()\nsender.setApp(APP_SLUG).setRoom('general').emit('messages', { text: 'Hello!', sender: 'user-123' })\n","ts",[84,2568,2569,2580,2584,2589,2594,2599,2611,2615,2620,2638,2650,2654,2685,2700,2723,2737,2741,2745,2750,2755,2773,2784],{"__ignoreMap":144},[148,2570,2571,2573,2576,2578],{"class":150,"line":151},[148,2572,155],{"class":154},[148,2574,2575],{"class":161}," { NoLag } ",[148,2577,165],{"class":154},[148,2579,169],{"class":168},[148,2581,2582],{"class":150,"line":172},[148,2583,176],{"emptyLinePlaceholder":175},[148,2585,2586],{"class":150,"line":179},[148,2587,2588],{"class":182},"// Create the app and its `general` room in the control plane first. The app\n",[148,2590,2591],{"class":150,"line":186},[148,2592,2593],{"class":182},"// slug you get back has a random suffix (for example `chat-a3f9`): that is\n",[148,2595,2596],{"class":150,"line":192},[148,2597,2598],{"class":182},"// what you pass to setApp(). Room slugs are kept verbatim.\n",[148,2600,2601,2603,2606,2608],{"class":150,"line":198},[148,2602,323],{"class":154},[148,2604,2605],{"class":235}," APP_SLUG",[148,2607,329],{"class":154},[148,2609,2610],{"class":168}," 'chat-a3f9'\n",[148,2612,2613],{"class":150,"line":214},[148,2614,176],{"emptyLinePlaceholder":175},[148,2616,2617],{"class":150,"line":227},[148,2618,2619],{"class":182},"// One actor listens...\n",[148,2621,2622,2624,2627,2629,2631,2633,2636],{"class":150,"line":239},[148,2623,323],{"class":154},[148,2625,2626],{"class":235}," receiver",[148,2628,329],{"class":154},[148,2630,1594],{"class":207},[148,2632,369],{"class":161},[148,2634,2635],{"class":168},"'receiver_access_token'",[148,2637,375],{"class":161},[148,2639,2640,2642,2645,2648],{"class":150,"line":249},[148,2641,343],{"class":154},[148,2643,2644],{"class":161}," receiver.",[148,2646,2647],{"class":207},"connect",[148,2649,352],{"class":161},[148,2651,2652],{"class":150,"line":259},[148,2653,176],{"emptyLinePlaceholder":175},[148,2655,2656,2658,2661,2663,2665,2668,2670,2673,2676,2679,2681,2683],{"class":150,"line":654},[148,2657,323],{"class":154},[148,2659,2660],{"class":235}," inbox",[148,2662,329],{"class":154},[148,2664,2644],{"class":161},[148,2666,2667],{"class":207},"setApp",[148,2669,369],{"class":161},[148,2671,2672],{"class":235},"APP_SLUG",[148,2674,2675],{"class":161},").",[148,2677,2678],{"class":207},"setRoom",[148,2680,369],{"class":161},[148,2682,372],{"class":168},[148,2684,375],{"class":161},[148,2686,2687,2690,2693,2695,2698],{"class":150,"line":660},[148,2688,2689],{"class":161},"inbox.",[148,2691,2692],{"class":207},"subscribe",[148,2694,369],{"class":161},[148,2696,2697],{"class":168},"'messages'",[148,2699,375],{"class":161},[148,2701,2702,2704,2707,2709,2711,2714,2717,2719,2721],{"class":150,"line":1289},[148,2703,2689],{"class":161},[148,2705,2706],{"class":207},"on",[148,2708,369],{"class":161},[148,2710,2697],{"class":168},[148,2712,2713],{"class":161},", (",[148,2715,2716],{"class":217},"data",[148,2718,633],{"class":161},[148,2720,636],{"class":154},[148,2722,211],{"class":161},[148,2724,2725,2727,2729,2731,2734],{"class":150,"line":1295},[148,2726,2514],{"class":161},[148,2728,2517],{"class":207},[148,2730,369],{"class":161},[148,2732,2733],{"class":168},"'Message received:'",[148,2735,2736],{"class":161},", data)\n",[148,2738,2739],{"class":150,"line":1313},[148,2740,853],{"class":161},[148,2742,2743],{"class":150,"line":1331},[148,2744,176],{"emptyLinePlaceholder":175},[148,2746,2747],{"class":150,"line":1346},[148,2748,2749],{"class":182},"// ...and another publishes. Publishers never receive their own messages,\n",[148,2751,2752],{"class":150,"line":1355},[148,2753,2754],{"class":182},"// so the sender appends its own message to the UI locally.\n",[148,2756,2757,2759,2762,2764,2766,2768,2771],{"class":150,"line":1361},[148,2758,323],{"class":154},[148,2760,2761],{"class":235}," sender",[148,2763,329],{"class":154},[148,2765,1594],{"class":207},[148,2767,369],{"class":161},[148,2769,2770],{"class":168},"'sender_access_token'",[148,2772,375],{"class":161},[148,2774,2775,2777,2780,2782],{"class":150,"line":1370},[148,2776,343],{"class":154},[148,2778,2779],{"class":161}," sender.",[148,2781,2647],{"class":207},[148,2783,352],{"class":161},[148,2785,2786,2789,2791,2793,2795,2797,2799,2801,2803,2805,2808,2810,2812,2815,2817,2819,2821],{"class":150,"line":1376},[148,2787,2788],{"class":161},"sender.",[148,2790,2667],{"class":207},[148,2792,369],{"class":161},[148,2794,2672],{"class":235},[148,2796,2675],{"class":161},[148,2798,2678],{"class":207},[148,2800,369],{"class":161},[148,2802,372],{"class":168},[148,2804,2675],{"class":161},[148,2806,2807],{"class":207},"emit",[148,2809,369],{"class":161},[148,2811,2697],{"class":168},[148,2813,2814],{"class":161},", { text: ",[148,2816,388],{"class":168},[148,2818,2551],{"class":161},[148,2820,2554],{"class":168},[148,2822,2557],{"class":161},[12,2824,2825],{},"The key shift is from \"write to a path and listen for changes\" to \"publish an event and subscribe to it.\" You send exactly the message you mean, rather than reshaping your data model so that a write triggers the right listeners. The other shift is that a Firebase listener fires for the writer too, and a NoLag publisher never receives its own message: update your local state when you send, and let the subscription carry everyone else's.",[35,2827,2829],{"id":2828},"access-control","Access control",[12,2831,2832,2833,2836,2837,114,2840,87,2843,87,2846,87,2849,87,2852,87,2855,694,2857,2860,2861,2865],{},"Firebase security rules become ",[30,2834,2835],{},"per-topic ACL"," in NoLag, tied to a typed ",[30,2838,2839],{},"actor",[84,2841,2842],{},"user",[84,2844,2845],{},"device",[84,2847,2848],{},"service",[84,2850,2851],{},"session",[84,2853,2854],{},"agent",[84,2856,1254],{},[84,2858,2859],{},"observer",") and enforced at the broker rather than written in a rules language. A connection authenticates with an actor token, and browser clients use short-lived JWTs (",[952,2862,2864],{"href":2863},"/docs/client-tokens","client tokens",") your backend signs with a project signing key.",[35,2867,2248],{"id":2247},[882,2869,2870,2875,2883],{},[885,2871,2872,2873,611],{},"Follow the ",[952,2874,2263],{"href":2262},[885,2876,2877,2878,2882],{},"See the full ",[952,2879,2881],{"href":2880},"/compare/firebase","NoLag vs Firebase"," comparison.",[885,2884,2885],{},"If you are keeping a database, use NoLag alongside it for the live layer only.",[969,2887,2888],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":144,"searchDepth":172,"depth":172,"links":2890},[2891,2892,2893,2894,2895,2896,2897],{"id":2301,"depth":172,"text":2302},{"id":2324,"depth":172,"text":2325},{"id":2354,"depth":172,"text":2355},{"id":2431,"depth":172,"text":2432},{"id":2560,"depth":172,"text":2561},{"id":2828,"depth":172,"text":2829},{"id":2247,"depth":172,"text":2248},"Migration Guide","2026-08-01","How to move the realtime layer of a Firebase app to NoLag, when it makes sense, and what to keep. Includes a concept map and before-and-after code.",{},"/blog/migrate-from-firebase-to-nolag","7 min read",{"title":2293,"description":2900},"blog/migrate-from-firebase-to-nolag","xIPKlr1J0w7D2fXKJufUpJexA4dHSjvGnS3dw29OtCk",{"id":2908,"title":2909,"author":7,"body":2910,"category":2898,"date":3498,"description":3499,"excerpt":986,"extension":987,"meta":3500,"navigation":175,"path":3501,"readTime":3502,"seo":3503,"stem":3504,"__hash__":3505},"blog/blog/migrate-from-ably-to-nolag.md","Migrate from Ably to NoLag",{"type":9,"value":2911,"toc":3488},[2912,2915,2919,2922,2937,2939,3006,3010,3149,3151,3376,3379,3383,3405,3411,3415,3433,3437,3462,3464,3485],[12,2913,2914],{},"Ably is a mature realtime platform with rich delivery semantics. NoLag shares the same pub/sub foundation and presence model, so moving across is mostly a matter of renaming a few concepts. This guide maps Ably onto NoLag and shows the same flow in both.",[35,2916,2918],{"id":2917},"why-teams-move","Why teams move",[12,2920,2921],{},"NoLag keeps the realtime transport you rely on and adds:",[882,2923,2924,2929,2934],{},[885,2925,2926,2928],{},[30,2927,2344],{}," for chat, notifications, dashboards, tracking, and more.",[885,2930,2931,2933],{},[30,2932,2350],{},": dispatch, share state, observe, and approve, on the same platform as your app features.",[885,2935,2936],{},"One place for human-facing and agent-facing realtime.",[35,2938,2355],{"id":2354},[40,2940,2941,2950],{},[43,2942,2943],{},[46,2944,2945,2948],{},[49,2946,2947],{},"Ably",[49,2949,2367],{},[59,2951,2952,2960,2967,2978,2989,2998],{},[46,2953,2954,2957],{},[64,2955,2956],{},"API key / token auth",[64,2958,2959],{},"Actor access tokens",[46,2961,2962,2965],{},[64,2963,2964],{},"Channel",[64,2966,2377],{},[46,2968,2969,2974],{},[64,2970,2971],{},[84,2972,2973],{},"channel.subscribe('event', cb)",[64,2975,2976],{},[84,2977,2394],{},[46,2979,2980,2985],{},[64,2981,2982],{},[84,2983,2984],{},"channel.publish('event', data)",[64,2986,2987],{},[84,2988,2412],{},[46,2990,2991,2996],{},[64,2992,2993],{},[84,2994,2995],{},"channel.presence",[64,2997,2428],{},[46,2999,3000,3003],{},[64,3001,3002],{},"History",[64,3004,3005],{},"Your own store (NoLag is a messaging layer, not a database)",[35,3007,3009],{"id":3008},"before-ably","Before: Ably",[138,3011,3013],{"className":2435,"code":3012,"language":2437,"meta":144,"style":144},"import * as Ably from 'ably'\n\nconst ably = new Ably.Realtime('API_KEY')\nconst channel = ably.channels.get('chat')\n\nchannel.subscribe('message', (msg) => {\n  console.log('Received:', msg.data)\n})\n\nchannel.publish('message', { text: 'Hello!' })\n",[84,3014,3015,3033,3037,3061,3083,3087,3110,3124,3128,3132],{"__ignoreMap":144},[148,3016,3017,3019,3022,3025,3028,3030],{"class":150,"line":151},[148,3018,155],{"class":154},[148,3020,3021],{"class":235}," *",[148,3023,3024],{"class":154}," as",[148,3026,3027],{"class":161}," Ably ",[148,3029,165],{"class":154},[148,3031,3032],{"class":168}," 'ably'\n",[148,3034,3035],{"class":150,"line":172},[148,3036,176],{"emptyLinePlaceholder":175},[148,3038,3039,3041,3044,3046,3048,3051,3054,3056,3059],{"class":150,"line":179},[148,3040,323],{"class":154},[148,3042,3043],{"class":235}," ably",[148,3045,329],{"class":154},[148,3047,332],{"class":154},[148,3049,3050],{"class":161}," Ably.",[148,3052,3053],{"class":207},"Realtime",[148,3055,369],{"class":161},[148,3057,3058],{"class":168},"'API_KEY'",[148,3060,375],{"class":161},[148,3062,3063,3065,3068,3070,3073,3076,3078,3081],{"class":150,"line":186},[148,3064,323],{"class":154},[148,3066,3067],{"class":235}," channel",[148,3069,329],{"class":154},[148,3071,3072],{"class":161}," ably.channels.",[148,3074,3075],{"class":207},"get",[148,3077,369],{"class":161},[148,3079,3080],{"class":168},"'chat'",[148,3082,375],{"class":161},[148,3084,3085],{"class":150,"line":192},[148,3086,176],{"emptyLinePlaceholder":175},[148,3088,3089,3092,3094,3096,3099,3101,3104,3106,3108],{"class":150,"line":198},[148,3090,3091],{"class":161},"channel.",[148,3093,2692],{"class":207},[148,3095,369],{"class":161},[148,3097,3098],{"class":168},"'message'",[148,3100,2713],{"class":161},[148,3102,3103],{"class":217},"msg",[148,3105,633],{"class":161},[148,3107,636],{"class":154},[148,3109,211],{"class":161},[148,3111,3112,3114,3116,3118,3121],{"class":150,"line":214},[148,3113,2514],{"class":161},[148,3115,2517],{"class":207},[148,3117,369],{"class":161},[148,3119,3120],{"class":168},"'Received:'",[148,3122,3123],{"class":161},", msg.data)\n",[148,3125,3126],{"class":150,"line":227},[148,3127,853],{"class":161},[148,3129,3130],{"class":150,"line":239},[148,3131,176],{"emptyLinePlaceholder":175},[148,3133,3134,3136,3139,3141,3143,3145,3147],{"class":150,"line":249},[148,3135,3091],{"class":161},[148,3137,3138],{"class":207},"publish",[148,3140,369],{"class":161},[148,3142,3098],{"class":168},[148,3144,2814],{"class":161},[148,3146,388],{"class":168},[148,3148,2557],{"class":161},[35,3150,2561],{"id":2560},[138,3152,3154],{"className":2564,"code":3153,"language":2566,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\n\n// Create the app and its `general` room in the control plane first. The app\n// slug you get back has a random suffix (for example `chat-a3f9`): that is\n// what you pass to setApp(). Room slugs are kept verbatim.\nconst APP_SLUG = 'chat-a3f9'\n\n// One actor subscribes...\nconst receiver = NoLag('receiver_access_token')\nawait receiver.connect()\n\nconst inbox = receiver.setApp(APP_SLUG).setRoom('general')\ninbox.subscribe('message')\ninbox.on('message', (data) => {\n  console.log('Received:', data)\n})\n\n// ...and another publishes. Publishers never receive their own messages,\n// so a single client that subscribes and emits will not log \"Received\".\nconst sender = NoLag('sender_access_token')\nawait sender.connect()\nsender.setApp(APP_SLUG).setRoom('general').emit('message', { text: 'Hello!' })\n",[84,3155,3156,3166,3170,3174,3178,3182,3192,3196,3201,3217,3227,3231,3257,3269,3289,3301,3305,3309,3313,3318,3334,3344],{"__ignoreMap":144},[148,3157,3158,3160,3162,3164],{"class":150,"line":151},[148,3159,155],{"class":154},[148,3161,2575],{"class":161},[148,3163,165],{"class":154},[148,3165,169],{"class":168},[148,3167,3168],{"class":150,"line":172},[148,3169,176],{"emptyLinePlaceholder":175},[148,3171,3172],{"class":150,"line":179},[148,3173,2588],{"class":182},[148,3175,3176],{"class":150,"line":186},[148,3177,2593],{"class":182},[148,3179,3180],{"class":150,"line":192},[148,3181,2598],{"class":182},[148,3183,3184,3186,3188,3190],{"class":150,"line":198},[148,3185,323],{"class":154},[148,3187,2605],{"class":235},[148,3189,329],{"class":154},[148,3191,2610],{"class":168},[148,3193,3194],{"class":150,"line":214},[148,3195,176],{"emptyLinePlaceholder":175},[148,3197,3198],{"class":150,"line":227},[148,3199,3200],{"class":182},"// One actor subscribes...\n",[148,3202,3203,3205,3207,3209,3211,3213,3215],{"class":150,"line":239},[148,3204,323],{"class":154},[148,3206,2626],{"class":235},[148,3208,329],{"class":154},[148,3210,1594],{"class":207},[148,3212,369],{"class":161},[148,3214,2635],{"class":168},[148,3216,375],{"class":161},[148,3218,3219,3221,3223,3225],{"class":150,"line":249},[148,3220,343],{"class":154},[148,3222,2644],{"class":161},[148,3224,2647],{"class":207},[148,3226,352],{"class":161},[148,3228,3229],{"class":150,"line":259},[148,3230,176],{"emptyLinePlaceholder":175},[148,3232,3233,3235,3237,3239,3241,3243,3245,3247,3249,3251,3253,3255],{"class":150,"line":654},[148,3234,323],{"class":154},[148,3236,2660],{"class":235},[148,3238,329],{"class":154},[148,3240,2644],{"class":161},[148,3242,2667],{"class":207},[148,3244,369],{"class":161},[148,3246,2672],{"class":235},[148,3248,2675],{"class":161},[148,3250,2678],{"class":207},[148,3252,369],{"class":161},[148,3254,372],{"class":168},[148,3256,375],{"class":161},[148,3258,3259,3261,3263,3265,3267],{"class":150,"line":660},[148,3260,2689],{"class":161},[148,3262,2692],{"class":207},[148,3264,369],{"class":161},[148,3266,3098],{"class":168},[148,3268,375],{"class":161},[148,3270,3271,3273,3275,3277,3279,3281,3283,3285,3287],{"class":150,"line":1289},[148,3272,2689],{"class":161},[148,3274,2706],{"class":207},[148,3276,369],{"class":161},[148,3278,3098],{"class":168},[148,3280,2713],{"class":161},[148,3282,2716],{"class":217},[148,3284,633],{"class":161},[148,3286,636],{"class":154},[148,3288,211],{"class":161},[148,3290,3291,3293,3295,3297,3299],{"class":150,"line":1295},[148,3292,2514],{"class":161},[148,3294,2517],{"class":207},[148,3296,369],{"class":161},[148,3298,3120],{"class":168},[148,3300,2736],{"class":161},[148,3302,3303],{"class":150,"line":1313},[148,3304,853],{"class":161},[148,3306,3307],{"class":150,"line":1331},[148,3308,176],{"emptyLinePlaceholder":175},[148,3310,3311],{"class":150,"line":1346},[148,3312,2749],{"class":182},[148,3314,3315],{"class":150,"line":1355},[148,3316,3317],{"class":182},"// so a single client that subscribes and emits will not log \"Received\".\n",[148,3319,3320,3322,3324,3326,3328,3330,3332],{"class":150,"line":1361},[148,3321,323],{"class":154},[148,3323,2761],{"class":235},[148,3325,329],{"class":154},[148,3327,1594],{"class":207},[148,3329,369],{"class":161},[148,3331,2770],{"class":168},[148,3333,375],{"class":161},[148,3335,3336,3338,3340,3342],{"class":150,"line":1370},[148,3337,343],{"class":154},[148,3339,2779],{"class":161},[148,3341,2647],{"class":207},[148,3343,352],{"class":161},[148,3345,3346,3348,3350,3352,3354,3356,3358,3360,3362,3364,3366,3368,3370,3372,3374],{"class":150,"line":1376},[148,3347,2788],{"class":161},[148,3349,2667],{"class":207},[148,3351,369],{"class":161},[148,3353,2672],{"class":235},[148,3355,2675],{"class":161},[148,3357,2678],{"class":207},[148,3359,369],{"class":161},[148,3361,372],{"class":168},[148,3363,2675],{"class":161},[148,3365,2807],{"class":207},[148,3367,369],{"class":161},[148,3369,3098],{"class":168},[148,3371,2814],{"class":161},[148,3373,388],{"class":168},[148,3375,2557],{"class":161},[12,3377,3378],{},"One behavioural difference from Ably is worth flagging before you port a test suite: an Ably client sees its own publishes, a NoLag actor never does. Append the sent message to your UI locally, and use a second actor in tests that expect to receive.",[35,3380,3382],{"id":3381},"presence-and-history","Presence and history",[12,3384,3385,3386,3390,3391,3394,3395,87,3398,1812,3401,3404],{},"Ably presence maps to NoLag ",[952,3387,3389],{"href":3388},"/docs/concepts/presence","presence",", tracked per room with join and leave events and a live member list. A client sets its presence on the room with ",[84,3392,3393],{},"room.setPresence(data)"," and listens for ",[84,3396,3397],{},"presence:join",[84,3399,3400],{},"presence:leave",[84,3402,3403],{},"presence:update"," on the client.",[12,3406,3407,3408,3410],{},"History is the one Ably feature that does not have a direct counterpart. NoLag is a messaging layer, not a database: a client that reconnects gets its subscriptions restored and nothing replayed, and a fresh subscription receives no history. If you use Ably history to fill gaps after a disconnect or to render the last N messages on load, keep those messages in your own store and read it on connect. The one place the broker does replay is for load-balanced worker groups with persistent sessions, which is a different job; see ",[952,3409,2320],{"href":2319}," for exactly what that covers.",[35,3412,3414],{"id":3413},"delivery-guarantees","Delivery guarantees",[12,3416,3417,3418,3422,3423,3425,3426,3428,3429,3432],{},"Ably is known for its delivery semantics. NoLag offers ",[952,3419,3421],{"href":3420},"/docs/concepts/qos","quality-of-service levels"," 0, 1, and 2 per message, chosen on each ",[84,3424,2807],{}," rather than paid for everywhere. Be precise about what they cover: the level governs the hop across NoLag's internal broker, and the WebSocket leg between your client and NoLag has a simpler contract. A publish can be acknowledged (pass a callback to ",[84,3427,2807],{}," and it resolves on the broker's ",[84,3430,3431],{},"published"," ack), nothing is resent for you, and there is no exactly-once guarantee end to end. Put an idempotency key in the payload of anything that must not be applied twice.",[35,3434,3436],{"id":3435},"authentication","Authentication",[12,3438,3439,3440,3442,3443,87,3445,87,3447,87,3449,87,3451,87,3453,694,3455,3457,3458,3461],{},"Ably authenticates with API keys and tokens, often carrying capabilities in the token itself. NoLag centres permissions on the ",[30,3441,2839],{},": every credential resolves to a typed actor (",[84,3444,2842],{},[84,3446,2845],{},[84,3448,2848],{},[84,3450,2851],{},[84,3452,2854],{},[84,3454,1254],{},[84,3456,2859],{},"), and its read and write access lives server-side as per-topic ACL rather than in each token. For browser clients, your backend mints a short-lived JWT (",[952,3459,3460],{"href":2863},"client token",") signed with a project signing key, so long-lived credentials never leave your servers.",[35,3463,2248],{"id":2247},[882,3465,3466,3470,3477],{},[885,3467,2259,3468,611],{},[952,3469,2263],{"href":2262},[885,3471,3472,3473,2882],{},"Read the deeper ",[952,3474,3476],{"href":3475},"/compare/ably","NoLag vs Ably",[885,3478,3479,3480,3484],{},"Explore the ",[952,3481,3483],{"href":3482},"/docs/high-level-sdks","high-level SDKs"," if you would rather not rebuild chat or dashboards by hand.",[969,3486,3487],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":144,"searchDepth":172,"depth":172,"links":3489},[3490,3491,3492,3493,3494,3495,3496,3497],{"id":2917,"depth":172,"text":2918},{"id":2354,"depth":172,"text":2355},{"id":3008,"depth":172,"text":3009},{"id":2560,"depth":172,"text":2561},{"id":3381,"depth":172,"text":3382},{"id":3413,"depth":172,"text":3414},{"id":3435,"depth":172,"text":3436},{"id":2247,"depth":172,"text":2248},"2026-07-31","A practical guide to moving a realtime app from Ably to NoLag, with a concept map and before-and-after code for channels, publishing, presence, and history.",{},"/blog/migrate-from-ably-to-nolag","6 min read",{"title":2909,"description":3499},"blog/migrate-from-ably-to-nolag","AE-a8TLTDe-_j0hjVHx1X11NSVCYC_3KNfFgPYM7dEo",{"id":3507,"title":3508,"author":7,"body":3509,"category":2282,"date":4561,"description":4562,"excerpt":986,"extension":987,"meta":4563,"navigation":175,"path":4564,"readTime":2903,"seo":4565,"stem":4566,"__hash__":4567},"blog/blog/add-realtime-to-nextjs.md","Add Realtime to a Next.js App with NoLag",{"type":9,"value":3510,"toc":4554},[3511,3518,3522,3537,3541,3552,3744,3750,3754,4498,4508,4512,4527,4529,4551],[12,3512,3513,3514,3517],{},"Next.js renders on the server and hydrates in the browser, so the one rule for adding realtime is simple: the realtime connection belongs in a ",[30,3515,3516],{},"client component",". This guide shows the clean pattern, including a server route that hands the browser a token so your project key never ships to the client.",[35,3519,3521],{"id":3520},"_1-install-the-sdk","1. Install the SDK",[138,3523,3525],{"className":1891,"code":3524,"language":1893,"meta":144,"style":144},"npm install @nolag/js-sdk\n",[84,3526,3527],{"__ignoreMap":144},[148,3528,3529,3532,3534],{"class":150,"line":151},[148,3530,3531],{"class":207},"npm",[148,3533,1903],{"class":168},[148,3535,3536],{"class":168}," @nolag/js-sdk\n",[35,3538,3540],{"id":3539},"_2-mint-a-token-on-the-server","2. Mint a token on the server",[12,3542,3543,3544,3547,3548,3551],{},"Never put a project key in browser code. Add a route handler that creates a token server-side and returns it. The ",[84,3545,3546],{},"NOLAG_API_KEY"," (an ",[84,3549,3550],{},"nlg_live_..."," key) stays in your server environment.",[138,3553,3555],{"className":2564,"code":3554,"language":2566,"meta":144,"style":144},"// app/api/nolag-token/route.ts\nimport { NextResponse } from 'next/server'\n\nexport async function GET() {\n  const res = await fetch('https://api.nolag.app/v1/actors', {\n    method: 'POST',\n    headers: {\n      Authorization: `Bearer ${process.env.NOLAG_API_KEY}`,\n      'Content-Type': 'application/json',\n    },\n    body: JSON.stringify({ name: 'Web Client', actorType: 'user' }),\n  })\n\n  const actor = await res.json()\n  return NextResponse.json({ token: actor.accessToken })\n}\n",[84,3556,3557,3562,3574,3578,3592,3615,3625,3630,3655,3668,3673,3701,3705,3709,3728,3740],{"__ignoreMap":144},[148,3558,3559],{"class":150,"line":151},[148,3560,3561],{"class":182},"// app/api/nolag-token/route.ts\n",[148,3563,3564,3566,3569,3571],{"class":150,"line":172},[148,3565,155],{"class":154},[148,3567,3568],{"class":161}," { NextResponse } ",[148,3570,165],{"class":154},[148,3572,3573],{"class":168}," 'next/server'\n",[148,3575,3576],{"class":150,"line":179},[148,3577,176],{"emptyLinePlaceholder":175},[148,3579,3580,3582,3584,3586,3589],{"class":150,"line":186},[148,3581,201],{"class":154},[148,3583,518],{"class":154},[148,3585,521],{"class":154},[148,3587,3588],{"class":207}," GET",[148,3590,3591],{"class":161},"() {\n",[148,3593,3594,3597,3600,3602,3604,3607,3609,3612],{"class":150,"line":192},[148,3595,3596],{"class":154},"  const",[148,3598,3599],{"class":235}," res",[148,3601,329],{"class":154},[148,3603,1502],{"class":154},[148,3605,3606],{"class":207}," fetch",[148,3608,369],{"class":161},[148,3610,3611],{"class":168},"'https://api.nolag.app/v1/actors'",[148,3613,3614],{"class":161},", {\n",[148,3616,3617,3620,3623],{"class":150,"line":198},[148,3618,3619],{"class":161},"    method: ",[148,3621,3622],{"class":168},"'POST'",[148,3624,540],{"class":161},[148,3626,3627],{"class":150,"line":214},[148,3628,3629],{"class":161},"    headers: {\n",[148,3631,3632,3635,3638,3641,3643,3646,3648,3650,3653],{"class":150,"line":227},[148,3633,3634],{"class":161},"      Authorization: ",[148,3636,3637],{"class":168},"`Bearer ${",[148,3639,3640],{"class":161},"process",[148,3642,611],{"class":168},[148,3644,3645],{"class":161},"env",[148,3647,611],{"class":168},[148,3649,3546],{"class":235},[148,3651,3652],{"class":168},"}`",[148,3654,540],{"class":161},[148,3656,3657,3660,3663,3666],{"class":150,"line":239},[148,3658,3659],{"class":168},"      'Content-Type'",[148,3661,3662],{"class":161},": ",[148,3664,3665],{"class":168},"'application/json'",[148,3667,540],{"class":161},[148,3669,3670],{"class":150,"line":249},[148,3671,3672],{"class":161},"    },\n",[148,3674,3675,3678,3681,3683,3686,3689,3692,3695,3698],{"class":150,"line":259},[148,3676,3677],{"class":161},"    body: ",[148,3679,3680],{"class":235},"JSON",[148,3682,611],{"class":161},[148,3684,3685],{"class":207},"stringify",[148,3687,3688],{"class":161},"({ name: ",[148,3690,3691],{"class":168},"'Web Client'",[148,3693,3694],{"class":161},", actorType: ",[148,3696,3697],{"class":168},"'user'",[148,3699,3700],{"class":161}," }),\n",[148,3702,3703],{"class":150,"line":654},[148,3704,1530],{"class":161},[148,3706,3707],{"class":150,"line":660},[148,3708,176],{"emptyLinePlaceholder":175},[148,3710,3711,3713,3716,3718,3720,3723,3726],{"class":150,"line":1289},[148,3712,3596],{"class":154},[148,3714,3715],{"class":235}," actor",[148,3717,329],{"class":154},[148,3719,1502],{"class":154},[148,3721,3722],{"class":161}," res.",[148,3724,3725],{"class":207},"json",[148,3727,352],{"class":161},[148,3729,3730,3732,3735,3737],{"class":150,"line":1295},[148,3731,605],{"class":154},[148,3733,3734],{"class":161}," NextResponse.",[148,3736,3725],{"class":207},[148,3738,3739],{"class":161},"({ token: actor.accessToken })\n",[148,3741,3742],{"class":150,"line":1313},[148,3743,262],{"class":161},[12,3745,3746,3747,3749],{},"For production, prefer short-lived ",[952,3748,2864],{"href":2863}," minted from your project signing keys so browser sessions get scoped, expiring credentials.",[35,3751,3753],{"id":3752},"_3-connect-from-a-client-component","3. Connect from a client component",[138,3755,3759],{"className":3756,"code":3757,"language":3758,"meta":144,"style":144},"language-tsx shiki shiki-themes github-light github-dark","'use client'\nimport { useEffect, useRef, useState } from 'react'\nimport { NoLag } from '@nolag/js-sdk'\nimport type { RoomContext } from '@nolag/js-sdk'\n\n// Create the app and its `general` room first. The slug you get back has a\n// random suffix (for example `chat-a3f9`), and that suffixed slug is what\n// setApp() takes.\nconst APP_SLUG = process.env.NEXT_PUBLIC_NOLAG_APP_SLUG!\n\ntype Message = { text: string }\n\nexport default function Chat() {\n  const [messages, setMessages] = useState([] as Message[])\n  const roomRef = useRef(null as RoomContext | null)\n\n  useEffect(() => {\n    let client: ReturnType\u003Ctypeof NoLag> | undefined\n    let active = true\n\n    ;(async () => {\n      const { token } = await fetch('/api/nolag-token').then((r) => r.json())\n      if (!active) return\n\n      client = NoLag(token)\n      await client.connect()\n\n      const room = client.setApp(APP_SLUG).setRoom('general')\n      room.subscribe('message')\n      room.on\u003CMessage>('message', (data) => setMessages((m) => [...m, data]))\n      roomRef.current = room\n    })()\n\n    return () => {\n      active = false\n      roomRef.current = null\n      client?.disconnect()\n    }\n  }, [])\n\n  const send = (text: string) => {\n    const msg: Message = { text }\n    roomRef.current?.emit('message', msg)\n    // Publishers never receive their own messages, so append it locally\n    setMessages((m) => [...m, msg])\n  }\n\n  return (\n    \u003Cdiv>\n      \u003Cul>\n        {messages.map((m, i) => (\n          \u003Cli key={i}>{m.text}\u003C/li>\n        ))}\n      \u003C/ul>\n      \u003Cbutton onClick={() => send('Hello!')}>Send\u003C/button>\n    \u003C/div>\n  )\n}\n","tsx",[84,3760,3761,3766,3778,3788,3801,3805,3810,3815,3820,3837,3841,3862,3866,3880,3914,3944,3948,3960,3986,3998,4002,4016,4060,4076,4080,4092,4104,4108,4134,4147,4190,4200,4205,4209,4220,4230,4239,4249,4254,4259,4264,4288,4305,4320,4326,4347,4352,4357,4365,4378,4388,4411,4431,4437,4447,4478,4488,4493],{"__ignoreMap":144},[148,3762,3763],{"class":150,"line":151},[148,3764,3765],{"class":168},"'use client'\n",[148,3767,3768,3770,3773,3775],{"class":150,"line":172},[148,3769,155],{"class":154},[148,3771,3772],{"class":161}," { useEffect, useRef, useState } ",[148,3774,165],{"class":154},[148,3776,3777],{"class":168}," 'react'\n",[148,3779,3780,3782,3784,3786],{"class":150,"line":179},[148,3781,155],{"class":154},[148,3783,2575],{"class":161},[148,3785,165],{"class":154},[148,3787,169],{"class":168},[148,3789,3790,3792,3794,3797,3799],{"class":150,"line":186},[148,3791,155],{"class":154},[148,3793,158],{"class":154},[148,3795,3796],{"class":161}," { RoomContext } ",[148,3798,165],{"class":154},[148,3800,169],{"class":168},[148,3802,3803],{"class":150,"line":192},[148,3804,176],{"emptyLinePlaceholder":175},[148,3806,3807],{"class":150,"line":198},[148,3808,3809],{"class":182},"// Create the app and its `general` room first. The slug you get back has a\n",[148,3811,3812],{"class":150,"line":214},[148,3813,3814],{"class":182},"// random suffix (for example `chat-a3f9`), and that suffixed slug is what\n",[148,3816,3817],{"class":150,"line":227},[148,3818,3819],{"class":182},"// setApp() takes.\n",[148,3821,3822,3824,3826,3828,3831,3834],{"class":150,"line":239},[148,3823,323],{"class":154},[148,3825,2605],{"class":235},[148,3827,329],{"class":154},[148,3829,3830],{"class":161}," process.env.",[148,3832,3833],{"class":235},"NEXT_PUBLIC_NOLAG_APP_SLUG",[148,3835,3836],{"class":154},"!\n",[148,3838,3839],{"class":150,"line":249},[148,3840,176],{"emptyLinePlaceholder":175},[148,3842,3843,3846,3849,3851,3853,3855,3857,3859],{"class":150,"line":259},[148,3844,3845],{"class":154},"type",[148,3847,3848],{"class":207}," Message",[148,3850,329],{"class":154},[148,3852,550],{"class":161},[148,3854,1114],{"class":217},[148,3856,221],{"class":154},[148,3858,558],{"class":235},[148,3860,3861],{"class":161}," }\n",[148,3863,3864],{"class":150,"line":654},[148,3865,176],{"emptyLinePlaceholder":175},[148,3867,3868,3870,3873,3875,3878],{"class":150,"line":660},[148,3869,201],{"class":154},[148,3871,3872],{"class":154}," default",[148,3874,521],{"class":154},[148,3876,3877],{"class":207}," Chat",[148,3879,3591],{"class":161},[148,3881,3882,3884,3887,3890,3892,3895,3898,3900,3903,3906,3909,3911],{"class":150,"line":1289},[148,3883,3596],{"class":154},[148,3885,3886],{"class":161}," [",[148,3888,3889],{"class":235},"messages",[148,3891,87],{"class":161},[148,3893,3894],{"class":235},"setMessages",[148,3896,3897],{"class":161},"] ",[148,3899,1337],{"class":154},[148,3901,3902],{"class":207}," useState",[148,3904,3905],{"class":161},"([] ",[148,3907,3908],{"class":154},"as",[148,3910,3848],{"class":207},[148,3912,3913],{"class":161},"[])\n",[148,3915,3916,3918,3921,3923,3926,3928,3931,3933,3936,3939,3942],{"class":150,"line":1295},[148,3917,3596],{"class":154},[148,3919,3920],{"class":235}," roomRef",[148,3922,329],{"class":154},[148,3924,3925],{"class":207}," useRef",[148,3927,369],{"class":161},[148,3929,3930],{"class":235},"null",[148,3932,3024],{"class":154},[148,3934,3935],{"class":207}," RoomContext",[148,3937,3938],{"class":154}," |",[148,3940,3941],{"class":235}," null",[148,3943,375],{"class":161},[148,3945,3946],{"class":150,"line":1313},[148,3947,176],{"emptyLinePlaceholder":175},[148,3949,3950,3953,3956,3958],{"class":150,"line":1331},[148,3951,3952],{"class":207},"  useEffect",[148,3954,3955],{"class":161},"(() ",[148,3957,636],{"class":154},[148,3959,211],{"class":161},[148,3961,3962,3965,3967,3969,3972,3974,3977,3980,3983],{"class":150,"line":1346},[148,3963,3964],{"class":154},"    let",[148,3966,1589],{"class":161},[148,3968,221],{"class":154},[148,3970,3971],{"class":207}," ReturnType",[148,3973,572],{"class":161},[148,3975,3976],{"class":154},"typeof",[148,3978,3979],{"class":161}," NoLag> ",[148,3981,3982],{"class":154},"|",[148,3984,3985],{"class":235}," undefined\n",[148,3987,3988,3990,3993,3995],{"class":150,"line":1355},[148,3989,3964],{"class":154},[148,3991,3992],{"class":161}," active ",[148,3994,1337],{"class":154},[148,3996,3997],{"class":235}," true\n",[148,3999,4000],{"class":150,"line":1361},[148,4001,176],{"emptyLinePlaceholder":175},[148,4003,4004,4007,4009,4012,4014],{"class":150,"line":1370},[148,4005,4006],{"class":161},"    ;(",[148,4008,1447],{"class":154},[148,4010,4011],{"class":161}," () ",[148,4013,636],{"class":154},[148,4015,211],{"class":161},[148,4017,4018,4021,4023,4026,4028,4030,4032,4034,4036,4039,4041,4044,4046,4049,4051,4053,4056,4058],{"class":150,"line":1376},[148,4019,4020],{"class":154},"      const",[148,4022,550],{"class":161},[148,4024,4025],{"class":235},"token",[148,4027,1485],{"class":161},[148,4029,1337],{"class":154},[148,4031,1502],{"class":154},[148,4033,3606],{"class":207},[148,4035,369],{"class":161},[148,4037,4038],{"class":168},"'/api/nolag-token'",[148,4040,2675],{"class":161},[148,4042,4043],{"class":207},"then",[148,4045,627],{"class":161},[148,4047,4048],{"class":217},"r",[148,4050,633],{"class":161},[148,4052,636],{"class":154},[148,4054,4055],{"class":161}," r.",[148,4057,3725],{"class":207},[148,4059,2531],{"class":161},[148,4061,4062,4065,4067,4070,4073],{"class":150,"line":2033},[148,4063,4064],{"class":154},"      if",[148,4066,114],{"class":161},[148,4068,4069],{"class":154},"!",[148,4071,4072],{"class":161},"active) ",[148,4074,4075],{"class":154},"return\n",[148,4077,4078],{"class":150,"line":2039},[148,4079,176],{"emptyLinePlaceholder":175},[148,4081,4082,4085,4087,4089],{"class":150,"line":2044},[148,4083,4084],{"class":161},"      client ",[148,4086,1337],{"class":154},[148,4088,1594],{"class":207},[148,4090,4091],{"class":161},"(token)\n",[148,4093,4094,4097,4100,4102],{"class":150,"line":2050},[148,4095,4096],{"class":154},"      await",[148,4098,4099],{"class":161}," client.",[148,4101,2647],{"class":207},[148,4103,352],{"class":161},[148,4105,4106],{"class":150,"line":2056},[148,4107,176],{"emptyLinePlaceholder":175},[148,4109,4110,4112,4114,4116,4118,4120,4122,4124,4126,4128,4130,4132],{"class":150,"line":2062},[148,4111,4020],{"class":154},[148,4113,359],{"class":235},[148,4115,329],{"class":154},[148,4117,4099],{"class":161},[148,4119,2667],{"class":207},[148,4121,369],{"class":161},[148,4123,2672],{"class":235},[148,4125,2675],{"class":161},[148,4127,2678],{"class":207},[148,4129,369],{"class":161},[148,4131,372],{"class":168},[148,4133,375],{"class":161},[148,4135,4136,4139,4141,4143,4145],{"class":150,"line":2068},[148,4137,4138],{"class":161},"      room.",[148,4140,2692],{"class":207},[148,4142,369],{"class":161},[148,4144,3098],{"class":168},[148,4146,375],{"class":161},[148,4148,4149,4151,4153,4155,4158,4161,4163,4165,4167,4169,4171,4174,4176,4179,4181,4183,4185,4187],{"class":150,"line":2074},[148,4150,4138],{"class":161},[148,4152,2706],{"class":207},[148,4154,572],{"class":161},[148,4156,4157],{"class":207},"Message",[148,4159,4160],{"class":161},">(",[148,4162,3098],{"class":168},[148,4164,2713],{"class":161},[148,4166,2716],{"class":217},[148,4168,633],{"class":161},[148,4170,636],{"class":154},[148,4172,4173],{"class":207}," setMessages",[148,4175,627],{"class":161},[148,4177,4178],{"class":217},"m",[148,4180,633],{"class":161},[148,4182,636],{"class":154},[148,4184,3886],{"class":161},[148,4186,1307],{"class":154},[148,4188,4189],{"class":161},"m, data]))\n",[148,4191,4192,4195,4197],{"class":150,"line":2080},[148,4193,4194],{"class":161},"      roomRef.current ",[148,4196,1337],{"class":154},[148,4198,4199],{"class":161}," room\n",[148,4201,4202],{"class":150,"line":2086},[148,4203,4204],{"class":161},"    })()\n",[148,4206,4207],{"class":150,"line":2092},[148,4208,176],{"emptyLinePlaceholder":175},[148,4210,4211,4214,4216,4218],{"class":150,"line":2097},[148,4212,4213],{"class":154},"    return",[148,4215,4011],{"class":161},[148,4217,636],{"class":154},[148,4219,211],{"class":161},[148,4221,4222,4225,4227],{"class":150,"line":2103},[148,4223,4224],{"class":161},"      active ",[148,4226,1337],{"class":154},[148,4228,4229],{"class":235}," false\n",[148,4231,4232,4234,4236],{"class":150,"line":2108},[148,4233,4194],{"class":161},[148,4235,1337],{"class":154},[148,4237,4238],{"class":235}," null\n",[148,4240,4241,4244,4247],{"class":150,"line":2114},[148,4242,4243],{"class":161},"      client?.",[148,4245,4246],{"class":207},"disconnect",[148,4248,352],{"class":161},[148,4250,4251],{"class":150,"line":2120},[148,4252,4253],{"class":161},"    }\n",[148,4255,4256],{"class":150,"line":2125},[148,4257,4258],{"class":161},"  }, [])\n",[148,4260,4262],{"class":150,"line":4261},40,[148,4263,176],{"emptyLinePlaceholder":175},[148,4265,4267,4269,4272,4274,4276,4278,4280,4282,4284,4286],{"class":150,"line":4266},41,[148,4268,3596],{"class":154},[148,4270,4271],{"class":207}," send",[148,4273,329],{"class":154},[148,4275,114],{"class":161},[148,4277,1114],{"class":217},[148,4279,221],{"class":154},[148,4281,558],{"class":235},[148,4283,633],{"class":161},[148,4285,636],{"class":154},[148,4287,211],{"class":161},[148,4289,4291,4293,4296,4298,4300,4302],{"class":150,"line":4290},42,[148,4292,1467],{"class":154},[148,4294,4295],{"class":235}," msg",[148,4297,221],{"class":154},[148,4299,3848],{"class":207},[148,4301,329],{"class":154},[148,4303,4304],{"class":161}," { text }\n",[148,4306,4308,4311,4313,4315,4317],{"class":150,"line":4307},43,[148,4309,4310],{"class":161},"    roomRef.current?.",[148,4312,2807],{"class":207},[148,4314,369],{"class":161},[148,4316,3098],{"class":168},[148,4318,4319],{"class":161},", msg)\n",[148,4321,4323],{"class":150,"line":4322},44,[148,4324,4325],{"class":182},"    // Publishers never receive their own messages, so append it locally\n",[148,4327,4329,4332,4334,4336,4338,4340,4342,4344],{"class":150,"line":4328},45,[148,4330,4331],{"class":207},"    setMessages",[148,4333,627],{"class":161},[148,4335,4178],{"class":217},[148,4337,633],{"class":161},[148,4339,636],{"class":154},[148,4341,3886],{"class":161},[148,4343,1307],{"class":154},[148,4345,4346],{"class":161},"m, msg])\n",[148,4348,4350],{"class":150,"line":4349},46,[148,4351,1716],{"class":161},[148,4353,4355],{"class":150,"line":4354},47,[148,4356,176],{"emptyLinePlaceholder":175},[148,4358,4360,4362],{"class":150,"line":4359},48,[148,4361,605],{"class":154},[148,4363,4364],{"class":161}," (\n",[148,4366,4368,4371,4375],{"class":150,"line":4367},49,[148,4369,4370],{"class":161},"    \u003C",[148,4372,4374],{"class":4373},"s9eBZ","div",[148,4376,4377],{"class":161},">\n",[148,4379,4381,4384,4386],{"class":150,"line":4380},50,[148,4382,4383],{"class":161},"      \u003C",[148,4385,882],{"class":4373},[148,4387,4377],{"class":161},[148,4389,4391,4394,4396,4398,4400,4402,4405,4407,4409],{"class":150,"line":4390},51,[148,4392,4393],{"class":161},"        {messages.",[148,4395,624],{"class":207},[148,4397,627],{"class":161},[148,4399,4178],{"class":217},[148,4401,87],{"class":161},[148,4403,4404],{"class":217},"i",[148,4406,633],{"class":161},[148,4408,636],{"class":154},[148,4410,4364],{"class":161},[148,4412,4414,4417,4419,4422,4424,4427,4429],{"class":150,"line":4413},52,[148,4415,4416],{"class":161},"          \u003C",[148,4418,885],{"class":4373},[148,4420,4421],{"class":207}," key",[148,4423,1337],{"class":154},[148,4425,4426],{"class":161},"{i}>{m.text}\u003C/",[148,4428,885],{"class":4373},[148,4430,4377],{"class":161},[148,4432,4434],{"class":150,"line":4433},53,[148,4435,4436],{"class":161},"        ))}\n",[148,4438,4440,4443,4445],{"class":150,"line":4439},54,[148,4441,4442],{"class":161},"      \u003C/",[148,4444,882],{"class":4373},[148,4446,4377],{"class":161},[148,4448,4450,4452,4455,4458,4460,4463,4465,4467,4469,4471,4474,4476],{"class":150,"line":4449},55,[148,4451,4383],{"class":161},[148,4453,4454],{"class":4373},"button",[148,4456,4457],{"class":207}," onClick",[148,4459,1337],{"class":154},[148,4461,4462],{"class":161},"{() ",[148,4464,636],{"class":154},[148,4466,4271],{"class":207},[148,4468,369],{"class":161},[148,4470,388],{"class":168},[148,4472,4473],{"class":161},")}>Send\u003C/",[148,4475,4454],{"class":4373},[148,4477,4377],{"class":161},[148,4479,4481,4484,4486],{"class":150,"line":4480},56,[148,4482,4483],{"class":161},"    \u003C/",[148,4485,4374],{"class":4373},[148,4487,4377],{"class":161},[148,4489,4491],{"class":150,"line":4490},57,[148,4492,657],{"class":161},[148,4494,4496],{"class":150,"line":4495},58,[148,4497,262],{"class":161},[12,4499,1533,4500,4503,4504,4507],{},[84,4501,4502],{},"active"," flag guards against React running effects twice in development and against a connection resolving after the component unmounts. The ",[84,4505,4506],{},"send"," function appends the message to local state as well as emitting it: NoLag never delivers a message back to the actor that published it, so the sender's own text would otherwise never appear on screen.",[35,4509,4511],{"id":4510},"why-the-client-component-rule-matters","Why the client-component rule matters",[12,4513,4514,4515,4518,4519,4522,4523,4526],{},"The NoLag SDK runs in Node as well as in the browser, so this is not about where the code can execute. It is about lifecycle. A server component renders once, has no mount or unmount, and its output is serialised and hydrated in the browser, so there is nowhere for a long-lived connection to live and nothing it receives would survive hydration. Keep the ",[84,4516,4517],{},"NoLag(...)"," call inside ",[84,4520,4521],{},"useEffect"," (or an event handler) in a ",[84,4524,4525],{},"'use client'"," file and the connection opens after hydration and closes on unmount, which avoids hydration mismatches and duplicated connections.",[35,4528,2248],{"id":2247},[882,4530,4531,4537,4544],{},[885,4532,4533,4534,4536],{},"Read the ",[952,4535,2263],{"href":2262}," for the full connect, subscribe, and publish flow.",[885,4538,4539,4540,4543],{},"Skip the plumbing with the ",[952,4541,4542],{"href":3482},"chat blueprint",", which wraps rooms, typing, and presence.",[885,4545,4546,4547,4550],{},"Building agent features into your app? See the ",[952,4548,4549],{"href":966},"AI agents"," guide.",[969,4552,4553],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":144,"searchDepth":172,"depth":172,"links":4555},[4556,4557,4558,4559,4560],{"id":3520,"depth":172,"text":3521},{"id":3539,"depth":172,"text":3540},{"id":3752,"depth":172,"text":3753},{"id":4510,"depth":172,"text":4511},{"id":2247,"depth":172,"text":2248},"2026-07-30","A step-by-step guide to adding realtime messaging to a Next.js app with NoLag, including a server route that mints tokens so your API key never reaches the browser.",{},"/blog/add-realtime-to-nextjs",{"title":3508,"description":4562},"blog/add-realtime-to-nextjs","H-HyiePHT11E8HOanHVWgfkc06xK1OFcyucZ3WoaP_s",{"id":4569,"title":4570,"author":7,"body":4571,"category":2898,"date":5122,"description":5123,"excerpt":986,"extension":987,"meta":5124,"navigation":175,"path":5125,"readTime":3502,"seo":5126,"stem":5127,"__hash__":5128},"blog/blog/migrate-from-pusher-to-nolag.md","Migrate from Pusher to NoLag",{"type":9,"value":4572,"toc":5113},[4573,4576,4578,4581,4599,4601,4665,4669,4791,4793,5024,5034,5038,5059,5065,5068,5088,5090,5110],[12,4574,4575],{},"Pusher Channels popularised hosted realtime pub/sub, and a lot of apps still run on it. If you are moving to NoLag, the good news is that the mental model is almost identical: you subscribe to a channel, you bind to events, and a publisher sends data to everyone listening. This guide maps Pusher concepts onto NoLag and shows the same subscribe-and-publish flow in both.",[35,4577,2918],{"id":2917},[12,4579,4580],{},"NoLag keeps the pub/sub foundation you already rely on and adds a few things Pusher does not focus on:",[882,4582,4583,4588,4593],{},[885,4584,4585,4587],{},[30,4586,2344],{}," for chat, notifications, dashboards, tracking, and more, so common patterns are a few lines instead of a from-scratch build.",[885,4589,4590,4592],{},[30,4591,2350],{},": dispatch work, share state, observe decisions, and gate actions with human approval, on the same infrastructure as your app.",[885,4594,4595,4598],{},[30,4596,4597],{},"Per-message QoS"," on the broker hop, and presence built in.",[35,4600,2355],{"id":2354},[40,4602,4603,4612],{},[43,4604,4605],{},[46,4606,4607,4610],{},[49,4608,4609],{},"Pusher",[49,4611,2367],{},[59,4613,4614,4622,4628,4639,4650,4657],{},[46,4615,4616,4619],{},[64,4617,4618],{},"App key + cluster",[64,4620,4621],{},"App (a container for rooms and topics)",[46,4623,4624,4626],{},[64,4625,2964],{},[64,4627,2377],{},[46,4629,4630,4635],{},[64,4631,4632],{},[84,4633,4634],{},"channel.bind('event', cb)",[64,4636,4637],{},[84,4638,2394],{},[46,4640,4641,4646],{},[64,4642,4643],{},[84,4644,4645],{},"pusher.trigger('channel', 'event', data)",[64,4647,4648],{},[84,4649,2412],{},[46,4651,4652,4655],{},[64,4653,4654],{},"Presence channel",[64,4656,2428],{},[46,4658,4659,4662],{},[64,4660,4661],{},"Private channel + auth endpoint",[64,4663,4664],{},"Actors, access tokens, and per-topic ACL",[35,4666,4668],{"id":4667},"before-pusher","Before: Pusher",[138,4670,4672],{"className":2435,"code":4671,"language":2437,"meta":144,"style":144},"import Pusher from 'pusher-js'\n\nconst pusher = new Pusher('APP_KEY', { cluster: 'eu' })\n\nconst channel = pusher.subscribe('chat')\nchannel.bind('message', (data) => {\n  console.log('Received:', data)\n})\n\n// On your server, using the pusher server SDK:\n// pusher.trigger('chat', 'message', { text: 'Hello!' })\n",[84,4673,4674,4686,4690,4717,4721,4740,4761,4773,4777,4781,4786],{"__ignoreMap":144},[148,4675,4676,4678,4681,4683],{"class":150,"line":151},[148,4677,155],{"class":154},[148,4679,4680],{"class":161}," Pusher ",[148,4682,165],{"class":154},[148,4684,4685],{"class":168}," 'pusher-js'\n",[148,4687,4688],{"class":150,"line":172},[148,4689,176],{"emptyLinePlaceholder":175},[148,4691,4692,4694,4697,4699,4701,4704,4706,4709,4712,4715],{"class":150,"line":179},[148,4693,323],{"class":154},[148,4695,4696],{"class":235}," pusher",[148,4698,329],{"class":154},[148,4700,332],{"class":154},[148,4702,4703],{"class":207}," Pusher",[148,4705,369],{"class":161},[148,4707,4708],{"class":168},"'APP_KEY'",[148,4710,4711],{"class":161},", { cluster: ",[148,4713,4714],{"class":168},"'eu'",[148,4716,2557],{"class":161},[148,4718,4719],{"class":150,"line":186},[148,4720,176],{"emptyLinePlaceholder":175},[148,4722,4723,4725,4727,4729,4732,4734,4736,4738],{"class":150,"line":192},[148,4724,323],{"class":154},[148,4726,3067],{"class":235},[148,4728,329],{"class":154},[148,4730,4731],{"class":161}," pusher.",[148,4733,2692],{"class":207},[148,4735,369],{"class":161},[148,4737,3080],{"class":168},[148,4739,375],{"class":161},[148,4741,4742,4744,4747,4749,4751,4753,4755,4757,4759],{"class":150,"line":198},[148,4743,3091],{"class":161},[148,4745,4746],{"class":207},"bind",[148,4748,369],{"class":161},[148,4750,3098],{"class":168},[148,4752,2713],{"class":161},[148,4754,2716],{"class":217},[148,4756,633],{"class":161},[148,4758,636],{"class":154},[148,4760,211],{"class":161},[148,4762,4763,4765,4767,4769,4771],{"class":150,"line":214},[148,4764,2514],{"class":161},[148,4766,2517],{"class":207},[148,4768,369],{"class":161},[148,4770,3120],{"class":168},[148,4772,2736],{"class":161},[148,4774,4775],{"class":150,"line":227},[148,4776,853],{"class":161},[148,4778,4779],{"class":150,"line":239},[148,4780,176],{"emptyLinePlaceholder":175},[148,4782,4783],{"class":150,"line":249},[148,4784,4785],{"class":182},"// On your server, using the pusher server SDK:\n",[148,4787,4788],{"class":150,"line":259},[148,4789,4790],{"class":182},"// pusher.trigger('chat', 'message', { text: 'Hello!' })\n",[35,4792,2561],{"id":2560},[138,4794,4796],{"className":2564,"code":4795,"language":2566,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\n\n// Create the app and its `general` room in the control plane first. The app\n// slug you get back has a random suffix (for example `chat-a3f9`): that is\n// what you pass to setApp(). Room slugs are kept verbatim.\nconst APP_SLUG = 'chat-a3f9'\n\n// Browser: subscribe as one actor\nconst client = NoLag('your_access_token')\nawait client.connect()\n\nconst room = client.setApp(APP_SLUG).setRoom('general')\nroom.subscribe('message')\nroom.on('message', (data) => {\n  console.log('Received:', data)\n})\n\n// Server (or any other authorised actor): publish. Publishers never receive\n// their own messages, so publish from a different actor than the one listening.\nconst server = NoLag('service_access_token')\nawait server.connect()\nserver.setApp(APP_SLUG).setRoom('general').emit('message', { text: 'Hello!' })\n",[84,4797,4798,4808,4812,4816,4820,4824,4834,4838,4843,4860,4870,4874,4900,4912,4932,4944,4948,4952,4957,4962,4980,4991],{"__ignoreMap":144},[148,4799,4800,4802,4804,4806],{"class":150,"line":151},[148,4801,155],{"class":154},[148,4803,2575],{"class":161},[148,4805,165],{"class":154},[148,4807,169],{"class":168},[148,4809,4810],{"class":150,"line":172},[148,4811,176],{"emptyLinePlaceholder":175},[148,4813,4814],{"class":150,"line":179},[148,4815,2588],{"class":182},[148,4817,4818],{"class":150,"line":186},[148,4819,2593],{"class":182},[148,4821,4822],{"class":150,"line":192},[148,4823,2598],{"class":182},[148,4825,4826,4828,4830,4832],{"class":150,"line":198},[148,4827,323],{"class":154},[148,4829,2605],{"class":235},[148,4831,329],{"class":154},[148,4833,2610],{"class":168},[148,4835,4836],{"class":150,"line":214},[148,4837,176],{"emptyLinePlaceholder":175},[148,4839,4840],{"class":150,"line":227},[148,4841,4842],{"class":182},"// Browser: subscribe as one actor\n",[148,4844,4845,4847,4849,4851,4853,4855,4858],{"class":150,"line":239},[148,4846,323],{"class":154},[148,4848,1589],{"class":235},[148,4850,329],{"class":154},[148,4852,1594],{"class":207},[148,4854,369],{"class":161},[148,4856,4857],{"class":168},"'your_access_token'",[148,4859,375],{"class":161},[148,4861,4862,4864,4866,4868],{"class":150,"line":249},[148,4863,343],{"class":154},[148,4865,4099],{"class":161},[148,4867,2647],{"class":207},[148,4869,352],{"class":161},[148,4871,4872],{"class":150,"line":259},[148,4873,176],{"emptyLinePlaceholder":175},[148,4875,4876,4878,4880,4882,4884,4886,4888,4890,4892,4894,4896,4898],{"class":150,"line":654},[148,4877,323],{"class":154},[148,4879,359],{"class":235},[148,4881,329],{"class":154},[148,4883,4099],{"class":161},[148,4885,2667],{"class":207},[148,4887,369],{"class":161},[148,4889,2672],{"class":235},[148,4891,2675],{"class":161},[148,4893,2678],{"class":207},[148,4895,369],{"class":161},[148,4897,372],{"class":168},[148,4899,375],{"class":161},[148,4901,4902,4904,4906,4908,4910],{"class":150,"line":660},[148,4903,380],{"class":161},[148,4905,2692],{"class":207},[148,4907,369],{"class":161},[148,4909,3098],{"class":168},[148,4911,375],{"class":161},[148,4913,4914,4916,4918,4920,4922,4924,4926,4928,4930],{"class":150,"line":1289},[148,4915,380],{"class":161},[148,4917,2706],{"class":207},[148,4919,369],{"class":161},[148,4921,3098],{"class":168},[148,4923,2713],{"class":161},[148,4925,2716],{"class":217},[148,4927,633],{"class":161},[148,4929,636],{"class":154},[148,4931,211],{"class":161},[148,4933,4934,4936,4938,4940,4942],{"class":150,"line":1295},[148,4935,2514],{"class":161},[148,4937,2517],{"class":207},[148,4939,369],{"class":161},[148,4941,3120],{"class":168},[148,4943,2736],{"class":161},[148,4945,4946],{"class":150,"line":1313},[148,4947,853],{"class":161},[148,4949,4950],{"class":150,"line":1331},[148,4951,176],{"emptyLinePlaceholder":175},[148,4953,4954],{"class":150,"line":1346},[148,4955,4956],{"class":182},"// Server (or any other authorised actor): publish. Publishers never receive\n",[148,4958,4959],{"class":150,"line":1355},[148,4960,4961],{"class":182},"// their own messages, so publish from a different actor than the one listening.\n",[148,4963,4964,4966,4969,4971,4973,4975,4978],{"class":150,"line":1361},[148,4965,323],{"class":154},[148,4967,4968],{"class":235}," server",[148,4970,329],{"class":154},[148,4972,1594],{"class":207},[148,4974,369],{"class":161},[148,4976,4977],{"class":168},"'service_access_token'",[148,4979,375],{"class":161},[148,4981,4982,4984,4987,4989],{"class":150,"line":1370},[148,4983,343],{"class":154},[148,4985,4986],{"class":161}," server.",[148,4988,2647],{"class":207},[148,4990,352],{"class":161},[148,4992,4993,4996,4998,5000,5002,5004,5006,5008,5010,5012,5014,5016,5018,5020,5022],{"class":150,"line":1376},[148,4994,4995],{"class":161},"server.",[148,4997,2667],{"class":207},[148,4999,369],{"class":161},[148,5001,2672],{"class":235},[148,5003,2675],{"class":161},[148,5005,2678],{"class":207},[148,5007,369],{"class":161},[148,5009,372],{"class":168},[148,5011,2675],{"class":161},[148,5013,2807],{"class":207},[148,5015,369],{"class":161},[148,5017,3098],{"class":168},[148,5019,2814],{"class":161},[148,5021,388],{"class":168},[148,5023,2557],{"class":161},[12,5025,5026,5027,5029,5030,5033],{},"The shape is the same. The main differences are that NoLag groups topics inside a ",[30,5028,1421],{}," (so you get natural namespacing and presence per room), access is controlled with ",[30,5031,5032],{},"actors and tokens"," rather than a per-channel auth endpoint, and any authorised actor can publish from the client side, not only your server. A NoLag publisher never hears its own message back, so append it to your UI locally when you send.",[35,5035,5037],{"id":5036},"auth-delete-your-auth-endpoint","Auth: delete your auth endpoint",[12,5039,5040,5041,5044,5045,87,5047,87,5049,87,5051,87,5053,87,5055,694,5057,611],{},"Pusher private and presence channels call back to an auth endpoint you host, on every subscribe. NoLag does not need one. A connection authenticates once with an ",[30,5042,5043],{},"actor access token",", and what that actor can read or write is enforced at the broker with per-topic ACL, so there is no per-subscribe round-trip to your server. Every credential resolves to a typed actor: ",[84,5046,2842],{},[84,5048,2845],{},[84,5050,2848],{},[84,5052,2851],{},[84,5054,2854],{},[84,5056,1254],{},[84,5058,2859],{},[12,5060,5061,5062,5064],{},"For browser clients, mint a short-lived JWT (",[952,5063,3460],{"href":2863},") on your backend, signed with a project signing key. No NoLag API call is needed to issue one, and it expires on its own within minutes, so a long-lived secret never reaches the browser.",[35,5066,5067],{"id":3389},"Presence",[12,5069,5070,5071,5073,5074,5076,5077,87,5079,1812,5081,5083,5084,5087],{},"Pusher presence channels map to NoLag ",[952,5072,3389],{"href":3388},", tracked per room. A client joins the member list by calling ",[84,5075,3393],{},", and everyone else gets ",[84,5078,3397],{},[84,5080,3400],{},[84,5082,3403],{}," events on the client, plus ",[84,5085,5086],{},"room.fetchPresence()"," for the current member list, without standing up your own tracking.",[35,5089,2248],{"id":2247},[882,5091,5092,5097,5103],{},[885,5093,2872,5094,5096],{},[952,5095,2263],{"href":2262}," to connect for the first time.",[885,5098,5099,5100,5102],{},"If you are building chat, the ",[952,5101,4542],{"href":3482}," gives you rooms, who-is-online presence, typing indicators and streamed messages out of the box. It keeps an in-memory cache of the messages it has seen; message history is yours to store.",[885,5104,5105,5106,611],{},"Compare the platforms in more detail: ",[952,5107,5109],{"href":5108},"/compare/pusher","NoLag vs Pusher",[969,5111,5112],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":144,"searchDepth":172,"depth":172,"links":5114},[5115,5116,5117,5118,5119,5120,5121],{"id":2917,"depth":172,"text":2918},{"id":2354,"depth":172,"text":2355},{"id":4667,"depth":172,"text":4668},{"id":2560,"depth":172,"text":2561},{"id":5036,"depth":172,"text":5037},{"id":3389,"depth":172,"text":5067},{"id":2247,"depth":172,"text":2248},"2026-07-29","A practical guide to moving a realtime app from Pusher Channels to NoLag, with a concept map and before-and-after code for subscribing and publishing.",{},"/blog/migrate-from-pusher-to-nolag",{"title":4570,"description":5123},"blog/migrate-from-pusher-to-nolag","BM4Kg579K86xBAie0DLQF52NIXMoxVQ7cqUX5zijc3A",{"id":5130,"title":5131,"author":7,"body":5132,"category":2282,"date":5904,"description":5905,"excerpt":986,"extension":987,"meta":5906,"navigation":175,"path":5907,"readTime":3502,"seo":5908,"stem":5909,"__hash__":5910},"blog/blog/add-realtime-to-react.md","Add Realtime to a React App with NoLag",{"type":9,"value":5133,"toc":5896},[5134,5141,5143,5155,5159,5636,5641,5645,5857,5861,5870,5874,5877,5879,5893],[12,5135,5136,5137,5140],{},"React apps built with Vite, Create React App, or any other bundler can add realtime with a small custom hook. The hook owns the connection lifecycle so your components just render messages and call a publish function. This works in any React setup; if you are on Next.js, see the ",[952,5138,5139],{"href":4564},"Next.js guide"," for the extra server-token step.",[35,5142,3521],{"id":3520},[138,5144,5145],{"className":1891,"code":3524,"language":1893,"meta":144,"style":144},[84,5146,5147],{"__ignoreMap":144},[148,5148,5149,5151,5153],{"class":150,"line":151},[148,5150,3531],{"class":207},[148,5152,1903],{"class":168},[148,5154,3536],{"class":168},[35,5156,5158],{"id":5157},"_2-a-reusable-hook","2. A reusable hook",[138,5160,5162],{"className":3756,"code":5161,"language":3758,"meta":144,"style":144},"import { useEffect, useRef, useState, useCallback } from 'react'\nimport { NoLag } from '@nolag/js-sdk'\nimport type { RoomContext } from '@nolag/js-sdk'\n\ntype Message = { text: string }\n\nexport function useNoLagRoom(token: string, app: string, roomName: string, topic: string) {\n  const [messages, setMessages] = useState([] as Message[])\n  const roomRef = useRef(null as RoomContext | null)\n\n  useEffect(() => {\n    let client: ReturnType\u003Ctypeof NoLag> | undefined\n    let active = true\n\n    ;(async () => {\n      client = NoLag(token)\n      await client.connect()\n      if (!active) return\n\n      const room = client.setApp(app).setRoom(roomName)\n      room.subscribe(topic)\n      room.on\u003CMessage>(topic, (data) => setMessages((m) => [...m, data]))\n      roomRef.current = room\n    })()\n\n    return () => {\n      active = false\n      roomRef.current = null\n      client?.disconnect()\n    }\n  }, [token, app, roomName, topic])\n\n  const publish = useCallback(\n    (data: Message) => {\n      roomRef.current?.emit(topic, data)\n      // Publishers never receive their own messages, so append it locally\n      setMessages((m) => [...m, data])\n    },\n    [topic],\n  )\n\n  return { messages, publish }\n}\n",[84,5163,5164,5175,5185,5197,5201,5219,5223,5269,5295,5319,5323,5333,5353,5363,5367,5379,5389,5399,5411,5415,5435,5444,5479,5487,5491,5495,5505,5513,5521,5529,5533,5538,5542,5556,5573,5583,5588,5608,5612,5617,5621,5625,5632],{"__ignoreMap":144},[148,5165,5166,5168,5171,5173],{"class":150,"line":151},[148,5167,155],{"class":154},[148,5169,5170],{"class":161}," { useEffect, useRef, useState, useCallback } ",[148,5172,165],{"class":154},[148,5174,3777],{"class":168},[148,5176,5177,5179,5181,5183],{"class":150,"line":172},[148,5178,155],{"class":154},[148,5180,2575],{"class":161},[148,5182,165],{"class":154},[148,5184,169],{"class":168},[148,5186,5187,5189,5191,5193,5195],{"class":150,"line":179},[148,5188,155],{"class":154},[148,5190,158],{"class":154},[148,5192,3796],{"class":161},[148,5194,165],{"class":154},[148,5196,169],{"class":168},[148,5198,5199],{"class":150,"line":186},[148,5200,176],{"emptyLinePlaceholder":175},[148,5202,5203,5205,5207,5209,5211,5213,5215,5217],{"class":150,"line":192},[148,5204,3845],{"class":154},[148,5206,3848],{"class":207},[148,5208,329],{"class":154},[148,5210,550],{"class":161},[148,5212,1114],{"class":217},[148,5214,221],{"class":154},[148,5216,558],{"class":235},[148,5218,3861],{"class":161},[148,5220,5221],{"class":150,"line":198},[148,5222,176],{"emptyLinePlaceholder":175},[148,5224,5225,5227,5229,5232,5234,5236,5238,5240,5242,5245,5247,5249,5251,5254,5256,5258,5260,5263,5265,5267],{"class":150,"line":214},[148,5226,201],{"class":154},[148,5228,521],{"class":154},[148,5230,5231],{"class":207}," useNoLagRoom",[148,5233,369],{"class":161},[148,5235,4025],{"class":217},[148,5237,221],{"class":154},[148,5239,558],{"class":235},[148,5241,87],{"class":161},[148,5243,5244],{"class":217},"app",[148,5246,221],{"class":154},[148,5248,558],{"class":235},[148,5250,87],{"class":161},[148,5252,5253],{"class":217},"roomName",[148,5255,221],{"class":154},[148,5257,558],{"class":235},[148,5259,87],{"class":161},[148,5261,5262],{"class":217},"topic",[148,5264,221],{"class":154},[148,5266,558],{"class":235},[148,5268,600],{"class":161},[148,5270,5271,5273,5275,5277,5279,5281,5283,5285,5287,5289,5291,5293],{"class":150,"line":227},[148,5272,3596],{"class":154},[148,5274,3886],{"class":161},[148,5276,3889],{"class":235},[148,5278,87],{"class":161},[148,5280,3894],{"class":235},[148,5282,3897],{"class":161},[148,5284,1337],{"class":154},[148,5286,3902],{"class":207},[148,5288,3905],{"class":161},[148,5290,3908],{"class":154},[148,5292,3848],{"class":207},[148,5294,3913],{"class":161},[148,5296,5297,5299,5301,5303,5305,5307,5309,5311,5313,5315,5317],{"class":150,"line":239},[148,5298,3596],{"class":154},[148,5300,3920],{"class":235},[148,5302,329],{"class":154},[148,5304,3925],{"class":207},[148,5306,369],{"class":161},[148,5308,3930],{"class":235},[148,5310,3024],{"class":154},[148,5312,3935],{"class":207},[148,5314,3938],{"class":154},[148,5316,3941],{"class":235},[148,5318,375],{"class":161},[148,5320,5321],{"class":150,"line":249},[148,5322,176],{"emptyLinePlaceholder":175},[148,5324,5325,5327,5329,5331],{"class":150,"line":259},[148,5326,3952],{"class":207},[148,5328,3955],{"class":161},[148,5330,636],{"class":154},[148,5332,211],{"class":161},[148,5334,5335,5337,5339,5341,5343,5345,5347,5349,5351],{"class":150,"line":654},[148,5336,3964],{"class":154},[148,5338,1589],{"class":161},[148,5340,221],{"class":154},[148,5342,3971],{"class":207},[148,5344,572],{"class":161},[148,5346,3976],{"class":154},[148,5348,3979],{"class":161},[148,5350,3982],{"class":154},[148,5352,3985],{"class":235},[148,5354,5355,5357,5359,5361],{"class":150,"line":660},[148,5356,3964],{"class":154},[148,5358,3992],{"class":161},[148,5360,1337],{"class":154},[148,5362,3997],{"class":235},[148,5364,5365],{"class":150,"line":1289},[148,5366,176],{"emptyLinePlaceholder":175},[148,5368,5369,5371,5373,5375,5377],{"class":150,"line":1295},[148,5370,4006],{"class":161},[148,5372,1447],{"class":154},[148,5374,4011],{"class":161},[148,5376,636],{"class":154},[148,5378,211],{"class":161},[148,5380,5381,5383,5385,5387],{"class":150,"line":1313},[148,5382,4084],{"class":161},[148,5384,1337],{"class":154},[148,5386,1594],{"class":207},[148,5388,4091],{"class":161},[148,5390,5391,5393,5395,5397],{"class":150,"line":1331},[148,5392,4096],{"class":154},[148,5394,4099],{"class":161},[148,5396,2647],{"class":207},[148,5398,352],{"class":161},[148,5400,5401,5403,5405,5407,5409],{"class":150,"line":1346},[148,5402,4064],{"class":154},[148,5404,114],{"class":161},[148,5406,4069],{"class":154},[148,5408,4072],{"class":161},[148,5410,4075],{"class":154},[148,5412,5413],{"class":150,"line":1355},[148,5414,176],{"emptyLinePlaceholder":175},[148,5416,5417,5419,5421,5423,5425,5427,5430,5432],{"class":150,"line":1361},[148,5418,4020],{"class":154},[148,5420,359],{"class":235},[148,5422,329],{"class":154},[148,5424,4099],{"class":161},[148,5426,2667],{"class":207},[148,5428,5429],{"class":161},"(app).",[148,5431,2678],{"class":207},[148,5433,5434],{"class":161},"(roomName)\n",[148,5436,5437,5439,5441],{"class":150,"line":1370},[148,5438,4138],{"class":161},[148,5440,2692],{"class":207},[148,5442,5443],{"class":161},"(topic)\n",[148,5445,5446,5448,5450,5452,5454,5457,5459,5461,5463,5465,5467,5469,5471,5473,5475,5477],{"class":150,"line":1376},[148,5447,4138],{"class":161},[148,5449,2706],{"class":207},[148,5451,572],{"class":161},[148,5453,4157],{"class":207},[148,5455,5456],{"class":161},">(topic, (",[148,5458,2716],{"class":217},[148,5460,633],{"class":161},[148,5462,636],{"class":154},[148,5464,4173],{"class":207},[148,5466,627],{"class":161},[148,5468,4178],{"class":217},[148,5470,633],{"class":161},[148,5472,636],{"class":154},[148,5474,3886],{"class":161},[148,5476,1307],{"class":154},[148,5478,4189],{"class":161},[148,5480,5481,5483,5485],{"class":150,"line":2033},[148,5482,4194],{"class":161},[148,5484,1337],{"class":154},[148,5486,4199],{"class":161},[148,5488,5489],{"class":150,"line":2039},[148,5490,4204],{"class":161},[148,5492,5493],{"class":150,"line":2044},[148,5494,176],{"emptyLinePlaceholder":175},[148,5496,5497,5499,5501,5503],{"class":150,"line":2050},[148,5498,4213],{"class":154},[148,5500,4011],{"class":161},[148,5502,636],{"class":154},[148,5504,211],{"class":161},[148,5506,5507,5509,5511],{"class":150,"line":2056},[148,5508,4224],{"class":161},[148,5510,1337],{"class":154},[148,5512,4229],{"class":235},[148,5514,5515,5517,5519],{"class":150,"line":2062},[148,5516,4194],{"class":161},[148,5518,1337],{"class":154},[148,5520,4238],{"class":235},[148,5522,5523,5525,5527],{"class":150,"line":2068},[148,5524,4243],{"class":161},[148,5526,4246],{"class":207},[148,5528,352],{"class":161},[148,5530,5531],{"class":150,"line":2074},[148,5532,4253],{"class":161},[148,5534,5535],{"class":150,"line":2080},[148,5536,5537],{"class":161},"  }, [token, app, roomName, topic])\n",[148,5539,5540],{"class":150,"line":2086},[148,5541,176],{"emptyLinePlaceholder":175},[148,5543,5544,5546,5549,5551,5554],{"class":150,"line":2092},[148,5545,3596],{"class":154},[148,5547,5548],{"class":235}," publish",[148,5550,329],{"class":154},[148,5552,5553],{"class":207}," useCallback",[148,5555,527],{"class":161},[148,5557,5558,5561,5563,5565,5567,5569,5571],{"class":150,"line":2097},[148,5559,5560],{"class":161},"    (",[148,5562,2716],{"class":217},[148,5564,221],{"class":154},[148,5566,3848],{"class":207},[148,5568,633],{"class":161},[148,5570,636],{"class":154},[148,5572,211],{"class":161},[148,5574,5575,5578,5580],{"class":150,"line":2103},[148,5576,5577],{"class":161},"      roomRef.current?.",[148,5579,2807],{"class":207},[148,5581,5582],{"class":161},"(topic, data)\n",[148,5584,5585],{"class":150,"line":2108},[148,5586,5587],{"class":182},"      // Publishers never receive their own messages, so append it locally\n",[148,5589,5590,5593,5595,5597,5599,5601,5603,5605],{"class":150,"line":2114},[148,5591,5592],{"class":207},"      setMessages",[148,5594,627],{"class":161},[148,5596,4178],{"class":217},[148,5598,633],{"class":161},[148,5600,636],{"class":154},[148,5602,3886],{"class":161},[148,5604,1307],{"class":154},[148,5606,5607],{"class":161},"m, data])\n",[148,5609,5610],{"class":150,"line":2120},[148,5611,3672],{"class":161},[148,5613,5614],{"class":150,"line":2125},[148,5615,5616],{"class":161},"    [topic],\n",[148,5618,5619],{"class":150,"line":4261},[148,5620,657],{"class":161},[148,5622,5623],{"class":150,"line":4266},[148,5624,176],{"emptyLinePlaceholder":175},[148,5626,5627,5629],{"class":150,"line":4290},[148,5628,605],{"class":154},[148,5630,5631],{"class":161}," { messages, publish }\n",[148,5633,5634],{"class":150,"line":4307},[148,5635,262],{"class":161},[12,5637,1533,5638,5640],{},[84,5639,3138],{}," function appends the message to local state as well as emitting it. NoLag never delivers a message back to the actor that published it, so without that line the sender's own text would never show up in their list.",[35,5642,5644],{"id":5643},"_3-use-it-in-a-component","3. Use it in a component",[138,5646,5648],{"className":3756,"code":5647,"language":3758,"meta":144,"style":144},"// Create the app and its `general` room first. The slug you get back has a\n// random suffix (for example `chat-a3f9`), and that suffixed slug is the app\n// name to pass.\nconst APP_SLUG = 'chat-a3f9'\n\nfunction Chat({ token }: { token: string }) {\n  const { messages, publish } = useNoLagRoom(token, APP_SLUG, 'general', 'message')\n\n  return (\n    \u003Cdiv>\n      \u003Cul>\n        {messages.map((m, i) => (\n          \u003Cli key={i}>{m.text}\u003C/li>\n        ))}\n      \u003C/ul>\n      \u003Cbutton onClick={() => publish({ text: 'Hello!' })}>Send\u003C/button>\n    \u003C/div>\n  )\n}\n",[84,5649,5650,5654,5659,5664,5674,5678,5706,5739,5743,5749,5757,5765,5785,5801,5805,5813,5841,5849,5853],{"__ignoreMap":144},[148,5651,5652],{"class":150,"line":151},[148,5653,3809],{"class":182},[148,5655,5656],{"class":150,"line":172},[148,5657,5658],{"class":182},"// random suffix (for example `chat-a3f9`), and that suffixed slug is the app\n",[148,5660,5661],{"class":150,"line":179},[148,5662,5663],{"class":182},"// name to pass.\n",[148,5665,5666,5668,5670,5672],{"class":150,"line":186},[148,5667,323],{"class":154},[148,5669,2605],{"class":235},[148,5671,329],{"class":154},[148,5673,2610],{"class":168},[148,5675,5676],{"class":150,"line":192},[148,5677,176],{"emptyLinePlaceholder":175},[148,5679,5680,5683,5685,5688,5690,5693,5695,5697,5699,5701,5703],{"class":150,"line":198},[148,5681,5682],{"class":154},"function",[148,5684,3877],{"class":207},[148,5686,5687],{"class":161},"({ ",[148,5689,4025],{"class":217},[148,5691,5692],{"class":161}," }",[148,5694,221],{"class":154},[148,5696,550],{"class":161},[148,5698,4025],{"class":217},[148,5700,221],{"class":154},[148,5702,558],{"class":235},[148,5704,5705],{"class":161}," }) {\n",[148,5707,5708,5710,5712,5714,5716,5718,5720,5722,5724,5727,5729,5731,5733,5735,5737],{"class":150,"line":214},[148,5709,3596],{"class":154},[148,5711,550],{"class":161},[148,5713,3889],{"class":235},[148,5715,87],{"class":161},[148,5717,3138],{"class":235},[148,5719,1485],{"class":161},[148,5721,1337],{"class":154},[148,5723,5231],{"class":207},[148,5725,5726],{"class":161},"(token, ",[148,5728,2672],{"class":235},[148,5730,87],{"class":161},[148,5732,372],{"class":168},[148,5734,87],{"class":161},[148,5736,3098],{"class":168},[148,5738,375],{"class":161},[148,5740,5741],{"class":150,"line":227},[148,5742,176],{"emptyLinePlaceholder":175},[148,5744,5745,5747],{"class":150,"line":239},[148,5746,605],{"class":154},[148,5748,4364],{"class":161},[148,5750,5751,5753,5755],{"class":150,"line":249},[148,5752,4370],{"class":161},[148,5754,4374],{"class":4373},[148,5756,4377],{"class":161},[148,5758,5759,5761,5763],{"class":150,"line":259},[148,5760,4383],{"class":161},[148,5762,882],{"class":4373},[148,5764,4377],{"class":161},[148,5766,5767,5769,5771,5773,5775,5777,5779,5781,5783],{"class":150,"line":654},[148,5768,4393],{"class":161},[148,5770,624],{"class":207},[148,5772,627],{"class":161},[148,5774,4178],{"class":217},[148,5776,87],{"class":161},[148,5778,4404],{"class":217},[148,5780,633],{"class":161},[148,5782,636],{"class":154},[148,5784,4364],{"class":161},[148,5786,5787,5789,5791,5793,5795,5797,5799],{"class":150,"line":660},[148,5788,4416],{"class":161},[148,5790,885],{"class":4373},[148,5792,4421],{"class":207},[148,5794,1337],{"class":154},[148,5796,4426],{"class":161},[148,5798,885],{"class":4373},[148,5800,4377],{"class":161},[148,5802,5803],{"class":150,"line":1289},[148,5804,4436],{"class":161},[148,5806,5807,5809,5811],{"class":150,"line":1295},[148,5808,4442],{"class":161},[148,5810,882],{"class":4373},[148,5812,4377],{"class":161},[148,5814,5815,5817,5819,5821,5823,5825,5827,5829,5832,5834,5837,5839],{"class":150,"line":1313},[148,5816,4383],{"class":161},[148,5818,4454],{"class":4373},[148,5820,4457],{"class":207},[148,5822,1337],{"class":154},[148,5824,4462],{"class":161},[148,5826,636],{"class":154},[148,5828,5548],{"class":207},[148,5830,5831],{"class":161},"({ text: ",[148,5833,388],{"class":168},[148,5835,5836],{"class":161}," })}>Send\u003C/",[148,5838,4454],{"class":4373},[148,5840,4377],{"class":161},[148,5842,5843,5845,5847],{"class":150,"line":1331},[148,5844,4483],{"class":161},[148,5846,4374],{"class":4373},[148,5848,4377],{"class":161},[148,5850,5851],{"class":150,"line":1346},[148,5852,657],{"class":161},[148,5854,5855],{"class":150,"line":1355},[148,5856,262],{"class":161},[35,5858,5860],{"id":5859},"keep-the-token-off-the-client","Keep the token off the client",[12,5862,5863,5864,5866,5867,5869],{},"Fetch the ",[84,5865,4025],{}," from your own backend rather than hardcoding a project key in the bundle. Any server can mint one by calling the NoLag REST API, and for browser sessions the production pattern is a short-lived ",[952,5868,3460],{"href":2863}," scoped to what that user may do.",[35,5871,5873],{"id":5872},"why-the-cleanup-matters","Why the cleanup matters",[12,5875,5876],{},"The hook's cleanup function runs on unmount and when the dependencies change. Clearing the room ref and disconnecting prevents duplicate subscriptions and leaked WebSocket connections as users navigate around your app.",[35,5878,2248],{"id":2247},[882,5880,5881,5886],{},[885,5882,5883,5884,611],{},"Walk through the full flow in the ",[952,5885,2263],{"href":2262},[885,5887,5888,5889,5892],{},"Use a ",[952,5890,5891],{"href":3482},"blueprint SDK"," if you would rather not wire chat, notifications, or dashboards by hand.",[969,5894,5895],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":144,"searchDepth":172,"depth":172,"links":5897},[5898,5899,5900,5901,5902,5903],{"id":3520,"depth":172,"text":3521},{"id":5157,"depth":172,"text":5158},{"id":5643,"depth":172,"text":5644},{"id":5859,"depth":172,"text":5860},{"id":5872,"depth":172,"text":5873},{"id":2247,"depth":172,"text":2248},"2026-07-28","How to add realtime messaging to any React app with NoLag using a small reusable hook that handles connect, subscribe, and cleanup.",{},"/blog/add-realtime-to-react",{"title":5131,"description":5905},"blog/add-realtime-to-react","YUI-jNKwW0QAersgtON2S4voIfDTb9ZvZGGThoRykJA",{"id":5912,"title":5913,"author":7,"body":5914,"category":7894,"date":7895,"description":7896,"excerpt":986,"extension":987,"meta":7897,"navigation":175,"path":7898,"readTime":2287,"seo":7899,"stem":7900,"__hash__":7901},"blog/blog/connect-local-llm.md","Connect Your Local LLM to NoLag in 5 Minutes",{"type":9,"value":5915,"toc":7879},[5916,5919,5922,5926,5929,5943,5946,5950,6006,6010,6013,6033,6036,6498,6510,6514,6517,6868,6875,6879,6882,6897,7281,7284,7454,7465,7469,7472,7475,7722,7725,7729,7740,7744,7747,7787,7790,7794,7801,7805,7808,7822,7825,7828,7862,7865,7876],[12,5917,5918],{},"You have a local model running. Ollama, llama.cpp, vLLM, it doesn't matter. It answers prompts on localhost. Now you want other parts of your system to send it work and get results back. The usual next step is to reach for a framework: LangChain, CrewAI, AutoGen. That's a lot of machinery for what is fundamentally a messaging problem.",[12,5920,5921],{},"This tutorial skips the framework. You'll wire up your local model as a NoLag worker agent in under 5 minutes. The model handles inference. NoLag handles the transport: getting tasks to the model and results back to whoever asked.",[35,5923,5925],{"id":5924},"what-youll-build","What You'll Build",[12,5927,5928],{},"A local Ollama worker that:",[882,5930,5931,5934,5937,5940],{},[885,5932,5933],{},"Connects to NoLag and advertises its capabilities via presence",[885,5935,5936],{},"Picks up tasks dispatched by any other agent in the system",[885,5938,5939],{},"Calls your local model and sends results back",[885,5941,5942],{},"Shows up in the NoLag portal dashboard so you can see it's alive",[12,5944,5945],{},"Then you'll add a second model with different capabilities and watch tasks reach the right worker. Zero changes to the dispatching code.",[35,5947,5949],{"id":5948},"prerequisites","Prerequisites",[914,5951,5952,5960,5968,6001],{},[885,5953,5954,3662,5957],{},[30,5955,5956],{},"Ollama installed",[84,5958,5959],{},"curl -fsSL https://ollama.com/install.sh | sh",[885,5961,5962,3662,5965],{},[30,5963,5964],{},"A model pulled",[84,5966,5967],{},"ollama pull llama3.2",[885,5969,5970,5973,5974,5977,5978,5982,5983,5985,5986,5988,5989,5992,5993,5996,5997,6000],{},[30,5971,5972],{},"A NoLag account",": create an app from the ",[84,5975,5976],{},"nolag-agents-sdk"," blueprint in the ",[952,5979,5981],{"href":5980},"/pricing","portal"," and create one actor of type ",[84,5984,2854],{}," per worker plus one of type ",[84,5987,1254],{}," for the dispatcher. The app slug you get back has a random suffix (",[84,5990,5991],{},"my-agents-app-a3f9","); that suffixed slug is the ",[84,5994,5995],{},"appName"," below. The blueprint seeds a ",[84,5998,5999],{},"default-workflow"," room, which is the room this tutorial uses.",[885,6002,6003],{},[30,6004,6005],{},"Node.js 18+",[35,6007,6009],{"id":6008},"step-1-create-the-worker","Step 1: Create the Worker",[12,6011,6012],{},"Install the dependencies:",[138,6014,6016],{"className":1891,"code":6015,"language":1893,"meta":144,"style":144},"npm install @nolag/agents @nolag/js-sdk ollama\n",[84,6017,6018],{"__ignoreMap":144},[148,6019,6020,6022,6024,6027,6030],{"class":150,"line":151},[148,6021,3531],{"class":207},[148,6023,1903],{"class":168},[148,6025,6026],{"class":168}," @nolag/agents",[148,6028,6029],{"class":168}," @nolag/js-sdk",[148,6031,6032],{"class":168}," ollama\n",[12,6034,6035],{},"The worker connects to NoLag, declares what it can do, and listens for matching tasks. When a task arrives, it calls Ollama and sends the result back.",[135,6037,6038],{},[138,6039,6042],{"className":140,"code":6040,"filename":6041,"language":143,"meta":144,"style":144},"import { NoLag } from \"@nolag/js-sdk\";\nimport { NoLagAgents, Handoff } from \"@nolag/agents\";\nimport { Ollama } from \"ollama\";\n\nconst ollama = new Ollama({ host: \"http://localhost:11434\" });\n\nconst client = NoLag(WORKER_TOKEN);\nconst worker = new NoLagAgents({\n  client,\n  appName: APP_SLUG, // the suffixed slug returned when you created the app\n  agentId: \"llama-worker\",\n  presence: {\n    name: \"llama-worker\",\n    role: \"agent\",\n    capabilities: [\"summarize\", \"classify\"],\n    metadata: { model: \"llama3.2\", provider: \"local\" },\n  },\n});\n\nawait client.connect();\nawait worker.ready();\nconst room = worker.room(\"default-workflow\");\nconst handoff = new Handoff(room);\n\nhandoff.onTask([\"summarize\", \"classify\"], async (task, respond) => {\n  const response = await ollama.chat({\n    model: \"llama3.2\",\n    messages: [\n      { role: \"system\", content: `You are a ${task.capability} agent.` },\n      { role: \"user\", content: String(task.payload.text) },\n    ],\n  });\n\n  respond(\"success\", {\n    result: response.message.content,\n    model: \"llama3.2\",\n    latencyMs: Date.now() - task.createdAt,\n  });\n});\n\nconsole.log(\"Worker online. Waiting for tasks...\");\n","worker.ts",[84,6043,6044,6058,6072,6086,6090,6113,6117,6135,6151,6156,6168,6178,6183,6192,6202,6218,6235,6240,6245,6249,6260,6271,6290,6306,6310,6343,6362,6371,6376,6401,6416,6421,6426,6430,6442,6447,6455,6472,6476,6480,6484],{"__ignoreMap":144},[148,6045,6046,6048,6050,6052,6055],{"class":150,"line":151},[148,6047,155],{"class":154},[148,6049,2575],{"class":161},[148,6051,165],{"class":154},[148,6053,6054],{"class":168}," \"@nolag/js-sdk\"",[148,6056,6057],{"class":161},";\n",[148,6059,6060,6062,6065,6067,6070],{"class":150,"line":172},[148,6061,155],{"class":154},[148,6063,6064],{"class":161}," { NoLagAgents, Handoff } ",[148,6066,165],{"class":154},[148,6068,6069],{"class":168}," \"@nolag/agents\"",[148,6071,6057],{"class":161},[148,6073,6074,6076,6079,6081,6084],{"class":150,"line":179},[148,6075,155],{"class":154},[148,6077,6078],{"class":161}," { Ollama } ",[148,6080,165],{"class":154},[148,6082,6083],{"class":168}," \"ollama\"",[148,6085,6057],{"class":161},[148,6087,6088],{"class":150,"line":186},[148,6089,176],{"emptyLinePlaceholder":175},[148,6091,6092,6094,6097,6099,6101,6104,6107,6110],{"class":150,"line":192},[148,6093,323],{"class":154},[148,6095,6096],{"class":235}," ollama",[148,6098,329],{"class":154},[148,6100,332],{"class":154},[148,6102,6103],{"class":207}," Ollama",[148,6105,6106],{"class":161},"({ host: ",[148,6108,6109],{"class":168},"\"http://localhost:11434\"",[148,6111,6112],{"class":161}," });\n",[148,6114,6115],{"class":150,"line":198},[148,6116,176],{"emptyLinePlaceholder":175},[148,6118,6119,6121,6123,6125,6127,6129,6132],{"class":150,"line":214},[148,6120,323],{"class":154},[148,6122,1589],{"class":235},[148,6124,329],{"class":154},[148,6126,1594],{"class":207},[148,6128,369],{"class":161},[148,6130,6131],{"class":235},"WORKER_TOKEN",[148,6133,6134],{"class":161},");\n",[148,6136,6137,6139,6142,6144,6146,6149],{"class":150,"line":227},[148,6138,323],{"class":154},[148,6140,6141],{"class":235}," worker",[148,6143,329],{"class":154},[148,6145,332],{"class":154},[148,6147,6148],{"class":207}," NoLagAgents",[148,6150,1343],{"class":161},[148,6152,6153],{"class":150,"line":239},[148,6154,6155],{"class":161},"  client,\n",[148,6157,6158,6161,6163,6165],{"class":150,"line":249},[148,6159,6160],{"class":161},"  appName: ",[148,6162,2672],{"class":235},[148,6164,87],{"class":161},[148,6166,6167],{"class":182},"// the suffixed slug returned when you created the app\n",[148,6169,6170,6173,6176],{"class":150,"line":259},[148,6171,6172],{"class":161},"  agentId: ",[148,6174,6175],{"class":168},"\"llama-worker\"",[148,6177,540],{"class":161},[148,6179,6180],{"class":150,"line":654},[148,6181,6182],{"class":161},"  presence: {\n",[148,6184,6185,6188,6190],{"class":150,"line":660},[148,6186,6187],{"class":161},"    name: ",[148,6189,6175],{"class":168},[148,6191,540],{"class":161},[148,6193,6194,6197,6200],{"class":150,"line":1289},[148,6195,6196],{"class":161},"    role: ",[148,6198,6199],{"class":168},"\"agent\"",[148,6201,540],{"class":161},[148,6203,6204,6207,6210,6212,6215],{"class":150,"line":1295},[148,6205,6206],{"class":161},"    capabilities: [",[148,6208,6209],{"class":168},"\"summarize\"",[148,6211,87],{"class":161},[148,6213,6214],{"class":168},"\"classify\"",[148,6216,6217],{"class":161},"],\n",[148,6219,6220,6223,6226,6229,6232],{"class":150,"line":1313},[148,6221,6222],{"class":161},"    metadata: { model: ",[148,6224,6225],{"class":168},"\"llama3.2\"",[148,6227,6228],{"class":161},", provider: ",[148,6230,6231],{"class":168},"\"local\"",[148,6233,6234],{"class":161}," },\n",[148,6236,6237],{"class":150,"line":1331},[148,6238,6239],{"class":161},"  },\n",[148,6241,6242],{"class":150,"line":1346},[148,6243,6244],{"class":161},"});\n",[148,6246,6247],{"class":150,"line":1355},[148,6248,176],{"emptyLinePlaceholder":175},[148,6250,6251,6253,6255,6257],{"class":150,"line":1361},[148,6252,343],{"class":154},[148,6254,4099],{"class":161},[148,6256,2647],{"class":207},[148,6258,6259],{"class":161},"();\n",[148,6261,6262,6264,6267,6269],{"class":150,"line":1370},[148,6263,343],{"class":154},[148,6265,6266],{"class":161}," worker.",[148,6268,349],{"class":207},[148,6270,6259],{"class":161},[148,6272,6273,6275,6277,6279,6281,6283,6285,6288],{"class":150,"line":1376},[148,6274,323],{"class":154},[148,6276,359],{"class":235},[148,6278,329],{"class":154},[148,6280,6266],{"class":161},[148,6282,1421],{"class":207},[148,6284,369],{"class":161},[148,6286,6287],{"class":168},"\"default-workflow\"",[148,6289,6134],{"class":161},[148,6291,6292,6294,6297,6299,6301,6303],{"class":150,"line":2033},[148,6293,323],{"class":154},[148,6295,6296],{"class":235}," handoff",[148,6298,329],{"class":154},[148,6300,332],{"class":154},[148,6302,537],{"class":207},[148,6304,6305],{"class":161},"(room);\n",[148,6307,6308],{"class":150,"line":2039},[148,6309,176],{"emptyLinePlaceholder":175},[148,6311,6312,6315,6317,6319,6321,6323,6325,6327,6329,6331,6333,6335,6337,6339,6341],{"class":150,"line":2044},[148,6313,6314],{"class":161},"handoff.",[148,6316,1435],{"class":207},[148,6318,1438],{"class":161},[148,6320,6209],{"class":168},[148,6322,87],{"class":161},[148,6324,6214],{"class":168},[148,6326,1444],{"class":161},[148,6328,1447],{"class":154},[148,6330,114],{"class":161},[148,6332,1452],{"class":217},[148,6334,87],{"class":161},[148,6336,790],{"class":217},[148,6338,633],{"class":161},[148,6340,636],{"class":154},[148,6342,211],{"class":161},[148,6344,6345,6347,6350,6352,6354,6357,6360],{"class":150,"line":2050},[148,6346,3596],{"class":154},[148,6348,6349],{"class":235}," response",[148,6351,329],{"class":154},[148,6353,1502],{"class":154},[148,6355,6356],{"class":161}," ollama.",[148,6358,6359],{"class":207},"chat",[148,6361,1343],{"class":161},[148,6363,6364,6367,6369],{"class":150,"line":2056},[148,6365,6366],{"class":161},"    model: ",[148,6368,6225],{"class":168},[148,6370,540],{"class":161},[148,6372,6373],{"class":150,"line":2062},[148,6374,6375],{"class":161},"    messages: [\n",[148,6377,6378,6381,6384,6387,6390,6392,6394,6396,6399],{"class":150,"line":2068},[148,6379,6380],{"class":161},"      { role: ",[148,6382,6383],{"class":168},"\"system\"",[148,6385,6386],{"class":161},", content: ",[148,6388,6389],{"class":168},"`You are a ${",[148,6391,1452],{"class":161},[148,6393,611],{"class":168},[148,6395,553],{"class":161},[148,6397,6398],{"class":168},"} agent.`",[148,6400,6234],{"class":161},[148,6402,6403,6405,6408,6410,6413],{"class":150,"line":2074},[148,6404,6380],{"class":161},[148,6406,6407],{"class":168},"\"user\"",[148,6409,6386],{"class":161},[148,6411,6412],{"class":207},"String",[148,6414,6415],{"class":161},"(task.payload.text) },\n",[148,6417,6418],{"class":150,"line":2080},[148,6419,6420],{"class":161},"    ],\n",[148,6422,6423],{"class":150,"line":2086},[148,6424,6425],{"class":161},"  });\n",[148,6427,6428],{"class":150,"line":2092},[148,6429,176],{"emptyLinePlaceholder":175},[148,6431,6432,6435,6437,6440],{"class":150,"line":2097},[148,6433,6434],{"class":207},"  respond",[148,6436,369],{"class":161},[148,6438,6439],{"class":168},"\"success\"",[148,6441,3614],{"class":161},[148,6443,6444],{"class":150,"line":2103},[148,6445,6446],{"class":161},"    result: response.message.content,\n",[148,6448,6449,6451,6453],{"class":150,"line":2108},[148,6450,6366],{"class":161},[148,6452,6225],{"class":168},[148,6454,540],{"class":161},[148,6456,6457,6460,6463,6466,6469],{"class":150,"line":2114},[148,6458,6459],{"class":161},"    latencyMs: Date.",[148,6461,6462],{"class":207},"now",[148,6464,6465],{"class":161},"() ",[148,6467,6468],{"class":154},"-",[148,6470,6471],{"class":161}," task.createdAt,\n",[148,6473,6474],{"class":150,"line":2120},[148,6475,6425],{"class":161},[148,6477,6478],{"class":150,"line":2125},[148,6479,6244],{"class":161},[148,6481,6482],{"class":150,"line":4261},[148,6483,176],{"emptyLinePlaceholder":175},[148,6485,6486,6489,6491,6493,6496],{"class":150,"line":4266},[148,6487,6488],{"class":161},"console.",[148,6490,2517],{"class":207},[148,6492,369],{"class":161},[148,6494,6495],{"class":168},"\"Worker online. Waiting for tasks...\"",[148,6497,6134],{"class":161},[12,6499,6500,6501,6503,6504,6506,6507,6509],{},"That's the entire worker. No routing table, no queue to operate. The ",[84,6502,3389],{}," block tells the system what this worker can do, and ",[84,6505,1435],{}," keeps only the tasks whose ",[84,6508,553],{}," matches. Every task in the room reaches every worker; the capability filter decides which one acts on it.",[35,6511,6513],{"id":6512},"step-2-dispatch-a-task","Step 2: Dispatch a Task",[12,6515,6516],{},"From any other process (a backend service, a CLI script, another agent) dispatch a task by capability:",[135,6518,6519],{},[138,6520,6523],{"className":140,"code":6521,"filename":6522,"language":143,"meta":144,"style":144},"import { NoLag } from \"@nolag/js-sdk\";\nimport { NoLagAgents, Handoff } from \"@nolag/agents\";\n\nconst client = NoLag(ORCHESTRATOR_TOKEN);\nconst orchestrator = new NoLagAgents({\n  client,\n  appName: APP_SLUG,\n  agentId: \"orchestrator\",\n  presence: { name: \"orchestrator\", role: \"orchestrator\" },\n});\n\nawait client.connect();\nawait orchestrator.ready();\nconst room = orchestrator.room(\"default-workflow\");\nconst handoff = new Handoff(room);\n\n// Check who's online\nconst workers = room.findAgents(\"summarize\");\nconsole.log(`${workers.length} summarize worker(s) online`);\n\n// Dispatch a task. dispatch() checks presence first and throws if no\n// connected worker advertises the capability.\nconst result = await handoff.dispatch(\"summarize\", {\n  text: \"NoLag is a real-time messaging infrastructure...\"\n}, {\n  waitForResult: true,\n  timeout: 30000,\n});\n\nif (result) {\n  console.log(\"Result:\", result.payload.result);\n  console.log(\"Model:\", result.payload.model);\n  console.log(\"Latency:\", result.payload.latencyMs, \"ms\");\n}\n","dispatch.ts",[84,6524,6525,6537,6549,6553,6570,6585,6589,6597,6606,6620,6624,6628,6638,6649,6667,6681,6685,6690,6711,6734,6738,6743,6748,6769,6777,6782,6791,6801,6805,6809,6817,6831,6845,6864],{"__ignoreMap":144},[148,6526,6527,6529,6531,6533,6535],{"class":150,"line":151},[148,6528,155],{"class":154},[148,6530,2575],{"class":161},[148,6532,165],{"class":154},[148,6534,6054],{"class":168},[148,6536,6057],{"class":161},[148,6538,6539,6541,6543,6545,6547],{"class":150,"line":172},[148,6540,155],{"class":154},[148,6542,6064],{"class":161},[148,6544,165],{"class":154},[148,6546,6069],{"class":168},[148,6548,6057],{"class":161},[148,6550,6551],{"class":150,"line":179},[148,6552,176],{"emptyLinePlaceholder":175},[148,6554,6555,6557,6559,6561,6563,6565,6568],{"class":150,"line":186},[148,6556,323],{"class":154},[148,6558,1589],{"class":235},[148,6560,329],{"class":154},[148,6562,1594],{"class":207},[148,6564,369],{"class":161},[148,6566,6567],{"class":235},"ORCHESTRATOR_TOKEN",[148,6569,6134],{"class":161},[148,6571,6572,6574,6577,6579,6581,6583],{"class":150,"line":192},[148,6573,323],{"class":154},[148,6575,6576],{"class":235}," orchestrator",[148,6578,329],{"class":154},[148,6580,332],{"class":154},[148,6582,6148],{"class":207},[148,6584,1343],{"class":161},[148,6586,6587],{"class":150,"line":198},[148,6588,6155],{"class":161},[148,6590,6591,6593,6595],{"class":150,"line":214},[148,6592,6160],{"class":161},[148,6594,2672],{"class":235},[148,6596,540],{"class":161},[148,6598,6599,6601,6604],{"class":150,"line":227},[148,6600,6172],{"class":161},[148,6602,6603],{"class":168},"\"orchestrator\"",[148,6605,540],{"class":161},[148,6607,6608,6611,6613,6616,6618],{"class":150,"line":239},[148,6609,6610],{"class":161},"  presence: { name: ",[148,6612,6603],{"class":168},[148,6614,6615],{"class":161},", role: ",[148,6617,6603],{"class":168},[148,6619,6234],{"class":161},[148,6621,6622],{"class":150,"line":249},[148,6623,6244],{"class":161},[148,6625,6626],{"class":150,"line":259},[148,6627,176],{"emptyLinePlaceholder":175},[148,6629,6630,6632,6634,6636],{"class":150,"line":654},[148,6631,343],{"class":154},[148,6633,4099],{"class":161},[148,6635,2647],{"class":207},[148,6637,6259],{"class":161},[148,6639,6640,6642,6645,6647],{"class":150,"line":660},[148,6641,343],{"class":154},[148,6643,6644],{"class":161}," orchestrator.",[148,6646,349],{"class":207},[148,6648,6259],{"class":161},[148,6650,6651,6653,6655,6657,6659,6661,6663,6665],{"class":150,"line":1289},[148,6652,323],{"class":154},[148,6654,359],{"class":235},[148,6656,329],{"class":154},[148,6658,6644],{"class":161},[148,6660,1421],{"class":207},[148,6662,369],{"class":161},[148,6664,6287],{"class":168},[148,6666,6134],{"class":161},[148,6668,6669,6671,6673,6675,6677,6679],{"class":150,"line":1295},[148,6670,323],{"class":154},[148,6672,6296],{"class":235},[148,6674,329],{"class":154},[148,6676,332],{"class":154},[148,6678,537],{"class":207},[148,6680,6305],{"class":161},[148,6682,6683],{"class":150,"line":1313},[148,6684,176],{"emptyLinePlaceholder":175},[148,6686,6687],{"class":150,"line":1331},[148,6688,6689],{"class":182},"// Check who's online\n",[148,6691,6692,6694,6697,6699,6702,6705,6707,6709],{"class":150,"line":1346},[148,6693,323],{"class":154},[148,6695,6696],{"class":235}," workers",[148,6698,329],{"class":154},[148,6700,6701],{"class":161}," room.",[148,6703,6704],{"class":207},"findAgents",[148,6706,369],{"class":161},[148,6708,6209],{"class":168},[148,6710,6134],{"class":161},[148,6712,6713,6715,6717,6719,6722,6724,6726,6729,6732],{"class":150,"line":1355},[148,6714,6488],{"class":161},[148,6716,2517],{"class":207},[148,6718,369],{"class":161},[148,6720,6721],{"class":168},"`${",[148,6723,1868],{"class":161},[148,6725,611],{"class":168},[148,6727,6728],{"class":235},"length",[148,6730,6731],{"class":168},"} summarize worker(s) online`",[148,6733,6134],{"class":161},[148,6735,6736],{"class":150,"line":1361},[148,6737,176],{"emptyLinePlaceholder":175},[148,6739,6740],{"class":150,"line":1370},[148,6741,6742],{"class":182},"// Dispatch a task. dispatch() checks presence first and throws if no\n",[148,6744,6745],{"class":150,"line":1376},[148,6746,6747],{"class":182},"// connected worker advertises the capability.\n",[148,6749,6750,6752,6755,6757,6759,6761,6763,6765,6767],{"class":150,"line":2033},[148,6751,323],{"class":154},[148,6753,6754],{"class":235}," result",[148,6756,329],{"class":154},[148,6758,1502],{"class":154},[148,6760,639],{"class":161},[148,6762,642],{"class":207},[148,6764,369],{"class":161},[148,6766,6209],{"class":168},[148,6768,3614],{"class":161},[148,6770,6771,6774],{"class":150,"line":2039},[148,6772,6773],{"class":161},"  text: ",[148,6775,6776],{"class":168},"\"NoLag is a real-time messaging infrastructure...\"\n",[148,6778,6779],{"class":150,"line":2044},[148,6780,6781],{"class":161},"}, {\n",[148,6783,6784,6787,6789],{"class":150,"line":2050},[148,6785,6786],{"class":161},"  waitForResult: ",[148,6788,648],{"class":235},[148,6790,540],{"class":161},[148,6792,6793,6796,6799],{"class":150,"line":2056},[148,6794,6795],{"class":161},"  timeout: ",[148,6797,6798],{"class":235},"30000",[148,6800,540],{"class":161},[148,6802,6803],{"class":150,"line":2062},[148,6804,6244],{"class":161},[148,6806,6807],{"class":150,"line":2068},[148,6808,176],{"emptyLinePlaceholder":175},[148,6810,6811,6814],{"class":150,"line":2074},[148,6812,6813],{"class":154},"if",[148,6815,6816],{"class":161}," (result) {\n",[148,6818,6819,6821,6823,6825,6828],{"class":150,"line":2080},[148,6820,2514],{"class":161},[148,6822,2517],{"class":207},[148,6824,369],{"class":161},[148,6826,6827],{"class":168},"\"Result:\"",[148,6829,6830],{"class":161},", result.payload.result);\n",[148,6832,6833,6835,6837,6839,6842],{"class":150,"line":2086},[148,6834,2514],{"class":161},[148,6836,2517],{"class":207},[148,6838,369],{"class":161},[148,6840,6841],{"class":168},"\"Model:\"",[148,6843,6844],{"class":161},", result.payload.model);\n",[148,6846,6847,6849,6851,6853,6856,6859,6862],{"class":150,"line":2092},[148,6848,2514],{"class":161},[148,6850,2517],{"class":207},[148,6852,369],{"class":161},[148,6854,6855],{"class":168},"\"Latency:\"",[148,6857,6858],{"class":161},", result.payload.latencyMs, ",[148,6860,6861],{"class":168},"\"ms\"",[148,6863,6134],{"class":161},[148,6865,6866],{"class":150,"line":2097},[148,6867,262],{"class":161},[12,6869,6870,6871,6874],{},"The orchestrator doesn't import ",[84,6872,6873],{},"ollama",". It doesn't know the worker runs Llama. It dispatches a \"summarize\" task and gets a result. If you later swap the worker to use Mistral, or move it to a GPU server across the network, the dispatching code doesn't change. The dispatcher and the worker are different actors, which matters: a publishing actor never receives its own messages, so one token shared by both would never see the result.",[35,6876,6878],{"id":6877},"step-3-add-a-second-model","Step 3: Add a Second Model",[12,6880,6881],{},"Pull another model and spin up a second worker with different capabilities:",[138,6883,6885],{"className":1891,"code":6884,"language":1893,"meta":144,"style":144},"ollama pull mistral\n",[84,6886,6887],{"__ignoreMap":144},[148,6888,6889,6891,6894],{"class":150,"line":151},[148,6890,6873],{"class":207},[148,6892,6893],{"class":168}," pull",[148,6895,6896],{"class":168}," mistral\n",[135,6898,6899],{},[138,6900,6903],{"className":140,"code":6901,"filename":6902,"language":143,"meta":144,"style":144},"import { NoLag } from \"@nolag/js-sdk\";\nimport { NoLagAgents, Handoff } from \"@nolag/agents\";\nimport { Ollama } from \"ollama\";\n\nconst ollama = new Ollama({ host: \"http://localhost:11434\" });\n\nconst client = NoLag(MISTRAL_WORKER_TOKEN);\nconst worker = new NoLagAgents({\n  client,\n  appName: APP_SLUG,\n  agentId: \"mistral-worker\",\n  presence: {\n    name: \"mistral-worker\",\n    role: \"agent\",\n    capabilities: [\"extract-entities\", \"translate\"],\n    metadata: { model: \"mistral\", provider: \"local\" },\n  },\n});\n\nawait client.connect();\nawait worker.ready();\nconst room = worker.room(\"default-workflow\");\nconst handoff = new Handoff(room);\n\nhandoff.onTask([\"extract-entities\", \"translate\"], async (task, respond) => {\n  const response = await ollama.chat({\n    model: \"mistral\",\n    messages: [\n      { role: \"system\", content: `You are an ${task.capability} agent.` },\n      { role: \"user\", content: String(task.payload.text) },\n    ],\n  });\n\n  respond(\"success\", {\n    result: response.message.content,\n    model: \"mistral\",\n    latencyMs: Date.now() - task.createdAt,\n  });\n});\n","mistral-worker.ts",[84,6904,6905,6917,6929,6941,6945,6963,6967,6984,6998,7002,7010,7019,7023,7031,7039,7053,7066,7070,7074,7078,7088,7098,7116,7130,7134,7166,7182,7190,7194,7215,7227,7231,7235,7239,7249,7253,7261,7273,7277],{"__ignoreMap":144},[148,6906,6907,6909,6911,6913,6915],{"class":150,"line":151},[148,6908,155],{"class":154},[148,6910,2575],{"class":161},[148,6912,165],{"class":154},[148,6914,6054],{"class":168},[148,6916,6057],{"class":161},[148,6918,6919,6921,6923,6925,6927],{"class":150,"line":172},[148,6920,155],{"class":154},[148,6922,6064],{"class":161},[148,6924,165],{"class":154},[148,6926,6069],{"class":168},[148,6928,6057],{"class":161},[148,6930,6931,6933,6935,6937,6939],{"class":150,"line":179},[148,6932,155],{"class":154},[148,6934,6078],{"class":161},[148,6936,165],{"class":154},[148,6938,6083],{"class":168},[148,6940,6057],{"class":161},[148,6942,6943],{"class":150,"line":186},[148,6944,176],{"emptyLinePlaceholder":175},[148,6946,6947,6949,6951,6953,6955,6957,6959,6961],{"class":150,"line":192},[148,6948,323],{"class":154},[148,6950,6096],{"class":235},[148,6952,329],{"class":154},[148,6954,332],{"class":154},[148,6956,6103],{"class":207},[148,6958,6106],{"class":161},[148,6960,6109],{"class":168},[148,6962,6112],{"class":161},[148,6964,6965],{"class":150,"line":198},[148,6966,176],{"emptyLinePlaceholder":175},[148,6968,6969,6971,6973,6975,6977,6979,6982],{"class":150,"line":214},[148,6970,323],{"class":154},[148,6972,1589],{"class":235},[148,6974,329],{"class":154},[148,6976,1594],{"class":207},[148,6978,369],{"class":161},[148,6980,6981],{"class":235},"MISTRAL_WORKER_TOKEN",[148,6983,6134],{"class":161},[148,6985,6986,6988,6990,6992,6994,6996],{"class":150,"line":227},[148,6987,323],{"class":154},[148,6989,6141],{"class":235},[148,6991,329],{"class":154},[148,6993,332],{"class":154},[148,6995,6148],{"class":207},[148,6997,1343],{"class":161},[148,6999,7000],{"class":150,"line":239},[148,7001,6155],{"class":161},[148,7003,7004,7006,7008],{"class":150,"line":249},[148,7005,6160],{"class":161},[148,7007,2672],{"class":235},[148,7009,540],{"class":161},[148,7011,7012,7014,7017],{"class":150,"line":259},[148,7013,6172],{"class":161},[148,7015,7016],{"class":168},"\"mistral-worker\"",[148,7018,540],{"class":161},[148,7020,7021],{"class":150,"line":654},[148,7022,6182],{"class":161},[148,7024,7025,7027,7029],{"class":150,"line":660},[148,7026,6187],{"class":161},[148,7028,7016],{"class":168},[148,7030,540],{"class":161},[148,7032,7033,7035,7037],{"class":150,"line":1289},[148,7034,6196],{"class":161},[148,7036,6199],{"class":168},[148,7038,540],{"class":161},[148,7040,7041,7043,7046,7048,7051],{"class":150,"line":1295},[148,7042,6206],{"class":161},[148,7044,7045],{"class":168},"\"extract-entities\"",[148,7047,87],{"class":161},[148,7049,7050],{"class":168},"\"translate\"",[148,7052,6217],{"class":161},[148,7054,7055,7057,7060,7062,7064],{"class":150,"line":1313},[148,7056,6222],{"class":161},[148,7058,7059],{"class":168},"\"mistral\"",[148,7061,6228],{"class":161},[148,7063,6231],{"class":168},[148,7065,6234],{"class":161},[148,7067,7068],{"class":150,"line":1331},[148,7069,6239],{"class":161},[148,7071,7072],{"class":150,"line":1346},[148,7073,6244],{"class":161},[148,7075,7076],{"class":150,"line":1355},[148,7077,176],{"emptyLinePlaceholder":175},[148,7079,7080,7082,7084,7086],{"class":150,"line":1361},[148,7081,343],{"class":154},[148,7083,4099],{"class":161},[148,7085,2647],{"class":207},[148,7087,6259],{"class":161},[148,7089,7090,7092,7094,7096],{"class":150,"line":1370},[148,7091,343],{"class":154},[148,7093,6266],{"class":161},[148,7095,349],{"class":207},[148,7097,6259],{"class":161},[148,7099,7100,7102,7104,7106,7108,7110,7112,7114],{"class":150,"line":1376},[148,7101,323],{"class":154},[148,7103,359],{"class":235},[148,7105,329],{"class":154},[148,7107,6266],{"class":161},[148,7109,1421],{"class":207},[148,7111,369],{"class":161},[148,7113,6287],{"class":168},[148,7115,6134],{"class":161},[148,7117,7118,7120,7122,7124,7126,7128],{"class":150,"line":2033},[148,7119,323],{"class":154},[148,7121,6296],{"class":235},[148,7123,329],{"class":154},[148,7125,332],{"class":154},[148,7127,537],{"class":207},[148,7129,6305],{"class":161},[148,7131,7132],{"class":150,"line":2039},[148,7133,176],{"emptyLinePlaceholder":175},[148,7135,7136,7138,7140,7142,7144,7146,7148,7150,7152,7154,7156,7158,7160,7162,7164],{"class":150,"line":2044},[148,7137,6314],{"class":161},[148,7139,1435],{"class":207},[148,7141,1438],{"class":161},[148,7143,7045],{"class":168},[148,7145,87],{"class":161},[148,7147,7050],{"class":168},[148,7149,1444],{"class":161},[148,7151,1447],{"class":154},[148,7153,114],{"class":161},[148,7155,1452],{"class":217},[148,7157,87],{"class":161},[148,7159,790],{"class":217},[148,7161,633],{"class":161},[148,7163,636],{"class":154},[148,7165,211],{"class":161},[148,7167,7168,7170,7172,7174,7176,7178,7180],{"class":150,"line":2050},[148,7169,3596],{"class":154},[148,7171,6349],{"class":235},[148,7173,329],{"class":154},[148,7175,1502],{"class":154},[148,7177,6356],{"class":161},[148,7179,6359],{"class":207},[148,7181,1343],{"class":161},[148,7183,7184,7186,7188],{"class":150,"line":2056},[148,7185,6366],{"class":161},[148,7187,7059],{"class":168},[148,7189,540],{"class":161},[148,7191,7192],{"class":150,"line":2062},[148,7193,6375],{"class":161},[148,7195,7196,7198,7200,7202,7205,7207,7209,7211,7213],{"class":150,"line":2068},[148,7197,6380],{"class":161},[148,7199,6383],{"class":168},[148,7201,6386],{"class":161},[148,7203,7204],{"class":168},"`You are an ${",[148,7206,1452],{"class":161},[148,7208,611],{"class":168},[148,7210,553],{"class":161},[148,7212,6398],{"class":168},[148,7214,6234],{"class":161},[148,7216,7217,7219,7221,7223,7225],{"class":150,"line":2074},[148,7218,6380],{"class":161},[148,7220,6407],{"class":168},[148,7222,6386],{"class":161},[148,7224,6412],{"class":207},[148,7226,6415],{"class":161},[148,7228,7229],{"class":150,"line":2080},[148,7230,6420],{"class":161},[148,7232,7233],{"class":150,"line":2086},[148,7234,6425],{"class":161},[148,7236,7237],{"class":150,"line":2092},[148,7238,176],{"emptyLinePlaceholder":175},[148,7240,7241,7243,7245,7247],{"class":150,"line":2097},[148,7242,6434],{"class":207},[148,7244,369],{"class":161},[148,7246,6439],{"class":168},[148,7248,3614],{"class":161},[148,7250,7251],{"class":150,"line":2103},[148,7252,6446],{"class":161},[148,7254,7255,7257,7259],{"class":150,"line":2108},[148,7256,6366],{"class":161},[148,7258,7059],{"class":168},[148,7260,540],{"class":161},[148,7262,7263,7265,7267,7269,7271],{"class":150,"line":2114},[148,7264,6459],{"class":161},[148,7266,6462],{"class":207},[148,7268,6465],{"class":161},[148,7270,6468],{"class":154},[148,7272,6471],{"class":161},[148,7274,7275],{"class":150,"line":2120},[148,7276,6425],{"class":161},[148,7278,7279],{"class":150,"line":2125},[148,7280,6244],{"class":161},[12,7282,7283],{},"Now dispatch tasks and each one is acted on by the worker that registered its capability:",[135,7285,7286],{},[138,7287,7290],{"className":140,"code":7288,"filename":7289,"language":143,"meta":144,"style":144},"// The orchestrator doesn't change.\n// Dispatch by capability; the matching worker picks it up.\n\nconst summary = await handoff.dispatch(\"summarize\", {\n  text: document\n}, { waitForResult: true });\n// -> handled by llama-worker\n\nconst entities = await handoff.dispatch(\"extract-entities\", {\n  text: document\n}, { waitForResult: true });\n// -> handled by mistral-worker\n\nconst translation = await handoff.dispatch(\"translate\", {\n  text: summary ? summary.payload.result : document\n}, { waitForResult: true });\n// -> handled by mistral-worker\n\n// All three tasks ran on local models.\n// No API keys. No egress. No per-token billing.\n","dispatch-multi.ts",[84,7291,7292,7297,7302,7306,7327,7332,7341,7346,7350,7371,7375,7383,7388,7392,7413,7428,7436,7440,7444,7449],{"__ignoreMap":144},[148,7293,7294],{"class":150,"line":151},[148,7295,7296],{"class":182},"// The orchestrator doesn't change.\n",[148,7298,7299],{"class":150,"line":172},[148,7300,7301],{"class":182},"// Dispatch by capability; the matching worker picks it up.\n",[148,7303,7304],{"class":150,"line":179},[148,7305,176],{"emptyLinePlaceholder":175},[148,7307,7308,7310,7313,7315,7317,7319,7321,7323,7325],{"class":150,"line":186},[148,7309,323],{"class":154},[148,7311,7312],{"class":235}," summary",[148,7314,329],{"class":154},[148,7316,1502],{"class":154},[148,7318,639],{"class":161},[148,7320,642],{"class":207},[148,7322,369],{"class":161},[148,7324,6209],{"class":168},[148,7326,3614],{"class":161},[148,7328,7329],{"class":150,"line":192},[148,7330,7331],{"class":161},"  text: document\n",[148,7333,7334,7337,7339],{"class":150,"line":198},[148,7335,7336],{"class":161},"}, { waitForResult: ",[148,7338,648],{"class":235},[148,7340,6112],{"class":161},[148,7342,7343],{"class":150,"line":214},[148,7344,7345],{"class":182},"// -> handled by llama-worker\n",[148,7347,7348],{"class":150,"line":227},[148,7349,176],{"emptyLinePlaceholder":175},[148,7351,7352,7354,7357,7359,7361,7363,7365,7367,7369],{"class":150,"line":239},[148,7353,323],{"class":154},[148,7355,7356],{"class":235}," entities",[148,7358,329],{"class":154},[148,7360,1502],{"class":154},[148,7362,639],{"class":161},[148,7364,642],{"class":207},[148,7366,369],{"class":161},[148,7368,7045],{"class":168},[148,7370,3614],{"class":161},[148,7372,7373],{"class":150,"line":249},[148,7374,7331],{"class":161},[148,7376,7377,7379,7381],{"class":150,"line":259},[148,7378,7336],{"class":161},[148,7380,648],{"class":235},[148,7382,6112],{"class":161},[148,7384,7385],{"class":150,"line":654},[148,7386,7387],{"class":182},"// -> handled by mistral-worker\n",[148,7389,7390],{"class":150,"line":660},[148,7391,176],{"emptyLinePlaceholder":175},[148,7393,7394,7396,7399,7401,7403,7405,7407,7409,7411],{"class":150,"line":1289},[148,7395,323],{"class":154},[148,7397,7398],{"class":235}," translation",[148,7400,329],{"class":154},[148,7402,1502],{"class":154},[148,7404,639],{"class":161},[148,7406,642],{"class":207},[148,7408,369],{"class":161},[148,7410,7050],{"class":168},[148,7412,3614],{"class":161},[148,7414,7415,7418,7420,7423,7425],{"class":150,"line":1295},[148,7416,7417],{"class":161},"  text: summary ",[148,7419,836],{"class":154},[148,7421,7422],{"class":161}," summary.payload.result ",[148,7424,221],{"class":154},[148,7426,7427],{"class":161}," document\n",[148,7429,7430,7432,7434],{"class":150,"line":1313},[148,7431,7336],{"class":161},[148,7433,648],{"class":235},[148,7435,6112],{"class":161},[148,7437,7438],{"class":150,"line":1331},[148,7439,7387],{"class":182},[148,7441,7442],{"class":150,"line":1346},[148,7443,176],{"emptyLinePlaceholder":175},[148,7445,7446],{"class":150,"line":1355},[148,7447,7448],{"class":182},"// All three tasks ran on local models.\n",[148,7450,7451],{"class":150,"line":1361},[148,7452,7453],{"class":182},"// No API keys. No egress. No per-token billing.\n",[12,7455,7456,7457,7460,7461,7464],{},"The orchestrator dispatches by ",[22,7458,7459],{},"what needs doing",", not by ",[22,7462,7463],{},"which model does it",". Add a third model, remove one, restart a worker: the system adapts because discovery is based on presence, not hardcoded addresses.",[35,7466,7468],{"id":7467},"what-you-get-for-free","What You Get for Free",[7470,7471,5067],"h3",{"id":3389},[12,7473,7474],{},"Every connected worker shows up in real time. You always know which models are online and what they can do.",[135,7476,7477],{},[138,7478,7481],{"className":140,"code":7479,"filename":7480,"language":143,"meta":144,"style":144},"// See every connected agent, and react as they come and go\nfunction printAgents() {\n  for (const agent of room.getConnectedAgents()) {\n    console.log(`${agent.name} [${agent.status ?? \"online\"}]`);\n    console.log(`  capabilities: ${agent.capabilities.join(\", \") || \"(none)\"}`);\n    console.log(`  model: ${agent.metadata?.model}`);\n  }\n}\n\nroom.on(\"presenceJoin\", printAgents);\nroom.on(\"presenceUpdate\", printAgents);\nroom.on(\"presenceLeave\", printAgents);\n\n// Output:\n// llama-worker [online]\n//   capabilities: summarize, classify\n//   model: llama3.2\n// mistral-worker [online]\n//   capabilities: extract-entities, translate\n//   model: mistral\n// orchestrator [online]\n//   capabilities: (none)\n","presence.ts",[84,7482,7483,7488,7497,7520,7555,7593,7621,7625,7629,7633,7647,7660,7673,7677,7682,7687,7692,7697,7702,7707,7712,7717],{"__ignoreMap":144},[148,7484,7485],{"class":150,"line":151},[148,7486,7487],{"class":182},"// See every connected agent, and react as they come and go\n",[148,7489,7490,7492,7495],{"class":150,"line":172},[148,7491,5682],{"class":154},[148,7493,7494],{"class":207}," printAgents",[148,7496,3591],{"class":161},[148,7498,7499,7502,7504,7506,7509,7512,7514,7517],{"class":150,"line":179},[148,7500,7501],{"class":154},"  for",[148,7503,114],{"class":161},[148,7505,323],{"class":154},[148,7507,7508],{"class":235}," agent",[148,7510,7511],{"class":154}," of",[148,7513,6701],{"class":161},[148,7515,7516],{"class":207},"getConnectedAgents",[148,7518,7519],{"class":161},"()) {\n",[148,7521,7522,7525,7527,7529,7531,7533,7535,7538,7541,7543,7545,7547,7550,7553],{"class":150,"line":186},[148,7523,7524],{"class":161},"    console.",[148,7526,2517],{"class":207},[148,7528,369],{"class":161},[148,7530,6721],{"class":168},[148,7532,2854],{"class":161},[148,7534,611],{"class":168},[148,7536,7537],{"class":161},"name",[148,7539,7540],{"class":168},"} [${",[148,7542,2854],{"class":161},[148,7544,611],{"class":168},[148,7546,686],{"class":161},[148,7548,7549],{"class":154}," ??",[148,7551,7552],{"class":168}," \"online\"}]`",[148,7554,6134],{"class":161},[148,7556,7557,7559,7561,7563,7566,7568,7570,7573,7575,7578,7580,7583,7585,7588,7591],{"class":150,"line":192},[148,7558,7524],{"class":161},[148,7560,2517],{"class":207},[148,7562,369],{"class":161},[148,7564,7565],{"class":168},"`  capabilities: ${",[148,7567,2854],{"class":161},[148,7569,611],{"class":168},[148,7571,7572],{"class":161},"capabilities",[148,7574,611],{"class":168},[148,7576,7577],{"class":207},"join",[148,7579,369],{"class":168},[148,7581,7582],{"class":168},"\", \"",[148,7584,633],{"class":168},[148,7586,7587],{"class":154},"||",[148,7589,7590],{"class":168}," \"(none)\"}`",[148,7592,6134],{"class":161},[148,7594,7595,7597,7599,7601,7604,7606,7608,7611,7614,7617,7619],{"class":150,"line":198},[148,7596,7524],{"class":161},[148,7598,2517],{"class":207},[148,7600,369],{"class":161},[148,7602,7603],{"class":168},"`  model: ${",[148,7605,2854],{"class":161},[148,7607,611],{"class":168},[148,7609,7610],{"class":161},"metadata",[148,7612,7613],{"class":168},"?.",[148,7615,7616],{"class":161},"model",[148,7618,3652],{"class":168},[148,7620,6134],{"class":161},[148,7622,7623],{"class":150,"line":214},[148,7624,1716],{"class":161},[148,7626,7627],{"class":150,"line":227},[148,7628,262],{"class":161},[148,7630,7631],{"class":150,"line":239},[148,7632,176],{"emptyLinePlaceholder":175},[148,7634,7635,7637,7639,7641,7644],{"class":150,"line":249},[148,7636,380],{"class":161},[148,7638,2706],{"class":207},[148,7640,369],{"class":161},[148,7642,7643],{"class":168},"\"presenceJoin\"",[148,7645,7646],{"class":161},", printAgents);\n",[148,7648,7649,7651,7653,7655,7658],{"class":150,"line":259},[148,7650,380],{"class":161},[148,7652,2706],{"class":207},[148,7654,369],{"class":161},[148,7656,7657],{"class":168},"\"presenceUpdate\"",[148,7659,7646],{"class":161},[148,7661,7662,7664,7666,7668,7671],{"class":150,"line":654},[148,7663,380],{"class":161},[148,7665,2706],{"class":207},[148,7667,369],{"class":161},[148,7669,7670],{"class":168},"\"presenceLeave\"",[148,7672,7646],{"class":161},[148,7674,7675],{"class":150,"line":660},[148,7676,176],{"emptyLinePlaceholder":175},[148,7678,7679],{"class":150,"line":1289},[148,7680,7681],{"class":182},"// Output:\n",[148,7683,7684],{"class":150,"line":1295},[148,7685,7686],{"class":182},"// llama-worker [online]\n",[148,7688,7689],{"class":150,"line":1313},[148,7690,7691],{"class":182},"//   capabilities: summarize, classify\n",[148,7693,7694],{"class":150,"line":1331},[148,7695,7696],{"class":182},"//   model: llama3.2\n",[148,7698,7699],{"class":150,"line":1346},[148,7700,7701],{"class":182},"// mistral-worker [online]\n",[148,7703,7704],{"class":150,"line":1355},[148,7705,7706],{"class":182},"//   capabilities: extract-entities, translate\n",[148,7708,7709],{"class":150,"line":1361},[148,7710,7711],{"class":182},"//   model: mistral\n",[148,7713,7714],{"class":150,"line":1370},[148,7715,7716],{"class":182},"// orchestrator [online]\n",[148,7718,7719],{"class":150,"line":1376},[148,7720,7721],{"class":182},"//   capabilities: (none)\n",[12,7723,7724],{},"The NoLag portal's Agents view shows this same list graphically, no custom monitoring needed.",[7470,7726,7728],{"id":7727},"reconnection","Reconnection",[12,7730,7731,7732,7735,7736,7739],{},"If a worker disconnects (machine reboots, network blip, Ollama restart), the core client reconnects automatically and the wrapper restores its room and presence. Whether tasks dispatched in the meantime are waiting for it depends on how it subscribed: NoLag holds and replays missed tasks only for load-balanced worker groups with persistent sessions on the hosted platform (see ",[952,7733,7734],{"href":2319},"replay","). A single worker subscribed without load balancing misses whatever was dispatched while it was away, and the dispatcher's ",[84,7737,7738],{},"waitForResult"," timeout is what tells you.",[7470,7741,7743],{"id":7742},"load-balancing","Load Balancing",[12,7745,7746],{},"Run two instances of the same worker on two machines and turn load balancing on for both:",[135,7748,7749],{},[138,7750,7753],{"className":140,"code":7751,"filename":7752,"language":143,"meta":144,"style":144},"// Each task goes to ONE member of the \"llama-workers\" group\nconst client = NoLag(WORKER_TOKEN, { loadBalance: true, loadBalanceGroup: \"llama-workers\" });\n","pool.ts",[84,7754,7755,7760],{"__ignoreMap":144},[148,7756,7757],{"class":150,"line":151},[148,7758,7759],{"class":182},"// Each task goes to ONE member of the \"llama-workers\" group\n",[148,7761,7762,7764,7766,7768,7770,7772,7774,7777,7779,7782,7785],{"class":150,"line":172},[148,7763,323],{"class":154},[148,7765,1589],{"class":235},[148,7767,329],{"class":154},[148,7769,1594],{"class":207},[148,7771,369],{"class":161},[148,7773,6131],{"class":235},[148,7775,7776],{"class":161},", { loadBalance: ",[148,7778,648],{"class":235},[148,7780,7781],{"class":161},", loadBalanceGroup: ",[148,7783,7784],{"class":168},"\"llama-workers\"",[148,7786,6112],{"class":161},[12,7788,7789],{},"Load balancing is opt-in. Without it every instance receives, and answers, every task.",[7470,7791,7793],{"id":7792},"observability","Observability",[12,7795,7796,7797,7800],{},"The portal's Agents tab shows which agents are connected and what they advertise. Results carry ",[84,7798,7799],{},"completedBy"," and whatever metadata the worker adds (model, latency), and workers can emit structured events with the Observe pattern for an observer agent to log to your own system.",[35,7802,7804],{"id":7803},"why-not-a-framework","Why Not a Framework?",[12,7806,7807],{},"Frameworks like LangChain and CrewAI solve a real problem: making it easy to build agent logic. But they also own the transport. Your agents communicate through function calls inside a single process. That means:",[882,7809,7810,7813,7816,7819],{},[885,7811,7812],{},"Agents can't run on different machines",[885,7814,7815],{},"You can't scale workers independently",[885,7817,7818],{},"If the coordinator crashes, everything stops",[885,7820,7821],{},"Adding a human approval step means hacking the framework's internals",[12,7823,7824],{},"NoLag doesn't replace your agent logic. It replaces the wiring between agents. Use whatever prompt engineering, RAG pipeline, or chain-of-thought approach you want inside your worker. NoLag just makes sure tasks get to the right worker and results get back.",[35,7826,7827],{"id":2247},"Next Steps",[882,7829,7830,7841,7847,7853],{},[885,7831,7832,7835,7836,7840],{},[30,7833,7834],{},"Add a cloud model",": see ",[952,7837,7839],{"href":7838},"/blog/hybrid-llm-orchestration","Hybrid LLM Coordination"," for mixing local and proprietary models",[885,7842,7843,7846],{},[30,7844,7845],{},"Add human approval",": gate sensitive actions with the Approve pattern before they execute",[885,7848,7849,7852],{},[30,7850,7851],{},"Share state",": use the Blackboard pattern to track model performance metrics across all workers",[885,7854,7855,3662,7858],{},[30,7856,7857],{},"Read the docs",[952,7859,7861],{"href":7860},"/docs/agents/getting-started","@nolag/agents getting started guide",[12,7863,7864],{},"Your local model is the smart part. NoLag is the dumb pipe that makes it reachable. That's the whole idea.",[882,7866,7867,7871],{},[885,7868,7869],{},[952,7870,7839],{"href":7838},[885,7872,7873],{},[952,7874,7875],{"href":7860},"Agents Docs",[969,7877,7878],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":144,"searchDepth":172,"depth":172,"links":7880},[7881,7882,7883,7884,7885,7886,7892,7893],{"id":5924,"depth":172,"text":5925},{"id":5948,"depth":172,"text":5949},{"id":6008,"depth":172,"text":6009},{"id":6512,"depth":172,"text":6513},{"id":6877,"depth":172,"text":6878},{"id":7467,"depth":172,"text":7468,"children":7887},[7888,7889,7890,7891],{"id":3389,"depth":179,"text":5067},{"id":7727,"depth":179,"text":7728},{"id":7742,"depth":179,"text":7743},{"id":7792,"depth":179,"text":7793},{"id":7803,"depth":172,"text":7804},{"id":2247,"depth":172,"text":7827},"Tutorial","2026-05-25","Wire up Ollama as a NoLag worker agent in under 5 minutes. No framework, no boilerplate -- just a transport layer between your model and the rest of your system.",{},"/blog/connect-local-llm",{"title":5913,"description":7896},"blog/connect-local-llm","0YrORIdR01Y8sZpUYq7G9lqkAcN9g2GKi_6_FQGfVic",{"id":7903,"title":7904,"author":7,"body":7905,"category":1832,"date":9783,"description":9784,"excerpt":986,"extension":987,"meta":9785,"navigation":175,"path":7838,"readTime":1836,"seo":9786,"stem":9787,"__hash__":9788},"blog/blog/hybrid-llm-orchestration.md","Hybrid LLM Coordination: Mixing Local and Proprietary Models",{"type":9,"value":7906,"toc":9770},[7907,7910,7913,7917,7920,8107,8110,8142,8146,8156,8471,8480,8484,8487,8850,8853,8879,8883,8886,9267,9270,9274,9277,9371,9375,9378,9381,9625,9628,9632,9635,9644,9647,9651,9654,9684,9688,9691,9705,9708,9712,9741,9744,9748,9751,9754,9767],[12,7908,7909],{},"Running everything through a single LLM API is simple, until you hit the wall. Privacy policies require certain data to stay on-premises. Latency budgets demand sub-100ms classification that cloud APIs can't deliver. Cost projections show that routing every request through GPT-4 class models burns budget on tasks a 7B parameter model handles fine.",[12,7911,7912],{},"The answer is hybrid orchestration: use local models (Ollama, vLLM, llama.cpp) for tasks where speed, privacy, or cost matter, and proprietary APIs (Claude, GPT-4, Gemini) for tasks that need frontier reasoning. The hard part isn't calling two different APIs. It's coordinating them.",[35,7914,7916],{"id":7915},"why-hybrid-is-hard","Why Hybrid Is Hard",[12,7918,7919],{},"The naive approach hardcodes model assignments into the orchestrator:",[135,7921,7922],{},[138,7923,7925],{"className":140,"code":7924,"filename":142,"language":143,"meta":144,"style":144},"// The tightly-coupled approach\nasync function processDocument(doc: string) {\n  // Classification - fast, no data leaves your network\n  const category = await localLlama.classify(doc);\n\n  // Summarization - needs strong reasoning\n  const summary = await openai.chat(doc);\n\n  // Entity extraction - privacy-sensitive, run locally\n  const entities = await localMistral.extract(doc);\n\n  // Final synthesis - best model wins\n  const report = await anthropic.messages(summary, entities);\n\n  return report;\n}\n\n// Problems:\n// - Every model is hardcoded in the orchestrator\n// - Swapping a model means changing orchestration logic\n// - Can't scale local and cloud independently\n// - No visibility into which model handled what\n// - If the local model server goes down, everything stops\n",[84,7926,7927,7932,7952,7957,7977,7981,7986,8003,8007,8012,8030,8034,8039,8058,8062,8069,8073,8077,8082,8087,8092,8097,8102],{"__ignoreMap":144},[148,7928,7929],{"class":150,"line":151},[148,7930,7931],{"class":182},"// The tightly-coupled approach\n",[148,7933,7934,7936,7938,7941,7943,7946,7948,7950],{"class":150,"line":172},[148,7935,1447],{"class":154},[148,7937,521],{"class":154},[148,7939,7940],{"class":207}," processDocument",[148,7942,369],{"class":161},[148,7944,7945],{"class":217},"doc",[148,7947,221],{"class":154},[148,7949,558],{"class":235},[148,7951,600],{"class":161},[148,7953,7954],{"class":150,"line":179},[148,7955,7956],{"class":182},"  // Classification - fast, no data leaves your network\n",[148,7958,7959,7961,7964,7966,7968,7971,7974],{"class":150,"line":186},[148,7960,3596],{"class":154},[148,7962,7963],{"class":235}," category",[148,7965,329],{"class":154},[148,7967,1502],{"class":154},[148,7969,7970],{"class":161}," localLlama.",[148,7972,7973],{"class":207},"classify",[148,7975,7976],{"class":161},"(doc);\n",[148,7978,7979],{"class":150,"line":192},[148,7980,176],{"emptyLinePlaceholder":175},[148,7982,7983],{"class":150,"line":198},[148,7984,7985],{"class":182},"  // Summarization - needs strong reasoning\n",[148,7987,7988,7990,7992,7994,7996,7999,8001],{"class":150,"line":214},[148,7989,3596],{"class":154},[148,7991,7312],{"class":235},[148,7993,329],{"class":154},[148,7995,1502],{"class":154},[148,7997,7998],{"class":161}," openai.",[148,8000,6359],{"class":207},[148,8002,7976],{"class":161},[148,8004,8005],{"class":150,"line":227},[148,8006,176],{"emptyLinePlaceholder":175},[148,8008,8009],{"class":150,"line":239},[148,8010,8011],{"class":182},"  // Entity extraction - privacy-sensitive, run locally\n",[148,8013,8014,8016,8018,8020,8022,8025,8028],{"class":150,"line":249},[148,8015,3596],{"class":154},[148,8017,7356],{"class":235},[148,8019,329],{"class":154},[148,8021,1502],{"class":154},[148,8023,8024],{"class":161}," localMistral.",[148,8026,8027],{"class":207},"extract",[148,8029,7976],{"class":161},[148,8031,8032],{"class":150,"line":259},[148,8033,176],{"emptyLinePlaceholder":175},[148,8035,8036],{"class":150,"line":654},[148,8037,8038],{"class":182},"  // Final synthesis - best model wins\n",[148,8040,8041,8043,8046,8048,8050,8053,8055],{"class":150,"line":660},[148,8042,3596],{"class":154},[148,8044,8045],{"class":235}," report",[148,8047,329],{"class":154},[148,8049,1502],{"class":154},[148,8051,8052],{"class":161}," anthropic.",[148,8054,3889],{"class":207},[148,8056,8057],{"class":161},"(summary, entities);\n",[148,8059,8060],{"class":150,"line":1289},[148,8061,176],{"emptyLinePlaceholder":175},[148,8063,8064,8066],{"class":150,"line":1295},[148,8065,605],{"class":154},[148,8067,8068],{"class":161}," report;\n",[148,8070,8071],{"class":150,"line":1313},[148,8072,262],{"class":161},[148,8074,8075],{"class":150,"line":1331},[148,8076,176],{"emptyLinePlaceholder":175},[148,8078,8079],{"class":150,"line":1346},[148,8080,8081],{"class":182},"// Problems:\n",[148,8083,8084],{"class":150,"line":1355},[148,8085,8086],{"class":182},"// - Every model is hardcoded in the orchestrator\n",[148,8088,8089],{"class":150,"line":1361},[148,8090,8091],{"class":182},"// - Swapping a model means changing orchestration logic\n",[148,8093,8094],{"class":150,"line":1370},[148,8095,8096],{"class":182},"// - Can't scale local and cloud independently\n",[148,8098,8099],{"class":150,"line":1376},[148,8100,8101],{"class":182},"// - No visibility into which model handled what\n",[148,8103,8104],{"class":150,"line":2033},[148,8105,8106],{"class":182},"// - If the local model server goes down, everything stops\n",[12,8108,8109],{},"This works in a prototype. In production it falls apart:",[882,8111,8112,8118,8124,8130,8136],{},[885,8113,8114,8117],{},[30,8115,8116],{},"Model coupling",": swapping Llama for Mistral means changing orchestration code, not just a config value",[885,8119,8120,8123],{},[30,8121,8122],{},"No independent scaling",": your local GPU box and your cloud API calls are locked to the same process",[885,8125,8126,8129],{},[30,8127,8128],{},"No fault isolation",": if Ollama crashes, the entire pipeline stops, even for tasks that use cloud models",[885,8131,8132,8135],{},[30,8133,8134],{},"No visibility",": you can't see which model handled which task, what the latency was, or where errors occurred",[885,8137,8138,8141],{},[30,8139,8140],{},"No dynamic routing",": you can't shift traffic between local and cloud based on load, latency, or error rate",[35,8143,8145],{"id":8144},"the-decoupled-architecture","The Decoupled Architecture",[12,8147,8148,8149,8151,8152,8155],{},"The fix is to separate ",[22,8150,7459],{}," from ",[22,8153,8154],{},"who does it",". The orchestrator dispatches tasks by capability (\"classify\", \"summarize\", \"extract-entities\"). Workers advertise their capabilities via presence. Every worker in the room sees each task and acts only on the capabilities it registered for.",[135,8157,8158],{},[138,8159,8161],{"className":140,"code":8160,"filename":142,"language":143,"meta":144,"style":144},"import { NoLag } from \"@nolag/js-sdk\";\nimport { NoLagAgents, Handoff } from \"@nolag/agents\";\n\n// Orchestrator - dispatches by capability, not by model.\n// Create the app and the \"processing\" room first; the slug you get back has\n// a random suffix and that suffixed slug is the appName.\nconst client = NoLag(ORCHESTRATOR_TOKEN);\nconst orchestrator = new NoLagAgents({\n  client,\n  appName: APP_SLUG,\n  agentId: \"orchestrator\",\n  presence: { name: \"orchestrator\", role: \"orchestrator\" },\n});\nawait client.connect();\nawait orchestrator.ready();\nconst room = orchestrator.room(\"processing\");\nconst handoff = new Handoff(room);\n\n// Service discovery: see what's available\nconst classifiers = room.findAgents(\"classify\");\nconsole.log(\"Classifiers online:\", classifiers.length);\n\n// Dispatch tasks by what needs doing, not how.\n// The SDK checks presence and throws if no capable agent is connected.\nconst result = await handoff.dispatch(\"classify\", { document: doc }, {\n  priority: \"high\",\n  waitForResult: true,\n  timeout: 30000,\n});\nif (result) console.log(\"Category:\", result.payload.result);\n\n// A local Llama worker picks up \"classify\" tasks.\n// A Claude worker picks up \"synthesize\" tasks.\n// Workers self-register with their capabilities via presence.\n",[84,8162,8163,8175,8187,8191,8196,8201,8206,8222,8236,8240,8248,8256,8268,8272,8282,8292,8311,8325,8329,8334,8353,8371,8375,8380,8385,8406,8416,8424,8432,8436,8452,8456,8461,8466],{"__ignoreMap":144},[148,8164,8165,8167,8169,8171,8173],{"class":150,"line":151},[148,8166,155],{"class":154},[148,8168,2575],{"class":161},[148,8170,165],{"class":154},[148,8172,6054],{"class":168},[148,8174,6057],{"class":161},[148,8176,8177,8179,8181,8183,8185],{"class":150,"line":172},[148,8178,155],{"class":154},[148,8180,6064],{"class":161},[148,8182,165],{"class":154},[148,8184,6069],{"class":168},[148,8186,6057],{"class":161},[148,8188,8189],{"class":150,"line":179},[148,8190,176],{"emptyLinePlaceholder":175},[148,8192,8193],{"class":150,"line":186},[148,8194,8195],{"class":182},"// Orchestrator - dispatches by capability, not by model.\n",[148,8197,8198],{"class":150,"line":192},[148,8199,8200],{"class":182},"// Create the app and the \"processing\" room first; the slug you get back has\n",[148,8202,8203],{"class":150,"line":198},[148,8204,8205],{"class":182},"// a random suffix and that suffixed slug is the appName.\n",[148,8207,8208,8210,8212,8214,8216,8218,8220],{"class":150,"line":214},[148,8209,323],{"class":154},[148,8211,1589],{"class":235},[148,8213,329],{"class":154},[148,8215,1594],{"class":207},[148,8217,369],{"class":161},[148,8219,6567],{"class":235},[148,8221,6134],{"class":161},[148,8223,8224,8226,8228,8230,8232,8234],{"class":150,"line":227},[148,8225,323],{"class":154},[148,8227,6576],{"class":235},[148,8229,329],{"class":154},[148,8231,332],{"class":154},[148,8233,6148],{"class":207},[148,8235,1343],{"class":161},[148,8237,8238],{"class":150,"line":239},[148,8239,6155],{"class":161},[148,8241,8242,8244,8246],{"class":150,"line":249},[148,8243,6160],{"class":161},[148,8245,2672],{"class":235},[148,8247,540],{"class":161},[148,8249,8250,8252,8254],{"class":150,"line":259},[148,8251,6172],{"class":161},[148,8253,6603],{"class":168},[148,8255,540],{"class":161},[148,8257,8258,8260,8262,8264,8266],{"class":150,"line":654},[148,8259,6610],{"class":161},[148,8261,6603],{"class":168},[148,8263,6615],{"class":161},[148,8265,6603],{"class":168},[148,8267,6234],{"class":161},[148,8269,8270],{"class":150,"line":660},[148,8271,6244],{"class":161},[148,8273,8274,8276,8278,8280],{"class":150,"line":1289},[148,8275,343],{"class":154},[148,8277,4099],{"class":161},[148,8279,2647],{"class":207},[148,8281,6259],{"class":161},[148,8283,8284,8286,8288,8290],{"class":150,"line":1295},[148,8285,343],{"class":154},[148,8287,6644],{"class":161},[148,8289,349],{"class":207},[148,8291,6259],{"class":161},[148,8293,8294,8296,8298,8300,8302,8304,8306,8309],{"class":150,"line":1313},[148,8295,323],{"class":154},[148,8297,359],{"class":235},[148,8299,329],{"class":154},[148,8301,6644],{"class":161},[148,8303,1421],{"class":207},[148,8305,369],{"class":161},[148,8307,8308],{"class":168},"\"processing\"",[148,8310,6134],{"class":161},[148,8312,8313,8315,8317,8319,8321,8323],{"class":150,"line":1331},[148,8314,323],{"class":154},[148,8316,6296],{"class":235},[148,8318,329],{"class":154},[148,8320,332],{"class":154},[148,8322,537],{"class":207},[148,8324,6305],{"class":161},[148,8326,8327],{"class":150,"line":1346},[148,8328,176],{"emptyLinePlaceholder":175},[148,8330,8331],{"class":150,"line":1355},[148,8332,8333],{"class":182},"// Service discovery: see what's available\n",[148,8335,8336,8338,8341,8343,8345,8347,8349,8351],{"class":150,"line":1361},[148,8337,323],{"class":154},[148,8339,8340],{"class":235}," classifiers",[148,8342,329],{"class":154},[148,8344,6701],{"class":161},[148,8346,6704],{"class":207},[148,8348,369],{"class":161},[148,8350,6214],{"class":168},[148,8352,6134],{"class":161},[148,8354,8355,8357,8359,8361,8364,8367,8369],{"class":150,"line":1370},[148,8356,6488],{"class":161},[148,8358,2517],{"class":207},[148,8360,369],{"class":161},[148,8362,8363],{"class":168},"\"Classifiers online:\"",[148,8365,8366],{"class":161},", classifiers.",[148,8368,6728],{"class":235},[148,8370,6134],{"class":161},[148,8372,8373],{"class":150,"line":1376},[148,8374,176],{"emptyLinePlaceholder":175},[148,8376,8377],{"class":150,"line":2033},[148,8378,8379],{"class":182},"// Dispatch tasks by what needs doing, not how.\n",[148,8381,8382],{"class":150,"line":2039},[148,8383,8384],{"class":182},"// The SDK checks presence and throws if no capable agent is connected.\n",[148,8386,8387,8389,8391,8393,8395,8397,8399,8401,8403],{"class":150,"line":2044},[148,8388,323],{"class":154},[148,8390,6754],{"class":235},[148,8392,329],{"class":154},[148,8394,1502],{"class":154},[148,8396,639],{"class":161},[148,8398,642],{"class":207},[148,8400,369],{"class":161},[148,8402,6214],{"class":168},[148,8404,8405],{"class":161},", { document: doc }, {\n",[148,8407,8408,8411,8414],{"class":150,"line":2050},[148,8409,8410],{"class":161},"  priority: ",[148,8412,8413],{"class":168},"\"high\"",[148,8415,540],{"class":161},[148,8417,8418,8420,8422],{"class":150,"line":2056},[148,8419,6786],{"class":161},[148,8421,648],{"class":235},[148,8423,540],{"class":161},[148,8425,8426,8428,8430],{"class":150,"line":2062},[148,8427,6795],{"class":161},[148,8429,6798],{"class":235},[148,8431,540],{"class":161},[148,8433,8434],{"class":150,"line":2068},[148,8435,6244],{"class":161},[148,8437,8438,8440,8443,8445,8447,8450],{"class":150,"line":2074},[148,8439,6813],{"class":154},[148,8441,8442],{"class":161}," (result) console.",[148,8444,2517],{"class":207},[148,8446,369],{"class":161},[148,8448,8449],{"class":168},"\"Category:\"",[148,8451,6830],{"class":161},[148,8453,8454],{"class":150,"line":2080},[148,8455,176],{"emptyLinePlaceholder":175},[148,8457,8458],{"class":150,"line":2086},[148,8459,8460],{"class":182},"// A local Llama worker picks up \"classify\" tasks.\n",[148,8462,8463],{"class":150,"line":2092},[148,8464,8465],{"class":182},"// A Claude worker picks up \"synthesize\" tasks.\n",[148,8467,8468],{"class":150,"line":2097},[148,8469,8470],{"class":182},"// Workers self-register with their capabilities via presence.\n",[12,8472,8473,8474,694,8476,8479],{},"The orchestrator never imports ",[84,8475,6873],{},[84,8477,8478],{},"@anthropic-ai/sdk",". It doesn't know which model runs each task. Workers are independent processes: they can run on a GPU server in your data center, a cloud VM, or a serverless function. Each worker connects with its own actor token, which also matters for correctness: a publishing actor never receives its own messages, so an orchestrator and a worker sharing a token would never see each other's traffic.",[35,8481,8483],{"id":8482},"the-local-worker","The Local Worker",[12,8485,8486],{},"A local worker runs on your infrastructure, connects to NoLag, and processes tasks using a local model. It advertises its capabilities via presence so the orchestrator knows it exists.",[135,8488,8489],{},[138,8490,8492],{"className":140,"code":8491,"filename":142,"language":143,"meta":144,"style":144},"import { NoLag } from \"@nolag/js-sdk\";\nimport { NoLagAgents, Handoff } from \"@nolag/agents\";\nimport { Ollama } from \"ollama\";\n\nconst ollama = new Ollama({ host: \"http://localhost:11434\" });\n\nconst client = NoLag(LOCAL_WORKER_TOKEN);\nconst worker = new NoLagAgents({\n  client,\n  appName: APP_SLUG,\n  agentId: \"local-classifier\",\n  presence: {\n    name: \"local-classifier\",\n    role: \"agent\",\n    capabilities: [\"classify\", \"extract-entities\"],\n    metadata: { model: \"llama3.2\", provider: \"local\" },\n  },\n});\nawait client.connect();\nawait worker.ready();\nconst room = worker.room(\"processing\");\nconst handoff = new Handoff(room);\n\n// The SDK filters client-side: only tasks matching these capabilities reach the handler\nhandoff.onTask([\"classify\", \"extract-entities\"], async (task, respond) => {\n  const response = await ollama.chat({\n    model: \"llama3.2\",\n    messages: [{ role: \"user\", content: String(task.payload.document) }],\n  });\n\n  respond(\"success\", {\n    result: response.message.content,\n    model: \"llama3.2\",\n    provider: \"local\",\n    latencyMs: Date.now() - task.createdAt,\n  });\n});\n",[84,8493,8494,8506,8518,8530,8534,8552,8556,8573,8587,8591,8599,8608,8612,8620,8628,8640,8652,8656,8660,8670,8680,8698,8712,8716,8721,8753,8769,8777,8791,8795,8799,8809,8813,8821,8830,8842,8846],{"__ignoreMap":144},[148,8495,8496,8498,8500,8502,8504],{"class":150,"line":151},[148,8497,155],{"class":154},[148,8499,2575],{"class":161},[148,8501,165],{"class":154},[148,8503,6054],{"class":168},[148,8505,6057],{"class":161},[148,8507,8508,8510,8512,8514,8516],{"class":150,"line":172},[148,8509,155],{"class":154},[148,8511,6064],{"class":161},[148,8513,165],{"class":154},[148,8515,6069],{"class":168},[148,8517,6057],{"class":161},[148,8519,8520,8522,8524,8526,8528],{"class":150,"line":179},[148,8521,155],{"class":154},[148,8523,6078],{"class":161},[148,8525,165],{"class":154},[148,8527,6083],{"class":168},[148,8529,6057],{"class":161},[148,8531,8532],{"class":150,"line":186},[148,8533,176],{"emptyLinePlaceholder":175},[148,8535,8536,8538,8540,8542,8544,8546,8548,8550],{"class":150,"line":192},[148,8537,323],{"class":154},[148,8539,6096],{"class":235},[148,8541,329],{"class":154},[148,8543,332],{"class":154},[148,8545,6103],{"class":207},[148,8547,6106],{"class":161},[148,8549,6109],{"class":168},[148,8551,6112],{"class":161},[148,8553,8554],{"class":150,"line":198},[148,8555,176],{"emptyLinePlaceholder":175},[148,8557,8558,8560,8562,8564,8566,8568,8571],{"class":150,"line":214},[148,8559,323],{"class":154},[148,8561,1589],{"class":235},[148,8563,329],{"class":154},[148,8565,1594],{"class":207},[148,8567,369],{"class":161},[148,8569,8570],{"class":235},"LOCAL_WORKER_TOKEN",[148,8572,6134],{"class":161},[148,8574,8575,8577,8579,8581,8583,8585],{"class":150,"line":227},[148,8576,323],{"class":154},[148,8578,6141],{"class":235},[148,8580,329],{"class":154},[148,8582,332],{"class":154},[148,8584,6148],{"class":207},[148,8586,1343],{"class":161},[148,8588,8589],{"class":150,"line":239},[148,8590,6155],{"class":161},[148,8592,8593,8595,8597],{"class":150,"line":249},[148,8594,6160],{"class":161},[148,8596,2672],{"class":235},[148,8598,540],{"class":161},[148,8600,8601,8603,8606],{"class":150,"line":259},[148,8602,6172],{"class":161},[148,8604,8605],{"class":168},"\"local-classifier\"",[148,8607,540],{"class":161},[148,8609,8610],{"class":150,"line":654},[148,8611,6182],{"class":161},[148,8613,8614,8616,8618],{"class":150,"line":660},[148,8615,6187],{"class":161},[148,8617,8605],{"class":168},[148,8619,540],{"class":161},[148,8621,8622,8624,8626],{"class":150,"line":1289},[148,8623,6196],{"class":161},[148,8625,6199],{"class":168},[148,8627,540],{"class":161},[148,8629,8630,8632,8634,8636,8638],{"class":150,"line":1295},[148,8631,6206],{"class":161},[148,8633,6214],{"class":168},[148,8635,87],{"class":161},[148,8637,7045],{"class":168},[148,8639,6217],{"class":161},[148,8641,8642,8644,8646,8648,8650],{"class":150,"line":1313},[148,8643,6222],{"class":161},[148,8645,6225],{"class":168},[148,8647,6228],{"class":161},[148,8649,6231],{"class":168},[148,8651,6234],{"class":161},[148,8653,8654],{"class":150,"line":1331},[148,8655,6239],{"class":161},[148,8657,8658],{"class":150,"line":1346},[148,8659,6244],{"class":161},[148,8661,8662,8664,8666,8668],{"class":150,"line":1355},[148,8663,343],{"class":154},[148,8665,4099],{"class":161},[148,8667,2647],{"class":207},[148,8669,6259],{"class":161},[148,8671,8672,8674,8676,8678],{"class":150,"line":1361},[148,8673,343],{"class":154},[148,8675,6266],{"class":161},[148,8677,349],{"class":207},[148,8679,6259],{"class":161},[148,8681,8682,8684,8686,8688,8690,8692,8694,8696],{"class":150,"line":1370},[148,8683,323],{"class":154},[148,8685,359],{"class":235},[148,8687,329],{"class":154},[148,8689,6266],{"class":161},[148,8691,1421],{"class":207},[148,8693,369],{"class":161},[148,8695,8308],{"class":168},[148,8697,6134],{"class":161},[148,8699,8700,8702,8704,8706,8708,8710],{"class":150,"line":1376},[148,8701,323],{"class":154},[148,8703,6296],{"class":235},[148,8705,329],{"class":154},[148,8707,332],{"class":154},[148,8709,537],{"class":207},[148,8711,6305],{"class":161},[148,8713,8714],{"class":150,"line":2033},[148,8715,176],{"emptyLinePlaceholder":175},[148,8717,8718],{"class":150,"line":2039},[148,8719,8720],{"class":182},"// The SDK filters client-side: only tasks matching these capabilities reach the handler\n",[148,8722,8723,8725,8727,8729,8731,8733,8735,8737,8739,8741,8743,8745,8747,8749,8751],{"class":150,"line":2044},[148,8724,6314],{"class":161},[148,8726,1435],{"class":207},[148,8728,1438],{"class":161},[148,8730,6214],{"class":168},[148,8732,87],{"class":161},[148,8734,7045],{"class":168},[148,8736,1444],{"class":161},[148,8738,1447],{"class":154},[148,8740,114],{"class":161},[148,8742,1452],{"class":217},[148,8744,87],{"class":161},[148,8746,790],{"class":217},[148,8748,633],{"class":161},[148,8750,636],{"class":154},[148,8752,211],{"class":161},[148,8754,8755,8757,8759,8761,8763,8765,8767],{"class":150,"line":2050},[148,8756,3596],{"class":154},[148,8758,6349],{"class":235},[148,8760,329],{"class":154},[148,8762,1502],{"class":154},[148,8764,6356],{"class":161},[148,8766,6359],{"class":207},[148,8768,1343],{"class":161},[148,8770,8771,8773,8775],{"class":150,"line":2056},[148,8772,6366],{"class":161},[148,8774,6225],{"class":168},[148,8776,540],{"class":161},[148,8778,8779,8782,8784,8786,8788],{"class":150,"line":2062},[148,8780,8781],{"class":161},"    messages: [{ role: ",[148,8783,6407],{"class":168},[148,8785,6386],{"class":161},[148,8787,6412],{"class":207},[148,8789,8790],{"class":161},"(task.payload.document) }],\n",[148,8792,8793],{"class":150,"line":2068},[148,8794,6425],{"class":161},[148,8796,8797],{"class":150,"line":2074},[148,8798,176],{"emptyLinePlaceholder":175},[148,8800,8801,8803,8805,8807],{"class":150,"line":2080},[148,8802,6434],{"class":207},[148,8804,369],{"class":161},[148,8806,6439],{"class":168},[148,8808,3614],{"class":161},[148,8810,8811],{"class":150,"line":2086},[148,8812,6446],{"class":161},[148,8814,8815,8817,8819],{"class":150,"line":2092},[148,8816,6366],{"class":161},[148,8818,6225],{"class":168},[148,8820,540],{"class":161},[148,8822,8823,8826,8828],{"class":150,"line":2097},[148,8824,8825],{"class":161},"    provider: ",[148,8827,6231],{"class":168},[148,8829,540],{"class":161},[148,8831,8832,8834,8836,8838,8840],{"class":150,"line":2103},[148,8833,6459],{"class":161},[148,8835,6462],{"class":207},[148,8837,6465],{"class":161},[148,8839,6468],{"class":154},[148,8841,6471],{"class":161},[148,8843,8844],{"class":150,"line":2108},[148,8845,6425],{"class":161},[148,8847,8848],{"class":150,"line":2114},[148,8849,6244],{"class":161},[12,8851,8852],{},"Key properties of this worker:",[882,8854,8855,8861,8867,8873],{},[885,8856,8857,8860],{},[30,8858,8859],{},"Data never leaves your network",": the document is sent to Ollama on localhost, not to an external API",[885,8862,8863,8866],{},[30,8864,8865],{},"Presence-based discovery",": the orchestrator (and the portal dashboard) see this worker appear the moment it connects, with its model and capabilities visible",[885,8868,8869,8872],{},[30,8870,8871],{},"Selective task claiming",": the worker only processes tasks matching its capabilities, ignoring others",[885,8874,8875,8878],{},[30,8876,8877],{},"Result metadata",": every result includes the model, provider, and latency for observability",[35,8880,8882],{"id":8881},"the-cloud-worker","The Cloud Worker",[12,8884,8885],{},"A cloud worker does the same thing, but calls a proprietary API. It can run anywhere: it just needs network access to both NoLag and the API provider.",[135,8887,8888],{},[138,8889,8891],{"className":140,"code":8890,"filename":142,"language":143,"meta":144,"style":144},"import { NoLag } from \"@nolag/js-sdk\";\nimport { NoLagAgents, Handoff } from \"@nolag/agents\";\nimport Anthropic from \"@anthropic-ai/sdk\";\n\nconst anthropic = new Anthropic();\n\nconst client = NoLag(CLOUD_WORKER_TOKEN);\nconst worker = new NoLagAgents({\n  client,\n  appName: APP_SLUG,\n  agentId: \"cloud-synthesizer\",\n  presence: {\n    name: \"cloud-synthesizer\",\n    role: \"agent\",\n    capabilities: [\"summarize\", \"synthesize\"],\n    metadata: { model: \"claude-sonnet-4-6\", provider: \"anthropic\" },\n  },\n});\nawait client.connect();\nawait worker.ready();\nconst room = worker.room(\"processing\");\nconst handoff = new Handoff(room);\n\n// The SDK filters client-side: only tasks matching these capabilities reach the handler\nhandoff.onTask([\"summarize\", \"synthesize\"], async (task, respond) => {\n  const response = await anthropic.messages.create({\n    model: \"claude-sonnet-4-6\",\n    max_tokens: 4096,\n    messages: [{ role: \"user\", content: String(task.payload.document) }],\n  });\n\n  respond(\"success\", {\n    result: response.content[0].text,\n    model: \"claude-sonnet-4-6\",\n    provider: \"anthropic\",\n    latencyMs: Date.now() - task.createdAt,\n  });\n});\n",[84,8892,8893,8905,8917,8931,8935,8951,8955,8972,8986,8990,8998,9007,9011,9019,9027,9040,9054,9058,9062,9072,9082,9100,9114,9118,9122,9154,9172,9180,9190,9202,9206,9210,9220,9231,9239,9247,9259,9263],{"__ignoreMap":144},[148,8894,8895,8897,8899,8901,8903],{"class":150,"line":151},[148,8896,155],{"class":154},[148,8898,2575],{"class":161},[148,8900,165],{"class":154},[148,8902,6054],{"class":168},[148,8904,6057],{"class":161},[148,8906,8907,8909,8911,8913,8915],{"class":150,"line":172},[148,8908,155],{"class":154},[148,8910,6064],{"class":161},[148,8912,165],{"class":154},[148,8914,6069],{"class":168},[148,8916,6057],{"class":161},[148,8918,8919,8921,8924,8926,8929],{"class":150,"line":179},[148,8920,155],{"class":154},[148,8922,8923],{"class":161}," Anthropic ",[148,8925,165],{"class":154},[148,8927,8928],{"class":168}," \"@anthropic-ai/sdk\"",[148,8930,6057],{"class":161},[148,8932,8933],{"class":150,"line":186},[148,8934,176],{"emptyLinePlaceholder":175},[148,8936,8937,8939,8942,8944,8946,8949],{"class":150,"line":192},[148,8938,323],{"class":154},[148,8940,8941],{"class":235}," anthropic",[148,8943,329],{"class":154},[148,8945,332],{"class":154},[148,8947,8948],{"class":207}," Anthropic",[148,8950,6259],{"class":161},[148,8952,8953],{"class":150,"line":198},[148,8954,176],{"emptyLinePlaceholder":175},[148,8956,8957,8959,8961,8963,8965,8967,8970],{"class":150,"line":214},[148,8958,323],{"class":154},[148,8960,1589],{"class":235},[148,8962,329],{"class":154},[148,8964,1594],{"class":207},[148,8966,369],{"class":161},[148,8968,8969],{"class":235},"CLOUD_WORKER_TOKEN",[148,8971,6134],{"class":161},[148,8973,8974,8976,8978,8980,8982,8984],{"class":150,"line":227},[148,8975,323],{"class":154},[148,8977,6141],{"class":235},[148,8979,329],{"class":154},[148,8981,332],{"class":154},[148,8983,6148],{"class":207},[148,8985,1343],{"class":161},[148,8987,8988],{"class":150,"line":239},[148,8989,6155],{"class":161},[148,8991,8992,8994,8996],{"class":150,"line":249},[148,8993,6160],{"class":161},[148,8995,2672],{"class":235},[148,8997,540],{"class":161},[148,8999,9000,9002,9005],{"class":150,"line":259},[148,9001,6172],{"class":161},[148,9003,9004],{"class":168},"\"cloud-synthesizer\"",[148,9006,540],{"class":161},[148,9008,9009],{"class":150,"line":654},[148,9010,6182],{"class":161},[148,9012,9013,9015,9017],{"class":150,"line":660},[148,9014,6187],{"class":161},[148,9016,9004],{"class":168},[148,9018,540],{"class":161},[148,9020,9021,9023,9025],{"class":150,"line":1289},[148,9022,6196],{"class":161},[148,9024,6199],{"class":168},[148,9026,540],{"class":161},[148,9028,9029,9031,9033,9035,9038],{"class":150,"line":1295},[148,9030,6206],{"class":161},[148,9032,6209],{"class":168},[148,9034,87],{"class":161},[148,9036,9037],{"class":168},"\"synthesize\"",[148,9039,6217],{"class":161},[148,9041,9042,9044,9047,9049,9052],{"class":150,"line":1313},[148,9043,6222],{"class":161},[148,9045,9046],{"class":168},"\"claude-sonnet-4-6\"",[148,9048,6228],{"class":161},[148,9050,9051],{"class":168},"\"anthropic\"",[148,9053,6234],{"class":161},[148,9055,9056],{"class":150,"line":1331},[148,9057,6239],{"class":161},[148,9059,9060],{"class":150,"line":1346},[148,9061,6244],{"class":161},[148,9063,9064,9066,9068,9070],{"class":150,"line":1355},[148,9065,343],{"class":154},[148,9067,4099],{"class":161},[148,9069,2647],{"class":207},[148,9071,6259],{"class":161},[148,9073,9074,9076,9078,9080],{"class":150,"line":1361},[148,9075,343],{"class":154},[148,9077,6266],{"class":161},[148,9079,349],{"class":207},[148,9081,6259],{"class":161},[148,9083,9084,9086,9088,9090,9092,9094,9096,9098],{"class":150,"line":1370},[148,9085,323],{"class":154},[148,9087,359],{"class":235},[148,9089,329],{"class":154},[148,9091,6266],{"class":161},[148,9093,1421],{"class":207},[148,9095,369],{"class":161},[148,9097,8308],{"class":168},[148,9099,6134],{"class":161},[148,9101,9102,9104,9106,9108,9110,9112],{"class":150,"line":1376},[148,9103,323],{"class":154},[148,9105,6296],{"class":235},[148,9107,329],{"class":154},[148,9109,332],{"class":154},[148,9111,537],{"class":207},[148,9113,6305],{"class":161},[148,9115,9116],{"class":150,"line":2033},[148,9117,176],{"emptyLinePlaceholder":175},[148,9119,9120],{"class":150,"line":2039},[148,9121,8720],{"class":182},[148,9123,9124,9126,9128,9130,9132,9134,9136,9138,9140,9142,9144,9146,9148,9150,9152],{"class":150,"line":2044},[148,9125,6314],{"class":161},[148,9127,1435],{"class":207},[148,9129,1438],{"class":161},[148,9131,6209],{"class":168},[148,9133,87],{"class":161},[148,9135,9037],{"class":168},[148,9137,1444],{"class":161},[148,9139,1447],{"class":154},[148,9141,114],{"class":161},[148,9143,1452],{"class":217},[148,9145,87],{"class":161},[148,9147,790],{"class":217},[148,9149,633],{"class":161},[148,9151,636],{"class":154},[148,9153,211],{"class":161},[148,9155,9156,9158,9160,9162,9164,9167,9170],{"class":150,"line":2050},[148,9157,3596],{"class":154},[148,9159,6349],{"class":235},[148,9161,329],{"class":154},[148,9163,1502],{"class":154},[148,9165,9166],{"class":161}," anthropic.messages.",[148,9168,9169],{"class":207},"create",[148,9171,1343],{"class":161},[148,9173,9174,9176,9178],{"class":150,"line":2056},[148,9175,6366],{"class":161},[148,9177,9046],{"class":168},[148,9179,540],{"class":161},[148,9181,9182,9185,9188],{"class":150,"line":2062},[148,9183,9184],{"class":161},"    max_tokens: ",[148,9186,9187],{"class":235},"4096",[148,9189,540],{"class":161},[148,9191,9192,9194,9196,9198,9200],{"class":150,"line":2068},[148,9193,8781],{"class":161},[148,9195,6407],{"class":168},[148,9197,6386],{"class":161},[148,9199,6412],{"class":207},[148,9201,8790],{"class":161},[148,9203,9204],{"class":150,"line":2074},[148,9205,6425],{"class":161},[148,9207,9208],{"class":150,"line":2080},[148,9209,176],{"emptyLinePlaceholder":175},[148,9211,9212,9214,9216,9218],{"class":150,"line":2086},[148,9213,6434],{"class":207},[148,9215,369],{"class":161},[148,9217,6439],{"class":168},[148,9219,3614],{"class":161},[148,9221,9222,9225,9228],{"class":150,"line":2092},[148,9223,9224],{"class":161},"    result: response.content[",[148,9226,9227],{"class":235},"0",[148,9229,9230],{"class":161},"].text,\n",[148,9232,9233,9235,9237],{"class":150,"line":2097},[148,9234,6366],{"class":161},[148,9236,9046],{"class":168},[148,9238,540],{"class":161},[148,9240,9241,9243,9245],{"class":150,"line":2103},[148,9242,8825],{"class":161},[148,9244,9051],{"class":168},[148,9246,540],{"class":161},[148,9248,9249,9251,9253,9255,9257],{"class":150,"line":2108},[148,9250,6459],{"class":161},[148,9252,6462],{"class":207},[148,9254,6465],{"class":161},[148,9256,6468],{"class":154},[148,9258,6471],{"class":161},[148,9260,9261],{"class":150,"line":2114},[148,9262,6425],{"class":161},[148,9264,9265],{"class":150,"line":2120},[148,9266,6244],{"class":161},[12,9268,9269],{},"The cloud worker and local worker are structurally identical. The only difference is the inference call. This is the point: the coordination layer doesn't care where inference happens.",[35,9271,9273],{"id":9272},"when-to-use-which-model","When to Use Which Model",[12,9275,9276],{},"Here's a practical routing guide:",[40,9278,9279,9292],{},[43,9280,9281],{},[46,9282,9283,9286,9289],{},[49,9284,9285],{},"Task Type",[49,9287,9288],{},"Model",[49,9290,9291],{},"Why",[59,9293,9294,9305,9316,9327,9338,9349,9360],{},[46,9295,9296,9299,9302],{},[64,9297,9298],{},"Classification / tagging",[64,9300,9301],{},"Local (Llama 3, Mistral)",[64,9303,9304],{},"Fast, cheap, accurate enough for categorical outputs",[46,9306,9307,9310,9313],{},[64,9308,9309],{},"Entity extraction",[64,9311,9312],{},"Local (fine-tuned)",[64,9314,9315],{},"Privacy-sensitive data stays on-prem",[46,9317,9318,9321,9324],{},[64,9319,9320],{},"Summarization",[64,9322,9323],{},"Cloud (Claude, GPT-4)",[64,9325,9326],{},"Needs strong reasoning and coherent long-form output",[46,9328,9329,9332,9335],{},[64,9330,9331],{},"Code generation",[64,9333,9334],{},"Cloud (Claude, Codex)",[64,9336,9337],{},"Frontier models significantly outperform local for complex code",[46,9339,9340,9343,9346],{},[64,9341,9342],{},"Translation",[64,9344,9345],{},"Local (NLLB, Madlad)",[64,9347,9348],{},"Specialized translation models are fast and good",[46,9350,9351,9354,9357],{},[64,9352,9353],{},"Embedding / search",[64,9355,9356],{},"Local (Nomic, BGE)",[64,9358,9359],{},"Embedding models are small and latency-sensitive",[46,9361,9362,9365,9368],{},[64,9363,9364],{},"Complex reasoning",[64,9366,9367],{},"Cloud (Claude Opus, o1)",[64,9369,9370],{},"Multi-step reasoning is where frontier models shine",[35,9372,9374],{"id":9373},"shared-state-the-blackboard","Shared State: The Blackboard",[12,9376,9377],{},"In a hybrid system, you need shared state that all agents can read and write. Which models are performing well? What's the current error rate? Has a document already been classified?",[12,9379,9380],{},"The NoLag blackboard pattern gives every agent a shared key-value store, updated in real time. Writes are last-writer-wins, and each value carries a version so readers can tell a stale entry from a fresh one:",[135,9382,9383],{},[138,9384,9386],{"className":140,"code":9385,"filename":142,"language":143,"meta":144,"style":144},"import { Blackboard } from \"@nolag/agents\";\n\nconst blackboard = new Blackboard(room, orchestrator.agentId);\n\n// Orchestrator tracks model performance on the blackboard. Results are\n// routed back to the agent that dispatched the task, so this handler\n// sees every result for this orchestrator's tasks.\nroom.on(\"result\", (result) => {\n  const { model, provider, latencyMs } = result.payload;\n  const key = `model-stats:${provider}:${model}`;\n\n  // Publish to shared state (retained, so late joiners see the latest value)\n  blackboard.set(key, {\n    latencyMs,\n    provider,\n    model,\n    lastUsed: Date.now(),\n  });\n});\n\n// Any agent can read the blackboard to make routing decisions:\n// \"If local model latency > 500ms, fall back to cloud\"\n// \"If cloud error rate > 5%, route to backup provider\"\nblackboard.onChange(\"model-stats:local:llama3.2\", (envelope) => {\n  console.log(\"local stats v\" + envelope.version, envelope.value);\n});\n",[84,9387,9388,9401,9405,9422,9426,9431,9436,9441,9462,9487,9509,9513,9518,9528,9533,9538,9543,9553,9557,9561,9565,9570,9575,9580,9604,9621],{"__ignoreMap":144},[148,9389,9390,9392,9395,9397,9399],{"class":150,"line":151},[148,9391,155],{"class":154},[148,9393,9394],{"class":161}," { Blackboard } ",[148,9396,165],{"class":154},[148,9398,6069],{"class":168},[148,9400,6057],{"class":161},[148,9402,9403],{"class":150,"line":172},[148,9404,176],{"emptyLinePlaceholder":175},[148,9406,9407,9409,9412,9414,9416,9419],{"class":150,"line":179},[148,9408,323],{"class":154},[148,9410,9411],{"class":235}," blackboard",[148,9413,329],{"class":154},[148,9415,332],{"class":154},[148,9417,9418],{"class":207}," Blackboard",[148,9420,9421],{"class":161},"(room, orchestrator.agentId);\n",[148,9423,9424],{"class":150,"line":186},[148,9425,176],{"emptyLinePlaceholder":175},[148,9427,9428],{"class":150,"line":192},[148,9429,9430],{"class":182},"// Orchestrator tracks model performance on the blackboard. Results are\n",[148,9432,9433],{"class":150,"line":198},[148,9434,9435],{"class":182},"// routed back to the agent that dispatched the task, so this handler\n",[148,9437,9438],{"class":150,"line":214},[148,9439,9440],{"class":182},"// sees every result for this orchestrator's tasks.\n",[148,9442,9443,9445,9447,9449,9452,9454,9456,9458,9460],{"class":150,"line":227},[148,9444,380],{"class":161},[148,9446,2706],{"class":207},[148,9448,369],{"class":161},[148,9450,9451],{"class":168},"\"result\"",[148,9453,2713],{"class":161},[148,9455,710],{"class":217},[148,9457,633],{"class":161},[148,9459,636],{"class":154},[148,9461,211],{"class":161},[148,9463,9464,9466,9468,9470,9472,9475,9477,9480,9482,9484],{"class":150,"line":239},[148,9465,3596],{"class":154},[148,9467,550],{"class":161},[148,9469,7616],{"class":235},[148,9471,87],{"class":161},[148,9473,9474],{"class":235},"provider",[148,9476,87],{"class":161},[148,9478,9479],{"class":235},"latencyMs",[148,9481,1485],{"class":161},[148,9483,1337],{"class":154},[148,9485,9486],{"class":161}," result.payload;\n",[148,9488,9489,9491,9493,9495,9498,9500,9503,9505,9507],{"class":150,"line":249},[148,9490,3596],{"class":154},[148,9492,4421],{"class":235},[148,9494,329],{"class":154},[148,9496,9497],{"class":168}," `model-stats:${",[148,9499,9474],{"class":161},[148,9501,9502],{"class":168},"}:${",[148,9504,7616],{"class":161},[148,9506,3652],{"class":168},[148,9508,6057],{"class":161},[148,9510,9511],{"class":150,"line":259},[148,9512,176],{"emptyLinePlaceholder":175},[148,9514,9515],{"class":150,"line":654},[148,9516,9517],{"class":182},"  // Publish to shared state (retained, so late joiners see the latest value)\n",[148,9519,9520,9523,9525],{"class":150,"line":660},[148,9521,9522],{"class":161},"  blackboard.",[148,9524,2401],{"class":207},[148,9526,9527],{"class":161},"(key, {\n",[148,9529,9530],{"class":150,"line":1289},[148,9531,9532],{"class":161},"    latencyMs,\n",[148,9534,9535],{"class":150,"line":1295},[148,9536,9537],{"class":161},"    provider,\n",[148,9539,9540],{"class":150,"line":1313},[148,9541,9542],{"class":161},"    model,\n",[148,9544,9545,9548,9550],{"class":150,"line":1331},[148,9546,9547],{"class":161},"    lastUsed: Date.",[148,9549,6462],{"class":207},[148,9551,9552],{"class":161},"(),\n",[148,9554,9555],{"class":150,"line":1346},[148,9556,6425],{"class":161},[148,9558,9559],{"class":150,"line":1355},[148,9560,6244],{"class":161},[148,9562,9563],{"class":150,"line":1361},[148,9564,176],{"emptyLinePlaceholder":175},[148,9566,9567],{"class":150,"line":1370},[148,9568,9569],{"class":182},"// Any agent can read the blackboard to make routing decisions:\n",[148,9571,9572],{"class":150,"line":1376},[148,9573,9574],{"class":182},"// \"If local model latency > 500ms, fall back to cloud\"\n",[148,9576,9577],{"class":150,"line":2033},[148,9578,9579],{"class":182},"// \"If cloud error rate > 5%, route to backup provider\"\n",[148,9581,9582,9585,9588,9590,9593,9595,9598,9600,9602],{"class":150,"line":2039},[148,9583,9584],{"class":161},"blackboard.",[148,9586,9587],{"class":207},"onChange",[148,9589,369],{"class":161},[148,9591,9592],{"class":168},"\"model-stats:local:llama3.2\"",[148,9594,2713],{"class":161},[148,9596,9597],{"class":217},"envelope",[148,9599,633],{"class":161},[148,9601,636],{"class":154},[148,9603,211],{"class":161},[148,9605,9606,9608,9610,9612,9615,9618],{"class":150,"line":2044},[148,9607,2514],{"class":161},[148,9609,2517],{"class":207},[148,9611,369],{"class":161},[148,9613,9614],{"class":168},"\"local stats v\"",[148,9616,9617],{"class":154}," +",[148,9619,9620],{"class":161}," envelope.version, envelope.value);\n",[148,9622,9623],{"class":150,"line":2050},[148,9624,6244],{"class":161},[12,9626,9627],{},"This enables dynamic routing decisions. If your local Llama instance starts responding slowly (GPU thermal throttling, memory pressure), an orchestrator whose routing logic reads the blackboard can temporarily send classification tasks to a cloud model instead, and shift traffic back when the local stats recover. The blackboard carries the numbers; the decision is yours to write.",[35,9629,9631],{"id":9630},"observability-see-everything","Observability: See Everything",[12,9633,9634],{},"With hybrid orchestration, visibility matters more than ever. You need to answer: which model handled this task? How long did it take? Did it succeed? Is the local model server healthy?",[135,9636,9637],{},[138,9638,9642],{"className":9639,"code":9640,"filename":9641,"language":1114,"meta":144},[1111],"The Agents tab in the NoLag portal shows, in real time:\n- Which agents are online, with their role, model and capabilities (presence)\n- A topology graph whose edges are drawn from actual handoff and tool traffic\n- Task counters: dispatched, in progress, completed, failed, events per minute\n- The event stream (whatever your agents emit with Observe)\n- The current blackboard state (the model-stats keys above)\n\n   [orchestrator] ---handoff---> [local-classifier]\n         |                           (llama3.2)\n         |\n         +--------handoff---> [cloud-synthesizer]\n                                   (claude-sonnet-4-6)\n","Portal: Agents tab",[84,9643,9640],{"__ignoreMap":144},[12,9645,9646],{},"The per-model latency lives on the blackboard, so it shows up in the portal alongside the counters without any custom dashboard or separate monitoring stack.",[35,9648,9650],{"id":9649},"scaling-independently","Scaling Independently",[12,9652,9653],{},"Because workers are independent processes that connect to NoLag, you can scale each tier independently:",[882,9655,9656,9666,9672,9678],{},[885,9657,9658,9661,9662,9665],{},[30,9659,9660],{},"Scale local horizontally",": add more GPU boxes, each running a worker. Load balancing is opt-in: connect each replica with ",[84,9663,9664],{},"NoLag(token, { loadBalance: true, loadBalanceGroup: \"local-classifiers\" })"," and each task goes to one member of the group. Without it every replica would run every task.",[885,9667,9668,9671],{},[30,9669,9670],{},"Scale cloud vertically",": increase API rate limits and concurrency. Cloud workers can run as serverless functions that auto-scale with demand.",[885,9673,9674,9677],{},[30,9675,9676],{},"Scale the orchestrator to zero",": since it's just dispatching tasks (not running inference), the orchestrator is lightweight. It can run on a small VM or even a serverless function.",[885,9679,9680,9683],{},[30,9681,9682],{},"Mix providers freely",": run three workers with different cloud providers (Anthropic, OpenAI, Google) for the same capability, in one load-balance group. If one provider has an outage, the others keep processing.",[35,9685,9687],{"id":9686},"the-privacy-boundary","The Privacy Boundary",[12,9689,9690],{},"For regulated industries (healthcare, finance, legal), hybrid orchestration solves a real compliance problem. Sensitive data stays on your infrastructure:",[914,9692,9693,9696,9699,9702],{},[885,9694,9695],{},"Documents arrive at your on-prem worker via NoLag (encrypted WebSocket)",[885,9697,9698],{},"The local worker extracts entities, classifies, and redacts PII",[885,9700,9701],{},"Only the redacted summary is sent to a cloud model for synthesis",[885,9703,9704],{},"The cloud model never sees raw PII, medical records, or financial data",[12,9706,9707],{},"NoLag's access scopes enforce this boundary at the infrastructure level. Scoped actors can only see data within their tenant, and webhook payloads include scope info so your backend can route accordingly.",[35,9709,9711],{"id":9710},"getting-started","Getting Started",[914,9713,9714,9720,9729,9732,9735,9738],{},[885,9715,9716,9717],{},"Install the SDKs: ",[84,9718,9719],{},"npm install @nolag/agents @nolag/js-sdk",[885,9721,9722,9723,9725,9726,9728],{},"Create an app from the ",[84,9724,5976],{}," blueprint (it seeds a ",[84,9727,5999],{}," room; create any other room you want on the control plane) and one actor per process",[885,9730,9731],{},"Write an orchestrator that dispatches tasks by capability",[885,9733,9734],{},"Write a local worker with Ollama for fast, private tasks",[885,9736,9737],{},"Write a cloud worker with your preferred API for reasoning tasks",[885,9739,9740],{},"Open the Agents tab in the portal to watch the topology live",[12,9742,9743],{},"Each worker is a standalone script. Deploy them wherever makes sense: the local worker on your GPU server, the cloud worker on a VM or Lambda, the orchestrator anywhere. They find each other through NoLag presence, no service discovery, no hardcoded addresses.",[35,9745,9747],{"id":9746},"whats-next","What's Next",[12,9749,9750],{},"Hybrid orchestration is just one pattern enabled by decoupled coordination. Once your agents communicate through NoLag instead of direct function calls, you can add approval gates for sensitive operations, A/B test models by routing a percentage of traffic to each, implement automatic fallback chains, and build custom dashboards on the event stream.",[12,9752,9753],{},"The models will keep getting better. The coordination infrastructure shouldn't need to change every time they do.",[882,9755,9756,9761],{},[885,9757,9758],{},[952,9759,9760],{"href":954},"Coordination Layer",[885,9762,9763],{},[952,9764,9766],{"href":9765},"/docs/concepts/webhooks","Webhooks",[969,9768,9769],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":144,"searchDepth":172,"depth":172,"links":9771},[9772,9773,9774,9775,9776,9777,9778,9779,9780,9781,9782],{"id":7915,"depth":172,"text":7916},{"id":8144,"depth":172,"text":8145},{"id":8482,"depth":172,"text":8483},{"id":8881,"depth":172,"text":8882},{"id":9272,"depth":172,"text":9273},{"id":9373,"depth":172,"text":9374},{"id":9630,"depth":172,"text":9631},{"id":9649,"depth":172,"text":9650},{"id":9686,"depth":172,"text":9687},{"id":9710,"depth":172,"text":9711},{"id":9746,"depth":172,"text":9747},"2026-05-21","Route tasks to the right model: local LLMs for speed and privacy, proprietary APIs for reasoning. NoLag coordinates the handoffs without coupling your agents to any single provider.",{},{"title":7904,"description":9784},"blog/hybrid-llm-orchestration","i7DdISV8hxJmxHDL67CMDoEoPNx1fFNqnMDfYScW7vM",{"id":9790,"title":9791,"author":7,"body":9792,"category":1832,"date":11998,"description":11999,"excerpt":986,"extension":987,"meta":12000,"navigation":175,"path":12001,"readTime":12002,"seo":12003,"stem":12004,"__hash__":12005},"blog/blog/building-agentic-chatbot.md","Building an Agentic Chatbot with NoLag",{"type":9,"value":9793,"toc":11988},[9794,9804,9808,9811,9834,9838,9859,9863,9866,9892,9915,9918,9947,9951,9954,10491,10509,10513,10516,11040,11047,11051,11054,11473,11476,11480,11490,11953,11957,11977,11980,11985],[12,9795,9796,9797,9800,9801,9803],{},"This tutorial walks through building a customer support chatbot that combines two NoLag Blueprints: ",[84,9798,9799],{},"@nolag/chat"," for the user-facing interface and ",[84,9802,90],{}," for AI-powered response generation. The result is a production-ready chatbot with typing indicators, conversation context, sentiment-based escalation, and observability.",[35,9805,9807],{"id":9806},"architecture-overview","Architecture Overview",[12,9809,9810],{},"The system has three components:",[914,9812,9813,9822,9828],{},[885,9814,9815,9818,9819,9821],{},[30,9816,9817],{},"Chat frontend"," - Users interact with a standard chat room powered by ",[84,9820,9799],{},". They see typing indicators, message history, and presence.",[885,9823,9824,9827],{},[30,9825,9826],{},"Bridge service"," - A server-side process that connects to both the chat app and the agents app. It forwards user messages to the agent workflow and sends responses back to chat.",[885,9829,9830,9833],{},[30,9831,9832],{},"AI workers"," - Agent processes that receive tasks via the Handoff pattern, call an LLM, and return responses. They can be scaled horizontally.",[35,9835,9837],{"id":9836},"step-1-install-dependencies","Step 1: Install Dependencies",[135,9839,9840],{},[138,9841,9844],{"className":1891,"code":9842,"filename":9843,"language":1893,"meta":144,"style":144},"npm install @nolag/chat @nolag/agents @nolag/js-sdk\n","Terminal",[84,9845,9846],{"__ignoreMap":144},[148,9847,9848,9850,9852,9855,9857],{"class":150,"line":151},[148,9849,3531],{"class":207},[148,9851,1903],{"class":168},[148,9853,9854],{"class":168}," @nolag/chat",[148,9856,6026],{"class":168},[148,9858,3536],{"class":168},[35,9860,9862],{"id":9861},"step-2-set-up-apps-in-the-portal","Step 2: Set Up Apps in the Portal",[12,9864,9865],{},"Create two apps in your NoLag project:",[914,9867,9868,9881],{},[885,9869,1854,9870,9873,9874,9877,9878],{},[30,9871,9872],{},"Chat"," Blueprint app (",[84,9875,9876],{},"nolag-chat-sdk","), named for example ",[84,9879,9880],{},"support-chat",[885,9882,9883,9884,9873,9887,9877,9889],{},"An ",[30,9885,9886],{},"Agents",[84,9888,5976],{},[84,9890,9891],{},"support-agents",[12,9893,9894,9895,5992,9898,9900,9901,9904,9905,9908,9909,1812,9912,9914],{},"The slug you get back for each app has a random suffix (",[84,9896,9897],{},"support-chat-a3f9",[84,9899,5995],{}," you pass to each wrapper. Rooms never exist implicitly, so also create a ",[84,9902,9903],{},"support"," room in the chat app and a ",[84,9906,9907],{},"support-workflow"," room in the agents app (or use the seeded ",[84,9910,9911],{},"general",[84,9913,5999],{}," rooms).",[12,9916,9917],{},"Create three kinds of actors:",[882,9919,9920,9929,9937],{},[885,9921,9922,9925,9926,9928],{},[30,9923,9924],{},"Bridge actor"," (type ",[84,9927,1254],{},") - granted access to both apps",[885,9930,9931,9925,9934,9936],{},[30,9932,9933],{},"Worker actor",[84,9935,2854],{},") - granted access to the agents app",[885,9938,9939,9925,9942,9944,9945,130],{},[30,9940,9941],{},"User actors",[84,9943,2842],{},") - granted access to the chat app (one per user, or use ",[952,9946,2864],{"href":2863},[35,9948,9950],{"id":9949},"step-3-build-the-bridge","Step 3: Build the Bridge",[12,9952,9953],{},"The bridge is the key piece. One connection carries two wrappers, one per app, and the bridge translates between chat messages and agent tasks:",[135,9955,9956],{},[138,9957,9960],{"className":140,"code":9958,"filename":9959,"language":143,"meta":144,"style":144},"import { NoLag } from \"@nolag/js-sdk\";\nimport { NoLagChat } from \"@nolag/chat\";\nimport { NoLagAgents, Handoff } from \"@nolag/agents\";\n\n// One connection, two wrappers: the bridge actor has access to both apps\nconst client = NoLag(BRIDGE_TOKEN);\nconst chat = new NoLagChat({\n  client,\n  appName: CHAT_APP_SLUG, // the suffixed slug of the chat app\n  username: \"Support Bot\",\n  avatar: \"/bot.png\",\n});\nconst agents = new NoLagAgents({\n  client,\n  appName: AGENTS_APP_SLUG, // the suffixed slug of the agents app\n  agentId: \"bridge\",\n  presence: { name: \"bridge\", role: \"orchestrator\" },\n});\n\nawait client.connect();\nawait Promise.all([chat.ready(), agents.ready()]);\n\nconst chatRoom = chat.joinRoom(\"support\");\nconst agentRoom = agents.room(\"support-workflow\");\nconst handoff = new Handoff(agentRoom);\nconst botId = chat.localUser!.userId;\n\n// Forward user messages to the agent workflow. Publishers never receive\n// their own messages, so the bot's replies never come back through here.\nchatRoom.on(\"message\", async (msg) => {\n  // Show typing indicator while the worker thinks\n  chatRoom.startTyping();\n\n  const history = chatRoom.getMessages().slice(-10).map((m) => ({\n    role: m.userId === botId ? \"assistant\" : \"user\",\n    content: m.text,\n  }));\n\n  const result = await handoff.dispatch(\n    \"customer-support\",\n    { userMessage: msg.text, userId: msg.userId, history },\n    { waitForResult: true, timeout: 30000 },\n  );\n\n  chatRoom.stopTyping();\n  if (result && result.status === \"success\") {\n    chatRoom.sendMessage(String(result.payload.response));\n  }\n});\n","bridge.ts",[84,9961,9962,9974,9987,9999,10003,10008,10025,10039,10043,10055,10065,10075,10079,10094,10098,10110,10119,10131,10135,10139,10149,10173,10177,10197,10218,10233,10250,10254,10259,10264,10290,10295,10305,10309,10352,10374,10379,10384,10388,10404,10411,10416,10430,10435,10439,10448,10469,10483,10487],{"__ignoreMap":144},[148,9963,9964,9966,9968,9970,9972],{"class":150,"line":151},[148,9965,155],{"class":154},[148,9967,2575],{"class":161},[148,9969,165],{"class":154},[148,9971,6054],{"class":168},[148,9973,6057],{"class":161},[148,9975,9976,9978,9980,9982,9985],{"class":150,"line":172},[148,9977,155],{"class":154},[148,9979,304],{"class":161},[148,9981,165],{"class":154},[148,9983,9984],{"class":168}," \"@nolag/chat\"",[148,9986,6057],{"class":161},[148,9988,9989,9991,9993,9995,9997],{"class":150,"line":179},[148,9990,155],{"class":154},[148,9992,6064],{"class":161},[148,9994,165],{"class":154},[148,9996,6069],{"class":168},[148,9998,6057],{"class":161},[148,10000,10001],{"class":150,"line":186},[148,10002,176],{"emptyLinePlaceholder":175},[148,10004,10005],{"class":150,"line":192},[148,10006,10007],{"class":182},"// One connection, two wrappers: the bridge actor has access to both apps\n",[148,10009,10010,10012,10014,10016,10018,10020,10023],{"class":150,"line":198},[148,10011,323],{"class":154},[148,10013,1589],{"class":235},[148,10015,329],{"class":154},[148,10017,1594],{"class":207},[148,10019,369],{"class":161},[148,10021,10022],{"class":235},"BRIDGE_TOKEN",[148,10024,6134],{"class":161},[148,10026,10027,10029,10031,10033,10035,10037],{"class":150,"line":214},[148,10028,323],{"class":154},[148,10030,326],{"class":235},[148,10032,329],{"class":154},[148,10034,332],{"class":154},[148,10036,335],{"class":207},[148,10038,1343],{"class":161},[148,10040,10041],{"class":150,"line":227},[148,10042,6155],{"class":161},[148,10044,10045,10047,10050,10052],{"class":150,"line":239},[148,10046,6160],{"class":161},[148,10048,10049],{"class":235},"CHAT_APP_SLUG",[148,10051,87],{"class":161},[148,10053,10054],{"class":182},"// the suffixed slug of the chat app\n",[148,10056,10057,10060,10063],{"class":150,"line":249},[148,10058,10059],{"class":161},"  username: ",[148,10061,10062],{"class":168},"\"Support Bot\"",[148,10064,540],{"class":161},[148,10066,10067,10070,10073],{"class":150,"line":259},[148,10068,10069],{"class":161},"  avatar: ",[148,10071,10072],{"class":168},"\"/bot.png\"",[148,10074,540],{"class":161},[148,10076,10077],{"class":150,"line":654},[148,10078,6244],{"class":161},[148,10080,10081,10083,10086,10088,10090,10092],{"class":150,"line":660},[148,10082,323],{"class":154},[148,10084,10085],{"class":235}," agents",[148,10087,329],{"class":154},[148,10089,332],{"class":154},[148,10091,6148],{"class":207},[148,10093,1343],{"class":161},[148,10095,10096],{"class":150,"line":1289},[148,10097,6155],{"class":161},[148,10099,10100,10102,10105,10107],{"class":150,"line":1295},[148,10101,6160],{"class":161},[148,10103,10104],{"class":235},"AGENTS_APP_SLUG",[148,10106,87],{"class":161},[148,10108,10109],{"class":182},"// the suffixed slug of the agents app\n",[148,10111,10112,10114,10117],{"class":150,"line":1313},[148,10113,6172],{"class":161},[148,10115,10116],{"class":168},"\"bridge\"",[148,10118,540],{"class":161},[148,10120,10121,10123,10125,10127,10129],{"class":150,"line":1331},[148,10122,6610],{"class":161},[148,10124,10116],{"class":168},[148,10126,6615],{"class":161},[148,10128,6603],{"class":168},[148,10130,6234],{"class":161},[148,10132,10133],{"class":150,"line":1346},[148,10134,6244],{"class":161},[148,10136,10137],{"class":150,"line":1355},[148,10138,176],{"emptyLinePlaceholder":175},[148,10140,10141,10143,10145,10147],{"class":150,"line":1361},[148,10142,343],{"class":154},[148,10144,4099],{"class":161},[148,10146,2647],{"class":207},[148,10148,6259],{"class":161},[148,10150,10151,10153,10155,10157,10160,10163,10165,10168,10170],{"class":150,"line":1370},[148,10152,343],{"class":154},[148,10154,608],{"class":235},[148,10156,611],{"class":161},[148,10158,10159],{"class":207},"all",[148,10161,10162],{"class":161},"([chat.",[148,10164,349],{"class":207},[148,10166,10167],{"class":161},"(), agents.",[148,10169,349],{"class":207},[148,10171,10172],{"class":161},"()]);\n",[148,10174,10175],{"class":150,"line":1376},[148,10176,176],{"emptyLinePlaceholder":175},[148,10178,10179,10181,10184,10186,10188,10190,10192,10195],{"class":150,"line":2033},[148,10180,323],{"class":154},[148,10182,10183],{"class":235}," chatRoom",[148,10185,329],{"class":154},[148,10187,346],{"class":161},[148,10189,366],{"class":207},[148,10191,369],{"class":161},[148,10193,10194],{"class":168},"\"support\"",[148,10196,6134],{"class":161},[148,10198,10199,10201,10204,10206,10209,10211,10213,10216],{"class":150,"line":2039},[148,10200,323],{"class":154},[148,10202,10203],{"class":235}," agentRoom",[148,10205,329],{"class":154},[148,10207,10208],{"class":161}," agents.",[148,10210,1421],{"class":207},[148,10212,369],{"class":161},[148,10214,10215],{"class":168},"\"support-workflow\"",[148,10217,6134],{"class":161},[148,10219,10220,10222,10224,10226,10228,10230],{"class":150,"line":2044},[148,10221,323],{"class":154},[148,10223,6296],{"class":235},[148,10225,329],{"class":154},[148,10227,332],{"class":154},[148,10229,537],{"class":207},[148,10231,10232],{"class":161},"(agentRoom);\n",[148,10234,10235,10237,10240,10242,10245,10247],{"class":150,"line":2050},[148,10236,323],{"class":154},[148,10238,10239],{"class":235}," botId",[148,10241,329],{"class":154},[148,10243,10244],{"class":161}," chat.localUser",[148,10246,4069],{"class":154},[148,10248,10249],{"class":161},".userId;\n",[148,10251,10252],{"class":150,"line":2056},[148,10253,176],{"emptyLinePlaceholder":175},[148,10255,10256],{"class":150,"line":2062},[148,10257,10258],{"class":182},"// Forward user messages to the agent workflow. Publishers never receive\n",[148,10260,10261],{"class":150,"line":2068},[148,10262,10263],{"class":182},"// their own messages, so the bot's replies never come back through here.\n",[148,10265,10266,10269,10271,10273,10276,10278,10280,10282,10284,10286,10288],{"class":150,"line":2074},[148,10267,10268],{"class":161},"chatRoom.",[148,10270,2706],{"class":207},[148,10272,369],{"class":161},[148,10274,10275],{"class":168},"\"message\"",[148,10277,87],{"class":161},[148,10279,1447],{"class":154},[148,10281,114],{"class":161},[148,10283,3103],{"class":217},[148,10285,633],{"class":161},[148,10287,636],{"class":154},[148,10289,211],{"class":161},[148,10291,10292],{"class":150,"line":2080},[148,10293,10294],{"class":182},"  // Show typing indicator while the worker thinks\n",[148,10296,10297,10300,10303],{"class":150,"line":2086},[148,10298,10299],{"class":161},"  chatRoom.",[148,10301,10302],{"class":207},"startTyping",[148,10304,6259],{"class":161},[148,10306,10307],{"class":150,"line":2092},[148,10308,176],{"emptyLinePlaceholder":175},[148,10310,10311,10313,10316,10318,10321,10324,10327,10330,10332,10334,10337,10339,10341,10343,10345,10347,10349],{"class":150,"line":2097},[148,10312,3596],{"class":154},[148,10314,10315],{"class":235}," history",[148,10317,329],{"class":154},[148,10319,10320],{"class":161}," chatRoom.",[148,10322,10323],{"class":207},"getMessages",[148,10325,10326],{"class":161},"().",[148,10328,10329],{"class":207},"slice",[148,10331,369],{"class":161},[148,10333,6468],{"class":154},[148,10335,10336],{"class":235},"10",[148,10338,2675],{"class":161},[148,10340,624],{"class":207},[148,10342,627],{"class":161},[148,10344,4178],{"class":217},[148,10346,633],{"class":161},[148,10348,636],{"class":154},[148,10350,10351],{"class":161}," ({\n",[148,10353,10354,10357,10359,10362,10364,10367,10369,10372],{"class":150,"line":2103},[148,10355,10356],{"class":161},"    role: m.userId ",[148,10358,1700],{"class":154},[148,10360,10361],{"class":161}," botId ",[148,10363,836],{"class":154},[148,10365,10366],{"class":168}," \"assistant\"",[148,10368,842],{"class":154},[148,10370,10371],{"class":168}," \"user\"",[148,10373,540],{"class":161},[148,10375,10376],{"class":150,"line":2108},[148,10377,10378],{"class":161},"    content: m.text,\n",[148,10380,10381],{"class":150,"line":2114},[148,10382,10383],{"class":161},"  }));\n",[148,10385,10386],{"class":150,"line":2120},[148,10387,176],{"emptyLinePlaceholder":175},[148,10389,10390,10392,10394,10396,10398,10400,10402],{"class":150,"line":2125},[148,10391,3596],{"class":154},[148,10393,6754],{"class":235},[148,10395,329],{"class":154},[148,10397,1502],{"class":154},[148,10399,639],{"class":161},[148,10401,642],{"class":207},[148,10403,527],{"class":161},[148,10405,10406,10409],{"class":150,"line":4261},[148,10407,10408],{"class":168},"    \"customer-support\"",[148,10410,540],{"class":161},[148,10412,10413],{"class":150,"line":4266},[148,10414,10415],{"class":161},"    { userMessage: msg.text, userId: msg.userId, history },\n",[148,10417,10418,10421,10423,10426,10428],{"class":150,"line":4290},[148,10419,10420],{"class":161},"    { waitForResult: ",[148,10422,648],{"class":235},[148,10424,10425],{"class":161},", timeout: ",[148,10427,6798],{"class":235},[148,10429,6234],{"class":161},[148,10431,10432],{"class":150,"line":4307},[148,10433,10434],{"class":161},"  );\n",[148,10436,10437],{"class":150,"line":4322},[148,10438,176],{"emptyLinePlaceholder":175},[148,10440,10441,10443,10446],{"class":150,"line":4328},[148,10442,10299],{"class":161},[148,10444,10445],{"class":207},"stopTyping",[148,10447,6259],{"class":161},[148,10449,10450,10453,10456,10459,10462,10464,10467],{"class":150,"line":4349},[148,10451,10452],{"class":154},"  if",[148,10454,10455],{"class":161}," (result ",[148,10457,10458],{"class":154},"&&",[148,10460,10461],{"class":161}," result.status ",[148,10463,1700],{"class":154},[148,10465,10466],{"class":168}," \"success\"",[148,10468,600],{"class":161},[148,10470,10471,10474,10476,10478,10480],{"class":150,"line":4354},[148,10472,10473],{"class":161},"    chatRoom.",[148,10475,383],{"class":207},[148,10477,369],{"class":161},[148,10479,6412],{"class":207},[148,10481,10482],{"class":161},"(result.payload.response));\n",[148,10484,10485],{"class":150,"line":4359},[148,10486,1716],{"class":161},[148,10488,10489],{"class":150,"line":4367},[148,10490,6244],{"class":161},[12,10492,10493,10494,10497,10498,10501,10502,10505,10506,611],{},"The bridge shows typing indicators while the agent is processing, giving users a natural chat experience. The last ten messages are passed to the agent as ",[84,10495,10496],{},"role","/",[84,10499,10500],{},"content"," pairs so it has conversation context. ",[84,10503,10504],{},"dispatch()"," checks room presence before publishing and throws if no connected worker advertises the capability, so start a worker before the bridge or pass ",[84,10507,10508],{},"allowNoWorkers: true",[35,10510,10512],{"id":10511},"step-4-build-the-ai-worker","Step 4: Build the AI Worker",[12,10514,10515],{},"Workers advertise their capabilities through presence and process incoming tasks. You can use any LLM - OpenAI, Anthropic, a local model, or a custom pipeline:",[135,10517,10518],{},[138,10519,10521],{"className":140,"code":10520,"filename":6041,"language":143,"meta":144,"style":144},"import { NoLag } from \"@nolag/js-sdk\";\nimport { NoLagAgents, Handoff, Observe } from \"@nolag/agents\";\n\n// loadBalance: with several workers in the \"support-workers\" group, each task\n// goes to one of them. Without it every worker answers every task.\nconst client = NoLag(WORKER_TOKEN, { loadBalance: true, loadBalanceGroup: \"support-workers\" });\nconst agents = new NoLagAgents({\n  client,\n  appName: AGENTS_APP_SLUG,\n  agentId: \"support-worker-1\",\n  presence: { name: \"support-worker-1\", role: \"agent\", capabilities: [\"customer-support\"] },\n});\nawait client.connect();\nawait agents.ready();\n\nconst room = agents.room(\"support-workflow\");\nconst handoff = new Handoff(room);\nconst observe = new Observe(room, agents.agentId);\n\ntype Turn = { role: \"user\" | \"assistant\"; content: string };\n\nhandoff.onTask([\"customer-support\"], async (task, respond) => {\n  const userMessage = String(task.payload.userMessage);\n  const history = (task.payload.history ?? []) as Turn[];\n  const started = Date.now();\n\n  try {\n    // Call your LLM of choice\n    const completion = await openai.chat.completions.create({\n      model: \"gpt-4o\",\n      messages: [\n        { role: \"system\", content: \"You are a helpful customer support agent.\" },\n        ...history,\n        { role: \"user\", content: userMessage },\n      ],\n    });\n\n    respond(\"success\", { response: completion.choices[0].message.content });\n    observe.emit(\"task:completed\", { taskId: task.taskId, durationMs: Date.now() - started });\n  } catch (err) {\n    respond(\"error\", {}, { code: \"llm_failed\", message: String(err) });\n    observe.emit(\"task:failed\", { taskId: task.taskId, error: String(err) }, \"error\");\n  }\n});\n",[84,10522,10523,10535,10548,10552,10557,10562,10587,10601,10605,10613,10622,10641,10645,10655,10665,10669,10687,10701,10718,10722,10754,10758,10786,10801,10825,10841,10845,10852,10857,10875,10885,10890,10904,10912,10921,10926,10931,10935,10951,10975,10986,11009,11032,11036],{"__ignoreMap":144},[148,10524,10525,10527,10529,10531,10533],{"class":150,"line":151},[148,10526,155],{"class":154},[148,10528,2575],{"class":161},[148,10530,165],{"class":154},[148,10532,6054],{"class":168},[148,10534,6057],{"class":161},[148,10536,10537,10539,10542,10544,10546],{"class":150,"line":172},[148,10538,155],{"class":154},[148,10540,10541],{"class":161}," { NoLagAgents, Handoff, Observe } ",[148,10543,165],{"class":154},[148,10545,6069],{"class":168},[148,10547,6057],{"class":161},[148,10549,10550],{"class":150,"line":179},[148,10551,176],{"emptyLinePlaceholder":175},[148,10553,10554],{"class":150,"line":186},[148,10555,10556],{"class":182},"// loadBalance: with several workers in the \"support-workers\" group, each task\n",[148,10558,10559],{"class":150,"line":192},[148,10560,10561],{"class":182},"// goes to one of them. Without it every worker answers every task.\n",[148,10563,10564,10566,10568,10570,10572,10574,10576,10578,10580,10582,10585],{"class":150,"line":198},[148,10565,323],{"class":154},[148,10567,1589],{"class":235},[148,10569,329],{"class":154},[148,10571,1594],{"class":207},[148,10573,369],{"class":161},[148,10575,6131],{"class":235},[148,10577,7776],{"class":161},[148,10579,648],{"class":235},[148,10581,7781],{"class":161},[148,10583,10584],{"class":168},"\"support-workers\"",[148,10586,6112],{"class":161},[148,10588,10589,10591,10593,10595,10597,10599],{"class":150,"line":214},[148,10590,323],{"class":154},[148,10592,10085],{"class":235},[148,10594,329],{"class":154},[148,10596,332],{"class":154},[148,10598,6148],{"class":207},[148,10600,1343],{"class":161},[148,10602,10603],{"class":150,"line":227},[148,10604,6155],{"class":161},[148,10606,10607,10609,10611],{"class":150,"line":239},[148,10608,6160],{"class":161},[148,10610,10104],{"class":235},[148,10612,540],{"class":161},[148,10614,10615,10617,10620],{"class":150,"line":249},[148,10616,6172],{"class":161},[148,10618,10619],{"class":168},"\"support-worker-1\"",[148,10621,540],{"class":161},[148,10623,10624,10626,10628,10630,10632,10635,10638],{"class":150,"line":259},[148,10625,6610],{"class":161},[148,10627,10619],{"class":168},[148,10629,6615],{"class":161},[148,10631,6199],{"class":168},[148,10633,10634],{"class":161},", capabilities: [",[148,10636,10637],{"class":168},"\"customer-support\"",[148,10639,10640],{"class":161},"] },\n",[148,10642,10643],{"class":150,"line":654},[148,10644,6244],{"class":161},[148,10646,10647,10649,10651,10653],{"class":150,"line":660},[148,10648,343],{"class":154},[148,10650,4099],{"class":161},[148,10652,2647],{"class":207},[148,10654,6259],{"class":161},[148,10656,10657,10659,10661,10663],{"class":150,"line":1289},[148,10658,343],{"class":154},[148,10660,10208],{"class":161},[148,10662,349],{"class":207},[148,10664,6259],{"class":161},[148,10666,10667],{"class":150,"line":1295},[148,10668,176],{"emptyLinePlaceholder":175},[148,10670,10671,10673,10675,10677,10679,10681,10683,10685],{"class":150,"line":1313},[148,10672,323],{"class":154},[148,10674,359],{"class":235},[148,10676,329],{"class":154},[148,10678,10208],{"class":161},[148,10680,1421],{"class":207},[148,10682,369],{"class":161},[148,10684,10215],{"class":168},[148,10686,6134],{"class":161},[148,10688,10689,10691,10693,10695,10697,10699],{"class":150,"line":1331},[148,10690,323],{"class":154},[148,10692,6296],{"class":235},[148,10694,329],{"class":154},[148,10696,332],{"class":154},[148,10698,537],{"class":207},[148,10700,6305],{"class":161},[148,10702,10703,10705,10708,10710,10712,10715],{"class":150,"line":1346},[148,10704,323],{"class":154},[148,10706,10707],{"class":235}," observe",[148,10709,329],{"class":154},[148,10711,332],{"class":154},[148,10713,10714],{"class":207}," Observe",[148,10716,10717],{"class":161},"(room, agents.agentId);\n",[148,10719,10720],{"class":150,"line":1355},[148,10721,176],{"emptyLinePlaceholder":175},[148,10723,10724,10726,10729,10731,10733,10735,10737,10739,10741,10743,10745,10747,10749,10751],{"class":150,"line":1361},[148,10725,3845],{"class":154},[148,10727,10728],{"class":207}," Turn",[148,10730,329],{"class":154},[148,10732,550],{"class":161},[148,10734,10496],{"class":217},[148,10736,221],{"class":154},[148,10738,10371],{"class":168},[148,10740,3938],{"class":154},[148,10742,10366],{"class":168},[148,10744,561],{"class":161},[148,10746,10500],{"class":217},[148,10748,221],{"class":154},[148,10750,558],{"class":235},[148,10752,10753],{"class":161}," };\n",[148,10755,10756],{"class":150,"line":1370},[148,10757,176],{"emptyLinePlaceholder":175},[148,10759,10760,10762,10764,10766,10768,10770,10772,10774,10776,10778,10780,10782,10784],{"class":150,"line":1376},[148,10761,6314],{"class":161},[148,10763,1435],{"class":207},[148,10765,1438],{"class":161},[148,10767,10637],{"class":168},[148,10769,1444],{"class":161},[148,10771,1447],{"class":154},[148,10773,114],{"class":161},[148,10775,1452],{"class":217},[148,10777,87],{"class":161},[148,10779,790],{"class":217},[148,10781,633],{"class":161},[148,10783,636],{"class":154},[148,10785,211],{"class":161},[148,10787,10788,10790,10793,10795,10798],{"class":150,"line":2033},[148,10789,3596],{"class":154},[148,10791,10792],{"class":235}," userMessage",[148,10794,329],{"class":154},[148,10796,10797],{"class":207}," String",[148,10799,10800],{"class":161},"(task.payload.userMessage);\n",[148,10802,10803,10805,10807,10809,10812,10815,10818,10820,10822],{"class":150,"line":2039},[148,10804,3596],{"class":154},[148,10806,10315],{"class":235},[148,10808,329],{"class":154},[148,10810,10811],{"class":161}," (task.payload.history ",[148,10813,10814],{"class":154},"??",[148,10816,10817],{"class":161}," []) ",[148,10819,3908],{"class":154},[148,10821,10728],{"class":207},[148,10823,10824],{"class":161},"[];\n",[148,10826,10827,10829,10832,10834,10837,10839],{"class":150,"line":2044},[148,10828,3596],{"class":154},[148,10830,10831],{"class":235}," started",[148,10833,329],{"class":154},[148,10835,10836],{"class":161}," Date.",[148,10838,6462],{"class":207},[148,10840,6259],{"class":161},[148,10842,10843],{"class":150,"line":2050},[148,10844,176],{"emptyLinePlaceholder":175},[148,10846,10847,10850],{"class":150,"line":2056},[148,10848,10849],{"class":154},"  try",[148,10851,211],{"class":161},[148,10853,10854],{"class":150,"line":2062},[148,10855,10856],{"class":182},"    // Call your LLM of choice\n",[148,10858,10859,10861,10864,10866,10868,10871,10873],{"class":150,"line":2068},[148,10860,1467],{"class":154},[148,10862,10863],{"class":235}," completion",[148,10865,329],{"class":154},[148,10867,1502],{"class":154},[148,10869,10870],{"class":161}," openai.chat.completions.",[148,10872,9169],{"class":207},[148,10874,1343],{"class":161},[148,10876,10877,10880,10883],{"class":150,"line":2074},[148,10878,10879],{"class":161},"      model: ",[148,10881,10882],{"class":168},"\"gpt-4o\"",[148,10884,540],{"class":161},[148,10886,10887],{"class":150,"line":2080},[148,10888,10889],{"class":161},"      messages: [\n",[148,10891,10892,10895,10897,10899,10902],{"class":150,"line":2086},[148,10893,10894],{"class":161},"        { role: ",[148,10896,6383],{"class":168},[148,10898,6386],{"class":161},[148,10900,10901],{"class":168},"\"You are a helpful customer support agent.\"",[148,10903,6234],{"class":161},[148,10905,10906,10909],{"class":150,"line":2092},[148,10907,10908],{"class":154},"        ...",[148,10910,10911],{"class":161},"history,\n",[148,10913,10914,10916,10918],{"class":150,"line":2097},[148,10915,10894],{"class":161},[148,10917,6407],{"class":168},[148,10919,10920],{"class":161},", content: userMessage },\n",[148,10922,10923],{"class":150,"line":2103},[148,10924,10925],{"class":161},"      ],\n",[148,10927,10928],{"class":150,"line":2108},[148,10929,10930],{"class":161},"    });\n",[148,10932,10933],{"class":150,"line":2114},[148,10934,176],{"emptyLinePlaceholder":175},[148,10936,10937,10939,10941,10943,10946,10948],{"class":150,"line":2120},[148,10938,1517],{"class":207},[148,10940,369],{"class":161},[148,10942,6439],{"class":168},[148,10944,10945],{"class":161},", { response: completion.choices[",[148,10947,9227],{"class":235},[148,10949,10950],{"class":161},"].message.content });\n",[148,10952,10953,10956,10958,10960,10963,10966,10968,10970,10972],{"class":150,"line":2125},[148,10954,10955],{"class":161},"    observe.",[148,10957,2807],{"class":207},[148,10959,369],{"class":161},[148,10961,10962],{"class":168},"\"task:completed\"",[148,10964,10965],{"class":161},", { taskId: task.taskId, durationMs: Date.",[148,10967,6462],{"class":207},[148,10969,6465],{"class":161},[148,10971,6468],{"class":154},[148,10973,10974],{"class":161}," started });\n",[148,10976,10977,10980,10983],{"class":150,"line":4261},[148,10978,10979],{"class":161},"  } ",[148,10981,10982],{"class":154},"catch",[148,10984,10985],{"class":161}," (err) {\n",[148,10987,10988,10990,10992,10995,10998,11001,11004,11006],{"class":150,"line":4266},[148,10989,1517],{"class":207},[148,10991,369],{"class":161},[148,10993,10994],{"class":168},"\"error\"",[148,10996,10997],{"class":161},", {}, { code: ",[148,10999,11000],{"class":168},"\"llm_failed\"",[148,11002,11003],{"class":161},", message: ",[148,11005,6412],{"class":207},[148,11007,11008],{"class":161},"(err) });\n",[148,11010,11011,11013,11015,11017,11020,11023,11025,11028,11030],{"class":150,"line":4290},[148,11012,10955],{"class":161},[148,11014,2807],{"class":207},[148,11016,369],{"class":161},[148,11018,11019],{"class":168},"\"task:failed\"",[148,11021,11022],{"class":161},", { taskId: task.taskId, error: ",[148,11024,6412],{"class":207},[148,11026,11027],{"class":161},"(err) }, ",[148,11029,10994],{"class":168},[148,11031,6134],{"class":161},[148,11033,11034],{"class":150,"line":4307},[148,11035,1716],{"class":161},[148,11037,11038],{"class":150,"line":4322},[148,11039,6244],{"class":161},[12,11041,11042,11043,11046],{},"Workers are stateless and horizontally scalable. Run more instances with the same ",[84,11044,11045],{},"loadBalanceGroup"," and the broker hands each task to one member of the group. Load balancing is opt-in on the core client; leave it off and every instance receives, and answers, every task.",[35,11048,11050],{"id":11049},"step-5-add-escalation","Step 5: Add Escalation",[12,11052,11053],{},"Use the Approve pattern to escalate sensitive conversations to a human. The worker detects negative sentiment and requests approval before responding:",[135,11055,11056,11375],{},[138,11057,11060],{"className":140,"code":11058,"filename":11059,"language":143,"meta":144,"style":144},"import { Approve } from \"@nolag/agents\";\n\n// In the worker, detect when to escalate\nconst approve = new Approve(room, agents.agentId);\n\nhandoff.onTask([\"customer-support\"], async (task, respond) => {\n  const userMessage = String(task.payload.userMessage);\n  const sentiment = await analyzeSentiment(userMessage);\n\n  if (sentiment.score \u003C -0.7) {\n    const suggestedResponse = await generateResponse(task.payload);\n\n    // Escalate: a human sees the request in an onRequest() handler and\n    // answers approved, rejected, or deferred\n    const approval = await approve.request(\n      \"escalate-to-human\",\n      { userMessage, suggestedResponse },\n      { urgency: \"high\", timeout: 5 * 60 * 1000 },\n    );\n\n    if (approval.decision === \"approved\") {\n      respond(\"success\", { response: suggestedResponse });\n    } else {\n      respond(\"success\", { response: \"A member of our team will follow up with you shortly.\" });\n    }\n  } else {\n    // Normal LLM response\n    const response = await generateResponse(task.payload);\n    respond(\"success\", { response });\n  }\n});\n","escalation.ts",[84,11061,11062,11074,11078,11083,11097,11101,11129,11141,11158,11162,11179,11196,11200,11205,11210,11228,11235,11240,11264,11269,11273,11287,11299,11309,11325,11329,11337,11342,11356,11367,11371],{"__ignoreMap":144},[148,11063,11064,11066,11068,11070,11072],{"class":150,"line":151},[148,11065,155],{"class":154},[148,11067,742],{"class":161},[148,11069,165],{"class":154},[148,11071,6069],{"class":168},[148,11073,6057],{"class":161},[148,11075,11076],{"class":150,"line":172},[148,11077,176],{"emptyLinePlaceholder":175},[148,11079,11080],{"class":150,"line":179},[148,11081,11082],{"class":182},"// In the worker, detect when to escalate\n",[148,11084,11085,11087,11089,11091,11093,11095],{"class":150,"line":186},[148,11086,323],{"class":154},[148,11088,762],{"class":235},[148,11090,329],{"class":154},[148,11092,332],{"class":154},[148,11094,769],{"class":207},[148,11096,10717],{"class":161},[148,11098,11099],{"class":150,"line":192},[148,11100,176],{"emptyLinePlaceholder":175},[148,11102,11103,11105,11107,11109,11111,11113,11115,11117,11119,11121,11123,11125,11127],{"class":150,"line":198},[148,11104,6314],{"class":161},[148,11106,1435],{"class":207},[148,11108,1438],{"class":161},[148,11110,10637],{"class":168},[148,11112,1444],{"class":161},[148,11114,1447],{"class":154},[148,11116,114],{"class":161},[148,11118,1452],{"class":217},[148,11120,87],{"class":161},[148,11122,790],{"class":217},[148,11124,633],{"class":161},[148,11126,636],{"class":154},[148,11128,211],{"class":161},[148,11130,11131,11133,11135,11137,11139],{"class":150,"line":214},[148,11132,3596],{"class":154},[148,11134,10792],{"class":235},[148,11136,329],{"class":154},[148,11138,10797],{"class":207},[148,11140,10800],{"class":161},[148,11142,11143,11145,11148,11150,11152,11155],{"class":150,"line":227},[148,11144,3596],{"class":154},[148,11146,11147],{"class":235}," sentiment",[148,11149,329],{"class":154},[148,11151,1502],{"class":154},[148,11153,11154],{"class":207}," analyzeSentiment",[148,11156,11157],{"class":161},"(userMessage);\n",[148,11159,11160],{"class":150,"line":239},[148,11161,176],{"emptyLinePlaceholder":175},[148,11163,11164,11166,11169,11171,11174,11177],{"class":150,"line":249},[148,11165,10452],{"class":154},[148,11167,11168],{"class":161}," (sentiment.score ",[148,11170,572],{"class":154},[148,11172,11173],{"class":154}," -",[148,11175,11176],{"class":235},"0.7",[148,11178,600],{"class":161},[148,11180,11181,11183,11186,11188,11190,11193],{"class":150,"line":259},[148,11182,1467],{"class":154},[148,11184,11185],{"class":235}," suggestedResponse",[148,11187,329],{"class":154},[148,11189,1502],{"class":154},[148,11191,11192],{"class":207}," generateResponse",[148,11194,11195],{"class":161},"(task.payload);\n",[148,11197,11198],{"class":150,"line":654},[148,11199,176],{"emptyLinePlaceholder":175},[148,11201,11202],{"class":150,"line":660},[148,11203,11204],{"class":182},"    // Escalate: a human sees the request in an onRequest() handler and\n",[148,11206,11207],{"class":150,"line":1289},[148,11208,11209],{"class":182},"    // answers approved, rejected, or deferred\n",[148,11211,11212,11214,11217,11219,11221,11224,11226],{"class":150,"line":1295},[148,11213,1467],{"class":154},[148,11215,11216],{"class":235}," approval",[148,11218,329],{"class":154},[148,11220,1502],{"class":154},[148,11222,11223],{"class":161}," approve.",[148,11225,785],{"class":207},[148,11227,527],{"class":161},[148,11229,11230,11233],{"class":150,"line":1313},[148,11231,11232],{"class":168},"      \"escalate-to-human\"",[148,11234,540],{"class":161},[148,11236,11237],{"class":150,"line":1331},[148,11238,11239],{"class":161},"      { userMessage, suggestedResponse },\n",[148,11241,11242,11245,11247,11249,11252,11254,11257,11259,11262],{"class":150,"line":1346},[148,11243,11244],{"class":161},"      { urgency: ",[148,11246,8413],{"class":168},[148,11248,10425],{"class":161},[148,11250,11251],{"class":235},"5",[148,11253,3021],{"class":154},[148,11255,11256],{"class":235}," 60",[148,11258,3021],{"class":154},[148,11260,11261],{"class":235}," 1000",[148,11263,6234],{"class":161},[148,11265,11266],{"class":150,"line":1355},[148,11267,11268],{"class":161},"    );\n",[148,11270,11271],{"class":150,"line":1361},[148,11272,176],{"emptyLinePlaceholder":175},[148,11274,11275,11277,11280,11282,11285],{"class":150,"line":1370},[148,11276,1694],{"class":154},[148,11278,11279],{"class":161}," (approval.decision ",[148,11281,1700],{"class":154},[148,11283,11284],{"class":168}," \"approved\"",[148,11286,600],{"class":161},[148,11288,11289,11292,11294,11296],{"class":150,"line":1376},[148,11290,11291],{"class":207},"      respond",[148,11293,369],{"class":161},[148,11295,6439],{"class":168},[148,11297,11298],{"class":161},", { response: suggestedResponse });\n",[148,11300,11301,11304,11307],{"class":150,"line":2033},[148,11302,11303],{"class":161},"    } ",[148,11305,11306],{"class":154},"else",[148,11308,211],{"class":161},[148,11310,11311,11313,11315,11317,11320,11323],{"class":150,"line":2039},[148,11312,11291],{"class":207},[148,11314,369],{"class":161},[148,11316,6439],{"class":168},[148,11318,11319],{"class":161},", { response: ",[148,11321,11322],{"class":168},"\"A member of our team will follow up with you shortly.\"",[148,11324,6112],{"class":161},[148,11326,11327],{"class":150,"line":2044},[148,11328,4253],{"class":161},[148,11330,11331,11333,11335],{"class":150,"line":2050},[148,11332,10979],{"class":161},[148,11334,11306],{"class":154},[148,11336,211],{"class":161},[148,11338,11339],{"class":150,"line":2056},[148,11340,11341],{"class":182},"    // Normal LLM response\n",[148,11343,11344,11346,11348,11350,11352,11354],{"class":150,"line":2062},[148,11345,1467],{"class":154},[148,11347,6349],{"class":235},[148,11349,329],{"class":154},[148,11351,1502],{"class":154},[148,11353,11192],{"class":207},[148,11355,11195],{"class":161},[148,11357,11358,11360,11362,11364],{"class":150,"line":2068},[148,11359,1517],{"class":207},[148,11361,369],{"class":161},[148,11363,6439],{"class":168},[148,11365,11366],{"class":161},", { response });\n",[148,11368,11369],{"class":150,"line":2074},[148,11370,1716],{"class":161},[148,11372,11373],{"class":150,"line":2080},[148,11374,6244],{"class":161},[138,11376,11379],{"className":140,"code":11377,"filename":11378,"language":143,"meta":144,"style":144},"import { Approve } from \"@nolag/agents\";\n\n// On the human reviewer's side (a dashboard or CLI in the same room)\nconst approve = new Approve(room, agents.agentId);\n\napprove.onRequest((request, respond) => {\n  console.log(\"Review needed:\", request.action, request.context);\n  respond(\"approved\"); // or \"rejected\" / \"deferred\", with an optional reason\n});\n","reviewer.ts",[84,11380,11381,11393,11397,11402,11416,11420,11440,11454,11469],{"__ignoreMap":144},[148,11382,11383,11385,11387,11389,11391],{"class":150,"line":151},[148,11384,155],{"class":154},[148,11386,742],{"class":161},[148,11388,165],{"class":154},[148,11390,6069],{"class":168},[148,11392,6057],{"class":161},[148,11394,11395],{"class":150,"line":172},[148,11396,176],{"emptyLinePlaceholder":175},[148,11398,11399],{"class":150,"line":179},[148,11400,11401],{"class":182},"// On the human reviewer's side (a dashboard or CLI in the same room)\n",[148,11403,11404,11406,11408,11410,11412,11414],{"class":150,"line":186},[148,11405,323],{"class":154},[148,11407,762],{"class":235},[148,11409,329],{"class":154},[148,11411,332],{"class":154},[148,11413,769],{"class":207},[148,11415,10717],{"class":161},[148,11417,11418],{"class":150,"line":192},[148,11419,176],{"emptyLinePlaceholder":175},[148,11421,11422,11424,11426,11428,11430,11432,11434,11436,11438],{"class":150,"line":198},[148,11423,777],{"class":161},[148,11425,780],{"class":207},[148,11427,627],{"class":161},[148,11429,785],{"class":217},[148,11431,87],{"class":161},[148,11433,790],{"class":217},[148,11435,633],{"class":161},[148,11437,636],{"class":154},[148,11439,211],{"class":161},[148,11441,11442,11444,11446,11448,11451],{"class":150,"line":214},[148,11443,2514],{"class":161},[148,11445,2517],{"class":207},[148,11447,369],{"class":161},[148,11449,11450],{"class":168},"\"Review needed:\"",[148,11452,11453],{"class":161},", request.action, request.context);\n",[148,11455,11456,11458,11460,11463,11466],{"class":150,"line":227},[148,11457,6434],{"class":207},[148,11459,369],{"class":161},[148,11461,11462],{"class":168},"\"approved\"",[148,11464,11465],{"class":161},"); ",[148,11467,11468],{"class":182},"// or \"rejected\" / \"deferred\", with an optional reason\n",[148,11470,11471],{"class":150,"line":239},[148,11472,6244],{"class":161},[12,11474,11475],{},"The approval topic is published retained, so the most recent approval message is delivered to a reviewer who joins the room later.",[35,11477,11479],{"id":11478},"step-6-monitor-everything","Step 6: Monitor Everything",[12,11481,11482,11483,1812,11486,11489],{},"The Observe pattern gives you visibility into the chatbot's performance. Handoff does not emit events on its own; the worker in Step 4 emits ",[84,11484,11485],{},"task:completed",[84,11487,11488],{},"task:failed",", and any observer in the room can consume them:",[135,11491,11492],{},[138,11493,11496],{"className":140,"code":11494,"filename":11495,"language":143,"meta":144,"style":144},"import { NoLag } from \"@nolag/js-sdk\";\nimport { NoLagAgents, Observe } from \"@nolag/agents\";\n\nconst client = NoLag(MONITOR_TOKEN);\nconst agents = new NoLagAgents({\n  client,\n  appName: AGENTS_APP_SLUG,\n  agentId: \"monitor\",\n  presence: { name: \"monitor\", role: \"observer\" },\n});\nawait client.connect();\nawait agents.ready();\n\nconst room = agents.room(\"support-workflow\");\nconst observe = new Observe(room, agents.agentId);\n\n// Track metrics\nlet totalQueries = 0;\nlet avgResponseTime = 0;\n\nobserve.on((event) => {\n  totalQueries++;\n  const duration = Number(event.payload.durationMs);\n  avgResponseTime = (avgResponseTime * (totalQueries - 1) + duration) / totalQueries;\n  console.log(`Avg response: ${avgResponseTime}ms | Total: ${totalQueries}`);\n}, { category: \"task:completed\" });\n\nobserve.on((event) => {\n  console.error(\"Agent failed:\", event.payload.error, \"from\", event.emittedBy);\n}, { category: \"task:failed\" });\n\n// Monitor agent health\nconst workers = room.findAgents(\"customer-support\");\nconsole.log(\"Workers online:\", workers.length);\n\nroom.on(\"presenceLeave\", (actorId) => {\n  console.warn(\"Agent left:\", actorId, \"remaining:\", room.getConnectedAgents().length);\n});\n","monitor.ts",[84,11497,11498,11510,11523,11527,11544,11558,11562,11570,11579,11592,11596,11606,11616,11620,11638,11652,11656,11661,11676,11689,11693,11710,11720,11735,11769,11793,11802,11806,11822,11842,11850,11854,11859,11877,11895,11899,11920,11949],{"__ignoreMap":144},[148,11499,11500,11502,11504,11506,11508],{"class":150,"line":151},[148,11501,155],{"class":154},[148,11503,2575],{"class":161},[148,11505,165],{"class":154},[148,11507,6054],{"class":168},[148,11509,6057],{"class":161},[148,11511,11512,11514,11517,11519,11521],{"class":150,"line":172},[148,11513,155],{"class":154},[148,11515,11516],{"class":161}," { NoLagAgents, Observe } ",[148,11518,165],{"class":154},[148,11520,6069],{"class":168},[148,11522,6057],{"class":161},[148,11524,11525],{"class":150,"line":179},[148,11526,176],{"emptyLinePlaceholder":175},[148,11528,11529,11531,11533,11535,11537,11539,11542],{"class":150,"line":186},[148,11530,323],{"class":154},[148,11532,1589],{"class":235},[148,11534,329],{"class":154},[148,11536,1594],{"class":207},[148,11538,369],{"class":161},[148,11540,11541],{"class":235},"MONITOR_TOKEN",[148,11543,6134],{"class":161},[148,11545,11546,11548,11550,11552,11554,11556],{"class":150,"line":192},[148,11547,323],{"class":154},[148,11549,10085],{"class":235},[148,11551,329],{"class":154},[148,11553,332],{"class":154},[148,11555,6148],{"class":207},[148,11557,1343],{"class":161},[148,11559,11560],{"class":150,"line":198},[148,11561,6155],{"class":161},[148,11563,11564,11566,11568],{"class":150,"line":214},[148,11565,6160],{"class":161},[148,11567,10104],{"class":235},[148,11569,540],{"class":161},[148,11571,11572,11574,11577],{"class":150,"line":227},[148,11573,6172],{"class":161},[148,11575,11576],{"class":168},"\"monitor\"",[148,11578,540],{"class":161},[148,11580,11581,11583,11585,11587,11590],{"class":150,"line":239},[148,11582,6610],{"class":161},[148,11584,11576],{"class":168},[148,11586,6615],{"class":161},[148,11588,11589],{"class":168},"\"observer\"",[148,11591,6234],{"class":161},[148,11593,11594],{"class":150,"line":249},[148,11595,6244],{"class":161},[148,11597,11598,11600,11602,11604],{"class":150,"line":259},[148,11599,343],{"class":154},[148,11601,4099],{"class":161},[148,11603,2647],{"class":207},[148,11605,6259],{"class":161},[148,11607,11608,11610,11612,11614],{"class":150,"line":654},[148,11609,343],{"class":154},[148,11611,10208],{"class":161},[148,11613,349],{"class":207},[148,11615,6259],{"class":161},[148,11617,11618],{"class":150,"line":660},[148,11619,176],{"emptyLinePlaceholder":175},[148,11621,11622,11624,11626,11628,11630,11632,11634,11636],{"class":150,"line":1289},[148,11623,323],{"class":154},[148,11625,359],{"class":235},[148,11627,329],{"class":154},[148,11629,10208],{"class":161},[148,11631,1421],{"class":207},[148,11633,369],{"class":161},[148,11635,10215],{"class":168},[148,11637,6134],{"class":161},[148,11639,11640,11642,11644,11646,11648,11650],{"class":150,"line":1295},[148,11641,323],{"class":154},[148,11643,10707],{"class":235},[148,11645,329],{"class":154},[148,11647,332],{"class":154},[148,11649,10714],{"class":207},[148,11651,10717],{"class":161},[148,11653,11654],{"class":150,"line":1313},[148,11655,176],{"emptyLinePlaceholder":175},[148,11657,11658],{"class":150,"line":1331},[148,11659,11660],{"class":182},"// Track metrics\n",[148,11662,11663,11666,11669,11671,11674],{"class":150,"line":1346},[148,11664,11665],{"class":154},"let",[148,11667,11668],{"class":161}," totalQueries ",[148,11670,1337],{"class":154},[148,11672,11673],{"class":235}," 0",[148,11675,6057],{"class":161},[148,11677,11678,11680,11683,11685,11687],{"class":150,"line":1355},[148,11679,11665],{"class":154},[148,11681,11682],{"class":161}," avgResponseTime ",[148,11684,1337],{"class":154},[148,11686,11673],{"class":235},[148,11688,6057],{"class":161},[148,11690,11691],{"class":150,"line":1361},[148,11692,176],{"emptyLinePlaceholder":175},[148,11694,11695,11698,11700,11702,11704,11706,11708],{"class":150,"line":1370},[148,11696,11697],{"class":161},"observe.",[148,11699,2706],{"class":207},[148,11701,627],{"class":161},[148,11703,1683],{"class":217},[148,11705,633],{"class":161},[148,11707,636],{"class":154},[148,11709,211],{"class":161},[148,11711,11712,11715,11718],{"class":150,"line":1376},[148,11713,11714],{"class":161},"  totalQueries",[148,11716,11717],{"class":154},"++",[148,11719,6057],{"class":161},[148,11721,11722,11724,11727,11729,11732],{"class":150,"line":2033},[148,11723,3596],{"class":154},[148,11725,11726],{"class":235}," duration",[148,11728,329],{"class":154},[148,11730,11731],{"class":207}," Number",[148,11733,11734],{"class":161},"(event.payload.durationMs);\n",[148,11736,11737,11740,11742,11745,11748,11751,11753,11756,11758,11761,11764,11766],{"class":150,"line":2039},[148,11738,11739],{"class":161},"  avgResponseTime ",[148,11741,1337],{"class":154},[148,11743,11744],{"class":161}," (avgResponseTime ",[148,11746,11747],{"class":154},"*",[148,11749,11750],{"class":161}," (totalQueries ",[148,11752,6468],{"class":154},[148,11754,11755],{"class":235}," 1",[148,11757,633],{"class":161},[148,11759,11760],{"class":154},"+",[148,11762,11763],{"class":161}," duration) ",[148,11765,10497],{"class":154},[148,11767,11768],{"class":161}," totalQueries;\n",[148,11770,11771,11773,11775,11777,11780,11783,11786,11789,11791],{"class":150,"line":2044},[148,11772,2514],{"class":161},[148,11774,2517],{"class":207},[148,11776,369],{"class":161},[148,11778,11779],{"class":168},"`Avg response: ${",[148,11781,11782],{"class":161},"avgResponseTime",[148,11784,11785],{"class":168},"}ms | Total: ${",[148,11787,11788],{"class":161},"totalQueries",[148,11790,3652],{"class":168},[148,11792,6134],{"class":161},[148,11794,11795,11798,11800],{"class":150,"line":2050},[148,11796,11797],{"class":161},"}, { category: ",[148,11799,10962],{"class":168},[148,11801,6112],{"class":161},[148,11803,11804],{"class":150,"line":2056},[148,11805,176],{"emptyLinePlaceholder":175},[148,11807,11808,11810,11812,11814,11816,11818,11820],{"class":150,"line":2062},[148,11809,11697],{"class":161},[148,11811,2706],{"class":207},[148,11813,627],{"class":161},[148,11815,1683],{"class":217},[148,11817,633],{"class":161},[148,11819,636],{"class":154},[148,11821,211],{"class":161},[148,11823,11824,11826,11828,11830,11833,11836,11839],{"class":150,"line":2068},[148,11825,2514],{"class":161},[148,11827,693],{"class":207},[148,11829,369],{"class":161},[148,11831,11832],{"class":168},"\"Agent failed:\"",[148,11834,11835],{"class":161},", event.payload.error, ",[148,11837,11838],{"class":168},"\"from\"",[148,11840,11841],{"class":161},", event.emittedBy);\n",[148,11843,11844,11846,11848],{"class":150,"line":2074},[148,11845,11797],{"class":161},[148,11847,11019],{"class":168},[148,11849,6112],{"class":161},[148,11851,11852],{"class":150,"line":2080},[148,11853,176],{"emptyLinePlaceholder":175},[148,11855,11856],{"class":150,"line":2086},[148,11857,11858],{"class":182},"// Monitor agent health\n",[148,11860,11861,11863,11865,11867,11869,11871,11873,11875],{"class":150,"line":2092},[148,11862,323],{"class":154},[148,11864,6696],{"class":235},[148,11866,329],{"class":154},[148,11868,6701],{"class":161},[148,11870,6704],{"class":207},[148,11872,369],{"class":161},[148,11874,10637],{"class":168},[148,11876,6134],{"class":161},[148,11878,11879,11881,11883,11885,11888,11891,11893],{"class":150,"line":2097},[148,11880,6488],{"class":161},[148,11882,2517],{"class":207},[148,11884,369],{"class":161},[148,11886,11887],{"class":168},"\"Workers online:\"",[148,11889,11890],{"class":161},", workers.",[148,11892,6728],{"class":235},[148,11894,6134],{"class":161},[148,11896,11897],{"class":150,"line":2103},[148,11898,176],{"emptyLinePlaceholder":175},[148,11900,11901,11903,11905,11907,11909,11911,11914,11916,11918],{"class":150,"line":2108},[148,11902,380],{"class":161},[148,11904,2706],{"class":207},[148,11906,369],{"class":161},[148,11908,7670],{"class":168},[148,11910,2713],{"class":161},[148,11912,11913],{"class":217},"actorId",[148,11915,633],{"class":161},[148,11917,636],{"class":154},[148,11919,211],{"class":161},[148,11921,11922,11924,11927,11929,11932,11935,11938,11941,11943,11945,11947],{"class":150,"line":2114},[148,11923,2514],{"class":161},[148,11925,11926],{"class":207},"warn",[148,11928,369],{"class":161},[148,11930,11931],{"class":168},"\"Agent left:\"",[148,11933,11934],{"class":161},", actorId, ",[148,11936,11937],{"class":168},"\"remaining:\"",[148,11939,11940],{"class":161},", room.",[148,11942,7516],{"class":207},[148,11944,10326],{"class":161},[148,11946,6728],{"class":235},[148,11948,6134],{"class":161},[148,11950,11951],{"class":150,"line":2120},[148,11952,6244],{"class":161},[35,11954,11956],{"id":11955},"what-you-get","What You Get",[882,11958,11959,11962,11965,11968,11971,11974],{},[885,11960,11961],{},"A chat interface with typing indicators and presence - users don't know they're talking to an AI",[885,11963,11964],{},"Horizontally scalable AI workers, load balanced across a group when you enable it on the client",[885,11966,11967],{},"Human escalation for sensitive conversations",[885,11969,11970],{},"Observability: response times, error rates, agent health",[885,11972,11973],{},"The last ten messages passed as conversation context on every task",[885,11975,11976],{},"Per-topic access control separating user traffic from agent traffic",[12,11978,11979],{},"The entire system runs on NoLag's real-time infrastructure. No Redis queues, no custom WebSocket servers, no separate monitoring stack.",[12,11981,11982],{},[952,11983,11984],{"href":7860},"Get started with @nolag/agents →",[969,11986,11987],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":144,"searchDepth":172,"depth":172,"links":11989},[11990,11991,11992,11993,11994,11995,11996,11997],{"id":9806,"depth":172,"text":9807},{"id":9836,"depth":172,"text":9837},{"id":9861,"depth":172,"text":9862},{"id":9949,"depth":172,"text":9950},{"id":10511,"depth":172,"text":10512},{"id":11049,"depth":172,"text":11050},{"id":11478,"depth":172,"text":11479},{"id":11955,"depth":172,"text":11956},"2026-05-19","Step-by-step tutorial: combine @nolag/chat for the user-facing interface with @nolag/agents for AI-powered responses. Full code walkthrough.",{},"/blog/building-agentic-chatbot","14 min read",{"title":9791,"description":11999},"blog/building-agentic-chatbot","PhW0KekSQNo_lb45lq06cax_x5EqktkRbVHywGq5DcY",{"id":12007,"title":12008,"author":7,"body":12009,"category":1832,"date":11998,"description":12850,"excerpt":986,"extension":987,"meta":12851,"navigation":175,"path":12852,"readTime":990,"seo":12853,"stem":12854,"__hash__":12855},"blog/blog/human-to-agent-realtime.md","From Human Realtime to Agent Realtime",{"type":9,"value":12010,"toc":12840},[12011,12014,12017,12021,12024,12033,12043,12047,12056,12075,12086,12090,12093,12301,12349,12358,12362,12368,12691,12694,12698,12709,12715,12719,12722,12805,12808,12812,12815,12818,12822,12825,12831,12837],[12,12012,12013],{},"NoLag was built to power human real-time experiences: chat rooms where people exchange messages, dashboards where operators monitor live data, and tracking systems where dispatchers watch vehicles move on a map. When we started exploring multi-agent AI systems, we realized the infrastructure we built for humans maps almost perfectly to agent coordination.",[12,12015,12016],{},"This post explains how NoLag's real-time primitives translate to agent coordination patterns, the design decisions we made for each, and the performance characteristics that matter for agent workloads.",[35,12018,12020],{"id":12019},"the-mapping","The Mapping",[12,12022,12023],{},"Real-time messaging has three core primitives: topics (named channels for routing), rooms (groups of related topics), and presence (who is connected). Agent coordination has the same three needs: task routing, workflow grouping, and agent health tracking.",[135,12025,12026],{},[138,12027,12031],{"className":12028,"code":12029,"filename":12030,"language":1114,"meta":144},[1111],"# Human chat (@nolag/chat): the topics inside a room\nmessages     chat messages\n_typing      typing indicators, ephemeral\n_stream      live token chunks of a streamed message, ephemeral\n\n# Agent coordination (@nolag/agents): the topics inside a room\ntasks        task envelopes (Handoff)\nresults      result envelopes, routed to the dispatcher by filter\nstate        shared key-value state (Blackboard), published retained\nevents       observability stream (Observe)\ninbox        direct agent-to-agent messages (Inbox)\ntools        tool requests (Tools)\napproval     human-in-the-loop requests and decisions (Approve), published retained\n","Topic Mapping",[84,12032,12029],{"__ignoreMap":144},[12,12034,12035,12036,1812,12039,12042],{},"The topic names change. The semantics are different. But the underlying infrastructure - message routing, delivery guarantees, retained state, and fan-out - is identical. Both wrappers publish at the client's default QoS (1 on the broker hop); the two agent topics that hold state rather than a stream, ",[84,12037,12038],{},"state",[84,12040,12041],{},"approval",", are published retained so a late joiner receives the latest value.",[35,12044,12046],{"id":12045},"topics-as-task-queues","Topics as Task Queues",[12,12048,12049,12050,12052,12053,12055],{},"In a chat app, a topic is a channel where messages flow from publishers to subscribers. In an agent system, a topic is a task queue. The Handoff pattern uses ",[84,12051,1861],{}," as a work queue: the orchestrator publishes task envelopes, workers subscribe and act on the ones whose ",[84,12054,553],{}," they registered for.",[12,12057,12058,12059,12061,12062,12065,12066,12068,12069,1812,12071,12074],{},"The property that turns a topic into a queue is not a QoS level, it is load balancing. By default every subscriber to ",[84,12060,1861],{}," receives every task, which is what you want for a single worker per capability and wrong for a pool of identical workers. Connect the pool with ",[84,12063,12064],{},"loadBalance: true"," and a shared ",[84,12067,11045],{}," and the broker delivers each task to exactly one member of the group. The agents SDK inherits that setting from the core client for ",[84,12070,1861],{},[84,12072,12073],{},"tools"," only, and forces it off for the broadcast topics.",[12,12076,12077,12078,1812,12080,12082,12083,12085],{},"Retained messages serve a different purpose in each context. For chat, retention on the broker is not used: history lives in your own store. For agents, retention is how ",[84,12079,12038],{},[84,12081,12041],{}," behave like a whiteboard rather than a stream: whoever joins sees the current value without waiting for the next write. Holding a task until a worker is available is a separate feature, replay, and it applies only to load-balanced worker groups with persistent sessions on the hosted platform (see ",[952,12084,7734],{"href":2319},"); a lone worker that was offline when a task was dispatched does not get it later.",[35,12087,12089],{"id":12088},"presence-as-health-monitoring","Presence as Health Monitoring",[12,12091,12092],{},"Human presence is simple: online, away, or offline. Agent presence needs more metadata: capabilities, model, provider, whatever routing needs. We extended the presence payload to carry this information without changing the underlying protocol.",[135,12094,12095,12179],{},[138,12096,12100],{"className":12097,"code":12098,"filename":12099,"language":3725,"meta":144,"style":144},"language-json shiki shiki-themes github-light github-dark","{\n  \"actorTokenId\": \"at_live_7f3a...\",\n  \"presence\": {\n    \"userId\": \"u_8x2k9\",\n    \"username\": \"Alice\",\n    \"status\": \"online\"\n  },\n  \"joinedAt\": 1745827200000\n}\n","Human: a chat user's presence",[84,12101,12102,12107,12119,12127,12139,12151,12161,12165,12175],{"__ignoreMap":144},[148,12103,12104],{"class":150,"line":151},[148,12105,12106],{"class":161},"{\n",[148,12108,12109,12112,12114,12117],{"class":150,"line":172},[148,12110,12111],{"class":235},"  \"actorTokenId\"",[148,12113,3662],{"class":161},[148,12115,12116],{"class":168},"\"at_live_7f3a...\"",[148,12118,540],{"class":161},[148,12120,12121,12124],{"class":150,"line":179},[148,12122,12123],{"class":235},"  \"presence\"",[148,12125,12126],{"class":161},": {\n",[148,12128,12129,12132,12134,12137],{"class":150,"line":186},[148,12130,12131],{"class":235},"    \"userId\"",[148,12133,3662],{"class":161},[148,12135,12136],{"class":168},"\"u_8x2k9\"",[148,12138,540],{"class":161},[148,12140,12141,12144,12146,12149],{"class":150,"line":192},[148,12142,12143],{"class":235},"    \"username\"",[148,12145,3662],{"class":161},[148,12147,12148],{"class":168},"\"Alice\"",[148,12150,540],{"class":161},[148,12152,12153,12156,12158],{"class":150,"line":198},[148,12154,12155],{"class":235},"    \"status\"",[148,12157,3662],{"class":161},[148,12159,12160],{"class":168},"\"online\"\n",[148,12162,12163],{"class":150,"line":214},[148,12164,6239],{"class":161},[148,12166,12167,12170,12172],{"class":150,"line":227},[148,12168,12169],{"class":235},"  \"joinedAt\"",[148,12171,3662],{"class":161},[148,12173,12174],{"class":235},"1745827200000\n",[148,12176,12177],{"class":150,"line":239},[148,12178,262],{"class":161},[138,12180,12183],{"className":12097,"code":12181,"filename":12182,"language":3725,"meta":144,"style":144},"{\n  \"actorTokenId\": \"at_live_c41d...\",\n  \"presence\": {\n    \"name\": \"research-worker-3\",\n    \"role\": \"agent\",\n    \"capabilities\": [\"research\", \"finance\"],\n    \"metadata\": { \"model\": \"llama3.2\", \"provider\": \"local\" }\n  },\n  \"joinedAt\": 1745827200000,\n  \"status\": \"online\"\n}\n","Agent: a worker's presence",[84,12184,12185,12189,12200,12206,12218,12229,12247,12273,12277,12288,12297],{"__ignoreMap":144},[148,12186,12187],{"class":150,"line":151},[148,12188,12106],{"class":161},[148,12190,12191,12193,12195,12198],{"class":150,"line":172},[148,12192,12111],{"class":235},[148,12194,3662],{"class":161},[148,12196,12197],{"class":168},"\"at_live_c41d...\"",[148,12199,540],{"class":161},[148,12201,12202,12204],{"class":150,"line":179},[148,12203,12123],{"class":235},[148,12205,12126],{"class":161},[148,12207,12208,12211,12213,12216],{"class":150,"line":186},[148,12209,12210],{"class":235},"    \"name\"",[148,12212,3662],{"class":161},[148,12214,12215],{"class":168},"\"research-worker-3\"",[148,12217,540],{"class":161},[148,12219,12220,12223,12225,12227],{"class":150,"line":192},[148,12221,12222],{"class":235},"    \"role\"",[148,12224,3662],{"class":161},[148,12226,6199],{"class":168},[148,12228,540],{"class":161},[148,12230,12231,12234,12237,12240,12242,12245],{"class":150,"line":198},[148,12232,12233],{"class":235},"    \"capabilities\"",[148,12235,12236],{"class":161},": [",[148,12238,12239],{"class":168},"\"research\"",[148,12241,87],{"class":161},[148,12243,12244],{"class":168},"\"finance\"",[148,12246,6217],{"class":161},[148,12248,12249,12252,12255,12258,12260,12262,12264,12267,12269,12271],{"class":150,"line":214},[148,12250,12251],{"class":235},"    \"metadata\"",[148,12253,12254],{"class":161},": { ",[148,12256,12257],{"class":235},"\"model\"",[148,12259,3662],{"class":161},[148,12261,6225],{"class":168},[148,12263,87],{"class":161},[148,12265,12266],{"class":235},"\"provider\"",[148,12268,3662],{"class":161},[148,12270,6231],{"class":168},[148,12272,3861],{"class":161},[148,12274,12275],{"class":150,"line":227},[148,12276,6239],{"class":161},[148,12278,12279,12281,12283,12286],{"class":150,"line":239},[148,12280,12169],{"class":235},[148,12282,3662],{"class":161},[148,12284,12285],{"class":235},"1745827200000",[148,12287,540],{"class":161},[148,12289,12290,12293,12295],{"class":150,"line":249},[148,12291,12292],{"class":235},"  \"status\"",[148,12294,3662],{"class":161},[148,12296,12160],{"class":168},[148,12298,12299],{"class":150,"line":259},[148,12300,262],{"class":161},[12,12302,12303,12304,12307,12308,12311,12312,12314,12315,12318,12319,12321,12322,114,12325,87,12327,12329,12330,1812,12332,12334,12335,12337,12338,87,12341,12344,12345,12348],{},"Both are the same ",[84,12305,12306],{},"ActorPresence"," shape: an ",[84,12309,12310],{},"actorTokenId",", the ",[84,12313,3389],{}," data the actor set, and (in fetched snapshots) ",[84,12316,12317],{},"joinedAt",". The agents SDK fills ",[84,12320,3389],{}," with ",[84,12323,12324],{},"AgentPresenceData",[84,12326,7537],{},[84,12328,10496],{},", optional ",[84,12331,7572],{},[84,12333,7610],{},"); the outer ",[84,12336,686],{}," is ",[84,12339,12340],{},"online",[84,12342,12343],{},"offline",", or ",[84,12346,12347],{},"waking"," and only appears for persistent agents that stay discoverable while disconnected.",[12,12350,12351,12352,12354,12355,12357],{},"The Handoff pattern uses presence as a gate. Before publishing a task, ",[84,12353,10504],{}," looks at who is in the room and throws if no connected agent lists the required capability. It does not look at load or capacity, because presence does not carry them: the worker's own ",[84,12356,1435],{}," filter and, for pools, the broker's load-balance group decide who acts on a task.",[35,12359,12361],{"id":12360},"qos-levels-for-different-patterns","QoS Levels for Different Patterns",[12,12363,12364,12365,12367],{},"Not every agent message needs the same delivery guarantee. Just like in human real-time, the right QoS level depends on the consequence of a lost message, and it is worth being precise about what QoS covers on NoLag. The level you pick (0, 1 or 2, default 1) is applied to the broker's internal MQTT hop. The WebSocket leg between your process and the broker has an optional ",[84,12366,3431],{}," acknowledgement, surfaced as the emit callback, and no resend. There is no exactly-once guarantee end to end, so the envelopes carry the ids you need to tolerate a duplicate.",[135,12369,12370],{},[138,12371,12374],{"className":140,"code":12372,"filename":12373,"language":143,"meta":144,"style":144},"import { NoLag } from \"@nolag/js-sdk\";\nimport { NoLagAgents, Handoff, createStateEnvelope } from \"@nolag/agents\";\n\nconst client = NoLag(TOKEN);\nawait client.connect();\n\n// QoS 0 (fire and forget): typing indicators, cursor positions.\n// Lost messages are harmless: the next update overwrites anyway.\nconst doc = client.setApp(COLLAB_APP_SLUG).setRoom(\"my-doc\");\ndoc.emit(\"cursors\", { x: 120, y: 48 }, { qos: 0 });\n\n// QoS 1 (at least once): the default for everything the wrappers publish.\n// Results carry a correlationId, so a pending dispatch resolves once and a\n// duplicate result is dropped. Tasks carry a taskId for your worker to do\n// the same on its side.\nconst agents = new NoLagAgents({ client, appName: AGENTS_APP_SLUG, agentId: \"orchestrator\" });\nawait agents.ready();\nconst room = agents.room(\"research-pipeline\");\nconst handoff = new Handoff(room);\nconst result = await handoff.dispatch(\"research\", { query }, { waitForResult: true });\n\n// QoS 2 on the broker hop: for a raw publish where you also want the\n// broker-side dedup. It adds nothing on the WebSocket leg.\nconst envelope = createStateEnvelope(\"run:42\", \"started\", 1, agents.agentId);\nroom.context.emit(\"state\", envelope, { qos: 2, retain: true });\n","QoS Choices",[84,12375,12376,12388,12401,12405,12422,12432,12436,12441,12446,12475,12506,12510,12515,12520,12525,12530,12554,12564,12583,12597,12622,12626,12631,12636,12666],{"__ignoreMap":144},[148,12377,12378,12380,12382,12384,12386],{"class":150,"line":151},[148,12379,155],{"class":154},[148,12381,2575],{"class":161},[148,12383,165],{"class":154},[148,12385,6054],{"class":168},[148,12387,6057],{"class":161},[148,12389,12390,12392,12395,12397,12399],{"class":150,"line":172},[148,12391,155],{"class":154},[148,12393,12394],{"class":161}," { NoLagAgents, Handoff, createStateEnvelope } ",[148,12396,165],{"class":154},[148,12398,6069],{"class":168},[148,12400,6057],{"class":161},[148,12402,12403],{"class":150,"line":179},[148,12404,176],{"emptyLinePlaceholder":175},[148,12406,12407,12409,12411,12413,12415,12417,12420],{"class":150,"line":186},[148,12408,323],{"class":154},[148,12410,1589],{"class":235},[148,12412,329],{"class":154},[148,12414,1594],{"class":207},[148,12416,369],{"class":161},[148,12418,12419],{"class":235},"TOKEN",[148,12421,6134],{"class":161},[148,12423,12424,12426,12428,12430],{"class":150,"line":192},[148,12425,343],{"class":154},[148,12427,4099],{"class":161},[148,12429,2647],{"class":207},[148,12431,6259],{"class":161},[148,12433,12434],{"class":150,"line":198},[148,12435,176],{"emptyLinePlaceholder":175},[148,12437,12438],{"class":150,"line":214},[148,12439,12440],{"class":182},"// QoS 0 (fire and forget): typing indicators, cursor positions.\n",[148,12442,12443],{"class":150,"line":227},[148,12444,12445],{"class":182},"// Lost messages are harmless: the next update overwrites anyway.\n",[148,12447,12448,12450,12453,12455,12457,12459,12461,12464,12466,12468,12470,12473],{"class":150,"line":239},[148,12449,323],{"class":154},[148,12451,12452],{"class":235}," doc",[148,12454,329],{"class":154},[148,12456,4099],{"class":161},[148,12458,2667],{"class":207},[148,12460,369],{"class":161},[148,12462,12463],{"class":235},"COLLAB_APP_SLUG",[148,12465,2675],{"class":161},[148,12467,2678],{"class":207},[148,12469,369],{"class":161},[148,12471,12472],{"class":168},"\"my-doc\"",[148,12474,6134],{"class":161},[148,12476,12477,12480,12482,12484,12487,12490,12493,12496,12499,12502,12504],{"class":150,"line":249},[148,12478,12479],{"class":161},"doc.",[148,12481,2807],{"class":207},[148,12483,369],{"class":161},[148,12485,12486],{"class":168},"\"cursors\"",[148,12488,12489],{"class":161},", { x: ",[148,12491,12492],{"class":235},"120",[148,12494,12495],{"class":161},", y: ",[148,12497,12498],{"class":235},"48",[148,12500,12501],{"class":161}," }, { qos: ",[148,12503,9227],{"class":235},[148,12505,6112],{"class":161},[148,12507,12508],{"class":150,"line":259},[148,12509,176],{"emptyLinePlaceholder":175},[148,12511,12512],{"class":150,"line":654},[148,12513,12514],{"class":182},"// QoS 1 (at least once): the default for everything the wrappers publish.\n",[148,12516,12517],{"class":150,"line":660},[148,12518,12519],{"class":182},"// Results carry a correlationId, so a pending dispatch resolves once and a\n",[148,12521,12522],{"class":150,"line":1289},[148,12523,12524],{"class":182},"// duplicate result is dropped. Tasks carry a taskId for your worker to do\n",[148,12526,12527],{"class":150,"line":1295},[148,12528,12529],{"class":182},"// the same on its side.\n",[148,12531,12532,12534,12536,12538,12540,12542,12545,12547,12550,12552],{"class":150,"line":1313},[148,12533,323],{"class":154},[148,12535,10085],{"class":235},[148,12537,329],{"class":154},[148,12539,332],{"class":154},[148,12541,6148],{"class":207},[148,12543,12544],{"class":161},"({ client, appName: ",[148,12546,10104],{"class":235},[148,12548,12549],{"class":161},", agentId: ",[148,12551,6603],{"class":168},[148,12553,6112],{"class":161},[148,12555,12556,12558,12560,12562],{"class":150,"line":1331},[148,12557,343],{"class":154},[148,12559,10208],{"class":161},[148,12561,349],{"class":207},[148,12563,6259],{"class":161},[148,12565,12566,12568,12570,12572,12574,12576,12578,12581],{"class":150,"line":1346},[148,12567,323],{"class":154},[148,12569,359],{"class":235},[148,12571,329],{"class":154},[148,12573,10208],{"class":161},[148,12575,1421],{"class":207},[148,12577,369],{"class":161},[148,12579,12580],{"class":168},"\"research-pipeline\"",[148,12582,6134],{"class":161},[148,12584,12585,12587,12589,12591,12593,12595],{"class":150,"line":1355},[148,12586,323],{"class":154},[148,12588,6296],{"class":235},[148,12590,329],{"class":154},[148,12592,332],{"class":154},[148,12594,537],{"class":207},[148,12596,6305],{"class":161},[148,12598,12599,12601,12603,12605,12607,12609,12611,12613,12615,12618,12620],{"class":150,"line":1361},[148,12600,323],{"class":154},[148,12602,6754],{"class":235},[148,12604,329],{"class":154},[148,12606,1502],{"class":154},[148,12608,639],{"class":161},[148,12610,642],{"class":207},[148,12612,369],{"class":161},[148,12614,12239],{"class":168},[148,12616,12617],{"class":161},", { query }, { waitForResult: ",[148,12619,648],{"class":235},[148,12621,6112],{"class":161},[148,12623,12624],{"class":150,"line":1370},[148,12625,176],{"emptyLinePlaceholder":175},[148,12627,12628],{"class":150,"line":1376},[148,12629,12630],{"class":182},"// QoS 2 on the broker hop: for a raw publish where you also want the\n",[148,12632,12633],{"class":150,"line":2033},[148,12634,12635],{"class":182},"// broker-side dedup. It adds nothing on the WebSocket leg.\n",[148,12637,12638,12640,12643,12645,12648,12650,12653,12655,12658,12660,12663],{"class":150,"line":2039},[148,12639,323],{"class":154},[148,12641,12642],{"class":235}," envelope",[148,12644,329],{"class":154},[148,12646,12647],{"class":207}," createStateEnvelope",[148,12649,369],{"class":161},[148,12651,12652],{"class":168},"\"run:42\"",[148,12654,87],{"class":161},[148,12656,12657],{"class":168},"\"started\"",[148,12659,87],{"class":161},[148,12661,12662],{"class":235},"1",[148,12664,12665],{"class":161},", agents.agentId);\n",[148,12667,12668,12671,12673,12675,12678,12681,12684,12687,12689],{"class":150,"line":2044},[148,12669,12670],{"class":161},"room.context.",[148,12672,2807],{"class":207},[148,12674,369],{"class":161},[148,12676,12677],{"class":168},"\"state\"",[148,12679,12680],{"class":161},", envelope, { qos: ",[148,12682,12683],{"class":235},"2",[148,12685,12686],{"class":161},", retain: ",[148,12688,648],{"class":235},[148,12690,6112],{"class":161},[12,12692,12693],{},"This is one of the advantages of building on real messaging infrastructure rather than a custom agent framework. Delivery levels are a solved problem in messaging. We don't need to reinvent them; we map each pattern to the level it needs and put the idempotency keys (task ids, correlation ids, state versions) in the envelopes so your handlers can be idempotent.",[35,12695,12697],{"id":12696},"rooms-as-workflow-boundaries","Rooms as Workflow Boundaries",[12,12699,12700,12701,12704,12705,12708],{},"In chat, rooms group related conversations. In agent coordination, rooms group related workflows. A room called ",[84,12702,12703],{},"research-pipeline"," contains all the topics for that pipeline: task dispatch, results, shared state, and events. A separate room called ",[84,12706,12707],{},"content-review"," contains its own isolated set of topics.",[12,12710,12711,12712,611],{},"This isolation is important. Just as you don't want messages from #general leaking into #engineering, you don't want tasks from one workflow accidentally being picked up by workers in another. Rooms provide that boundary for free. They are created on the control plane, never implicitly: subscribing to a room that does not exist returns ",[84,12713,12714],{},"unknown_topic",[35,12716,12718],{"id":12717},"performance-characteristics","Performance Characteristics",[12,12720,12721],{},"Agent workloads have different performance profiles than human real-time:",[40,12723,12724,12737],{},[43,12725,12726],{},[46,12727,12728,12731,12734],{},[49,12729,12730],{},"Metric",[49,12732,12733],{},"Human Realtime",[49,12735,12736],{},"Agent Realtime",[59,12738,12739,12750,12761,12772,12783,12794],{},[46,12740,12741,12744,12747],{},[64,12742,12743],{},"Message size",[64,12745,12746],{},"Small (chat text, GPS coords)",[64,12748,12749],{},"Medium-large (task payloads, LLM outputs)",[46,12751,12752,12755,12758],{},[64,12753,12754],{},"Message rate",[64,12756,12757],{},"Bursty (typing, idle cycles)",[64,12759,12760],{},"Steady (continuous task dispatch)",[46,12762,12763,12766,12769],{},[64,12764,12765],{},"Latency sensitivity",[64,12767,12768],{},"High (users notice 200ms+)",[64,12770,12771],{},"Moderate (agents tolerate seconds)",[46,12773,12774,12777,12780],{},[64,12775,12776],{},"Delivery guarantee",[64,12778,12779],{},"Mixed (QoS 0-1)",[64,12781,12782],{},"QoS 1 with idempotent envelopes",[46,12784,12785,12788,12791],{},[64,12786,12787],{},"Connection count",[64,12789,12790],{},"Many (one per user/tab)",[64,12792,12793],{},"Few (one per agent process)",[46,12795,12796,12799,12802],{},[64,12797,12798],{},"Fan-out ratio",[64,12800,12801],{},"High (1:1000 in large rooms)",[64,12803,12804],{},"Low (1:1 task dispatch, 1:N observe)",[12,12806,12807],{},"Agent workloads typically involve fewer connections but larger payloads and stricter delivery requirements. NoLag's 900KB message size limit accommodates most LLM outputs. The binary MessagePack protocol keeps overhead low even for large payloads. And for worker pools, load-balanced groups with persistent sessions keep tasks from being lost while a worker restarts.",[35,12809,12811],{"id":12810},"access-control-carries-over","Access Control Carries Over",[12,12813,12814],{},"In human systems, access control prevents unauthorized users from reading messages in rooms they haven't joined. In agent systems, the same ACL mechanism prevents agents from accessing workflows they shouldn't participate in.",[12,12816,12817],{},"Each agent connects with an actor token that grants access to specific apps and rooms. A research worker can only subscribe to topics in the research workflow. A monitoring dashboard can observe events but not dispatch tasks. The same per-topic ACL that protects chat messages protects agent coordination.",[35,12819,12821],{"id":12820},"the-insight","The Insight",[12,12823,12824],{},"Agent coordination is a real-time messaging problem wearing a different hat. The primitives are the same: publish, subscribe, presence, retained state, and access control. The patterns change (task handoff instead of chat messages, blackboard instead of typing indicators, approval gates instead of read receipts), but the infrastructure doesn't.",[12,12826,12827,12828,12830],{},"That's why we built ",[84,12829,90],{}," as a high-level SDK on top of the same core infrastructure. No new servers, no new protocols, no new persistence layer. Just new patterns mapped onto proven primitives.",[12,12832,12833],{},[952,12834,12836],{"href":12835},"/agents","Explore @nolag/agents →",[969,12838,12839],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":144,"searchDepth":172,"depth":172,"links":12841},[12842,12843,12844,12845,12846,12847,12848,12849],{"id":12019,"depth":172,"text":12020},{"id":12045,"depth":172,"text":12046},{"id":12088,"depth":172,"text":12089},{"id":12360,"depth":172,"text":12361},{"id":12696,"depth":172,"text":12697},{"id":12717,"depth":172,"text":12718},{"id":12810,"depth":172,"text":12811},{"id":12820,"depth":172,"text":12821},"How NoLag's real-time messaging infrastructure maps to agent coordination. The design decisions behind each pattern and performance characteristics for agent workloads.",{},"/blog/human-to-agent-realtime",{"title":12008,"description":12850},"blog/human-to-agent-realtime","K60z4xRGVLGopGq4aFAWYcpn051216CHYm9-bsqQoaQ",{"id":12857,"title":12858,"author":7,"body":12859,"category":1832,"date":11998,"description":14038,"excerpt":986,"extension":987,"meta":14039,"navigation":175,"path":954,"readTime":14040,"seo":14041,"stem":14042,"__hash__":14043},"blog/blog/multi-agent-coordination-layer.md","Why Multi-Agent Systems Need a Coordination Layer",{"type":9,"value":12860,"toc":14031},[12861,12864,12867,12871,12874,13001,13004,13007,13011,13014,13020,13026,13038,13049,13053,13056,13089,13109,13113,13119,13979,13982,14008,14012,14015,14020,14023,14028],[12,12862,12863],{},"The AI agent landscape has a gap. We have incredible LLMs, solid frameworks for building individual agents (LangChain, CrewAI, AutoGen), and growing demand for multi-agent systems that can tackle complex workflows. What we don't have is good coordination infrastructure.",[12,12865,12866],{},"Most teams building multi-agent systems end up reinventing the same coordination plumbing: task queues on Redis, state management on a database, custom WebSocket servers for real-time updates, and bespoke logging for observability. This is undifferentiated heavy lifting that distracts from the actual agent logic.",[35,12868,12870],{"id":12869},"the-problem-with-direct-orchestration","The Problem with Direct Orchestration",[12,12872,12873],{},"The simplest multi-agent pattern is direct orchestration: a coordinator calls agents in sequence, passing outputs from one to the next. It looks clean in a demo.",[135,12875,12876],{},[138,12877,12879],{"className":140,"code":12878,"filename":142,"language":143,"meta":144,"style":144},"// The naive approach: direct function calls\nconst research = await researchAgent.run(query);\nconst draft = await draftAgent.run(research);\nconst review = await reviewAgent.run(draft);\nconst final = await publishAgent.run(review);\n\n// Problems:\n// - Sequential, no parallelism\n// - No visibility into what's happening\n// - No way to retry a single step\n// - No shared state between agents\n// - No human approval before publish\n// - If a worker crashes mid-step, the step is lost\n",[84,12880,12881,12886,12906,12925,12944,12963,12967,12971,12976,12981,12986,12991,12996],{"__ignoreMap":144},[148,12882,12883],{"class":150,"line":151},[148,12884,12885],{"class":182},"// The naive approach: direct function calls\n",[148,12887,12888,12890,12893,12895,12897,12900,12903],{"class":150,"line":172},[148,12889,323],{"class":154},[148,12891,12892],{"class":235}," research",[148,12894,329],{"class":154},[148,12896,1502],{"class":154},[148,12898,12899],{"class":161}," researchAgent.",[148,12901,12902],{"class":207},"run",[148,12904,12905],{"class":161},"(query);\n",[148,12907,12908,12910,12913,12915,12917,12920,12922],{"class":150,"line":179},[148,12909,323],{"class":154},[148,12911,12912],{"class":235}," draft",[148,12914,329],{"class":154},[148,12916,1502],{"class":154},[148,12918,12919],{"class":161}," draftAgent.",[148,12921,12902],{"class":207},[148,12923,12924],{"class":161},"(research);\n",[148,12926,12927,12929,12932,12934,12936,12939,12941],{"class":150,"line":186},[148,12928,323],{"class":154},[148,12930,12931],{"class":235}," review",[148,12933,329],{"class":154},[148,12935,1502],{"class":154},[148,12937,12938],{"class":161}," reviewAgent.",[148,12940,12902],{"class":207},[148,12942,12943],{"class":161},"(draft);\n",[148,12945,12946,12948,12951,12953,12955,12958,12960],{"class":150,"line":192},[148,12947,323],{"class":154},[148,12949,12950],{"class":235}," final",[148,12952,329],{"class":154},[148,12954,1502],{"class":154},[148,12956,12957],{"class":161}," publishAgent.",[148,12959,12902],{"class":207},[148,12961,12962],{"class":161},"(review);\n",[148,12964,12965],{"class":150,"line":198},[148,12966,176],{"emptyLinePlaceholder":175},[148,12968,12969],{"class":150,"line":214},[148,12970,8081],{"class":182},[148,12972,12973],{"class":150,"line":227},[148,12974,12975],{"class":182},"// - Sequential, no parallelism\n",[148,12977,12978],{"class":150,"line":239},[148,12979,12980],{"class":182},"// - No visibility into what's happening\n",[148,12982,12983],{"class":150,"line":249},[148,12984,12985],{"class":182},"// - No way to retry a single step\n",[148,12987,12988],{"class":150,"line":259},[148,12989,12990],{"class":182},"// - No shared state between agents\n",[148,12992,12993],{"class":150,"line":654},[148,12994,12995],{"class":182},"// - No human approval before publish\n",[148,12997,12998],{"class":150,"line":660},[148,12999,13000],{"class":182},"// - If a worker crashes mid-step, the step is lost\n",[12,13002,13003],{},"This approach breaks down as soon as you need any of the following: parallel execution across multiple workers, visibility into what each agent is doing, the ability to retry a single failed step, shared state that all agents can read and write, human approval gates before high-stakes actions, or resilience when a worker process crashes.",[12,13005,13006],{},"These aren't edge cases. They're table stakes for production multi-agent systems.",[35,13008,13010],{"id":13009},"why-pubsub-is-the-right-primitive","Why Pub/Sub Is the Right Primitive",[12,13012,13013],{},"Publish/subscribe messaging solves the coordination problem at the right level of abstraction. Here's why:",[12,13015,13016,13019],{},[30,13017,13018],{},"Decoupling."," Publishers don't know about subscribers. An orchestrator dispatches a task to a topic. Any worker subscribed to that topic with matching capabilities picks it up. You can add, remove, or restart workers without changing the orchestrator.",[12,13021,13022,13025],{},[30,13023,13024],{},"Fan-out."," A single event can reach multiple interested parties: the worker that processes it, the dashboard that displays it, the logger that records it, and the human who might need to approve the result.",[12,13027,13028,13031,13032,13034,13035,13037],{},[30,13029,13030],{},"Durable delivery for workers."," Put workers in a load-balanced group, connected as ",[84,13033,2854],{}," actors (the actor type whose broker session persists), and the broker hands each task to one member of the group and holds the tasks dispatched while the group was scaled to zero until a member comes back. Tasks dispatched while no worker was listening are not lost; see ",[952,13036,2320],{"href":2319}," for the precise contract, including what it does not cover. Shared state is published retained, so an agent that joins late reads the current plan instead of waiting for the next change. None of this is exactly-once delivery: a task can, in principle, be seen twice, so keep task handlers idempotent.",[12,13039,13040,13043,13044,694,13046,13048],{},[30,13041,13042],{},"Presence."," Built-in presence tracking means you always know which agents are online and what each one can do, because every agent advertises its capabilities as presence data. With persistent presence, a scaled-to-zero agent stays discoverable with a status of ",[84,13045,12343],{},[84,13047,12347],{}," and is woken when work arrives. No custom health check infrastructure needed. (Presence does not report load: \"idle\" or \"overloaded\" is something an agent would have to publish itself.)",[35,13050,13052],{"id":13051},"six-patterns-as-coordination-primitives","Six Patterns as Coordination Primitives",[12,13054,13055],{},"We identified six patterns that cover the coordination needs of multi-agent systems:",[914,13057,13058,13063,13069,13074,13079,13084],{},[885,13059,13060,13062],{},[30,13061,117],{}," - Task dispatch and result collection with capability-based routing",[885,13064,13065,13068],{},[30,13066,13067],{},"Inbox"," - Direct agent-to-agent messaging for point-to-point communication",[885,13070,13071,13073],{},[30,13072,129],{}," - Shared state with versioning (last writer wins) so all agents see the same world",[885,13075,13076,13078],{},[30,13077,120],{}," - Real-time event stream for monitoring, logging, and debugging",[885,13080,13081,13083],{},[30,13082,126],{}," - Human-in-the-loop gates for high-stakes decisions",[885,13085,13086,13088],{},[30,13087,123],{}," - Remote tool invocation so agents can share capabilities",[12,13090,13091,13092,87,13094,87,13096,87,13098,87,13101,87,13104,87,13106,13108],{},"Each pattern maps to specific topics in the room (",[84,13093,1861],{},[84,13095,1875],{},[84,13097,12038],{},[84,13099,13100],{},"events",[84,13102,13103],{},"inbox",[84,13105,12073],{},[84,13107,12041],{},") and a typed message envelope. But you interact with them through a high-level SDK that hides the pub/sub mechanics.",[35,13110,13112],{"id":13111},"what-coordination-actually-looks-like","What Coordination Actually Looks Like",[12,13114,13115,13116,13118],{},"Here's the same content pipeline from earlier, but with proper coordination infrastructure. The orchestrator and each worker connect with their own actor tokens (the broker never delivers a message back to the actor that published it), attach ",[84,13117,90],{}," to the connection, and meet in a room.",[135,13120,13121,13689],{},[138,13122,13125],{"className":140,"code":13123,"filename":13124,"language":143,"meta":144,"style":144},"import { NoLag } from \"@nolag/js-sdk\";\nimport { NoLagAgents, Handoff, Blackboard, Approve, Observe } from \"@nolag/agents\";\n\n// APP_SLUG is the slug returned when you created the app from the\n// nolag-agents-sdk blueprint (it has a random suffix). Create the\n// content-pipeline room in the control plane first: rooms never exist implicitly.\nconst client = NoLag(ORCHESTRATOR_TOKEN);\nconst agents = new NoLagAgents({\n  client,\n  appName: APP_SLUG,\n  agentId: \"orchestrator\",\n  presence: { name: \"orchestrator\", role: \"orchestrator\" },\n});\nawait client.connect();\nawait agents.ready();\nconst room = agents.room(\"content-pipeline\");\n\n// Shared state visible to all agents (retained, so late joiners see it)\nconst blackboard = new Blackboard(room, agents.agentId);\nblackboard.set(\"plan\", {\n  steps: [\"research\", \"draft\", \"review\", \"publish\"],\n  current: 0,\n});\n\n// Dispatch to any worker advertising the capability, and wait for its result.\n// dispatch() throws if no connected agent advertises the capability.\nconst handoff = new Handoff(room);\nasync function run(capability: string, payload: Record\u003Cstring, unknown>) {\n  const result = await handoff.dispatch(capability, payload, { waitForResult: true });\n  if (!result || result.status !== \"success\") throw new Error(`${capability} failed`);\n  return result.payload;\n}\n\nconst research = await run(\"research\", { query });\nconst draft = await run(\"draft\", { research });\nconst review = await run(\"review\", { draft });\n\n// Human gate before publish\nconst approve = new Approve(room, agents.agentId);\nconst { decision, reason } = await approve.request(\"publish\", review, { urgency: \"high\" });\nif (decision === \"approved\") {\n  await run(\"publish\", { review });\n} else {\n  log(\"publish\", decision, reason);\n}\n\n// Full observability: everything agents emit on the room's events topic\nnew Observe(room, agents.agentId).on((event) => log(event.category, event.severity, event.payload));\n","Orchestrator",[84,13126,13127,13139,13152,13156,13161,13166,13171,13187,13201,13205,13213,13221,13233,13237,13247,13257,13276,13280,13285,13299,13312,13336,13345,13349,13353,13358,13363,13377,13413,13434,13475,13481,13485,13489,13508,13527,13546,13550,13555,13569,13603,13616,13630,13639,13651,13655,13659,13664],{"__ignoreMap":144},[148,13128,13129,13131,13133,13135,13137],{"class":150,"line":151},[148,13130,155],{"class":154},[148,13132,2575],{"class":161},[148,13134,165],{"class":154},[148,13136,6054],{"class":168},[148,13138,6057],{"class":161},[148,13140,13141,13143,13146,13148,13150],{"class":150,"line":172},[148,13142,155],{"class":154},[148,13144,13145],{"class":161}," { NoLagAgents, Handoff, Blackboard, Approve, Observe } ",[148,13147,165],{"class":154},[148,13149,6069],{"class":168},[148,13151,6057],{"class":161},[148,13153,13154],{"class":150,"line":179},[148,13155,176],{"emptyLinePlaceholder":175},[148,13157,13158],{"class":150,"line":186},[148,13159,13160],{"class":182},"// APP_SLUG is the slug returned when you created the app from the\n",[148,13162,13163],{"class":150,"line":192},[148,13164,13165],{"class":182},"// nolag-agents-sdk blueprint (it has a random suffix). Create the\n",[148,13167,13168],{"class":150,"line":198},[148,13169,13170],{"class":182},"// content-pipeline room in the control plane first: rooms never exist implicitly.\n",[148,13172,13173,13175,13177,13179,13181,13183,13185],{"class":150,"line":214},[148,13174,323],{"class":154},[148,13176,1589],{"class":235},[148,13178,329],{"class":154},[148,13180,1594],{"class":207},[148,13182,369],{"class":161},[148,13184,6567],{"class":235},[148,13186,6134],{"class":161},[148,13188,13189,13191,13193,13195,13197,13199],{"class":150,"line":227},[148,13190,323],{"class":154},[148,13192,10085],{"class":235},[148,13194,329],{"class":154},[148,13196,332],{"class":154},[148,13198,6148],{"class":207},[148,13200,1343],{"class":161},[148,13202,13203],{"class":150,"line":239},[148,13204,6155],{"class":161},[148,13206,13207,13209,13211],{"class":150,"line":249},[148,13208,6160],{"class":161},[148,13210,2672],{"class":235},[148,13212,540],{"class":161},[148,13214,13215,13217,13219],{"class":150,"line":259},[148,13216,6172],{"class":161},[148,13218,6603],{"class":168},[148,13220,540],{"class":161},[148,13222,13223,13225,13227,13229,13231],{"class":150,"line":654},[148,13224,6610],{"class":161},[148,13226,6603],{"class":168},[148,13228,6615],{"class":161},[148,13230,6603],{"class":168},[148,13232,6234],{"class":161},[148,13234,13235],{"class":150,"line":660},[148,13236,6244],{"class":161},[148,13238,13239,13241,13243,13245],{"class":150,"line":1289},[148,13240,343],{"class":154},[148,13242,4099],{"class":161},[148,13244,2647],{"class":207},[148,13246,6259],{"class":161},[148,13248,13249,13251,13253,13255],{"class":150,"line":1295},[148,13250,343],{"class":154},[148,13252,10208],{"class":161},[148,13254,349],{"class":207},[148,13256,6259],{"class":161},[148,13258,13259,13261,13263,13265,13267,13269,13271,13274],{"class":150,"line":1313},[148,13260,323],{"class":154},[148,13262,359],{"class":235},[148,13264,329],{"class":154},[148,13266,10208],{"class":161},[148,13268,1421],{"class":207},[148,13270,369],{"class":161},[148,13272,13273],{"class":168},"\"content-pipeline\"",[148,13275,6134],{"class":161},[148,13277,13278],{"class":150,"line":1331},[148,13279,176],{"emptyLinePlaceholder":175},[148,13281,13282],{"class":150,"line":1346},[148,13283,13284],{"class":182},"// Shared state visible to all agents (retained, so late joiners see it)\n",[148,13286,13287,13289,13291,13293,13295,13297],{"class":150,"line":1355},[148,13288,323],{"class":154},[148,13290,9411],{"class":235},[148,13292,329],{"class":154},[148,13294,332],{"class":154},[148,13296,9418],{"class":207},[148,13298,10717],{"class":161},[148,13300,13301,13303,13305,13307,13310],{"class":150,"line":1361},[148,13302,9584],{"class":161},[148,13304,2401],{"class":207},[148,13306,369],{"class":161},[148,13308,13309],{"class":168},"\"plan\"",[148,13311,3614],{"class":161},[148,13313,13314,13317,13319,13321,13324,13326,13329,13331,13334],{"class":150,"line":1370},[148,13315,13316],{"class":161},"  steps: [",[148,13318,12239],{"class":168},[148,13320,87],{"class":161},[148,13322,13323],{"class":168},"\"draft\"",[148,13325,87],{"class":161},[148,13327,13328],{"class":168},"\"review\"",[148,13330,87],{"class":161},[148,13332,13333],{"class":168},"\"publish\"",[148,13335,6217],{"class":161},[148,13337,13338,13341,13343],{"class":150,"line":1376},[148,13339,13340],{"class":161},"  current: ",[148,13342,9227],{"class":235},[148,13344,540],{"class":161},[148,13346,13347],{"class":150,"line":2033},[148,13348,6244],{"class":161},[148,13350,13351],{"class":150,"line":2039},[148,13352,176],{"emptyLinePlaceholder":175},[148,13354,13355],{"class":150,"line":2044},[148,13356,13357],{"class":182},"// Dispatch to any worker advertising the capability, and wait for its result.\n",[148,13359,13360],{"class":150,"line":2050},[148,13361,13362],{"class":182},"// dispatch() throws if no connected agent advertises the capability.\n",[148,13364,13365,13367,13369,13371,13373,13375],{"class":150,"line":2056},[148,13366,323],{"class":154},[148,13368,6296],{"class":235},[148,13370,329],{"class":154},[148,13372,332],{"class":154},[148,13374,537],{"class":207},[148,13376,6305],{"class":161},[148,13378,13379,13381,13383,13386,13388,13390,13392,13394,13396,13398,13400,13402,13404,13406,13408,13410],{"class":150,"line":2062},[148,13380,1447],{"class":154},[148,13382,521],{"class":154},[148,13384,13385],{"class":207}," run",[148,13387,369],{"class":161},[148,13389,553],{"class":217},[148,13391,221],{"class":154},[148,13393,558],{"class":235},[148,13395,87],{"class":161},[148,13397,564],{"class":217},[148,13399,221],{"class":154},[148,13401,569],{"class":207},[148,13403,572],{"class":161},[148,13405,575],{"class":235},[148,13407,87],{"class":161},[148,13409,580],{"class":235},[148,13411,13412],{"class":161},">) {\n",[148,13414,13415,13417,13419,13421,13423,13425,13427,13430,13432],{"class":150,"line":2068},[148,13416,3596],{"class":154},[148,13418,6754],{"class":235},[148,13420,329],{"class":154},[148,13422,1502],{"class":154},[148,13424,639],{"class":161},[148,13426,642],{"class":207},[148,13428,13429],{"class":161},"(capability, payload, { waitForResult: ",[148,13431,648],{"class":235},[148,13433,6112],{"class":161},[148,13435,13436,13438,13440,13442,13445,13447,13449,13452,13454,13456,13459,13461,13464,13466,13468,13470,13473],{"class":150,"line":2074},[148,13437,10452],{"class":154},[148,13439,114],{"class":161},[148,13441,4069],{"class":154},[148,13443,13444],{"class":161},"result ",[148,13446,7587],{"class":154},[148,13448,10461],{"class":161},[148,13450,13451],{"class":154},"!==",[148,13453,10466],{"class":168},[148,13455,633],{"class":161},[148,13457,13458],{"class":154},"throw",[148,13460,332],{"class":154},[148,13462,13463],{"class":207}," Error",[148,13465,369],{"class":161},[148,13467,6721],{"class":168},[148,13469,553],{"class":161},[148,13471,13472],{"class":168},"} failed`",[148,13474,6134],{"class":161},[148,13476,13477,13479],{"class":150,"line":2080},[148,13478,605],{"class":154},[148,13480,9486],{"class":161},[148,13482,13483],{"class":150,"line":2086},[148,13484,262],{"class":161},[148,13486,13487],{"class":150,"line":2092},[148,13488,176],{"emptyLinePlaceholder":175},[148,13490,13491,13493,13495,13497,13499,13501,13503,13505],{"class":150,"line":2097},[148,13492,323],{"class":154},[148,13494,12892],{"class":235},[148,13496,329],{"class":154},[148,13498,1502],{"class":154},[148,13500,13385],{"class":207},[148,13502,369],{"class":161},[148,13504,12239],{"class":168},[148,13506,13507],{"class":161},", { query });\n",[148,13509,13510,13512,13514,13516,13518,13520,13522,13524],{"class":150,"line":2103},[148,13511,323],{"class":154},[148,13513,12912],{"class":235},[148,13515,329],{"class":154},[148,13517,1502],{"class":154},[148,13519,13385],{"class":207},[148,13521,369],{"class":161},[148,13523,13323],{"class":168},[148,13525,13526],{"class":161},", { research });\n",[148,13528,13529,13531,13533,13535,13537,13539,13541,13543],{"class":150,"line":2108},[148,13530,323],{"class":154},[148,13532,12931],{"class":235},[148,13534,329],{"class":154},[148,13536,1502],{"class":154},[148,13538,13385],{"class":207},[148,13540,369],{"class":161},[148,13542,13328],{"class":168},[148,13544,13545],{"class":161},", { draft });\n",[148,13547,13548],{"class":150,"line":2114},[148,13549,176],{"emptyLinePlaceholder":175},[148,13551,13552],{"class":150,"line":2120},[148,13553,13554],{"class":182},"// Human gate before publish\n",[148,13556,13557,13559,13561,13563,13565,13567],{"class":150,"line":2125},[148,13558,323],{"class":154},[148,13560,762],{"class":235},[148,13562,329],{"class":154},[148,13564,332],{"class":154},[148,13566,769],{"class":207},[148,13568,10717],{"class":161},[148,13570,13571,13573,13575,13577,13579,13582,13584,13586,13588,13590,13592,13594,13596,13599,13601],{"class":150,"line":4261},[148,13572,323],{"class":154},[148,13574,550],{"class":161},[148,13576,863],{"class":235},[148,13578,87],{"class":161},[148,13580,13581],{"class":235},"reason",[148,13583,1485],{"class":161},[148,13585,1337],{"class":154},[148,13587,1502],{"class":154},[148,13589,11223],{"class":161},[148,13591,785],{"class":207},[148,13593,369],{"class":161},[148,13595,13333],{"class":168},[148,13597,13598],{"class":161},", review, { urgency: ",[148,13600,8413],{"class":168},[148,13602,6112],{"class":161},[148,13604,13605,13607,13610,13612,13614],{"class":150,"line":4266},[148,13606,6813],{"class":154},[148,13608,13609],{"class":161}," (decision ",[148,13611,1700],{"class":154},[148,13613,11284],{"class":168},[148,13615,600],{"class":161},[148,13617,13618,13621,13623,13625,13627],{"class":150,"line":4290},[148,13619,13620],{"class":154},"  await",[148,13622,13385],{"class":207},[148,13624,369],{"class":161},[148,13626,13333],{"class":168},[148,13628,13629],{"class":161},", { review });\n",[148,13631,13632,13635,13637],{"class":150,"line":4307},[148,13633,13634],{"class":161},"} ",[148,13636,11306],{"class":154},[148,13638,211],{"class":161},[148,13640,13641,13644,13646,13648],{"class":150,"line":4322},[148,13642,13643],{"class":207},"  log",[148,13645,369],{"class":161},[148,13647,13333],{"class":168},[148,13649,13650],{"class":161},", decision, reason);\n",[148,13652,13653],{"class":150,"line":4328},[148,13654,262],{"class":161},[148,13656,13657],{"class":150,"line":4349},[148,13658,176],{"emptyLinePlaceholder":175},[148,13660,13661],{"class":150,"line":4354},[148,13662,13663],{"class":182},"// Full observability: everything agents emit on the room's events topic\n",[148,13665,13666,13668,13670,13673,13675,13677,13679,13681,13683,13686],{"class":150,"line":4359},[148,13667,436],{"class":154},[148,13669,10714],{"class":207},[148,13671,13672],{"class":161},"(room, agents.agentId).",[148,13674,2706],{"class":207},[148,13676,627],{"class":161},[148,13678,1683],{"class":217},[148,13680,633],{"class":161},[148,13682,636],{"class":154},[148,13684,13685],{"class":207}," log",[148,13687,13688],{"class":161},"(event.category, event.severity, event.payload));\n",[138,13690,13693],{"className":140,"code":13691,"filename":13692,"language":143,"meta":144,"style":144},"import { NoLag } from \"@nolag/js-sdk\";\nimport { NoLagAgents, Handoff, Observe } from \"@nolag/agents\";\n\n// Its own token (an `agent` actor, so its session persists), and its capabilities\n// advertised as presence so dispatch() can find it\nconst client = NoLag(WORKER_TOKEN, { loadBalance: true, loadBalanceGroup: \"researchers\" });\nconst agents = new NoLagAgents({\n  client,\n  appName: APP_SLUG,\n  agentId: \"researcher-1\",\n  presence: { name: \"researcher-1\", role: \"agent\", capabilities: [\"research\"] },\n});\nawait client.connect();\nawait agents.ready();\nconst room = agents.room(\"content-pipeline\");\n\nconst observe = new Observe(room, agents.agentId);\n\n// Only tasks whose capability matches are delivered to this handler\nnew Handoff(room).onTask([\"research\"], async (task, respond) => {\n  observe.emit(\"research\", { taskId: task.taskId, status: \"started\" });\n  const findings = await researchAgent.run(task.payload.query);\n  respond(\"success\", { findings });\n  observe.emit(\"research\", { taskId: task.taskId, status: \"done\" });\n});\n","Worker",[84,13694,13695,13707,13719,13723,13728,13733,13758,13772,13776,13784,13793,13809,13813,13823,13833,13851,13855,13869,13873,13878,13911,13929,13947,13958,13975],{"__ignoreMap":144},[148,13696,13697,13699,13701,13703,13705],{"class":150,"line":151},[148,13698,155],{"class":154},[148,13700,2575],{"class":161},[148,13702,165],{"class":154},[148,13704,6054],{"class":168},[148,13706,6057],{"class":161},[148,13708,13709,13711,13713,13715,13717],{"class":150,"line":172},[148,13710,155],{"class":154},[148,13712,10541],{"class":161},[148,13714,165],{"class":154},[148,13716,6069],{"class":168},[148,13718,6057],{"class":161},[148,13720,13721],{"class":150,"line":179},[148,13722,176],{"emptyLinePlaceholder":175},[148,13724,13725],{"class":150,"line":186},[148,13726,13727],{"class":182},"// Its own token (an `agent` actor, so its session persists), and its capabilities\n",[148,13729,13730],{"class":150,"line":192},[148,13731,13732],{"class":182},"// advertised as presence so dispatch() can find it\n",[148,13734,13735,13737,13739,13741,13743,13745,13747,13749,13751,13753,13756],{"class":150,"line":198},[148,13736,323],{"class":154},[148,13738,1589],{"class":235},[148,13740,329],{"class":154},[148,13742,1594],{"class":207},[148,13744,369],{"class":161},[148,13746,6131],{"class":235},[148,13748,7776],{"class":161},[148,13750,648],{"class":235},[148,13752,7781],{"class":161},[148,13754,13755],{"class":168},"\"researchers\"",[148,13757,6112],{"class":161},[148,13759,13760,13762,13764,13766,13768,13770],{"class":150,"line":214},[148,13761,323],{"class":154},[148,13763,10085],{"class":235},[148,13765,329],{"class":154},[148,13767,332],{"class":154},[148,13769,6148],{"class":207},[148,13771,1343],{"class":161},[148,13773,13774],{"class":150,"line":227},[148,13775,6155],{"class":161},[148,13777,13778,13780,13782],{"class":150,"line":239},[148,13779,6160],{"class":161},[148,13781,2672],{"class":235},[148,13783,540],{"class":161},[148,13785,13786,13788,13791],{"class":150,"line":249},[148,13787,6172],{"class":161},[148,13789,13790],{"class":168},"\"researcher-1\"",[148,13792,540],{"class":161},[148,13794,13795,13797,13799,13801,13803,13805,13807],{"class":150,"line":259},[148,13796,6610],{"class":161},[148,13798,13790],{"class":168},[148,13800,6615],{"class":161},[148,13802,6199],{"class":168},[148,13804,10634],{"class":161},[148,13806,12239],{"class":168},[148,13808,10640],{"class":161},[148,13810,13811],{"class":150,"line":654},[148,13812,6244],{"class":161},[148,13814,13815,13817,13819,13821],{"class":150,"line":660},[148,13816,343],{"class":154},[148,13818,4099],{"class":161},[148,13820,2647],{"class":207},[148,13822,6259],{"class":161},[148,13824,13825,13827,13829,13831],{"class":150,"line":1289},[148,13826,343],{"class":154},[148,13828,10208],{"class":161},[148,13830,349],{"class":207},[148,13832,6259],{"class":161},[148,13834,13835,13837,13839,13841,13843,13845,13847,13849],{"class":150,"line":1295},[148,13836,323],{"class":154},[148,13838,359],{"class":235},[148,13840,329],{"class":154},[148,13842,10208],{"class":161},[148,13844,1421],{"class":207},[148,13846,369],{"class":161},[148,13848,13273],{"class":168},[148,13850,6134],{"class":161},[148,13852,13853],{"class":150,"line":1313},[148,13854,176],{"emptyLinePlaceholder":175},[148,13856,13857,13859,13861,13863,13865,13867],{"class":150,"line":1331},[148,13858,323],{"class":154},[148,13860,10707],{"class":235},[148,13862,329],{"class":154},[148,13864,332],{"class":154},[148,13866,10714],{"class":207},[148,13868,10717],{"class":161},[148,13870,13871],{"class":150,"line":1346},[148,13872,176],{"emptyLinePlaceholder":175},[148,13874,13875],{"class":150,"line":1355},[148,13876,13877],{"class":182},"// Only tasks whose capability matches are delivered to this handler\n",[148,13879,13880,13882,13884,13887,13889,13891,13893,13895,13897,13899,13901,13903,13905,13907,13909],{"class":150,"line":1361},[148,13881,436],{"class":154},[148,13883,537],{"class":207},[148,13885,13886],{"class":161},"(room).",[148,13888,1435],{"class":207},[148,13890,1438],{"class":161},[148,13892,12239],{"class":168},[148,13894,1444],{"class":161},[148,13896,1447],{"class":154},[148,13898,114],{"class":161},[148,13900,1452],{"class":217},[148,13902,87],{"class":161},[148,13904,790],{"class":217},[148,13906,633],{"class":161},[148,13908,636],{"class":154},[148,13910,211],{"class":161},[148,13912,13913,13916,13918,13920,13922,13925,13927],{"class":150,"line":1370},[148,13914,13915],{"class":161},"  observe.",[148,13917,2807],{"class":207},[148,13919,369],{"class":161},[148,13921,12239],{"class":168},[148,13923,13924],{"class":161},", { taskId: task.taskId, status: ",[148,13926,12657],{"class":168},[148,13928,6112],{"class":161},[148,13930,13931,13933,13936,13938,13940,13942,13944],{"class":150,"line":1376},[148,13932,3596],{"class":154},[148,13934,13935],{"class":235}," findings",[148,13937,329],{"class":154},[148,13939,1502],{"class":154},[148,13941,12899],{"class":161},[148,13943,12902],{"class":207},[148,13945,13946],{"class":161},"(task.payload.query);\n",[148,13948,13949,13951,13953,13955],{"class":150,"line":2033},[148,13950,6434],{"class":207},[148,13952,369],{"class":161},[148,13954,6439],{"class":168},[148,13956,13957],{"class":161},", { findings });\n",[148,13959,13960,13962,13964,13966,13968,13970,13973],{"class":150,"line":2039},[148,13961,13915],{"class":161},[148,13963,2807],{"class":207},[148,13965,369],{"class":161},[148,13967,12239],{"class":168},[148,13969,13924],{"class":161},[148,13971,13972],{"class":168},"\"done\"",[148,13974,6112],{"class":161},[148,13976,13977],{"class":150,"line":2044},[148,13978,6244],{"class":161},[12,13980,13981],{},"The difference: workers can be scaled independently (every worker in a load-balanced group receives a share of the tasks rather than a copy), every event a worker emits is observable, shared state is accessible to all agents, human approval gates are built in, and a worker that restarts drains the tasks its group missed while it was away.",[12,13983,13984,13985,13987,13988,13990,13991,13994,13995,13998,13999,687,14001,87,14003,694,14005,14007],{},"Two honest details. ",[84,13986,117],{}," does not narrate itself: nothing appears on the ",[84,13989,13100],{}," topic unless an agent calls ",[84,13992,13993],{},"observe.emit",", which is why the worker above emits at each step. And ",[84,13996,13997],{},"approve.request"," resolves with a ",[84,14000,863],{},[84,14002,866],{},[84,14004,869],{},[84,14006,872],{},", so the orchestrator has to handle all three rather than a boolean.",[35,14009,14011],{"id":14010},"nolag-as-the-substrate","NoLag as the Substrate",[12,14013,14014],{},"NoLag was built as real-time messaging infrastructure for chat, notifications, and IoT. It turns out that the same primitives - topics, rooms, presence, QoS, and access control - map directly to multi-agent coordination needs.",[12,14016,14017,14019],{},[84,14018,90],{}," is a high-level SDK that wraps these primitives into the six patterns above. Under the hood, it uses the same WebSocket infrastructure that carries NoLag's chat, notification and IoT traffic. You get load-balanced dispatch, durable catch-up for worker groups, presence-based discovery, retained shared state and per-topic ACL without building any of it yourself.",[12,14021,14022],{},"The key insight: agent coordination is a real-time messaging problem. And real-time messaging is a solved problem - if you use the right infrastructure.",[12,14024,14025],{},[952,14026,14027],{"href":12835},"Learn more about @nolag/agents →",[969,14029,14030],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":144,"searchDepth":172,"depth":172,"links":14032},[14033,14034,14035,14036,14037],{"id":12869,"depth":172,"text":12870},{"id":13009,"depth":172,"text":13010},{"id":13051,"depth":172,"text":13052},{"id":13111,"depth":172,"text":13112},{"id":14010,"depth":172,"text":14011},"Multi-agent AI systems need more than an LLM API. They need coordination infrastructure: task dispatch, shared state, approval gates, and observability. Here is why pub/sub is the right primitive.",{},"11 min read",{"title":12858,"description":14038},"blog/multi-agent-coordination-layer","z6AWvofhR9AGR83-7_LdrniQhq_gLrzE8Ea7XHMmq54",{"id":14045,"title":14046,"author":7,"body":14047,"category":983,"date":14726,"description":14727,"excerpt":986,"extension":987,"meta":14728,"navigation":175,"path":14729,"readTime":2287,"seo":14730,"stem":14731,"__hash__":14732},"blog/blog/websocket-vs-polling.md","WebSockets vs Polling: Why Real-Time Apps Need a Persistent Connection",{"type":9,"value":14048,"toc":14717},[14049,14052,14055,14059,14062,14155,14158,14161,14165,14168,14267,14270,14274,14277,14360,14363,14366,14370,14373,14556,14559,14563,14566,14674,14677,14681,14684,14687,14691,14694,14697,14711,14714],[12,14050,14051],{},"If you have ever built a dashboard that refreshes every few seconds, a chat box that checks for new messages on a timer, or a notification badge that polls an endpoint every minute, you have already felt the friction of HTTP polling. It works. But it carries a hidden cost that compounds quickly as your user count grows.",[12,14053,14054],{},"This post breaks down the four main approaches to real-time communication, where each one breaks down, and why WebSockets are the right default for most interactive apps.",[35,14056,14058],{"id":14057},"short-polling-the-simplest-thing-that-could-possibly-work","Short Polling: The Simplest Thing That Could Possibly Work",[12,14060,14061],{},"Short polling is the most naive approach. The client sends an HTTP request, the server responds immediately with whatever data it has (or an empty response if there is nothing new), and the client waits a fixed interval before asking again.",[135,14063,14064],{},[138,14065,14067],{"className":140,"code":14066,"filename":142,"language":143,"meta":144,"style":144},"// Short polling every 3 seconds\nsetInterval(async () => {\n  const res = await fetch('/api/messages?since=' + lastTimestamp)\n  const data = await res.json()\n  if (data.messages.length) renderMessages(data.messages)\n}, 3000)\n",[84,14068,14069,14074,14089,14111,14128,14145],{"__ignoreMap":144},[148,14070,14071],{"class":150,"line":151},[148,14072,14073],{"class":182},"// Short polling every 3 seconds\n",[148,14075,14076,14079,14081,14083,14085,14087],{"class":150,"line":172},[148,14077,14078],{"class":207},"setInterval",[148,14080,369],{"class":161},[148,14082,1447],{"class":154},[148,14084,4011],{"class":161},[148,14086,636],{"class":154},[148,14088,211],{"class":161},[148,14090,14091,14093,14095,14097,14099,14101,14103,14106,14108],{"class":150,"line":179},[148,14092,3596],{"class":154},[148,14094,3599],{"class":235},[148,14096,329],{"class":154},[148,14098,1502],{"class":154},[148,14100,3606],{"class":207},[148,14102,369],{"class":161},[148,14104,14105],{"class":168},"'/api/messages?since='",[148,14107,9617],{"class":154},[148,14109,14110],{"class":161}," lastTimestamp)\n",[148,14112,14113,14115,14118,14120,14122,14124,14126],{"class":150,"line":186},[148,14114,3596],{"class":154},[148,14116,14117],{"class":235}," data",[148,14119,329],{"class":154},[148,14121,1502],{"class":154},[148,14123,3722],{"class":161},[148,14125,3725],{"class":207},[148,14127,352],{"class":161},[148,14129,14130,14132,14135,14137,14139,14142],{"class":150,"line":192},[148,14131,10452],{"class":154},[148,14133,14134],{"class":161}," (data.messages.",[148,14136,6728],{"class":235},[148,14138,633],{"class":161},[148,14140,14141],{"class":207},"renderMessages",[148,14143,14144],{"class":161},"(data.messages)\n",[148,14146,14147,14150,14153],{"class":150,"line":198},[148,14148,14149],{"class":161},"}, ",[148,14151,14152],{"class":235},"3000",[148,14154,375],{"class":161},[12,14156,14157],{},"The problem is structural. Every request pays a full TCP handshake cost if the connection is not reused, sends an HTTP request header block that can easily run 500-800 bytes, waits for server processing, and gets a response header block back. When nothing has changed, all of that work produces an empty payload. Multiply that by 1,000 users polling every three seconds and you are generating 20,000 pointless requests per minute.",[12,14159,14160],{},"Beyond bandwidth, there is the latency floor. If something happens 100ms after the client just polled, that event will not reach the user for up to 2.9 more seconds. For a notification that someone just paid you, that gap feels broken.",[35,14162,14164],{"id":14163},"long-polling-holding-the-connection-open","Long Polling: Holding the Connection Open",[12,14166,14167],{},"Long polling improves on short polling by having the server hold the request open until data is actually available. The client sends a request, the server parks it, and when an event occurs the server responds. The client immediately sends another request and the cycle continues.",[135,14169,14170],{},[138,14171,14173],{"className":140,"code":14172,"filename":142,"language":143,"meta":144,"style":144},"// Long polling - client re-requests immediately after each response\nasync function longPoll() {\n  try {\n    const res = await fetch('/api/events?timeout=30')\n    const data = await res.json()\n    handleEvent(data)\n  } finally {\n    longPoll() // reconnect immediately\n  }\n}\n",[84,14174,14175,14180,14191,14197,14216,14232,14240,14249,14259,14263],{"__ignoreMap":144},[148,14176,14177],{"class":150,"line":151},[148,14178,14179],{"class":182},"// Long polling - client re-requests immediately after each response\n",[148,14181,14182,14184,14186,14189],{"class":150,"line":172},[148,14183,1447],{"class":154},[148,14185,521],{"class":154},[148,14187,14188],{"class":207}," longPoll",[148,14190,3591],{"class":161},[148,14192,14193,14195],{"class":150,"line":179},[148,14194,10849],{"class":154},[148,14196,211],{"class":161},[148,14198,14199,14201,14203,14205,14207,14209,14211,14214],{"class":150,"line":186},[148,14200,1467],{"class":154},[148,14202,3599],{"class":235},[148,14204,329],{"class":154},[148,14206,1502],{"class":154},[148,14208,3606],{"class":207},[148,14210,369],{"class":161},[148,14212,14213],{"class":168},"'/api/events?timeout=30'",[148,14215,375],{"class":161},[148,14217,14218,14220,14222,14224,14226,14228,14230],{"class":150,"line":192},[148,14219,1467],{"class":154},[148,14221,14117],{"class":235},[148,14223,329],{"class":154},[148,14225,1502],{"class":154},[148,14227,3722],{"class":161},[148,14229,3725],{"class":207},[148,14231,352],{"class":161},[148,14233,14234,14237],{"class":150,"line":198},[148,14235,14236],{"class":207},"    handleEvent",[148,14238,14239],{"class":161},"(data)\n",[148,14241,14242,14244,14247],{"class":150,"line":214},[148,14243,10979],{"class":161},[148,14245,14246],{"class":154},"finally",[148,14248,211],{"class":161},[148,14250,14251,14254,14256],{"class":150,"line":227},[148,14252,14253],{"class":207},"    longPoll",[148,14255,6465],{"class":161},[148,14257,14258],{"class":182},"// reconnect immediately\n",[148,14260,14261],{"class":150,"line":239},[148,14262,1716],{"class":161},[148,14264,14265],{"class":150,"line":249},[148,14266,262],{"class":161},[12,14268,14269],{},"This cuts unnecessary requests and gets latency down close to zero for the actual event delivery. But every response still closes the connection, which means the client pays the reconnection cost for every single message. Under high message frequency that overhead adds up fast. There is also the server-side complexity: holding thousands of open HTTP connections consumes file descriptors and threads depending on your server model.",[35,14271,14273],{"id":14272},"server-sent-events-one-direction-low-overhead","Server-Sent Events: One Direction, Low Overhead",[12,14275,14276],{},"Server-Sent Events (SSE) establish a persistent HTTP connection where the server can push data to the client at any time. The connection stays open. The client does not have to re-request. The protocol is text-based and uses a simple format that browsers understand natively.",[135,14278,14279],{},[138,14280,14282],{"className":140,"code":14281,"filename":142,"language":143,"meta":144,"style":144},"// SSE on the client - browser handles reconnection automatically\nconst source = new EventSource('/api/stream')\nsource.onmessage = (event) => {\n  const data = JSON.parse(event.data)\n  renderUpdate(data)\n}\n",[84,14283,14284,14289,14310,14330,14349,14356],{"__ignoreMap":144},[148,14285,14286],{"class":150,"line":151},[148,14287,14288],{"class":182},"// SSE on the client - browser handles reconnection automatically\n",[148,14290,14291,14293,14296,14298,14300,14303,14305,14308],{"class":150,"line":172},[148,14292,323],{"class":154},[148,14294,14295],{"class":235}," source",[148,14297,329],{"class":154},[148,14299,332],{"class":154},[148,14301,14302],{"class":207}," EventSource",[148,14304,369],{"class":161},[148,14306,14307],{"class":168},"'/api/stream'",[148,14309,375],{"class":161},[148,14311,14312,14315,14318,14320,14322,14324,14326,14328],{"class":150,"line":179},[148,14313,14314],{"class":161},"source.",[148,14316,14317],{"class":207},"onmessage",[148,14319,329],{"class":154},[148,14321,114],{"class":161},[148,14323,1683],{"class":217},[148,14325,633],{"class":161},[148,14327,636],{"class":154},[148,14329,211],{"class":161},[148,14331,14332,14334,14336,14338,14341,14343,14346],{"class":150,"line":186},[148,14333,3596],{"class":154},[148,14335,14117],{"class":235},[148,14337,329],{"class":154},[148,14339,14340],{"class":235}," JSON",[148,14342,611],{"class":161},[148,14344,14345],{"class":207},"parse",[148,14347,14348],{"class":161},"(event.data)\n",[148,14350,14351,14354],{"class":150,"line":192},[148,14352,14353],{"class":207},"  renderUpdate",[148,14355,14239],{"class":161},[148,14357,14358],{"class":150,"line":198},[148,14359,262],{"class":161},[12,14361,14362],{},"SSE is a good fit when the server talks to the client but the client rarely needs to talk back. Stock tickers, live score feeds, and activity streams are natural matches. The browser handles reconnection automatically and there is no third-party library required.",[12,14364,14365],{},"The limitation is that SSE is strictly unidirectional. If your client needs to send data back with any frequency, you are back to firing separate HTTP requests. For bidirectional workflows like chat or collaborative editing, SSE plus POST is an awkward combination.",[35,14367,14369],{"id":14368},"websockets-persistent-bidirectional-low-overhead","WebSockets: Persistent, Bidirectional, Low Overhead",[12,14371,14372],{},"A WebSocket connection starts as an HTTP request and upgrades to a persistent TCP connection via a handshake. After that handshake, the connection stays open and both sides can send framed messages at any time without repeating the HTTP overhead.",[135,14374,14375],{},[138,14376,14378],{"className":140,"code":14377,"filename":142,"language":143,"meta":144,"style":144},"// WebSocket - bidirectional, persistent\nconst ws = new WebSocket('wss://api.example.com/ws')\n\nws.onopen = () => {\n  ws.send(JSON.stringify({ type: 'subscribe', room: 'general' }))\n}\n\nws.onmessage = (event) => {\n  const msg = JSON.parse(event.data)\n  handleMessage(msg)\n}\n\n// Send from client at any time - no new request needed\nfunction sendMessage(text) {\n  ws.send(JSON.stringify({ type: 'message', text }))\n}\n",[84,14379,14380,14385,14406,14410,14426,14455,14459,14463,14481,14497,14505,14509,14513,14518,14531,14552],{"__ignoreMap":144},[148,14381,14382],{"class":150,"line":151},[148,14383,14384],{"class":182},"// WebSocket - bidirectional, persistent\n",[148,14386,14387,14389,14392,14394,14396,14399,14401,14404],{"class":150,"line":172},[148,14388,323],{"class":154},[148,14390,14391],{"class":235}," ws",[148,14393,329],{"class":154},[148,14395,332],{"class":154},[148,14397,14398],{"class":207}," WebSocket",[148,14400,369],{"class":161},[148,14402,14403],{"class":168},"'wss://api.example.com/ws'",[148,14405,375],{"class":161},[148,14407,14408],{"class":150,"line":179},[148,14409,176],{"emptyLinePlaceholder":175},[148,14411,14412,14415,14418,14420,14422,14424],{"class":150,"line":186},[148,14413,14414],{"class":161},"ws.",[148,14416,14417],{"class":207},"onopen",[148,14419,329],{"class":154},[148,14421,4011],{"class":161},[148,14423,636],{"class":154},[148,14425,211],{"class":161},[148,14427,14428,14431,14433,14435,14437,14439,14441,14444,14447,14450,14452],{"class":150,"line":192},[148,14429,14430],{"class":161},"  ws.",[148,14432,4506],{"class":207},[148,14434,369],{"class":161},[148,14436,3680],{"class":235},[148,14438,611],{"class":161},[148,14440,3685],{"class":207},[148,14442,14443],{"class":161},"({ type: ",[148,14445,14446],{"class":168},"'subscribe'",[148,14448,14449],{"class":161},", room: ",[148,14451,372],{"class":168},[148,14453,14454],{"class":161}," }))\n",[148,14456,14457],{"class":150,"line":198},[148,14458,262],{"class":161},[148,14460,14461],{"class":150,"line":214},[148,14462,176],{"emptyLinePlaceholder":175},[148,14464,14465,14467,14469,14471,14473,14475,14477,14479],{"class":150,"line":227},[148,14466,14414],{"class":161},[148,14468,14317],{"class":207},[148,14470,329],{"class":154},[148,14472,114],{"class":161},[148,14474,1683],{"class":217},[148,14476,633],{"class":161},[148,14478,636],{"class":154},[148,14480,211],{"class":161},[148,14482,14483,14485,14487,14489,14491,14493,14495],{"class":150,"line":239},[148,14484,3596],{"class":154},[148,14486,4295],{"class":235},[148,14488,329],{"class":154},[148,14490,14340],{"class":235},[148,14492,611],{"class":161},[148,14494,14345],{"class":207},[148,14496,14348],{"class":161},[148,14498,14499,14502],{"class":150,"line":249},[148,14500,14501],{"class":207},"  handleMessage",[148,14503,14504],{"class":161},"(msg)\n",[148,14506,14507],{"class":150,"line":259},[148,14508,262],{"class":161},[148,14510,14511],{"class":150,"line":654},[148,14512,176],{"emptyLinePlaceholder":175},[148,14514,14515],{"class":150,"line":660},[148,14516,14517],{"class":182},"// Send from client at any time - no new request needed\n",[148,14519,14520,14522,14525,14527,14529],{"class":150,"line":1289},[148,14521,5682],{"class":154},[148,14523,14524],{"class":207}," sendMessage",[148,14526,369],{"class":161},[148,14528,1114],{"class":217},[148,14530,600],{"class":161},[148,14532,14533,14535,14537,14539,14541,14543,14545,14547,14549],{"class":150,"line":1295},[148,14534,14430],{"class":161},[148,14536,4506],{"class":207},[148,14538,369],{"class":161},[148,14540,3680],{"class":235},[148,14542,611],{"class":161},[148,14544,3685],{"class":207},[148,14546,14443],{"class":161},[148,14548,3098],{"class":168},[148,14550,14551],{"class":161},", text }))\n",[148,14553,14554],{"class":150,"line":1313},[148,14555,262],{"class":161},[12,14557,14558],{},"The per-frame overhead drops to 2-14 bytes for the WebSocket framing header, compared to hundreds of bytes of HTTP headers on every polling request. For a chat app sending dozens of messages per second, this is meaningful. For an IoT device sending 10 readings per second per device across a fleet of 10,000 sensors, it is the difference between a manageable bill and an infrastructure crisis.",[35,14560,14562],{"id":14561},"the-overhead-math","The Overhead Math",[12,14564,14565],{},"To make the comparison concrete, here is a rough per-message cost comparison for a minimal payload (say, a 40-byte JSON message):",[135,14567,14568],{},[138,14569,14571],{"className":1891,"code":14570,"filename":9843,"language":1893,"meta":144,"style":144},"Short polling (empty response):  ~800 bytes headers + 0 bytes payload = 800 bytes wasted\nShort polling (with message):    ~800 bytes headers + 40 bytes payload = 840 bytes total\nLong polling (with message):     ~600 bytes headers + 40 bytes payload = 640 bytes total\nSSE (with message):              ~400 bytes initial + ~50 bytes per event frame\nWebSocket (with message):        ~10 bytes framing  + 40 bytes payload = 50 bytes total\n",[84,14572,14573,14596,14616,14635,14657],{"__ignoreMap":144},[148,14574,14575,14578,14581,14584,14587,14590,14593],{"class":150,"line":151},[148,14576,14577],{"class":207},"Short",[148,14579,14580],{"class":168}," polling",[148,14582,14583],{"class":161}," (empty ",[148,14585,14586],{"class":168},"response",[148,14588,14589],{"class":161},"):  ",[148,14591,14592],{"class":154},"~",[148,14594,14595],{"class":161},"800 bytes headers + 0 bytes payload = 800 bytes wasted\n",[148,14597,14598,14600,14602,14605,14608,14611,14613],{"class":150,"line":172},[148,14599,14577],{"class":207},[148,14601,14580],{"class":168},[148,14603,14604],{"class":161}," (with ",[148,14606,14607],{"class":168},"message",[148,14609,14610],{"class":161},"):    ",[148,14612,14592],{"class":154},[148,14614,14615],{"class":161},"800 bytes headers + 40 bytes payload = 840 bytes total\n",[148,14617,14618,14621,14623,14625,14627,14630,14632],{"class":150,"line":179},[148,14619,14620],{"class":207},"Long",[148,14622,14580],{"class":168},[148,14624,14604],{"class":161},[148,14626,14607],{"class":168},[148,14628,14629],{"class":161},"):     ",[148,14631,14592],{"class":154},[148,14633,14634],{"class":161},"600 bytes headers + 40 bytes payload = 640 bytes total\n",[148,14636,14637,14640,14642,14644,14647,14649,14652,14654],{"class":150,"line":186},[148,14638,14639],{"class":207},"SSE",[148,14641,14604],{"class":161},[148,14643,14607],{"class":168},[148,14645,14646],{"class":161},"):              ",[148,14648,14592],{"class":154},[148,14650,14651],{"class":161},"400 bytes initial + ",[148,14653,14592],{"class":154},[148,14655,14656],{"class":161},"50 bytes per event frame\n",[148,14658,14659,14662,14664,14666,14669,14671],{"class":150,"line":192},[148,14660,14661],{"class":207},"WebSocket",[148,14663,14604],{"class":161},[148,14665,14607],{"class":168},[148,14667,14668],{"class":161},"):        ",[148,14670,14592],{"class":154},[148,14672,14673],{"class":161},"10 bytes framing  + 40 bytes payload = 50 bytes total\n",[12,14675,14676],{},"At low volume the difference is trivial. At high frequency or high concurrency it is the dominant cost in your system.",[35,14678,14680],{"id":14679},"when-polling-is-still-the-right-call","When Polling Is Still the Right Call",[12,14682,14683],{},"Not every use case needs WebSockets. Polling is completely reasonable when updates are infrequent (checking for a build result every 10 seconds), when the data is naturally pull-based (user manually refreshing a report), or when your infrastructure does not support long-lived connections (some edge runtimes and serverless environments). If you are sending fewer than one message per minute per user, a simple poll with a sensible interval is easier to operate and reason about than managing WebSocket connections.",[12,14685,14686],{},"The tipping point is usually somewhere around one event per 5-10 seconds. Below that, polling is fine. Above that, the overhead compounds fast enough that WebSockets pay for themselves in infrastructure cost alone, before you even count the latency improvement.",[35,14688,14690],{"id":14689},"how-nolag-approaches-this","How NoLag Approaches This",[12,14692,14693],{},"NoLag uses WebSockets as the transport layer for all real-time message delivery. Every client opens a single persistent connection when it connects, and all subscriptions, messages, and presence events flow over that connection in both directions.",[12,14695,14696],{},"On top of WebSockets, NoLag uses MessagePack as the wire format instead of JSON. MessagePack is a binary serialization format that encodes the same structure in fewer bytes than JSON text: numbers, booleans and binary blobs are stored natively rather than spelled out, and there are no quotes, colons or commas. How much smaller depends on the payload (keys are still strings, so a message of short string fields gains the least). Combined with the low framing overhead of WebSockets, the per-message cost is about as low as you can get without a custom binary protocol. For a chat app with thousands of concurrent users or an IoT fleet sending high-frequency telemetry, that efficiency matters.",[12,14698,14699,14700,14702,14703,14705,14706,14710],{},"The connection also handles reconnection automatically. If a client drops off the network, the SDK reconnects with backoff and the broker restores its subscriptions, so live delivery resumes without any code on your side. It does not replay what was published during the gap: an ordinary reconnect restores subscriptions and nothing else, so catch-up for a client is a fetch from your own store on the ",[84,14701,2647],{}," event. The one case where the broker does replay missed messages is load-balanced worker groups with persistent sessions; see ",[952,14704,2320],{"href":2319}," for exactly what that covers, and ",[952,14707,14709],{"href":14708},"/blog/reconnection-message-replay","Handling Disconnects Gracefully"," for the client-side pattern.",[12,14712,14713],{},"The short version: use polling when updates are rare and simplicity matters. Use WebSockets when you need low latency, high frequency, or bidirectional communication. Those are not the same tradeoff, and reaching for WebSockets by default for anything interactive is the right instinct.",[969,14715,14716],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":144,"searchDepth":172,"depth":172,"links":14718},[14719,14720,14721,14722,14723,14724,14725],{"id":14057,"depth":172,"text":14058},{"id":14163,"depth":172,"text":14164},{"id":14272,"depth":172,"text":14273},{"id":14368,"depth":172,"text":14369},{"id":14561,"depth":172,"text":14562},{"id":14679,"depth":172,"text":14680},{"id":14689,"depth":172,"text":14690},"2026-04-28","Compare HTTP polling, long polling, server-sent events, and WebSockets. Learn why persistent connections win for real-time apps and when polling is still fine.",{},"/blog/websocket-vs-polling",{"title":14046,"description":14727},"blog/websocket-vs-polling","ZrlI_azZ3Wv_TE7-kiABeOGTaGsbXZNAHwiy18MqdNE",{"id":14734,"title":14735,"author":7,"body":14736,"category":7894,"date":16229,"description":16230,"excerpt":986,"extension":987,"meta":16231,"navigation":175,"path":16232,"readTime":3502,"seo":16233,"stem":16234,"__hash__":16235},"blog/blog/build-chat-app-in-minutes.md","How to Build a Real-Time Chat App in Under 10 Minutes",{"type":9,"value":14737,"toc":16216},[14738,14741,14747,14751,14754,14771,14774,14778,14802,14808,14812,14822,15076,15079,15083,15094,15264,15279,15282,15309,15313,15320,15540,15547,15551,15554,15747,15754,15758,15764,15843,15847,15850,15935,15937,15940,15992,16150,16153,16156,16181,16190,16194,16202,16214],[12,14739,14740],{},"Most real-time chat tutorials start with raw WebSockets, a custom message format, and a server you have to build yourself. By the end you have a working toy that breaks the moment two people type at the same time or one of them loses their wifi signal.",[12,14742,14743,14744,14746],{},"This tutorial skips the plumbing. We will use ",[84,14745,9799],{},", a purpose-built chat SDK that wraps the NoLag core with a high-level API. Multi-room chat, typing indicators, presence, and unread counts are all included out of the box.",[35,14748,14750],{"id":14749},"step-1-install-the-sdk","Step 1: Install the SDK",[12,14752,14753],{},"The chat SDK is a peer of the core NoLag JS SDK. Install both:",[135,14755,14756],{},[138,14757,14759],{"className":1891,"code":14758,"filename":9843,"language":1893,"meta":144,"style":144},"npm install @nolag/chat @nolag/js-sdk\n",[84,14760,14761],{"__ignoreMap":144},[148,14762,14763,14765,14767,14769],{"class":150,"line":151},[148,14764,3531],{"class":207},[148,14766,1903],{"class":168},[148,14768,9854],{"class":168},[148,14770,3536],{"class":168},[12,14772,14773],{},"The package is framework agnostic. It works with React, Vue, vanilla JS, or anything else that runs JavaScript. It is pure events and callbacks, so you wire it to your framework's reactivity however you prefer.",[35,14775,14777],{"id":14776},"step-2-get-your-app-and-token","Step 2: Get Your App and Token",[12,14779,9722,14780,14782,14783,87,14785,87,14788,87,14791,14794,14795,14798,14799,14801],{},[84,14781,9876],{}," blueprint in the NoLag portal (or via the API). That seeds the rooms this tutorial uses (",[84,14784,9911],{},[84,14786,14787],{},"random",[84,14789,14790],{},"help",[84,14792,14793],{},"dev",") and returns an app slug with a random suffix, for example ",[84,14796,14797],{},"my-chat-app-a3f9",". That suffixed slug is the ",[84,14800,5995],{}," below; rooms never exist implicitly, so if you want other room names create them first.",[12,14803,14804,14805,14807],{},"Then create an actor (type ",[84,14806,2842],{},") and take its access token. The token authenticates the WebSocket connection and scopes what the client can access.",[35,14809,14811],{"id":14810},"step-3-connect","Step 3: Connect",[12,14813,14814,14815,14817,14818,14821],{},"Create a core ",[84,14816,2367],{}," client with your token, hand it to a ",[84,14819,14820],{},"NoLagChat"," instance along with a username and optionally the rooms you want to pre-subscribe to, then connect the client and wait for the wrapper to be ready.",[135,14823,14824],{},[138,14825,14827],{"className":140,"code":14826,"filename":142,"language":143,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk';\nimport { NoLagChat } from '@nolag/chat';\n\nconst client = NoLag(token);\nconst chat = new NoLagChat({\n  client,\n  username: 'Alice',\n  appName: APP_SLUG, // the suffixed slug returned when you created the app\n  rooms: ['general', 'random'],\n});\n\nchat.on('connected', () => {\n  console.log('Connected!');\n});\n\nchat.on('userOnline', (user) => {\n  console.log(user.username, 'is online');\n});\n\nchat.on('userOffline', (user) => {\n  console.log(user.username, 'went offline');\n});\n\nawait client.connect();\nawait chat.ready();\n",[84,14828,14829,14842,14855,14859,14872,14886,14890,14899,14909,14923,14927,14931,14950,14963,14967,14971,14992,15006,15010,15014,15035,15048,15052,15056,15066],{"__ignoreMap":144},[148,14830,14831,14833,14835,14837,14840],{"class":150,"line":151},[148,14832,155],{"class":154},[148,14834,2575],{"class":161},[148,14836,165],{"class":154},[148,14838,14839],{"class":168}," '@nolag/js-sdk'",[148,14841,6057],{"class":161},[148,14843,14844,14846,14848,14850,14853],{"class":150,"line":172},[148,14845,155],{"class":154},[148,14847,304],{"class":161},[148,14849,165],{"class":154},[148,14851,14852],{"class":168}," '@nolag/chat'",[148,14854,6057],{"class":161},[148,14856,14857],{"class":150,"line":179},[148,14858,176],{"emptyLinePlaceholder":175},[148,14860,14861,14863,14865,14867,14869],{"class":150,"line":186},[148,14862,323],{"class":154},[148,14864,1589],{"class":235},[148,14866,329],{"class":154},[148,14868,1594],{"class":207},[148,14870,14871],{"class":161},"(token);\n",[148,14873,14874,14876,14878,14880,14882,14884],{"class":150,"line":192},[148,14875,323],{"class":154},[148,14877,326],{"class":235},[148,14879,329],{"class":154},[148,14881,332],{"class":154},[148,14883,335],{"class":207},[148,14885,1343],{"class":161},[148,14887,14888],{"class":150,"line":198},[148,14889,6155],{"class":161},[148,14891,14892,14894,14897],{"class":150,"line":214},[148,14893,10059],{"class":161},[148,14895,14896],{"class":168},"'Alice'",[148,14898,540],{"class":161},[148,14900,14901,14903,14905,14907],{"class":150,"line":227},[148,14902,6160],{"class":161},[148,14904,2672],{"class":235},[148,14906,87],{"class":161},[148,14908,6167],{"class":182},[148,14910,14911,14914,14916,14918,14921],{"class":150,"line":239},[148,14912,14913],{"class":161},"  rooms: [",[148,14915,372],{"class":168},[148,14917,87],{"class":161},[148,14919,14920],{"class":168},"'random'",[148,14922,6217],{"class":161},[148,14924,14925],{"class":150,"line":249},[148,14926,6244],{"class":161},[148,14928,14929],{"class":150,"line":259},[148,14930,176],{"emptyLinePlaceholder":175},[148,14932,14933,14936,14938,14940,14943,14946,14948],{"class":150,"line":654},[148,14934,14935],{"class":161},"chat.",[148,14937,2706],{"class":207},[148,14939,369],{"class":161},[148,14941,14942],{"class":168},"'connected'",[148,14944,14945],{"class":161},", () ",[148,14947,636],{"class":154},[148,14949,211],{"class":161},[148,14951,14952,14954,14956,14958,14961],{"class":150,"line":660},[148,14953,2514],{"class":161},[148,14955,2517],{"class":207},[148,14957,369],{"class":161},[148,14959,14960],{"class":168},"'Connected!'",[148,14962,6134],{"class":161},[148,14964,14965],{"class":150,"line":1289},[148,14966,6244],{"class":161},[148,14968,14969],{"class":150,"line":1295},[148,14970,176],{"emptyLinePlaceholder":175},[148,14972,14973,14975,14977,14979,14982,14984,14986,14988,14990],{"class":150,"line":1313},[148,14974,14935],{"class":161},[148,14976,2706],{"class":207},[148,14978,369],{"class":161},[148,14980,14981],{"class":168},"'userOnline'",[148,14983,2713],{"class":161},[148,14985,2842],{"class":217},[148,14987,633],{"class":161},[148,14989,636],{"class":154},[148,14991,211],{"class":161},[148,14993,14994,14996,14998,15001,15004],{"class":150,"line":1331},[148,14995,2514],{"class":161},[148,14997,2517],{"class":207},[148,14999,15000],{"class":161},"(user.username, ",[148,15002,15003],{"class":168},"'is online'",[148,15005,6134],{"class":161},[148,15007,15008],{"class":150,"line":1346},[148,15009,6244],{"class":161},[148,15011,15012],{"class":150,"line":1355},[148,15013,176],{"emptyLinePlaceholder":175},[148,15015,15016,15018,15020,15022,15025,15027,15029,15031,15033],{"class":150,"line":1361},[148,15017,14935],{"class":161},[148,15019,2706],{"class":207},[148,15021,369],{"class":161},[148,15023,15024],{"class":168},"'userOffline'",[148,15026,2713],{"class":161},[148,15028,2842],{"class":217},[148,15030,633],{"class":161},[148,15032,636],{"class":154},[148,15034,211],{"class":161},[148,15036,15037,15039,15041,15043,15046],{"class":150,"line":1370},[148,15038,2514],{"class":161},[148,15040,2517],{"class":207},[148,15042,15000],{"class":161},[148,15044,15045],{"class":168},"'went offline'",[148,15047,6134],{"class":161},[148,15049,15050],{"class":150,"line":1376},[148,15051,6244],{"class":161},[148,15053,15054],{"class":150,"line":2033},[148,15055,176],{"emptyLinePlaceholder":175},[148,15057,15058,15060,15062,15064],{"class":150,"line":2039},[148,15059,343],{"class":154},[148,15061,4099],{"class":161},[148,15063,2647],{"class":207},[148,15065,6259],{"class":161},[148,15067,15068,15070,15072,15074],{"class":150,"line":2044},[148,15069,343],{"class":154},[148,15071,346],{"class":161},[148,15073,349],{"class":207},[148,15075,6259],{"class":161},[12,15077,15078],{},"That is it for connection setup. Your app owns the connection; the wrapper attaches to it, sets up global presence tracking via a lobby, and pre-subscribes to the rooms you listed. If the connection drops, the client reconnects automatically and the wrapper restores your rooms and presence.",[35,15080,15082],{"id":15081},"step-4-join-a-room-and-send-messages","Step 4: Join a Room and Send Messages",[12,15084,15085,15086,15089,15090,15093],{},"Rooms are the unit of conversation. Call ",[84,15087,15088],{},"joinRoom()"," to activate a room. This returns a ",[84,15091,15092],{},"ChatRoom"," object you use to send and receive messages.",[135,15095,15096],{},[138,15097,15099],{"className":140,"code":15098,"filename":142,"language":143,"meta":144,"style":144},"const room = chat.joinRoom('general');\n\n// Listen for incoming messages from other users\nroom.on('message', (msg) => {\n  console.log(`${msg.username}: ${msg.text}`);\n});\n\n// Your own messages arrive on `messageSent`. Publishers never receive their\n// own messages back, so the SDK adds them to the local store for you.\nroom.on('messageSent', (msg) => {\n  console.log(`me: ${msg.text}`);\n});\n\n// Send a message (returns an optimistic ChatMessage immediately)\nroom.sendMessage('Hello from the tutorial!');\n",[84,15100,15101,15119,15123,15128,15148,15178,15182,15186,15191,15196,15217,15238,15242,15246,15251],{"__ignoreMap":144},[148,15102,15103,15105,15107,15109,15111,15113,15115,15117],{"class":150,"line":151},[148,15104,323],{"class":154},[148,15106,359],{"class":235},[148,15108,329],{"class":154},[148,15110,346],{"class":161},[148,15112,366],{"class":207},[148,15114,369],{"class":161},[148,15116,372],{"class":168},[148,15118,6134],{"class":161},[148,15120,15121],{"class":150,"line":172},[148,15122,176],{"emptyLinePlaceholder":175},[148,15124,15125],{"class":150,"line":179},[148,15126,15127],{"class":182},"// Listen for incoming messages from other users\n",[148,15129,15130,15132,15134,15136,15138,15140,15142,15144,15146],{"class":150,"line":186},[148,15131,380],{"class":161},[148,15133,2706],{"class":207},[148,15135,369],{"class":161},[148,15137,3098],{"class":168},[148,15139,2713],{"class":161},[148,15141,3103],{"class":217},[148,15143,633],{"class":161},[148,15145,636],{"class":154},[148,15147,211],{"class":161},[148,15149,15150,15152,15154,15156,15158,15160,15162,15165,15168,15170,15172,15174,15176],{"class":150,"line":192},[148,15151,2514],{"class":161},[148,15153,2517],{"class":207},[148,15155,369],{"class":161},[148,15157,6721],{"class":168},[148,15159,3103],{"class":161},[148,15161,611],{"class":168},[148,15163,15164],{"class":161},"username",[148,15166,15167],{"class":168},"}: ${",[148,15169,3103],{"class":161},[148,15171,611],{"class":168},[148,15173,1114],{"class":161},[148,15175,3652],{"class":168},[148,15177,6134],{"class":161},[148,15179,15180],{"class":150,"line":198},[148,15181,6244],{"class":161},[148,15183,15184],{"class":150,"line":214},[148,15185,176],{"emptyLinePlaceholder":175},[148,15187,15188],{"class":150,"line":227},[148,15189,15190],{"class":182},"// Your own messages arrive on `messageSent`. Publishers never receive their\n",[148,15192,15193],{"class":150,"line":239},[148,15194,15195],{"class":182},"// own messages back, so the SDK adds them to the local store for you.\n",[148,15197,15198,15200,15202,15204,15207,15209,15211,15213,15215],{"class":150,"line":249},[148,15199,380],{"class":161},[148,15201,2706],{"class":207},[148,15203,369],{"class":161},[148,15205,15206],{"class":168},"'messageSent'",[148,15208,2713],{"class":161},[148,15210,3103],{"class":217},[148,15212,633],{"class":161},[148,15214,636],{"class":154},[148,15216,211],{"class":161},[148,15218,15219,15221,15223,15225,15228,15230,15232,15234,15236],{"class":150,"line":259},[148,15220,2514],{"class":161},[148,15222,2517],{"class":207},[148,15224,369],{"class":161},[148,15226,15227],{"class":168},"`me: ${",[148,15229,3103],{"class":161},[148,15231,611],{"class":168},[148,15233,1114],{"class":161},[148,15235,3652],{"class":168},[148,15237,6134],{"class":161},[148,15239,15240],{"class":150,"line":654},[148,15241,6244],{"class":161},[148,15243,15244],{"class":150,"line":660},[148,15245,176],{"emptyLinePlaceholder":175},[148,15247,15248],{"class":150,"line":1289},[148,15249,15250],{"class":182},"// Send a message (returns an optimistic ChatMessage immediately)\n",[148,15252,15253,15255,15257,15259,15262],{"class":150,"line":1295},[148,15254,380],{"class":161},[148,15256,383],{"class":207},[148,15258,369],{"class":161},[148,15260,15261],{"class":168},"'Hello from the tutorial!'",[148,15263,6134],{"class":161},[12,15265,15266,15267,15270,15271,15274,15275,15278],{},"Messages are delivered in order. The ",[84,15268,15269],{},"sendMessage()"," call returns an optimistic message object instantly so your UI can render it before the server confirms. The message status starts as ",[84,15272,15273],{},"'sending'"," and is ",[84,15276,15277],{},"'sent'"," once the SDK has handed it to the connection.",[12,15280,15281],{},"You can also fetch all messages in the room at any time:",[135,15283,15284],{},[138,15285,15287],{"className":140,"code":15286,"filename":142,"language":143,"meta":144,"style":144},"const messages = room.getMessages();\n// Returns all cached messages in timestamp order\n",[84,15288,15289,15304],{"__ignoreMap":144},[148,15290,15291,15293,15296,15298,15300,15302],{"class":150,"line":151},[148,15292,323],{"class":154},[148,15294,15295],{"class":235}," messages",[148,15297,329],{"class":154},[148,15299,6701],{"class":161},[148,15301,10323],{"class":207},[148,15303,6259],{"class":161},[148,15305,15306],{"class":150,"line":172},[148,15307,15308],{"class":182},"// Returns all cached messages in timestamp order\n",[35,15310,15312],{"id":15311},"step-5-add-typing-indicators","Step 5: Add Typing Indicators",[12,15314,15315,15316,15319],{},"Typing indicators make chat feel alive. The SDK handles the debounce and timeout for you. Call ",[84,15317,15318],{},"startTyping()"," when the user types, and it auto-stops after a configurable timeout (default 3 seconds).",[135,15321,15322],{},[138,15323,15325],{"className":140,"code":15324,"filename":142,"language":143,"meta":144,"style":144},"// Trigger when the user types in the input\ninputElement.addEventListener('input', () => {\n  room.startTyping();\n});\n\n// Listen for typing state changes\nroom.on('typing', ({ users }) => {\n  if (users.length === 1) {\n    indicator.textContent = `${users[0].username} is typing...`;\n  } else if (users.length > 1) {\n    indicator.textContent = `${users[0].username} and ${users.length - 1} others are typing...`;\n  } else {\n    indicator.textContent = '';\n  }\n});\n\n// You can also stop typing explicitly\nroom.stopTyping();\n",[84,15326,15327,15332,15351,15360,15364,15368,15373,15397,15413,15440,15460,15496,15504,15515,15519,15523,15527,15532],{"__ignoreMap":144},[148,15328,15329],{"class":150,"line":151},[148,15330,15331],{"class":182},"// Trigger when the user types in the input\n",[148,15333,15334,15337,15340,15342,15345,15347,15349],{"class":150,"line":172},[148,15335,15336],{"class":161},"inputElement.",[148,15338,15339],{"class":207},"addEventListener",[148,15341,369],{"class":161},[148,15343,15344],{"class":168},"'input'",[148,15346,14945],{"class":161},[148,15348,636],{"class":154},[148,15350,211],{"class":161},[148,15352,15353,15356,15358],{"class":150,"line":179},[148,15354,15355],{"class":161},"  room.",[148,15357,10302],{"class":207},[148,15359,6259],{"class":161},[148,15361,15362],{"class":150,"line":186},[148,15363,6244],{"class":161},[148,15365,15366],{"class":150,"line":192},[148,15367,176],{"emptyLinePlaceholder":175},[148,15369,15370],{"class":150,"line":198},[148,15371,15372],{"class":182},"// Listen for typing state changes\n",[148,15374,15375,15377,15379,15381,15384,15387,15390,15393,15395],{"class":150,"line":214},[148,15376,380],{"class":161},[148,15378,2706],{"class":207},[148,15380,369],{"class":161},[148,15382,15383],{"class":168},"'typing'",[148,15385,15386],{"class":161},", ({ ",[148,15388,15389],{"class":217},"users",[148,15391,15392],{"class":161}," }) ",[148,15394,636],{"class":154},[148,15396,211],{"class":161},[148,15398,15399,15401,15404,15406,15409,15411],{"class":150,"line":227},[148,15400,10452],{"class":154},[148,15402,15403],{"class":161}," (users.",[148,15405,6728],{"class":235},[148,15407,15408],{"class":154}," ===",[148,15410,11755],{"class":235},[148,15412,600],{"class":161},[148,15414,15415,15418,15420,15423,15425,15428,15430,15433,15435,15438],{"class":150,"line":239},[148,15416,15417],{"class":161},"    indicator.textContent ",[148,15419,1337],{"class":154},[148,15421,15422],{"class":168}," `${",[148,15424,15389],{"class":161},[148,15426,15427],{"class":168},"[",[148,15429,9227],{"class":235},[148,15431,15432],{"class":168},"].",[148,15434,15164],{"class":161},[148,15436,15437],{"class":168},"} is typing...`",[148,15439,6057],{"class":161},[148,15441,15442,15444,15446,15449,15451,15453,15456,15458],{"class":150,"line":249},[148,15443,10979],{"class":161},[148,15445,11306],{"class":154},[148,15447,15448],{"class":154}," if",[148,15450,15403],{"class":161},[148,15452,6728],{"class":235},[148,15454,15455],{"class":154}," >",[148,15457,11755],{"class":235},[148,15459,600],{"class":161},[148,15461,15462,15464,15466,15468,15470,15472,15474,15476,15478,15481,15483,15485,15487,15489,15491,15494],{"class":150,"line":259},[148,15463,15417],{"class":161},[148,15465,1337],{"class":154},[148,15467,15422],{"class":168},[148,15469,15389],{"class":161},[148,15471,15427],{"class":168},[148,15473,9227],{"class":235},[148,15475,15432],{"class":168},[148,15477,15164],{"class":161},[148,15479,15480],{"class":168},"} and ${",[148,15482,15389],{"class":161},[148,15484,611],{"class":168},[148,15486,6728],{"class":235},[148,15488,11173],{"class":154},[148,15490,11755],{"class":235},[148,15492,15493],{"class":168},"} others are typing...`",[148,15495,6057],{"class":161},[148,15497,15498,15500,15502],{"class":150,"line":654},[148,15499,10979],{"class":161},[148,15501,11306],{"class":154},[148,15503,211],{"class":161},[148,15505,15506,15508,15510,15513],{"class":150,"line":660},[148,15507,15417],{"class":161},[148,15509,1337],{"class":154},[148,15511,15512],{"class":168}," ''",[148,15514,6057],{"class":161},[148,15516,15517],{"class":150,"line":1289},[148,15518,1716],{"class":161},[148,15520,15521],{"class":150,"line":1295},[148,15522,6244],{"class":161},[148,15524,15525],{"class":150,"line":1313},[148,15526,176],{"emptyLinePlaceholder":175},[148,15528,15529],{"class":150,"line":1331},[148,15530,15531],{"class":182},"// You can also stop typing explicitly\n",[148,15533,15534,15536,15538],{"class":150,"line":1346},[148,15535,380],{"class":161},[148,15537,10445],{"class":207},[148,15539,6259],{"class":161},[12,15541,15542,15543,15546],{},"Typing events are sent on a separate internal topic (",[84,15544,15545],{},"_typing",") so they do not pollute your message stream. They are ephemeral and not persisted.",[35,15548,15550],{"id":15549},"step-6-show-who-is-online","Step 6: Show Who Is Online",[12,15552,15553],{},"Presence is built into the SDK at two levels: global (who is online across all rooms) and per-room (who is in this specific room).",[135,15555,15556],{},[138,15557,15559],{"className":140,"code":15558,"filename":142,"language":143,"meta":144,"style":144},"// Global: all users online across all rooms\nconst onlineUsers = chat.getOnlineUsers();\n\n// Per-room: users in this specific room\nconst roomMembers = room.getUsers();\n\n// Listen for users joining or leaving the room\nroom.on('userJoined', (user) => {\n  console.log(user.username, 'joined the room');\n});\n\nroom.on('userLeft', (user) => {\n  console.log(user.username, 'left the room');\n});\n\n// Update your own status\nchat.setStatus('away');   // 'online', 'away', 'busy', 'offline'\n\n// Update your profile\nchat.updateProfile({ username: 'Alice B' });\n",[84,15560,15561,15566,15582,15586,15591,15607,15611,15616,15637,15650,15654,15658,15679,15692,15696,15700,15705,15723,15727,15732],{"__ignoreMap":144},[148,15562,15563],{"class":150,"line":151},[148,15564,15565],{"class":182},"// Global: all users online across all rooms\n",[148,15567,15568,15570,15573,15575,15577,15580],{"class":150,"line":172},[148,15569,323],{"class":154},[148,15571,15572],{"class":235}," onlineUsers",[148,15574,329],{"class":154},[148,15576,346],{"class":161},[148,15578,15579],{"class":207},"getOnlineUsers",[148,15581,6259],{"class":161},[148,15583,15584],{"class":150,"line":179},[148,15585,176],{"emptyLinePlaceholder":175},[148,15587,15588],{"class":150,"line":186},[148,15589,15590],{"class":182},"// Per-room: users in this specific room\n",[148,15592,15593,15595,15598,15600,15602,15605],{"class":150,"line":192},[148,15594,323],{"class":154},[148,15596,15597],{"class":235}," roomMembers",[148,15599,329],{"class":154},[148,15601,6701],{"class":161},[148,15603,15604],{"class":207},"getUsers",[148,15606,6259],{"class":161},[148,15608,15609],{"class":150,"line":198},[148,15610,176],{"emptyLinePlaceholder":175},[148,15612,15613],{"class":150,"line":214},[148,15614,15615],{"class":182},"// Listen for users joining or leaving the room\n",[148,15617,15618,15620,15622,15624,15627,15629,15631,15633,15635],{"class":150,"line":227},[148,15619,380],{"class":161},[148,15621,2706],{"class":207},[148,15623,369],{"class":161},[148,15625,15626],{"class":168},"'userJoined'",[148,15628,2713],{"class":161},[148,15630,2842],{"class":217},[148,15632,633],{"class":161},[148,15634,636],{"class":154},[148,15636,211],{"class":161},[148,15638,15639,15641,15643,15645,15648],{"class":150,"line":239},[148,15640,2514],{"class":161},[148,15642,2517],{"class":207},[148,15644,15000],{"class":161},[148,15646,15647],{"class":168},"'joined the room'",[148,15649,6134],{"class":161},[148,15651,15652],{"class":150,"line":249},[148,15653,6244],{"class":161},[148,15655,15656],{"class":150,"line":259},[148,15657,176],{"emptyLinePlaceholder":175},[148,15659,15660,15662,15664,15666,15669,15671,15673,15675,15677],{"class":150,"line":654},[148,15661,380],{"class":161},[148,15663,2706],{"class":207},[148,15665,369],{"class":161},[148,15667,15668],{"class":168},"'userLeft'",[148,15670,2713],{"class":161},[148,15672,2842],{"class":217},[148,15674,633],{"class":161},[148,15676,636],{"class":154},[148,15678,211],{"class":161},[148,15680,15681,15683,15685,15687,15690],{"class":150,"line":660},[148,15682,2514],{"class":161},[148,15684,2517],{"class":207},[148,15686,15000],{"class":161},[148,15688,15689],{"class":168},"'left the room'",[148,15691,6134],{"class":161},[148,15693,15694],{"class":150,"line":1289},[148,15695,6244],{"class":161},[148,15697,15698],{"class":150,"line":1295},[148,15699,176],{"emptyLinePlaceholder":175},[148,15701,15702],{"class":150,"line":1313},[148,15703,15704],{"class":182},"// Update your own status\n",[148,15706,15707,15709,15712,15714,15717,15720],{"class":150,"line":1331},[148,15708,14935],{"class":161},[148,15710,15711],{"class":207},"setStatus",[148,15713,369],{"class":161},[148,15715,15716],{"class":168},"'away'",[148,15718,15719],{"class":161},");   ",[148,15721,15722],{"class":182},"// 'online', 'away', 'busy', 'offline'\n",[148,15724,15725],{"class":150,"line":1346},[148,15726,176],{"emptyLinePlaceholder":175},[148,15728,15729],{"class":150,"line":1355},[148,15730,15731],{"class":182},"// Update your profile\n",[148,15733,15734,15736,15739,15742,15745],{"class":150,"line":1361},[148,15735,14935],{"class":161},[148,15737,15738],{"class":207},"updateProfile",[148,15740,15741],{"class":161},"({ username: ",[148,15743,15744],{"class":168},"'Alice B'",[148,15746,6112],{"class":161},[12,15748,15749,15750,15753],{},"Global presence uses a lobby under the hood. When a user connects, they are tracked across rooms. When their connection closes, a ",[84,15751,15752],{},"userOffline"," event fires. Heartbeats are handled by the core client, so you do not have to think about them.",[35,15755,15757],{"id":15756},"switching-rooms","Switching Rooms",[12,15759,15760,15761,15763],{},"Calling ",[84,15762,15088],{}," activates that room and deactivates the previous one. Presence is tracked per active room. You can also leave rooms entirely:",[135,15765,15766],{},[138,15767,15769],{"className":140,"code":15768,"filename":142,"language":143,"meta":144,"style":144},"// Switch to a different room\nconst devRoom = chat.joinRoom('dev');\n\n// Leave a room completely\nchat.leaveRoom('random');\n\n// Get all joined rooms\nconst rooms = chat.getRooms();\n",[84,15770,15771,15776,15796,15800,15805,15818,15822,15827],{"__ignoreMap":144},[148,15772,15773],{"class":150,"line":151},[148,15774,15775],{"class":182},"// Switch to a different room\n",[148,15777,15778,15780,15783,15785,15787,15789,15791,15794],{"class":150,"line":172},[148,15779,323],{"class":154},[148,15781,15782],{"class":235}," devRoom",[148,15784,329],{"class":154},[148,15786,346],{"class":161},[148,15788,366],{"class":207},[148,15790,369],{"class":161},[148,15792,15793],{"class":168},"'dev'",[148,15795,6134],{"class":161},[148,15797,15798],{"class":150,"line":179},[148,15799,176],{"emptyLinePlaceholder":175},[148,15801,15802],{"class":150,"line":186},[148,15803,15804],{"class":182},"// Leave a room completely\n",[148,15806,15807,15809,15812,15814,15816],{"class":150,"line":192},[148,15808,14935],{"class":161},[148,15810,15811],{"class":207},"leaveRoom",[148,15813,369],{"class":161},[148,15815,14920],{"class":168},[148,15817,6134],{"class":161},[148,15819,15820],{"class":150,"line":198},[148,15821,176],{"emptyLinePlaceholder":175},[148,15823,15824],{"class":150,"line":214},[148,15825,15826],{"class":182},"// Get all joined rooms\n",[148,15828,15829,15831,15834,15836,15838,15841],{"class":150,"line":227},[148,15830,323],{"class":154},[148,15832,15833],{"class":235}," rooms",[148,15835,329],{"class":154},[148,15837,346],{"class":161},[148,15839,15840],{"class":207},"getRooms",[148,15842,6259],{"class":161},[35,15844,15846],{"id":15845},"unread-counts","Unread Counts",[12,15848,15849],{},"The SDK tracks unread messages per room automatically. When a room is not active, incoming messages increment the unread count:",[135,15851,15852],{},[138,15853,15855],{"className":140,"code":15854,"filename":142,"language":143,"meta":144,"style":144},"room.on('unreadChanged', ({ room: roomName, count }) => {\n  updateBadge(roomName, count);\n});\n\n// Check the current count\nconsole.log(room.unreadCount);\n\n// Mark as read (resets to 0)\nroom.markRead();\n",[84,15856,15857,15887,15895,15899,15903,15908,15917,15921,15926],{"__ignoreMap":144},[148,15858,15859,15861,15863,15865,15868,15870,15872,15874,15876,15878,15881,15883,15885],{"class":150,"line":151},[148,15860,380],{"class":161},[148,15862,2706],{"class":207},[148,15864,369],{"class":161},[148,15866,15867],{"class":168},"'unreadChanged'",[148,15869,15386],{"class":161},[148,15871,1421],{"class":217},[148,15873,3662],{"class":161},[148,15875,5253],{"class":217},[148,15877,87],{"class":161},[148,15879,15880],{"class":217},"count",[148,15882,15392],{"class":161},[148,15884,636],{"class":154},[148,15886,211],{"class":161},[148,15888,15889,15892],{"class":150,"line":172},[148,15890,15891],{"class":207},"  updateBadge",[148,15893,15894],{"class":161},"(roomName, count);\n",[148,15896,15897],{"class":150,"line":179},[148,15898,6244],{"class":161},[148,15900,15901],{"class":150,"line":186},[148,15902,176],{"emptyLinePlaceholder":175},[148,15904,15905],{"class":150,"line":192},[148,15906,15907],{"class":182},"// Check the current count\n",[148,15909,15910,15912,15914],{"class":150,"line":198},[148,15911,6488],{"class":161},[148,15913,2517],{"class":207},[148,15915,15916],{"class":161},"(room.unreadCount);\n",[148,15918,15919],{"class":150,"line":214},[148,15920,176],{"emptyLinePlaceholder":175},[148,15922,15923],{"class":150,"line":227},[148,15924,15925],{"class":182},"// Mark as read (resets to 0)\n",[148,15927,15928,15930,15933],{"class":150,"line":239},[148,15929,380],{"class":161},[148,15931,15932],{"class":207},"markRead",[148,15934,6259],{"class":161},[35,15936,7468],{"id":7467},[12,15938,15939],{},"Building all of this yourself from raw WebSockets takes weeks. Here is what the SDK handles without any extra code:",[882,15941,15942,15955,15967,15979],{},[885,15943,15944,15947,15948,15951,15952,15954],{},[30,15945,15946],{},"Reconnection."," The client reconnects automatically on disconnect and the wrapper restores all rooms and presence state. A ",[84,15949,15950],{},"reconnected"," event fires so you can update your UI. Messages published while you were away are not replayed to chat clients (NoLag replays only to load-balanced worker groups, see ",[952,15953,7734],{"href":2319},"), so keep your own history store if you need catch-up across sessions.",[885,15956,15957,15960,15961,15963,15964,15966],{},[30,15958,15959],{},"Optimistic sends."," Messages appear in the UI instantly with a ",[84,15962,15273],{}," status that flips to ",[84,15965,15277],{}," as soon as they are handed to the connection.",[885,15968,15969,15972,15973,1812,15976,611],{},[30,15970,15971],{},"Unread counts."," Per-room unread tracking with ",[84,15974,15975],{},"unreadCount",[84,15977,15978],{},"markRead()",[885,15980,15981,15984,15985,15987,15988,15991],{},[30,15982,15983],{},"Framework agnostic."," Wire it to any framework's reactivity. The SDK is pure events. Listen to both ",[84,15986,14607],{}," (others) and ",[84,15989,15990],{},"messageSent"," (you) to keep a rendered list complete.",[135,15993,15994,16076],{},[138,15995,15998],{"className":140,"code":15996,"filename":15997,"language":143,"meta":144,"style":144},"const messages = ref([]);\nconst refresh = () => {\n  messages.value = [...room.getMessages()];\n};\nroom.on('message', refresh);\nroom.on('messageSent', refresh);\n","Vue 3",[84,15999,16000,16013,16028,16046,16051,16064],{"__ignoreMap":144},[148,16001,16002,16004,16006,16008,16010],{"class":150,"line":151},[148,16003,323],{"class":154},[148,16005,15295],{"class":235},[148,16007,329],{"class":154},[148,16009,2481],{"class":207},[148,16011,16012],{"class":161},"([]);\n",[148,16014,16015,16017,16020,16022,16024,16026],{"class":150,"line":172},[148,16016,323],{"class":154},[148,16018,16019],{"class":207}," refresh",[148,16021,329],{"class":154},[148,16023,4011],{"class":161},[148,16025,636],{"class":154},[148,16027,211],{"class":161},[148,16029,16030,16033,16035,16037,16039,16041,16043],{"class":150,"line":179},[148,16031,16032],{"class":161},"  messages.value ",[148,16034,1337],{"class":154},[148,16036,3886],{"class":161},[148,16038,1307],{"class":154},[148,16040,380],{"class":161},[148,16042,10323],{"class":207},[148,16044,16045],{"class":161},"()];\n",[148,16047,16048],{"class":150,"line":186},[148,16049,16050],{"class":161},"};\n",[148,16052,16053,16055,16057,16059,16061],{"class":150,"line":192},[148,16054,380],{"class":161},[148,16056,2706],{"class":207},[148,16058,369],{"class":161},[148,16060,3098],{"class":168},[148,16062,16063],{"class":161},", refresh);\n",[148,16065,16066,16068,16070,16072,16074],{"class":150,"line":198},[148,16067,380],{"class":161},[148,16069,2706],{"class":207},[148,16071,369],{"class":161},[148,16073,15206],{"class":168},[148,16075,16063],{"class":161},[138,16077,16080],{"className":140,"code":16078,"filename":16079,"language":143,"meta":144,"style":144},"const [messages, setMessages] = useState([]);\nconst refresh = () => setMessages([...room.getMessages()]);\nroom.on('message', refresh);\nroom.on('messageSent', refresh);\n","React",[84,16081,16082,16102,16126,16138],{"__ignoreMap":144},[148,16083,16084,16086,16088,16090,16092,16094,16096,16098,16100],{"class":150,"line":151},[148,16085,323],{"class":154},[148,16087,3886],{"class":161},[148,16089,3889],{"class":235},[148,16091,87],{"class":161},[148,16093,3894],{"class":235},[148,16095,3897],{"class":161},[148,16097,1337],{"class":154},[148,16099,3902],{"class":207},[148,16101,16012],{"class":161},[148,16103,16104,16106,16108,16110,16112,16114,16116,16118,16120,16122,16124],{"class":150,"line":172},[148,16105,323],{"class":154},[148,16107,16019],{"class":207},[148,16109,329],{"class":154},[148,16111,4011],{"class":161},[148,16113,636],{"class":154},[148,16115,4173],{"class":207},[148,16117,1438],{"class":161},[148,16119,1307],{"class":154},[148,16121,380],{"class":161},[148,16123,10323],{"class":207},[148,16125,10172],{"class":161},[148,16127,16128,16130,16132,16134,16136],{"class":150,"line":179},[148,16129,380],{"class":161},[148,16131,2706],{"class":207},[148,16133,369],{"class":161},[148,16135,3098],{"class":168},[148,16137,16063],{"class":161},[148,16139,16140,16142,16144,16146,16148],{"class":150,"line":186},[148,16141,380],{"class":161},[148,16143,2706],{"class":207},[148,16145,369],{"class":161},[148,16147,15206],{"class":168},[148,16149,16063],{"class":161},[35,16151,16152],{"id":4246},"Disconnect",[12,16154,16155],{},"Clean up when the user leaves:",[135,16157,16158],{},[138,16159,16161],{"className":140,"code":16160,"filename":142,"language":143,"meta":144,"style":144},"chat.detach();\nclient.disconnect();\n",[84,16162,16163,16172],{"__ignoreMap":144},[148,16164,16165,16167,16170],{"class":150,"line":151},[148,16166,14935],{"class":161},[148,16168,16169],{"class":207},"detach",[148,16171,6259],{"class":161},[148,16173,16174,16177,16179],{"class":150,"line":172},[148,16175,16176],{"class":161},"client.",[148,16178,4246],{"class":207},[148,16180,6259],{"class":161},[12,16182,16183,16186,16187,16189],{},[84,16184,16185],{},"detach()"," leaves all rooms, unsubscribes from the lobby, and releases the wrapper's handlers; ",[84,16188,272],{}," closes the WebSocket. The wrapper never touches the socket itself, so the same client can carry other wrappers or raw subscriptions until you disconnect it.",[35,16191,16193],{"id":16192},"where-to-go-from-here","Where to Go From Here",[12,16195,16196,16197,16201],{},"This walkthrough covers the happy path. For a production app you will also want to handle token refresh, room-level permissions, and message moderation. All of that is covered in the ",[952,16198,16200],{"href":16199},"/docs","NoLag docs",". But for a working chat prototype with rooms, presence, typing indicators, optimistic sends, and unread counts, the steps above are all you need.",[12,16203,16204,16205,1812,16207,16209,16210,611],{},"The full API reference for ",[84,16206,14820],{},[84,16208,15092],{}," is on the ",[952,16211,16213],{"href":16212},"/docs/high-level-sdks/chat","chat SDK page",[969,16215,7878],{},{"title":144,"searchDepth":172,"depth":172,"links":16217},[16218,16219,16220,16221,16222,16223,16224,16225,16226,16227,16228],{"id":14749,"depth":172,"text":14750},{"id":14776,"depth":172,"text":14777},{"id":14810,"depth":172,"text":14811},{"id":15081,"depth":172,"text":15082},{"id":15311,"depth":172,"text":15312},{"id":15549,"depth":172,"text":15550},{"id":15756,"depth":172,"text":15757},{"id":15845,"depth":172,"text":15846},{"id":7467,"depth":172,"text":7468},{"id":4246,"depth":172,"text":16152},{"id":16192,"depth":172,"text":16193},"2026-04-21","Step-by-step tutorial: install the NoLag chat SDK, connect with a token, join a room, send messages, show typing indicators, and display online users.",{},"/blog/build-chat-app-in-minutes",{"title":14735,"description":16230},"blog/build-chat-app-in-minutes","8thDQoe2oyZCt5lZ3J44ATrZBQ_Gtwn_j5C6lrWmWIg",{"id":16237,"title":16238,"author":7,"body":16239,"category":16940,"date":16941,"description":16942,"excerpt":986,"extension":987,"meta":16943,"navigation":175,"path":16944,"readTime":2903,"seo":16945,"stem":16946,"__hash__":16947},"blog/blog/messagepack-vs-json.md","MessagePack vs JSON: Smaller Payloads for Real-Time Apps",{"type":9,"value":16240,"toc":16927},[16241,16244,16247,16251,16254,16257,16261,16264,16336,16339,16348,16359,16363,16366,16396,16400,16410,16483,16685,16688,16692,16695,16699,16702,16706,16709,16713,16720,16724,16727,16731,16734,16745,16921,16924],[12,16242,16243],{},"JSON is everywhere. It is human readable, trivially debuggable, and natively understood by every language runtime that matters. For most HTTP APIs it is the obvious default, and the right one. But for real-time systems moving hundreds of thousands of small messages per second, the overhead of text serialization starts to show up in your bandwidth bill.",[12,16245,16246],{},"MessagePack is the alternative. It is a binary serialization format that encodes the same data structure as JSON but in a more compact binary representation. Same types, same structure, fewer bytes. Here is how they compare and when the difference actually matters.",[35,16248,16250],{"id":16249},"what-messagepack-actually-is","What MessagePack Actually Is",[12,16252,16253],{},"MessagePack stores data using a compact type-tagged binary layout. Each value is prefixed with a single byte that encodes its type and sometimes part of its value. Small integers, for example, are stored in a single byte. Short strings store the length in the same byte as the type tag. This is fundamentally different from JSON, which must represent every value as a sequence of ASCII characters regardless of the underlying data type.",[12,16255,16256],{},"The format supports the same logical types as JSON: null, boolean, integer, float, string, binary (bytes), array, and map (object). The key difference is that the representation is binary and therefore not human-readable. You need a decoder to inspect MessagePack data, which is a real tradeoff to acknowledge. Debugging raw bytes is harder than reading a JSON string in your terminal.",[35,16258,16260],{"id":16259},"side-by-side-the-same-message-in-both-formats","Side-by-Side: The Same Message in Both Formats",[12,16262,16263],{},"Take a minimal chat message payload with a few fields:",[135,16265,16266],{},[138,16267,16269],{"className":12097,"code":16268,"filename":3680,"language":3725,"meta":144,"style":144},"{\n  \"id\": \"01906b2e-4f3c-7a1d-8e2b-9f3c4d5e6a7b\",\n  \"room\": \"general\",\n  \"text\": \"Hello, world!\",\n  \"ts\": 1745827200000,\n  \"uid\": \"u_8x2k9\"\n}\n",[84,16270,16271,16275,16287,16299,16311,16322,16332],{"__ignoreMap":144},[148,16272,16273],{"class":150,"line":151},[148,16274,12106],{"class":161},[148,16276,16277,16280,16282,16285],{"class":150,"line":172},[148,16278,16279],{"class":235},"  \"id\"",[148,16281,3662],{"class":161},[148,16283,16284],{"class":168},"\"01906b2e-4f3c-7a1d-8e2b-9f3c4d5e6a7b\"",[148,16286,540],{"class":161},[148,16288,16289,16292,16294,16297],{"class":150,"line":179},[148,16290,16291],{"class":235},"  \"room\"",[148,16293,3662],{"class":161},[148,16295,16296],{"class":168},"\"general\"",[148,16298,540],{"class":161},[148,16300,16301,16304,16306,16309],{"class":150,"line":186},[148,16302,16303],{"class":235},"  \"text\"",[148,16305,3662],{"class":161},[148,16307,16308],{"class":168},"\"Hello, world!\"",[148,16310,540],{"class":161},[148,16312,16313,16316,16318,16320],{"class":150,"line":192},[148,16314,16315],{"class":235},"  \"ts\"",[148,16317,3662],{"class":161},[148,16319,12285],{"class":235},[148,16321,540],{"class":161},[148,16323,16324,16327,16329],{"class":150,"line":198},[148,16325,16326],{"class":235},"  \"uid\"",[148,16328,3662],{"class":161},[148,16330,16331],{"class":168},"\"u_8x2k9\"\n",[148,16333,16334],{"class":150,"line":214},[148,16335,262],{"class":161},[12,16337,16338],{},"Encoded compactly (no whitespace, which is how every serializer sends it) and with the reference JavaScript MessagePack encoder, the two look like this:",[135,16340,16341],{},[138,16342,16346],{"className":16343,"code":16344,"filename":16345,"language":1114,"meta":144},[1111],"JSON encoding (compact):\n{\"id\":\"01906b2e-4f3c-7a1d-8e2b-9f3c4d5e6a7b\",\"room\":\"general\",\"text\":\"Hello, world!\",\"ts\":1745827200000,\"uid\":\"u_8x2k9\"}\nBytes: 120\n\nMessagePack encoding (hex):\n85 a2 69 64 d9 24 30 31 39 30 36 62 32 65 2d 34\n66 33 63 2d 37 61 31 64 2d 38 65 32 62 2d 39 66\n33 63 34 64 35 65 36 61 37 62 a4 72 6f 6f 6d a7\n67 65 6e 65 72 61 6c a4 74 65 78 74 ad 48 65 6c\n6c 6f 2c 20 77 6f 72 6c 64 21 a2 74 73 cf 00 00\n01 96 7b 68 fc 00 a3 75 69 64 a7 75 5f 38 78 32\n6b 39\nBytes: 98\n","Encodings",[84,16347,16344],{"__ignoreMap":144},[12,16349,16350,16351,16354,16355,16358],{},"That is an 18% reduction for one message. Read the hex and you can see why it is not more: the message is mostly string content (",[84,16352,16353],{},"85"," opens a five-entry map, ",[84,16356,16357],{},"d9 24"," announces a 36-byte string, and then the 36 bytes of the UUID follow unchanged). Strings cost the same in both formats; what MessagePack removes is the quoting and punctuation around them, and the ASCII expansion of numbers. Scale even that to 10,000 messages per second across a high-traffic chat server, or 100 telemetry readings per second from a fleet of 5,000 devices, and the savings are not theoretical.",[35,16360,16362],{"id":16361},"where-the-savings-come-from","Where the Savings Come From",[12,16364,16365],{},"The gains come from a few structural differences:",[882,16367,16368,16374,16380,16390],{},[885,16369,16370,16373],{},[30,16371,16372],{},"No quoting overhead."," JSON must quote every string key and string value. MessagePack encodes string length as a prefix byte and stores the string bytes directly. For a map with five keys, that eliminates 10 quotation characters before you count the values.",[885,16375,16376,16379],{},[30,16377,16378],{},"No key/value separator characters."," JSON uses colons and commas as structural delimiters. MessagePack uses a fixed-width map header that encodes the count of entries. No colons, no commas, no whitespace.",[885,16381,16382,16385,16386,16389],{},[30,16383,16384],{},"Compact integer encoding."," A 64-bit integer in JSON takes up to 20 ASCII characters. In MessagePack it takes at most 9 bytes (1 type byte + 8 data bytes), and as few as 1 byte for small positive integers. Floats are the exception: a double is 9 bytes in MessagePack, which is about what ",[84,16387,16388],{},"-33.8688"," costs as text, so float-heavy payloads save less than you might expect.",[885,16391,16392,16395],{},[30,16393,16394],{},"Native binary support."," This is the one that matters most for IoT and media. JSON has no binary type. To embed binary data in JSON you must Base64-encode it, which inflates the size by approximately 33%. MessagePack has a native binary type. You write raw bytes directly into the payload. No encoding overhead.",[35,16397,16399],{"id":16398},"measured-reduction-numbers","Measured Reduction Numbers",[12,16401,16402,16403,1812,16406,16409],{},"Rather than quote an industry range, here is what we measured for four representative payloads, encoded with ",[84,16404,16405],{},"JSON.stringify",[84,16407,16408],{},"@msgpack/msgpack"," 3.1.2 in Node. These are NoLag's own measurements and the script below reproduces them.",[40,16411,16412,16428],{},[43,16413,16414],{},[46,16415,16416,16419,16422,16425],{},[49,16417,16418],{},"Payload",[49,16420,16421],{},"JSON (bytes)",[49,16423,16424],{},"MessagePack (bytes)",[49,16426,16427],{},"Reduction",[59,16429,16430,16443,16457,16471],{},[46,16431,16432,16435,16437,16440],{},[64,16433,16434],{},"Chat message (the example above)",[64,16436,12492],{},[64,16438,16439],{},"98",[64,16441,16442],{},"18%",[46,16444,16445,16448,16451,16454],{},[64,16446,16447],{},"GPS telemetry (10 fields, mixed ints and floats)",[64,16449,16450],{},"151",[64,16452,16453],{},"121",[64,16455,16456],{},"20%",[46,16458,16459,16462,16465,16468],{},[64,16460,16461],{},"Sensor sample with a 256-byte binary blob",[64,16463,16464],{},"396",[64,16466,16467],{},"295",[64,16469,16470],{},"26%",[46,16472,16473,16476,16478,16481],{},[64,16474,16475],{},"Presence event (two short strings, nested map)",[64,16477,16353],{},[64,16479,16480],{},"70",[64,16482,16442],{},[135,16484,16485],{},[138,16486,16489],{"className":140,"code":16487,"filename":16488,"language":143,"meta":144,"style":144},"import { encode } from '@msgpack/msgpack'\n\nconst telemetry = {\n  vehicleId: 'v_abc123', lat: -33.8688, lng: 151.2093, speed: 62, heading: 287,\n  altitude: 41, satellites: 9, hdop: 0.8, battery: 87, ts: 1745827200000,\n}\n\nconst json = new TextEncoder().encode(JSON.stringify(telemetry)).length\nconst msgpack = encode(telemetry).length\nconsole.log({ json, msgpack, reduction: Math.round((1 - msgpack / json) * 100) + '%' })\n","measure.ts",[84,16490,16491,16503,16507,16518,16554,16587,16591,16595,16628,16645],{"__ignoreMap":144},[148,16492,16493,16495,16498,16500],{"class":150,"line":151},[148,16494,155],{"class":154},[148,16496,16497],{"class":161}," { encode } ",[148,16499,165],{"class":154},[148,16501,16502],{"class":168}," '@msgpack/msgpack'\n",[148,16504,16505],{"class":150,"line":172},[148,16506,176],{"emptyLinePlaceholder":175},[148,16508,16509,16511,16514,16516],{"class":150,"line":179},[148,16510,323],{"class":154},[148,16512,16513],{"class":235}," telemetry",[148,16515,329],{"class":154},[148,16517,211],{"class":161},[148,16519,16520,16523,16526,16529,16531,16534,16537,16540,16543,16546,16549,16552],{"class":150,"line":186},[148,16521,16522],{"class":161},"  vehicleId: ",[148,16524,16525],{"class":168},"'v_abc123'",[148,16527,16528],{"class":161},", lat: ",[148,16530,6468],{"class":154},[148,16532,16533],{"class":235},"33.8688",[148,16535,16536],{"class":161},", lng: ",[148,16538,16539],{"class":235},"151.2093",[148,16541,16542],{"class":161},", speed: ",[148,16544,16545],{"class":235},"62",[148,16547,16548],{"class":161},", heading: ",[148,16550,16551],{"class":235},"287",[148,16553,540],{"class":161},[148,16555,16556,16559,16562,16565,16568,16571,16574,16577,16580,16583,16585],{"class":150,"line":192},[148,16557,16558],{"class":161},"  altitude: ",[148,16560,16561],{"class":235},"41",[148,16563,16564],{"class":161},", satellites: ",[148,16566,16567],{"class":235},"9",[148,16569,16570],{"class":161},", hdop: ",[148,16572,16573],{"class":235},"0.8",[148,16575,16576],{"class":161},", battery: ",[148,16578,16579],{"class":235},"87",[148,16581,16582],{"class":161},", ts: ",[148,16584,12285],{"class":235},[148,16586,540],{"class":161},[148,16588,16589],{"class":150,"line":198},[148,16590,262],{"class":161},[148,16592,16593],{"class":150,"line":214},[148,16594,176],{"emptyLinePlaceholder":175},[148,16596,16597,16599,16602,16604,16606,16609,16611,16614,16616,16618,16620,16622,16625],{"class":150,"line":227},[148,16598,323],{"class":154},[148,16600,16601],{"class":235}," json",[148,16603,329],{"class":154},[148,16605,332],{"class":154},[148,16607,16608],{"class":207}," TextEncoder",[148,16610,10326],{"class":161},[148,16612,16613],{"class":207},"encode",[148,16615,369],{"class":161},[148,16617,3680],{"class":235},[148,16619,611],{"class":161},[148,16621,3685],{"class":207},[148,16623,16624],{"class":161},"(telemetry)).",[148,16626,16627],{"class":235},"length\n",[148,16629,16630,16632,16635,16637,16640,16643],{"class":150,"line":239},[148,16631,323],{"class":154},[148,16633,16634],{"class":235}," msgpack",[148,16636,329],{"class":154},[148,16638,16639],{"class":207}," encode",[148,16641,16642],{"class":161},"(telemetry).",[148,16644,16627],{"class":235},[148,16646,16647,16649,16651,16654,16657,16659,16661,16663,16666,16668,16671,16673,16676,16678,16680,16683],{"class":150,"line":249},[148,16648,6488],{"class":161},[148,16650,2517],{"class":207},[148,16652,16653],{"class":161},"({ json, msgpack, reduction: Math.",[148,16655,16656],{"class":207},"round",[148,16658,627],{"class":161},[148,16660,12662],{"class":235},[148,16662,11173],{"class":154},[148,16664,16665],{"class":161}," msgpack ",[148,16667,10497],{"class":154},[148,16669,16670],{"class":161}," json) ",[148,16672,11747],{"class":154},[148,16674,16675],{"class":235}," 100",[148,16677,633],{"class":161},[148,16679,11760],{"class":154},[148,16681,16682],{"class":168}," '%'",[148,16684,2557],{"class":161},[12,16686,16687],{},"The pattern is consistent: payloads made of short keys, small integers, and binary blobs see the largest reductions, and the figures you see quoted elsewhere in the 30 to 50% range come from payloads shaped like that. Payloads that are mostly long string values see smaller reductions because string content itself does not shrink. If your messages are chat text, expect the lower end; if they are sensor readings with integer fields and raw bytes, expect the upper end.",[35,16689,16691],{"id":16690},"when-this-actually-matters","When This Actually Matters",[12,16693,16694],{},"For a low-traffic app sending a few messages per second, the difference between JSON and MessagePack is noise. Choose JSON for the debuggability. The tradeoff changes in a few specific situations.",[7470,16696,16698],{"id":16697},"high-frequency-iot","High-Frequency IoT",[12,16700,16701],{},"A GPS tracker sending position updates 10 times per second is generating 600 messages per minute per device. At 10,000 devices that is 6 million messages per minute. A 20% payload reduction translates directly into server costs, egress fees, and mobile data usage for devices on cellular connections, and shaping the payload for the encoder (integers instead of floats where the precision allows, raw bytes instead of Base64) pushes it higher.",[7470,16703,16705],{"id":16704},"chat-at-scale","Chat at Scale",[12,16707,16708],{},"A busy chat platform might see 50,000 messages per minute across all rooms. Each message is fanned out to multiple subscribers. The total bytes transferred is the message size multiplied by the number of recipients. At scale, a smaller message size has a multiplier effect on your infrastructure cost.",[7470,16710,16712],{"id":16711},"mobile-clients-on-limited-data","Mobile Clients on Limited Data",[12,16714,16715,16716,16719],{},"Mobile apps on 3G or metered data plans benefit directly from smaller payloads. Do not count on a CPU win as well: encode and decode speed depends on the runtime, and V8's native JSON parser is heavily optimised, so a JavaScript MessagePack implementation is often no faster than ",[84,16717,16718],{},"JSON.parse",". On mobile the win is bytes on the radio, which is also where the battery goes.",[35,16721,16723],{"id":16722},"the-debuggability-tradeoff","The Debuggability Tradeoff",[12,16725,16726],{},"The biggest practical downside of MessagePack is that you cannot read a raw message in a network inspector without a decoder. This is a real cost during development. The mitigation is to build a small decoder step into your debugging tooling, or to keep a JSON path open for development. Most serious real-time systems end up doing this anyway because their debugging tooling ends up outliving any specific wire format decision.",[35,16728,16730],{"id":16729},"how-nolag-uses-messagepack","How NoLag Uses MessagePack",[12,16732,16733],{},"The NoLag SDKs encode every frame they send as MessagePack: every message published to a topic, every presence update, every acknowledgment travels as a MessagePack-encoded binary WebSocket frame, and the broker decodes it on arrival. The broker also accepts JSON text frames carrying the same message shapes, so a client on a platform without a MessagePack library, or a developer poking at the protocol with a plain WebSocket tool, can still talk to it.",[12,16735,16736,16737,16740,16741,16744],{},"The SDK handles encoding and decoding transparently. You pass a plain JavaScript object to ",[84,16738,16739],{},"room.emit()"," and receive a plain JavaScript object in your ",[84,16742,16743],{},"room.on()"," handler. The binary serialization is invisible to application code.",[135,16746,16747],{},[138,16748,16750],{"className":140,"code":16749,"filename":142,"language":143,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\n\nconst client = NoLag(TOKEN)\nawait client.connect()\nconst room = client.setApp(APP_SLUG).setRoom('fleet-zone')\n\nroom.subscribe('positions')\nroom.on('positions', (position, meta) => {\n  // `position` is already a decoded object; `meta.timestamp` came with the frame\n  console.log(position, meta.timestamp)\n})\n\n// Encoded as MessagePack on the way out\nroom.emit('positions', { vehicleId: 'v_abc123', lat: -33.8688, lng: 151.2093, ts: Date.now() })\n",[84,16751,16752,16762,16766,16782,16792,16819,16823,16836,16862,16867,16876,16880,16884,16889],{"__ignoreMap":144},[148,16753,16754,16756,16758,16760],{"class":150,"line":151},[148,16755,155],{"class":154},[148,16757,2575],{"class":161},[148,16759,165],{"class":154},[148,16761,169],{"class":168},[148,16763,16764],{"class":150,"line":172},[148,16765,176],{"emptyLinePlaceholder":175},[148,16767,16768,16770,16772,16774,16776,16778,16780],{"class":150,"line":179},[148,16769,323],{"class":154},[148,16771,1589],{"class":235},[148,16773,329],{"class":154},[148,16775,1594],{"class":207},[148,16777,369],{"class":161},[148,16779,12419],{"class":235},[148,16781,375],{"class":161},[148,16783,16784,16786,16788,16790],{"class":150,"line":186},[148,16785,343],{"class":154},[148,16787,4099],{"class":161},[148,16789,2647],{"class":207},[148,16791,352],{"class":161},[148,16793,16794,16796,16798,16800,16802,16804,16806,16808,16810,16812,16814,16817],{"class":150,"line":192},[148,16795,323],{"class":154},[148,16797,359],{"class":235},[148,16799,329],{"class":154},[148,16801,4099],{"class":161},[148,16803,2667],{"class":207},[148,16805,369],{"class":161},[148,16807,2672],{"class":235},[148,16809,2675],{"class":161},[148,16811,2678],{"class":207},[148,16813,369],{"class":161},[148,16815,16816],{"class":168},"'fleet-zone'",[148,16818,375],{"class":161},[148,16820,16821],{"class":150,"line":198},[148,16822,176],{"emptyLinePlaceholder":175},[148,16824,16825,16827,16829,16831,16834],{"class":150,"line":214},[148,16826,380],{"class":161},[148,16828,2692],{"class":207},[148,16830,369],{"class":161},[148,16832,16833],{"class":168},"'positions'",[148,16835,375],{"class":161},[148,16837,16838,16840,16842,16844,16846,16848,16851,16853,16856,16858,16860],{"class":150,"line":227},[148,16839,380],{"class":161},[148,16841,2706],{"class":207},[148,16843,369],{"class":161},[148,16845,16833],{"class":168},[148,16847,2713],{"class":161},[148,16849,16850],{"class":217},"position",[148,16852,87],{"class":161},[148,16854,16855],{"class":217},"meta",[148,16857,633],{"class":161},[148,16859,636],{"class":154},[148,16861,211],{"class":161},[148,16863,16864],{"class":150,"line":239},[148,16865,16866],{"class":182},"  // `position` is already a decoded object; `meta.timestamp` came with the frame\n",[148,16868,16869,16871,16873],{"class":150,"line":249},[148,16870,2514],{"class":161},[148,16872,2517],{"class":207},[148,16874,16875],{"class":161},"(position, meta.timestamp)\n",[148,16877,16878],{"class":150,"line":259},[148,16879,853],{"class":161},[148,16881,16882],{"class":150,"line":654},[148,16883,176],{"emptyLinePlaceholder":175},[148,16885,16886],{"class":150,"line":660},[148,16887,16888],{"class":182},"// Encoded as MessagePack on the way out\n",[148,16890,16891,16893,16895,16897,16899,16902,16904,16906,16908,16910,16912,16914,16917,16919],{"class":150,"line":1289},[148,16892,380],{"class":161},[148,16894,2807],{"class":207},[148,16896,369],{"class":161},[148,16898,16833],{"class":168},[148,16900,16901],{"class":161},", { vehicleId: ",[148,16903,16525],{"class":168},[148,16905,16528],{"class":161},[148,16907,6468],{"class":154},[148,16909,16533],{"class":235},[148,16911,16536],{"class":161},[148,16913,16539],{"class":235},[148,16915,16916],{"class":161},", ts: Date.",[148,16918,6462],{"class":207},[148,16920,1605],{"class":161},[12,16922,16923],{},"The combination of WebSocket framing (which has a 2 to 14 byte overhead per message against hundreds of bytes for HTTP headers) and MessagePack encoding gives NoLag a per-message overhead that is close to the minimum for a framed binary transport. For IoT workloads and high-frequency dashboards migrating from JSON-over-HTTP polling, the bytes saved per message are the smaller part of the story; dropping the per-request headers and the polling itself is the larger one.",[969,16925,16926],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":144,"searchDepth":172,"depth":172,"links":16928},[16929,16930,16931,16932,16933,16938,16939],{"id":16249,"depth":172,"text":16250},{"id":16259,"depth":172,"text":16260},{"id":16361,"depth":172,"text":16362},{"id":16398,"depth":172,"text":16399},{"id":16690,"depth":172,"text":16691,"children":16934},[16935,16936,16937],{"id":16697,"depth":179,"text":16698},{"id":16704,"depth":179,"text":16705},{"id":16711,"depth":179,"text":16712},{"id":16722,"depth":172,"text":16723},{"id":16729,"depth":172,"text":16730},"Performance","2026-04-14","MessagePack produces smaller payloads than JSON, with no Base64 overhead for binary data. We measured how much on real message shapes, and explain when binary serialization matters for real-time apps and how NoLag uses it.",{},"/blog/messagepack-vs-json",{"title":16238,"description":16942},"blog/messagepack-vs-json","L_JfjnM5T4NTWUDUiCePdoWbWFSFhJ5Cj6QgyWwWoOg",{"id":16949,"title":16950,"author":7,"body":16951,"category":983,"date":17686,"description":17687,"excerpt":986,"extension":987,"meta":17688,"navigation":175,"path":17689,"readTime":17690,"seo":17691,"stem":17692,"__hash__":17693},"blog/blog/pubsub-pattern-explained.md","The Pub/Sub Pattern Explained: Topics, Rooms, and Message Routing",{"type":9,"value":16952,"toc":17674},[16953,16956,16959,16963,16966,16969,16972,16976,16979,17056,17059,17063,17066,17069,17075,17078,17092,17096,17099,17105,17108,17112,17118,17124,17127,17134,17138,17141,17144,17305,17317,17320,17324,17333,17336,17340,17343,17614,17617,17621,17624,17639,17658,17668,17671],[12,16954,16955],{},"Publish/subscribe is one of those patterns that sounds abstract until you have built a real-time feature and realised that direct connections do not scale. Once you understand it properly, you see it everywhere: chat apps, IoT platforms, financial data feeds, multiplayer games, and collaborative editing tools all rely on some form of pub/sub under the hood.",[12,16957,16958],{},"This post explains pub/sub from the ground up, covers the three key concepts (topics, rooms, and filters), and walks through how they work together in practice.",[35,16960,16962],{"id":16961},"the-problem-with-direct-connections","The Problem with Direct Connections",[12,16964,16965],{},"Imagine a simple chat app. User A sends a message. Users B, C, and D need to see it. The naive approach: User A maintains a direct WebSocket connection to B, C, and D. User A sends the message directly to each of them.",[12,16967,16968],{},"This works in a demo. It falls apart at scale. If you have 1,000 users in a room, every sender needs 999 active connections. The number of connections grows as O(n²). At 10,000 concurrent users you need 100 million connections. Even ignoring the impossibility of that at the network level, the sender now has to manage connection state for every other user. What happens when User B disconnects? What if User C is on a slow connection and cannot keep up? What if User A wants to send to multiple rooms simultaneously?",[12,16970,16971],{},"Direct connections couple the sender to the receiver. Pub/sub breaks that coupling.",[35,16973,16975],{"id":16974},"the-core-idea-decouple-publishers-from-subscribers","The Core Idea: Decouple Publishers from Subscribers",[12,16977,16978],{},"In a pub/sub system, neither side knows about the other. A publisher sends a message to a named channel. A subscriber declares interest in a named channel. The broker, the thing in the middle, takes messages from publishers and delivers them to interested subscribers.",[135,16980,16981],{},[138,16982,16984],{"className":140,"code":16983,"filename":142,"language":143,"meta":144,"style":144},"// Publisher: doesn't know who is listening\nbroker.publish('chat:general', { text: 'Hello!', from: 'alice' })\n\n// Subscriber: doesn't know who is publishing\nbroker.subscribe('chat:general', (message) => {\n  renderMessage(message)\n})\n",[84,16985,16986,16991,17015,17019,17024,17044,17052],{"__ignoreMap":144},[148,16987,16988],{"class":150,"line":151},[148,16989,16990],{"class":182},"// Publisher: doesn't know who is listening\n",[148,16992,16993,16996,16998,17000,17003,17005,17007,17010,17013],{"class":150,"line":172},[148,16994,16995],{"class":161},"broker.",[148,16997,3138],{"class":207},[148,16999,369],{"class":161},[148,17001,17002],{"class":168},"'chat:general'",[148,17004,2814],{"class":161},[148,17006,388],{"class":168},[148,17008,17009],{"class":161},", from: ",[148,17011,17012],{"class":168},"'alice'",[148,17014,2557],{"class":161},[148,17016,17017],{"class":150,"line":179},[148,17018,176],{"emptyLinePlaceholder":175},[148,17020,17021],{"class":150,"line":186},[148,17022,17023],{"class":182},"// Subscriber: doesn't know who is publishing\n",[148,17025,17026,17028,17030,17032,17034,17036,17038,17040,17042],{"class":150,"line":192},[148,17027,16995],{"class":161},[148,17029,2692],{"class":207},[148,17031,369],{"class":161},[148,17033,17002],{"class":168},[148,17035,2713],{"class":161},[148,17037,14607],{"class":217},[148,17039,633],{"class":161},[148,17041,636],{"class":154},[148,17043,211],{"class":161},[148,17045,17046,17049],{"class":150,"line":198},[148,17047,17048],{"class":207},"  renderMessage",[148,17050,17051],{"class":161},"(message)\n",[148,17053,17054],{"class":150,"line":214},[148,17055,853],{"class":161},[12,17057,17058],{},"This decoupling has several important properties. Publishers can exist without any subscribers. Subscribers can join and leave without affecting publishers. The broker can fan out a single published message to thousands of subscribers without the publisher doing any extra work. Some brokers can also hold messages for subscribers that are temporarily offline, though which subscribers, and for how long, varies a lot between systems (NoLag does it only for load-balanced worker groups; more on that at the end).",[35,17060,17062],{"id":17061},"topics-named-channels-for-message-routing","Topics: Named Channels for Message Routing",[12,17064,17065],{},"A topic is the fundamental routing unit in a pub/sub system. It is just a named channel. Publishers address messages to a topic. Subscribers declare interest in a topic. The broker routes messages from publishers to all current subscribers of that topic.",[12,17067,17068],{},"In many systems topics are free-form hierarchical strings. Common conventions:",[138,17070,17073],{"className":17071,"code":17072,"language":1114,"meta":144},[1111],"MQTT-style hierarchy with /\nsensors/floor-2/temp-sensor-4\nusers/alice/notifications\norders/eu-west/status\n\nColon-delimited (common in web systems)\nchat:general:messages\npresence:room-42\ngame:session-7f3a:events\n",[84,17074,17072],{"__ignoreMap":144},[12,17076,17077],{},"The topic string is what the broker uses to route messages. It has no inherent meaning to the broker, it is just a string it uses as a key. The meaning is in the convention your app establishes.",[12,17079,17080,17081,17084,17085,17087,17088,17091],{},"NoLag is stricter than that on purpose. A topic name is a single token (",[84,17082,17083],{},"[a-zA-Z0-9_:-]",", no ",[84,17086,10497],{},"), and the only hierarchy is the fixed one, ",[84,17089,17090],{},"app/room/topic",". The grouping that other systems leave to a naming convention is a first-class object here, the room, which is what the next section is about.",[7470,17093,17095],{"id":17094},"wildcards-and-patterns","Wildcards and Patterns",[12,17097,17098],{},"Many pub/sub systems support wildcard subscriptions. Instead of subscribing to one specific topic, you subscribe to a pattern that matches many topics.",[138,17100,17103],{"className":17101,"code":17102,"language":1114,"meta":144},[1111],"Subscribe to all sensor readings on floor 2\nsensors/floor-2/+          (MQTT single-level wildcard)\n\nSubscribe to all events for any session\ngame/+/events\n\nSubscribe to everything under orders\norders/#                   (MQTT multi-level wildcard)\n",[84,17104,17102],{"__ignoreMap":144},[12,17106,17107],{},"Wildcard subscriptions are powerful but they push more routing work onto the broker. Most production systems have limits on wildcard subscription depth or require them to be granted explicitly. NoLag does not expose them to clients at all: a subscription names one topic in one room, and \"everything in this context\" is expressed by subscribing to the room's topics, which the blueprint SDKs do for you.",[35,17109,17111],{"id":17110},"rooms-grouping-topics-for-application-logic","Rooms: Grouping Topics for Application Logic",[12,17113,17114,17115,17117],{},"Topics are a low-level concept. They are just named channels. Rooms are a higher-level concept that groups related topics together around a shared context. In a chat app, a room is a channel that users join together. It contains a topic for messages, a topic for streamed replies, a topic for typing indicators, and possibly a topic for reactions. This is what the NoLag chat blueprint's ",[84,17116,9911],{}," room looks like on the wire:",[138,17119,17122],{"className":17120,"code":17121,"language":1114,"meta":144},[1111],"All topics that belong to the \"general\" room (APP is the app slug)\nAPP/general/messages\nAPP/general/_stream\nAPP/general/_typing\n\nPresence is not a topic. It is a property of the room itself.\n",[84,17123,17121],{"__ignoreMap":144},[12,17125,17126],{},"When a user joins the \"general\" room, they subscribe to these topics. When they leave, they unsubscribe from all of them. The room is an abstraction that bundles a set of subscriptions and gives them a shared lifecycle, and it is also the unit that presence and access control attach to.",[12,17128,17129,17130,17133],{},"Without the room abstraction, the application would have to manage these topic subscriptions individually. With it, you call ",[84,17131,17132],{},"joinRoom('general')"," and the SDK handles the rest.",[35,17135,17137],{"id":17136},"filters-narrowing-message-delivery","Filters: Narrowing Message Delivery",[12,17139,17140],{},"Even within a topic, not every subscriber always wants every message. A dashboard might be showing orders for a specific customer. A mobile app might only want notifications of certain severities. Filters let subscribers express finer-grained interest without creating a separate topic for every possible combination of criteria.",[12,17142,17143],{},"NoLag filters are routing keys, not predicates. The publisher tags each message with one key, the subscriber lists the keys it wants, and the broker routes on the key without ever looking inside the payload:",[135,17145,17146],{},[138,17147,17149],{"className":140,"code":17148,"filename":142,"language":143,"meta":144,"style":144},"// Subscribe to orders, but only for customer 42\nroom.subscribe('orders', { filters: ['customer_42'] })\n\n// Subscribe to notifications, but only these severities (any of them)\nroom.subscribe('notifications', { filters: ['warning', 'error', 'critical'] })\n\nroom.on('orders', (order, meta) => renderOrder(order, meta.filter))\n\n// The publisher tags each message with its routing key\nroom.emit('orders', { orderId: 'o_1', total: 42 }, { filter: 'customer_42' })\nroom.emit('notifications', { text: 'Disk almost full' }, { filter: 'warning' })\n",[84,17150,17151,17156,17176,17180,17185,17213,17217,17246,17250,17255,17284],{"__ignoreMap":144},[148,17152,17153],{"class":150,"line":151},[148,17154,17155],{"class":182},"// Subscribe to orders, but only for customer 42\n",[148,17157,17158,17160,17162,17164,17167,17170,17173],{"class":150,"line":172},[148,17159,380],{"class":161},[148,17161,2692],{"class":207},[148,17163,369],{"class":161},[148,17165,17166],{"class":168},"'orders'",[148,17168,17169],{"class":161},", { filters: [",[148,17171,17172],{"class":168},"'customer_42'",[148,17174,17175],{"class":161},"] })\n",[148,17177,17178],{"class":150,"line":179},[148,17179,176],{"emptyLinePlaceholder":175},[148,17181,17182],{"class":150,"line":186},[148,17183,17184],{"class":182},"// Subscribe to notifications, but only these severities (any of them)\n",[148,17186,17187,17189,17191,17193,17196,17198,17201,17203,17206,17208,17211],{"class":150,"line":192},[148,17188,380],{"class":161},[148,17190,2692],{"class":207},[148,17192,369],{"class":161},[148,17194,17195],{"class":168},"'notifications'",[148,17197,17169],{"class":161},[148,17199,17200],{"class":168},"'warning'",[148,17202,87],{"class":161},[148,17204,17205],{"class":168},"'error'",[148,17207,87],{"class":161},[148,17209,17210],{"class":168},"'critical'",[148,17212,17175],{"class":161},[148,17214,17215],{"class":150,"line":198},[148,17216,176],{"emptyLinePlaceholder":175},[148,17218,17219,17221,17223,17225,17227,17229,17232,17234,17236,17238,17240,17243],{"class":150,"line":214},[148,17220,380],{"class":161},[148,17222,2706],{"class":207},[148,17224,369],{"class":161},[148,17226,17166],{"class":168},[148,17228,2713],{"class":161},[148,17230,17231],{"class":217},"order",[148,17233,87],{"class":161},[148,17235,16855],{"class":217},[148,17237,633],{"class":161},[148,17239,636],{"class":154},[148,17241,17242],{"class":207}," renderOrder",[148,17244,17245],{"class":161},"(order, meta.filter))\n",[148,17247,17248],{"class":150,"line":227},[148,17249,176],{"emptyLinePlaceholder":175},[148,17251,17252],{"class":150,"line":239},[148,17253,17254],{"class":182},"// The publisher tags each message with its routing key\n",[148,17256,17257,17259,17261,17263,17265,17268,17271,17274,17277,17280,17282],{"class":150,"line":249},[148,17258,380],{"class":161},[148,17260,2807],{"class":207},[148,17262,369],{"class":161},[148,17264,17166],{"class":168},[148,17266,17267],{"class":161},", { orderId: ",[148,17269,17270],{"class":168},"'o_1'",[148,17272,17273],{"class":161},", total: ",[148,17275,17276],{"class":235},"42",[148,17278,17279],{"class":161}," }, { filter: ",[148,17281,17172],{"class":168},[148,17283,2557],{"class":161},[148,17285,17286,17288,17290,17292,17294,17296,17299,17301,17303],{"class":150,"line":259},[148,17287,380],{"class":161},[148,17289,2807],{"class":207},[148,17291,369],{"class":161},[148,17293,17195],{"class":168},[148,17295,2814],{"class":161},[148,17297,17298],{"class":168},"'Disk almost full'",[148,17300,17279],{"class":161},[148,17302,17200],{"class":168},[148,17304,2557],{"class":161},[12,17306,17307,17308,17311,17312,17316],{},"Two rules follow from \"keys, not predicates\". A subscription without filters is a wildcard: it receives every message on the topic, including the tagged ones, so filters narrow what a client asks for and are not a privacy boundary (that is what per-topic ACL is for). And a message published without a filter reaches only those wildcard subscribers. A subscription can carry up to 100 keys, and they can be swapped at any time with ",[84,17309,17310],{},"room.setFilters(topic, keys)"," without resubscribing. The ",[952,17313,17315],{"href":17314},"/docs/concepts/filters","filters guide"," has the details.",[12,17318,17319],{},"Filters can be applied at the broker (the broker only delivers matching messages, which saves bandwidth) or at the client (the client receives all messages but discards non-matching ones). Broker-side filtering is more efficient; routing on a key rather than a predicate is what lets NoLag do it without understanding your message schema. Client-side filtering is simpler to implement but wastes bandwidth on messages the client will ignore.",[35,17321,17323],{"id":17322},"how-pubsub-scales-vs-direct-connections","How Pub/Sub Scales vs Direct Connections",[12,17325,17326,17327,17329,17330,17332],{},"Going back to the 1,000-user chat room: with pub/sub, every user has exactly one connection, to the broker. When User A publishes to the ",[84,17328,3889],{}," topic in ",[84,17331,9911],{},", the broker fans the message out to the 999 other subscribers (never back to User A, who already has the message). User A does not know how many subscribers there are. It does not maintain a connection to any of them. The connection count grows as O(n), not O(n²).",[12,17334,17335],{},"The broker can also be distributed. Multiple broker nodes can share subscription state, so a message published to one node gets delivered by all nodes. This is how systems like Kafka, Redis Pub/Sub, and NATS scale horizontally. The application logic does not change when you add broker nodes. The routing layer absorbs the scale.",[35,17337,17339],{"id":17338},"using-pubsub-in-practice-the-chat-app","Using Pub/Sub in Practice: The Chat App",[12,17341,17342],{},"Here is how all three concepts (topics, rooms, filters) work together in a real chat app with multiple channels:",[135,17344,17345],{},[138,17346,17348],{"className":140,"code":17347,"filename":142,"language":143,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\nimport { NoLagChat } from '@nolag/chat'\n\n// APP_SLUG is the slug returned when you created the app from the chat\n// blueprint (it has a random suffix); the blueprint seeds general, random, help and dev.\nconst client = NoLag(ACTOR_TOKEN)\nconst chat = new NoLagChat({ client, appName: APP_SLUG, username: 'alice' })\nawait client.connect()\nawait chat.ready()\n\n// User joins two rooms (synchronous: the subscriptions go out, the handles come back)\nconst general = chat.joinRoom('general')\nconst dev = chat.joinRoom('dev')\n\n// Under the hood, each joinRoom subscribes to that room's topics:\n// APP_SLUG/general/messages, APP_SLUG/general/_stream, APP_SLUG/general/_typing\n// APP_SLUG/dev/messages, ...\n\n// A message published to general's messages topic only reaches\n// subscribers of that specific topic. Users in #dev\n// do not receive messages from #general.\n\n// User sends a message: published to APP_SLUG/general/messages.\n// The sender gets it back as the returned (optimistic) message, never from the broker.\ngeneral.sendMessage('Morning everyone')\n\n// User receives only messages from rooms they have joined\ngeneral.on('message', (msg) => appendToGeneralFeed(msg))\ndev.on('message', (msg) => appendToDevFeed(msg))\n",[84,17349,17350,17360,17370,17374,17379,17384,17401,17424,17434,17444,17448,17453,17472,17491,17495,17500,17505,17510,17514,17519,17524,17529,17533,17538,17543,17557,17561,17566,17590],{"__ignoreMap":144},[148,17351,17352,17354,17356,17358],{"class":150,"line":151},[148,17353,155],{"class":154},[148,17355,2575],{"class":161},[148,17357,165],{"class":154},[148,17359,169],{"class":168},[148,17361,17362,17364,17366,17368],{"class":150,"line":172},[148,17363,155],{"class":154},[148,17365,304],{"class":161},[148,17367,165],{"class":154},[148,17369,309],{"class":168},[148,17371,17372],{"class":150,"line":179},[148,17373,176],{"emptyLinePlaceholder":175},[148,17375,17376],{"class":150,"line":186},[148,17377,17378],{"class":182},"// APP_SLUG is the slug returned when you created the app from the chat\n",[148,17380,17381],{"class":150,"line":192},[148,17382,17383],{"class":182},"// blueprint (it has a random suffix); the blueprint seeds general, random, help and dev.\n",[148,17385,17386,17388,17390,17392,17394,17396,17399],{"class":150,"line":198},[148,17387,323],{"class":154},[148,17389,1589],{"class":235},[148,17391,329],{"class":154},[148,17393,1594],{"class":207},[148,17395,369],{"class":161},[148,17397,17398],{"class":235},"ACTOR_TOKEN",[148,17400,375],{"class":161},[148,17402,17403,17405,17407,17409,17411,17413,17415,17417,17420,17422],{"class":150,"line":214},[148,17404,323],{"class":154},[148,17406,326],{"class":235},[148,17408,329],{"class":154},[148,17410,332],{"class":154},[148,17412,335],{"class":207},[148,17414,12544],{"class":161},[148,17416,2672],{"class":235},[148,17418,17419],{"class":161},", username: ",[148,17421,17012],{"class":168},[148,17423,2557],{"class":161},[148,17425,17426,17428,17430,17432],{"class":150,"line":227},[148,17427,343],{"class":154},[148,17429,4099],{"class":161},[148,17431,2647],{"class":207},[148,17433,352],{"class":161},[148,17435,17436,17438,17440,17442],{"class":150,"line":239},[148,17437,343],{"class":154},[148,17439,346],{"class":161},[148,17441,349],{"class":207},[148,17443,352],{"class":161},[148,17445,17446],{"class":150,"line":249},[148,17447,176],{"emptyLinePlaceholder":175},[148,17449,17450],{"class":150,"line":259},[148,17451,17452],{"class":182},"// User joins two rooms (synchronous: the subscriptions go out, the handles come back)\n",[148,17454,17455,17457,17460,17462,17464,17466,17468,17470],{"class":150,"line":654},[148,17456,323],{"class":154},[148,17458,17459],{"class":235}," general",[148,17461,329],{"class":154},[148,17463,346],{"class":161},[148,17465,366],{"class":207},[148,17467,369],{"class":161},[148,17469,372],{"class":168},[148,17471,375],{"class":161},[148,17473,17474,17476,17479,17481,17483,17485,17487,17489],{"class":150,"line":660},[148,17475,323],{"class":154},[148,17477,17478],{"class":235}," dev",[148,17480,329],{"class":154},[148,17482,346],{"class":161},[148,17484,366],{"class":207},[148,17486,369],{"class":161},[148,17488,15793],{"class":168},[148,17490,375],{"class":161},[148,17492,17493],{"class":150,"line":1289},[148,17494,176],{"emptyLinePlaceholder":175},[148,17496,17497],{"class":150,"line":1295},[148,17498,17499],{"class":182},"// Under the hood, each joinRoom subscribes to that room's topics:\n",[148,17501,17502],{"class":150,"line":1313},[148,17503,17504],{"class":182},"// APP_SLUG/general/messages, APP_SLUG/general/_stream, APP_SLUG/general/_typing\n",[148,17506,17507],{"class":150,"line":1331},[148,17508,17509],{"class":182},"// APP_SLUG/dev/messages, ...\n",[148,17511,17512],{"class":150,"line":1346},[148,17513,176],{"emptyLinePlaceholder":175},[148,17515,17516],{"class":150,"line":1355},[148,17517,17518],{"class":182},"// A message published to general's messages topic only reaches\n",[148,17520,17521],{"class":150,"line":1361},[148,17522,17523],{"class":182},"// subscribers of that specific topic. Users in #dev\n",[148,17525,17526],{"class":150,"line":1370},[148,17527,17528],{"class":182},"// do not receive messages from #general.\n",[148,17530,17531],{"class":150,"line":1376},[148,17532,176],{"emptyLinePlaceholder":175},[148,17534,17535],{"class":150,"line":2033},[148,17536,17537],{"class":182},"// User sends a message: published to APP_SLUG/general/messages.\n",[148,17539,17540],{"class":150,"line":2039},[148,17541,17542],{"class":182},"// The sender gets it back as the returned (optimistic) message, never from the broker.\n",[148,17544,17545,17548,17550,17552,17555],{"class":150,"line":2044},[148,17546,17547],{"class":161},"general.",[148,17549,383],{"class":207},[148,17551,369],{"class":161},[148,17553,17554],{"class":168},"'Morning everyone'",[148,17556,375],{"class":161},[148,17558,17559],{"class":150,"line":2050},[148,17560,176],{"emptyLinePlaceholder":175},[148,17562,17563],{"class":150,"line":2056},[148,17564,17565],{"class":182},"// User receives only messages from rooms they have joined\n",[148,17567,17568,17570,17572,17574,17576,17578,17580,17582,17584,17587],{"class":150,"line":2062},[148,17569,17547],{"class":161},[148,17571,2706],{"class":207},[148,17573,369],{"class":161},[148,17575,3098],{"class":168},[148,17577,2713],{"class":161},[148,17579,3103],{"class":217},[148,17581,633],{"class":161},[148,17583,636],{"class":154},[148,17585,17586],{"class":207}," appendToGeneralFeed",[148,17588,17589],{"class":161},"(msg))\n",[148,17591,17592,17595,17597,17599,17601,17603,17605,17607,17609,17612],{"class":150,"line":2068},[148,17593,17594],{"class":161},"dev.",[148,17596,2706],{"class":207},[148,17598,369],{"class":161},[148,17600,3098],{"class":168},[148,17602,2713],{"class":161},[148,17604,3103],{"class":217},[148,17606,633],{"class":161},[148,17608,636],{"class":154},[148,17610,17611],{"class":207}," appendToDevFeed",[148,17613,17589],{"class":161},[12,17615,17616],{},"This is the pattern at the core of every real-time communication app. The room abstraction makes it ergonomic. The topic routing underneath makes it scalable. The broker in the middle makes it decoupled.",[35,17618,17620],{"id":17619},"nolags-three-noun-model-app-room-topic","NoLag's Three-Noun Model: App, Room, Topic",[12,17622,17623],{},"NoLag organises all of this around three nouns.",[12,17625,9883,17626,17628,17629,17631,17632,17635,17636,611],{},[30,17627,5244],{}," is the top-level namespace. It maps to your application, and all rooms and topics belong to it. Isolation between apps is complete: no topic in one app is visible to another. Credentials are not per app: API keys and actor tokens are issued at the project level, and an actor is granted access per app, room and topic. Every app slug gets a random suffix when it is created (",[84,17630,6359],{}," becomes ",[84,17633,17634],{},"chat-a3f9","), and that suffixed slug is what a client passes to ",[84,17637,17638],{},"setApp()",[12,17640,1854,17641,17643,17644,17647,17648,17651,17652,17655,17656,611],{},[30,17642,1421],{}," is a grouping context within an app. It corresponds to the room abstraction described above: a conversation channel, a game session, a document, a project. Rooms have their own presence list and their own topics, and access is granted per topic. A client subscribes to the topics it wants within a room; the fluent ",[84,17645,17646],{},"client.setApp(slug).setRoom(slug)"," context just supplies the prefix. Rooms never exist implicitly: create them in the control plane (with ",[84,17649,17650],{},"autoProvisionRooms"," enabled on the app, one ",[84,17653,17654],{},"rooms/ensure"," call does it), because subscribing or publishing to an unknown room is rejected with ",[84,17657,12714],{},[12,17659,1854,17660,17662,17663,17665,17666,611],{},[30,17661,5262],{}," is the actual message channel within a room. Each room contains multiple topics for different event types, declared on the app. When you call ",[84,17664,15088],{}," on a blueprint SDK, it subscribes to the topics that blueprint uses for the room. Message history is not a room property: a fresh subscription receives no history and an ordinary reconnect restores subscriptions without replaying anything, so keep what you need in your own store. The broker replays only for load-balanced worker groups with persistent sessions; see ",[952,17667,2320],{"href":2319},[12,17669,17670],{},"This three-level hierarchy covers the vast majority of real-time use cases without requiring you to design a topic namespace from scratch. App, room, topic. That is the model.",[969,17672,17673],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":144,"searchDepth":172,"depth":172,"links":17675},[17676,17677,17678,17681,17682,17683,17684,17685],{"id":16961,"depth":172,"text":16962},{"id":16974,"depth":172,"text":16975},{"id":17061,"depth":172,"text":17062,"children":17679},[17680],{"id":17094,"depth":179,"text":17095},{"id":17110,"depth":172,"text":17111},{"id":17136,"depth":172,"text":17137},{"id":17322,"depth":172,"text":17323},{"id":17338,"depth":172,"text":17339},{"id":17619,"depth":172,"text":17620},"2026-04-07","Understand publish/subscribe from first principles. Learn how topics, rooms, and filters route messages at scale, and how NoLag models real-time apps with apps, rooms, and topics.",{},"/blog/pubsub-pattern-explained","9 min read",{"title":16950,"description":17687},"blog/pubsub-pattern-explained","fEnp0vZkSE_28Obu8ijl-DCnbmWzHuMKBiXHLlQZR14",{"id":17695,"title":17696,"author":7,"body":17697,"category":983,"date":18526,"description":18527,"excerpt":986,"extension":987,"meta":18528,"navigation":175,"path":18529,"readTime":2903,"seo":18530,"stem":18531,"__hash__":18532},"blog/blog/qos-levels-real-time-messaging.md","QoS 0, 1, and 2: Choosing the Right Delivery Guarantee for Every Message",{"type":9,"value":17698,"toc":18510},[17699,17702,17705,17709,17722,17726,17729,17793,17796,17800,17826,17830,17833,17911,17914,17918,17944,17948,17951,18040,18043,18046,18050,18076,18080,18083,18141,18144,18148,18151,18154,18158,18188,18360,18363,18366,18396,18470,18477,18481,18484,18504,18507],[12,17700,17701],{},"Not all messages are equal. A GPS coordinate from a moving vehicle that arrives 200ms late is still useful. That same coordinate arriving twice is harmless. But a payment confirmation that gets processed twice is a serious problem, and a payment that disappears silently is even worse.",[12,17703,17704],{},"Quality of Service (QoS) levels let you express how important it is that a specific message gets delivered, and how much overhead you are willing to pay for it. The three levels originated in MQTT, and the concepts apply broadly to any real-time messaging system. What matters in practice is knowing exactly which part of a message's journey a level covers, so this post is precise about that for NoLag.",[35,17706,17708],{"id":17707},"two-legs-one-setting","Two legs, one setting",[12,17710,17711,17712,87,17714,694,17716,17718,17719,17721],{},"A message published through NoLag travels two legs: from your client to a NoLag gateway over WebSocket, and from that gateway across NoLag's internal MQTT broker to the gateways of every subscriber. The QoS level you set on a publish (",[84,17713,9227],{},[84,17715,12662],{},[84,17717,12683],{},", default ",[84,17720,12662],{},") is validated and applied to the second leg, the broker hop. The first leg, the WebSocket connection, has its own simpler contract, which the last section spells out. Keep that split in mind while reading what each level means.",[35,17723,17725],{"id":17724},"qos-0-at-most-once-fire-and-forget","QoS 0: At Most Once (Fire and Forget)",[12,17727,17728],{},"QoS 0 is the simplest delivery mode. The message is forwarded once across the broker hop. No acknowledgment, no retry, no storage. If a broker node is under pressure or a subscriber's gateway is unreachable at that instant, the message is gone.",[135,17730,17731],{},[138,17732,17734],{"className":140,"code":17733,"filename":142,"language":143,"meta":144,"style":144},"// QoS 0: forwarded once on the broker hop, no ack, no retry\nroom.emit('positions', { lat: 51.5074, lng: -0.1278, speed: 42 }, { qos: 0 })\n\n// The broker sends this to subscribers and forgets it.\n// If a subscriber is offline, they miss it. That's acceptable\n// for position updates because the next one arrives in 100ms.\n",[84,17735,17736,17741,17774,17778,17783,17788],{"__ignoreMap":144},[148,17737,17738],{"class":150,"line":151},[148,17739,17740],{"class":182},"// QoS 0: forwarded once on the broker hop, no ack, no retry\n",[148,17742,17743,17745,17747,17749,17751,17754,17757,17759,17761,17764,17766,17768,17770,17772],{"class":150,"line":172},[148,17744,380],{"class":161},[148,17746,2807],{"class":207},[148,17748,369],{"class":161},[148,17750,16833],{"class":168},[148,17752,17753],{"class":161},", { lat: ",[148,17755,17756],{"class":235},"51.5074",[148,17758,16536],{"class":161},[148,17760,6468],{"class":154},[148,17762,17763],{"class":235},"0.1278",[148,17765,16542],{"class":161},[148,17767,17276],{"class":235},[148,17769,12501],{"class":161},[148,17771,9227],{"class":235},[148,17773,2557],{"class":161},[148,17775,17776],{"class":150,"line":179},[148,17777,176],{"emptyLinePlaceholder":175},[148,17779,17780],{"class":150,"line":186},[148,17781,17782],{"class":182},"// The broker sends this to subscribers and forgets it.\n",[148,17784,17785],{"class":150,"line":192},[148,17786,17787],{"class":182},"// If a subscriber is offline, they miss it. That's acceptable\n",[148,17789,17790],{"class":150,"line":198},[148,17791,17792],{"class":182},"// for position updates because the next one arrives in 100ms.\n",[12,17794,17795],{},"QoS 0 has the lowest latency and the lowest broker overhead. There is no acknowledgment round-trip, no message storage, no retry queue. The broker processes the message and moves on.",[7470,17797,17799],{"id":17798},"when-to-use-qos-0","When to use QoS 0",[882,17801,17802,17808,17814,17820],{},[885,17803,17804,17807],{},[30,17805,17806],{},"High-frequency telemetry."," A temperature sensor sending a reading every second does not need guaranteed delivery. The next reading arrives momentarily. Losing one sample in a thousand is acceptable.",[885,17809,17810,17813],{},[30,17811,17812],{},"Typing indicators."," Whether or not \"Alice is typing...\" arrives is not important. It is a transient UI hint. A missed indicator does not corrupt state.",[885,17815,17816,17819],{},[30,17817,17818],{},"Live cursor positions."," In a collaborative document editor, cursor positions are sent many times per second. Missing one frame is invisible to users.",[885,17821,17822,17825],{},[30,17823,17824],{},"Animated dashboards."," A dashboard showing a live metric that updates 10 times per second can afford to drop occasional frames without the user noticing.",[35,17827,17829],{"id":17828},"qos-1-at-least-once-acknowledged-delivery","QoS 1: At Least Once (Acknowledged Delivery)",[12,17831,17832],{},"QoS 1 adds an acknowledgment handshake on the broker hop. The gateway publishes into the internal broker and waits for its acknowledgment; if that acknowledgment is lost, the MQTT client can resend, which is where a duplicate can come from. This is NoLag's default level.",[135,17834,17835],{},[138,17836,17838],{"className":140,"code":17837,"filename":142,"language":143,"meta":144,"style":144},"// QoS 1 (the default, shown explicitly): acknowledged on the broker hop\nroom.emit('messages', { text: 'Hey, are you free?', msgId: 'msg_01' }, { qos: 1 })\n\n// Flow on the broker hop:\n// 1. Gateway publishes the message into the internal broker\n// 2. Broker delivers to subscribers' gateways\n// 3. Broker acknowledges (PUBACK) to the publishing gateway\n//\n// If step 3 is lost the hop can resend, so a subscriber\n// may receive it twice. The application must handle this.\n",[84,17839,17840,17845,17872,17876,17881,17886,17891,17896,17901,17906],{"__ignoreMap":144},[148,17841,17842],{"class":150,"line":151},[148,17843,17844],{"class":182},"// QoS 1 (the default, shown explicitly): acknowledged on the broker hop\n",[148,17846,17847,17849,17851,17853,17855,17857,17860,17863,17866,17868,17870],{"class":150,"line":172},[148,17848,380],{"class":161},[148,17850,2807],{"class":207},[148,17852,369],{"class":161},[148,17854,2697],{"class":168},[148,17856,2814],{"class":161},[148,17858,17859],{"class":168},"'Hey, are you free?'",[148,17861,17862],{"class":161},", msgId: ",[148,17864,17865],{"class":168},"'msg_01'",[148,17867,12501],{"class":161},[148,17869,12662],{"class":235},[148,17871,2557],{"class":161},[148,17873,17874],{"class":150,"line":179},[148,17875,176],{"emptyLinePlaceholder":175},[148,17877,17878],{"class":150,"line":186},[148,17879,17880],{"class":182},"// Flow on the broker hop:\n",[148,17882,17883],{"class":150,"line":192},[148,17884,17885],{"class":182},"// 1. Gateway publishes the message into the internal broker\n",[148,17887,17888],{"class":150,"line":198},[148,17889,17890],{"class":182},"// 2. Broker delivers to subscribers' gateways\n",[148,17892,17893],{"class":150,"line":214},[148,17894,17895],{"class":182},"// 3. Broker acknowledges (PUBACK) to the publishing gateway\n",[148,17897,17898],{"class":150,"line":227},[148,17899,17900],{"class":182},"//\n",[148,17902,17903],{"class":150,"line":239},[148,17904,17905],{"class":182},"// If step 3 is lost the hop can resend, so a subscriber\n",[148,17907,17908],{"class":150,"line":249},[148,17909,17910],{"class":182},"// may receive it twice. The application must handle this.\n",[12,17912,17913],{},"The \"at least once\" label is precise: the message is delivered across the hop, but it might be delivered more than once if an ack gets lost and the hop retries. The subscriber must handle duplicate delivery, either by ignoring duplicates (idempotent processing) or by tracking message IDs and deduplicating.",[7470,17915,17917],{"id":17916},"when-to-use-qos-1","When to use QoS 1",[882,17919,17920,17926,17932,17938],{},[885,17921,17922,17925],{},[30,17923,17924],{},"Chat messages."," A message that never arrives is a broken product. A message that appears twice is an edge case the UI can handle by deduplicating on message ID. QoS 1 is the right default for chat.",[885,17927,17928,17931],{},[30,17929,17930],{},"Notifications."," A push notification that gets lost means the user never knew about an important event. Receiving it twice is an annoyance. The reliability side of that tradeoff wins.",[885,17933,17934,17937],{},[30,17935,17936],{},"Order status updates."," A user waiting for their delivery status to change needs that update. Missing it means a support ticket. Receiving it twice means a brief flicker in the UI.",[885,17939,17940,17943],{},[30,17941,17942],{},"IoT commands."," Sending a \"turn off\" command to a device that occasionally drops packets needs retry logic. A double-delivery of \"turn off\" when the device is already off is harmless.",[35,17945,17947],{"id":17946},"qos-2-deduplicated-on-the-broker-hop","QoS 2: Deduplicated on the Broker Hop",[12,17949,17950],{},"QoS 2 is MQTT's strongest level. On the broker hop it runs a four-step handshake so that a retry can never produce a duplicate across that hop.",[135,17952,17953],{},[138,17954,17956],{"className":140,"code":17955,"filename":142,"language":143,"meta":144,"style":144},"// QoS 2: four-way handshake on the broker hop\nroom.emit('payments', { amount: 299.00, orderId: 'ord_7x2k', idempotencyKey: 'pay_7x2k_1' }, { qos: 2 })\n\n// Flow on the broker hop:\n// 1. Gateway sends PUBLISH (stored by the broker)\n// 2. Broker sends PUBREC (publish received)\n// 3. Gateway sends PUBREL (publish release), broker can now deliver\n// 4. Broker delivers to subscribers' gateways, sends PUBCOMP (complete)\n//\n// A retry inside this flow cannot produce a duplicate on the hop.\n// It says nothing about the WebSocket legs on either side of it.\n",[84,17957,17958,17963,17998,18002,18006,18011,18016,18021,18026,18030,18035],{"__ignoreMap":144},[148,17959,17960],{"class":150,"line":151},[148,17961,17962],{"class":182},"// QoS 2: four-way handshake on the broker hop\n",[148,17964,17965,17967,17969,17971,17974,17977,17980,17983,17986,17989,17992,17994,17996],{"class":150,"line":172},[148,17966,380],{"class":161},[148,17968,2807],{"class":207},[148,17970,369],{"class":161},[148,17972,17973],{"class":168},"'payments'",[148,17975,17976],{"class":161},", { amount: ",[148,17978,17979],{"class":235},"299.00",[148,17981,17982],{"class":161},", orderId: ",[148,17984,17985],{"class":168},"'ord_7x2k'",[148,17987,17988],{"class":161},", idempotencyKey: ",[148,17990,17991],{"class":168},"'pay_7x2k_1'",[148,17993,12501],{"class":161},[148,17995,12683],{"class":235},[148,17997,2557],{"class":161},[148,17999,18000],{"class":150,"line":179},[148,18001,176],{"emptyLinePlaceholder":175},[148,18003,18004],{"class":150,"line":186},[148,18005,17880],{"class":182},[148,18007,18008],{"class":150,"line":192},[148,18009,18010],{"class":182},"// 1. Gateway sends PUBLISH (stored by the broker)\n",[148,18012,18013],{"class":150,"line":198},[148,18014,18015],{"class":182},"// 2. Broker sends PUBREC (publish received)\n",[148,18017,18018],{"class":150,"line":214},[148,18019,18020],{"class":182},"// 3. Gateway sends PUBREL (publish release), broker can now deliver\n",[148,18022,18023],{"class":150,"line":227},[148,18024,18025],{"class":182},"// 4. Broker delivers to subscribers' gateways, sends PUBCOMP (complete)\n",[148,18027,18028],{"class":150,"line":239},[148,18029,17900],{"class":182},[148,18031,18032],{"class":150,"line":249},[148,18033,18034],{"class":182},"// A retry inside this flow cannot produce a duplicate on the hop.\n",[148,18036,18037],{"class":150,"line":259},[148,18038,18039],{"class":182},"// It says nothing about the WebSocket legs on either side of it.\n",[12,18041,18042],{},"This four-step protocol is more expensive than QoS 1. Each message requires two round-trips on the hop instead of one, and the broker must hold message state across the full handshake, which means more writes, more memory, and more latency. For a high-volume stream this overhead is prohibitive.",[12,18044,18045],{},"What QoS 2 does not give you is exactly-once delivery end to end. The handshake runs between NoLag's gateways and its broker; your client's WebSocket connection is not part of it. If your client publishes the same payment twice (a retry after a lost acknowledgment, a double-click, a replayed request), QoS 2 will faithfully deliver both. So the rule for irreversible actions is: use QoS 2 for the hop, carry an idempotency key in the payload, and make the consumer idempotent. The key is what makes the action happen once; QoS 2 removes one source of duplicates on the way there.",[7470,18047,18049],{"id":18048},"when-to-use-qos-2","When to use QoS 2",[882,18051,18052,18058,18064,18070],{},[885,18053,18054,18057],{},[30,18055,18056],{},"Financial transactions."," A payment that processes twice charges the customer twice, and a payment that is silently dropped causes a lost sale. Pay the QoS 2 cost on the hop, and dedupe on the idempotency key at the consumer, because the level alone does not make the charge exactly-once.",[885,18059,18060,18063],{},[30,18061,18062],{},"Inventory mutations."," Decrementing stock by one must happen once per sale. Treat the sale ID as the idempotency key so a redelivered message is a no-op.",[885,18065,18066,18069],{},[30,18067,18068],{},"Audit log entries."," A compliance trail should not carry the same event twice. QoS 2 removes hop-level duplicates; a unique event ID lets the writer discard the rest.",[885,18071,18072,18075],{},[30,18073,18074],{},"Irreversible downstream triggers."," Any message that fires an action you cannot undo deserves the strongest hop-level setting and an idempotent receiver.",[35,18077,18079],{"id":18078},"the-latency-vs-reliability-tradeoff","The Latency vs Reliability Tradeoff",[12,18081,18082],{},"The cost of each level is concrete, and it is paid on the broker hop:",[40,18084,18085,18101],{},[43,18086,18087],{},[46,18088,18089,18092,18095,18098],{},[49,18090,18091],{},"QoS level",[49,18093,18094],{},"Round trips on the hop",[49,18096,18097],{},"Broker storage",[49,18099,18100],{},"Duplicate risk on the hop",[59,18102,18103,18116,18129],{},[46,18104,18105,18108,18110,18113],{},[64,18106,18107],{},"QoS 0",[64,18109,9227],{},[64,18111,18112],{},"None",[64,18114,18115],{},"None (but may be dropped)",[46,18117,18118,18121,18123,18126],{},[64,18119,18120],{},"QoS 1",[64,18122,12662],{},[64,18124,18125],{},"Until acknowledged",[64,18127,18128],{},"Possible",[46,18130,18131,18134,18136,18139],{},[64,18132,18133],{},"QoS 2",[64,18135,12683],{},[64,18137,18138],{},"Until complete",[64,18140,18112],{},[12,18142,18143],{},"For most interactive apps, QoS 0 and QoS 1 cover everything. QoS 0 for ephemeral state (presence, cursors, typing) and QoS 1 for durable events (messages, notifications). QoS 2 is reserved for cases where the downstream consequence of a duplicate or a drop is financially or legally significant, and even there it is one layer of a defence that also needs an idempotency key.",[35,18145,18147],{"id":18146},"per-message-qos-not-per-connection","Per-Message QoS, Not Per-Connection",[12,18149,18150],{},"One of the design decisions that matters most in practice is whether QoS is a per-connection setting or a per-message setting. If it is per-connection, every message on that connection uses the same delivery guarantee. That forces you to open multiple connections if you have mixed requirements, or to use the most conservative QoS for everything (which means paying the QoS 2 cost for typing indicators that do not need it).",[12,18152,18153],{},"Per-message QoS is the right model. A single connection can carry QoS 0 telemetry, QoS 1 chat messages, and QoS 2 payment events simultaneously. The broker applies the appropriate delivery logic per message based on what the publisher requested.",[35,18155,18157],{"id":18156},"how-nolag-handles-qos","How NoLag Handles QoS",[12,18159,18160,18161,18164,18165,18167,18168,18171,18172,18174,18175,18177,18178,18180,18181,18183,18184,18187],{},"NoLag attaches QoS per message. Pass ",[84,18162,18163],{},"{ qos }"," on any ",[84,18166,2807],{},", or set a connection-wide default with ",[84,18169,18170],{},"NoLag(token, { qos })"," (the platform default is ",[84,18173,12662],{},"). Anything outside ",[84,18176,9227],{}," to ",[84,18179,12683],{}," is treated as ",[84,18182,12662],{},". Subscriptions can carry their own ",[84,18185,18186],{},"qos"," too, for the delivering side of the hop.",[135,18189,18190],{},[138,18191,18193],{"className":140,"code":18192,"filename":142,"language":143,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\n\n// APP_SLUG is the slug returned when you created the app (it has a random\n// suffix); the room must already exist in the control plane.\nconst client = NoLag(ACTOR_TOKEN)\nawait client.connect()\nconst room = client.setApp(APP_SLUG).setRoom('general')\n\n// Ephemeral presence-style update: forwarded once, no storage\nroom.emit('cursors', cursorPosition, { qos: 0 })\n\n// Chat message: acknowledged on the hop, duplicates are manageable\nroom.emit('messages', chatMessage, { qos: 1 })\n\n// Payment event: deduplicated on the hop, and the payload carries the\n// idempotency key that makes the consumer safe against everything else\nroom.emit('payments', { ...paymentEvent, idempotencyKey }, { qos: 2 })\n",[84,18194,18195,18205,18209,18214,18219,18235,18245,18271,18275,18280,18298,18302,18307,18324,18328,18333,18338],{"__ignoreMap":144},[148,18196,18197,18199,18201,18203],{"class":150,"line":151},[148,18198,155],{"class":154},[148,18200,2575],{"class":161},[148,18202,165],{"class":154},[148,18204,169],{"class":168},[148,18206,18207],{"class":150,"line":172},[148,18208,176],{"emptyLinePlaceholder":175},[148,18210,18211],{"class":150,"line":179},[148,18212,18213],{"class":182},"// APP_SLUG is the slug returned when you created the app (it has a random\n",[148,18215,18216],{"class":150,"line":186},[148,18217,18218],{"class":182},"// suffix); the room must already exist in the control plane.\n",[148,18220,18221,18223,18225,18227,18229,18231,18233],{"class":150,"line":192},[148,18222,323],{"class":154},[148,18224,1589],{"class":235},[148,18226,329],{"class":154},[148,18228,1594],{"class":207},[148,18230,369],{"class":161},[148,18232,17398],{"class":235},[148,18234,375],{"class":161},[148,18236,18237,18239,18241,18243],{"class":150,"line":198},[148,18238,343],{"class":154},[148,18240,4099],{"class":161},[148,18242,2647],{"class":207},[148,18244,352],{"class":161},[148,18246,18247,18249,18251,18253,18255,18257,18259,18261,18263,18265,18267,18269],{"class":150,"line":214},[148,18248,323],{"class":154},[148,18250,359],{"class":235},[148,18252,329],{"class":154},[148,18254,4099],{"class":161},[148,18256,2667],{"class":207},[148,18258,369],{"class":161},[148,18260,2672],{"class":235},[148,18262,2675],{"class":161},[148,18264,2678],{"class":207},[148,18266,369],{"class":161},[148,18268,372],{"class":168},[148,18270,375],{"class":161},[148,18272,18273],{"class":150,"line":227},[148,18274,176],{"emptyLinePlaceholder":175},[148,18276,18277],{"class":150,"line":239},[148,18278,18279],{"class":182},"// Ephemeral presence-style update: forwarded once, no storage\n",[148,18281,18282,18284,18286,18288,18291,18294,18296],{"class":150,"line":249},[148,18283,380],{"class":161},[148,18285,2807],{"class":207},[148,18287,369],{"class":161},[148,18289,18290],{"class":168},"'cursors'",[148,18292,18293],{"class":161},", cursorPosition, { qos: ",[148,18295,9227],{"class":235},[148,18297,2557],{"class":161},[148,18299,18300],{"class":150,"line":259},[148,18301,176],{"emptyLinePlaceholder":175},[148,18303,18304],{"class":150,"line":654},[148,18305,18306],{"class":182},"// Chat message: acknowledged on the hop, duplicates are manageable\n",[148,18308,18309,18311,18313,18315,18317,18320,18322],{"class":150,"line":660},[148,18310,380],{"class":161},[148,18312,2807],{"class":207},[148,18314,369],{"class":161},[148,18316,2697],{"class":168},[148,18318,18319],{"class":161},", chatMessage, { qos: ",[148,18321,12662],{"class":235},[148,18323,2557],{"class":161},[148,18325,18326],{"class":150,"line":1289},[148,18327,176],{"emptyLinePlaceholder":175},[148,18329,18330],{"class":150,"line":1295},[148,18331,18332],{"class":182},"// Payment event: deduplicated on the hop, and the payload carries the\n",[148,18334,18335],{"class":150,"line":1313},[148,18336,18337],{"class":182},"// idempotency key that makes the consumer safe against everything else\n",[148,18339,18340,18342,18344,18346,18348,18351,18353,18356,18358],{"class":150,"line":1331},[148,18341,380],{"class":161},[148,18343,2807],{"class":207},[148,18345,369],{"class":161},[148,18347,17973],{"class":168},[148,18349,18350],{"class":161},", { ",[148,18352,1307],{"class":154},[148,18354,18355],{"class":161},"paymentEvent, idempotencyKey }, { qos: ",[148,18357,12683],{"class":235},[148,18359,2557],{"class":161},[12,18361,18362],{},"All three flow over the same WebSocket connection. The connection overhead is paid once. The per-message overhead reflects only what that specific message requires.",[12,18364,18365],{},"The WebSocket leg between your client and NoLag has a simpler and separate contract, and it is the same for every level:",[882,18367,18368,18384,18390],{},[885,18369,18370,18373,18374,18376,18377,18380,18381,18383],{},[30,18371,18372],{},"A publish can be acknowledged."," Pass a callback to ",[84,18375,2807],{}," and the SDK attaches a ",[84,18378,18379],{},"msgRef"," to the frame; the broker answers with a ",[84,18382,3431],{}," frame once it has accepted the message (protocol v2: js-sdk 1.6+, python-sdk 2.5+, go-sdk v0.5.0). That acknowledgment is what the callback resolves on, and a rejection (unknown room, ACL, rate limit, size) arrives as the callback's error instead.",[885,18385,18386,18389],{},[30,18387,18388],{},"Nothing is resent for you."," If no acknowledgment arrives within 10 seconds the callback receives an error, and it is up to you to publish again, which is exactly the moment an idempotency key earns its keep.",[885,18391,18392,18395],{},[30,18393,18394],{},"Two platform limits apply to every message."," A connection may publish at most 50 messages per second, and a message may be at most 900 KB on every plan; both are rejected with an error rather than queued.",[135,18397,18398],{},[138,18399,18401],{"className":140,"code":18400,"filename":142,"language":143,"meta":144,"style":144},"room.emit('payments', paymentEvent, { qos: 2 }, (err) => {\n  if (err) {\n    // Not accepted, or not acknowledged within 10s. The SDK did not retry.\n    schedulePublishRetry(paymentEvent, err)\n    return\n  }\n  markAsSent(paymentEvent)\n})\n",[84,18402,18403,18430,18436,18441,18449,18454,18458,18466],{"__ignoreMap":144},[148,18404,18405,18407,18409,18411,18413,18416,18418,18421,18424,18426,18428],{"class":150,"line":151},[148,18406,380],{"class":161},[148,18408,2807],{"class":207},[148,18410,369],{"class":161},[148,18412,17973],{"class":168},[148,18414,18415],{"class":161},", paymentEvent, { qos: ",[148,18417,12683],{"class":235},[148,18419,18420],{"class":161}," }, (",[148,18422,18423],{"class":217},"err",[148,18425,633],{"class":161},[148,18427,636],{"class":154},[148,18429,211],{"class":161},[148,18431,18432,18434],{"class":150,"line":172},[148,18433,10452],{"class":154},[148,18435,10985],{"class":161},[148,18437,18438],{"class":150,"line":179},[148,18439,18440],{"class":182},"    // Not accepted, or not acknowledged within 10s. The SDK did not retry.\n",[148,18442,18443,18446],{"class":150,"line":186},[148,18444,18445],{"class":207},"    schedulePublishRetry",[148,18447,18448],{"class":161},"(paymentEvent, err)\n",[148,18450,18451],{"class":150,"line":192},[148,18452,18453],{"class":154},"    return\n",[148,18455,18456],{"class":150,"line":198},[148,18457,1716],{"class":161},[148,18459,18460,18463],{"class":150,"line":214},[148,18461,18462],{"class":207},"  markAsSent",[148,18464,18465],{"class":161},"(paymentEvent)\n",[148,18467,18468],{"class":150,"line":227},[148,18469,853],{"class":161},[12,18471,18472,18473,18476],{},"Two things NoLag does not do, so you can plan for them: it does not replay messages to an ordinary client that reconnects (subscriptions are restored, nothing is resent; the exception is ",[952,18474,18475],{"href":2319},"durable delivery for load-balanced worker groups","), and it never delivers a message back to the actor that published it, whatever the level. Both are worth knowing before you write a test that publishes and expects to receive.",[35,18478,18480],{"id":18479},"practical-guidance-what-to-use-when","Practical Guidance: What to Use When",[12,18482,18483],{},"If you are not sure where to start, here is a simple decision tree:",[882,18485,18486,18492,18498],{},[885,18487,18488,18491],{},[30,18489,18490],{},"The message is transient state (typing, cursor, presence)."," Use QoS 0. A missed delivery has no lasting effect.",[885,18493,18494,18497],{},[30,18495,18496],{},"The message represents a durable event (chat, notification, status change)."," Use QoS 1, the default. Ensure your subscriber deduplicates on message ID for resilience.",[885,18499,18500,18503],{},[30,18501,18502],{},"The message triggers an irreversible action (payment, inventory decrement, audit record)."," Use QoS 2 on the hop, pass a callback so you know the broker accepted it, put an idempotency key in the payload, and make the consumer idempotent. QoS alone does not make an action happen exactly once.",[12,18505,18506],{},"The goal is to match the delivery guarantee to the actual consequence of failure. Over-engineering everything to QoS 2 wastes resources and adds latency. Under-engineering payment flows to QoS 0 is a production incident waiting to happen. The right answer is almost always a mix, applied per message type, with idempotency doing the part that no transport level can.",[969,18508,18509],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":144,"searchDepth":172,"depth":172,"links":18511},[18512,18513,18516,18519,18522,18523,18524,18525],{"id":17707,"depth":172,"text":17708},{"id":17724,"depth":172,"text":17725,"children":18514},[18515],{"id":17798,"depth":179,"text":17799},{"id":17828,"depth":172,"text":17829,"children":18517},[18518],{"id":17916,"depth":179,"text":17917},{"id":17946,"depth":172,"text":17947,"children":18520},[18521],{"id":18048,"depth":179,"text":18049},{"id":18078,"depth":172,"text":18079},{"id":18146,"depth":172,"text":18147},{"id":18156,"depth":172,"text":18157},{"id":18479,"depth":172,"text":18480},"2026-03-31","Learn what QoS 0, 1, and 2 mean in real-time messaging, what each level governs on NoLag, what the WebSocket connection acknowledges, and which to use for telemetry, chat, and payments.",{},"/blog/qos-levels-real-time-messaging",{"title":17696,"description":18527},"blog/qos-levels-real-time-messaging","sd3LLwyXgUq8QN4rXjK6YMb42Fn9Kr9jDFuBS5yqApo",{"id":18534,"title":18535,"author":7,"body":18536,"category":19425,"date":19426,"description":19427,"excerpt":986,"extension":987,"meta":19428,"navigation":175,"path":19429,"readTime":2287,"seo":19430,"stem":19431,"__hash__":19432},"blog/blog/presence-tracking-at-scale.md","Building Presence at Scale: Who Is Online and How to Track It",{"type":9,"value":18537,"toc":19412},[18538,18541,18544,18548,18551,18554,18580,18583,18587,18590,18709,18712,18715,18718,18721,18725,18728,18731,18865,18868,18872,18875,18881,18887,18890,18894,18898,18905,18909,18912,18916,18923,18926,18930,18933,18937,18940,18955,19216,19225,19234,19242,19399,19409],[12,18539,18540],{},"Presence is one of those features that looks trivial until you actually build it. Showing a green dot next to a username seems simple. But the moment you have thousands of users spread across hundreds of rooms, connecting and disconnecting on flaky mobile networks, switching tabs, backgrounding the app, and coming back after hours offline, it stops being simple very quickly.",[12,18542,18543],{},"This post covers what presence actually means, why the naive approaches fall apart, and how to build something that holds up at scale.",[35,18545,18547],{"id":18546},"what-presence-actually-means","What Presence Actually Means",[12,18549,18550],{},"Presence is the real-time state of a user or device as observed by others. The most basic form is binary: online or offline. Most production systems need more than that.",[12,18552,18553],{},"A richer presence model typically includes:",[882,18555,18556,18562,18568,18574],{},[885,18557,18558,18561],{},[30,18559,18560],{},"Status."," Online, offline, away, busy, do not disturb.",[885,18563,18564,18567],{},[30,18565,18566],{},"Last seen."," A timestamp of the most recent activity or heartbeat.",[885,18569,18570,18573],{},[30,18571,18572],{},"Custom metadata."," The current room a user is in, what they are viewing, their display name, avatar URL, or anything your application needs to show other users.",[885,18575,18576,18579],{},[30,18577,18578],{},"Device context."," Whether the user is on mobile or desktop, which can affect what status transitions mean.",[12,18581,18582],{},"Custom metadata is where presence gets genuinely useful. A collaborative document editor wants to show which paragraph each user is editing. A support tool wants to show which ticket an agent has open. A multiplayer game wants to show the player's current lobby and score. All of that is presence data, not just a green dot.",[35,18584,18586],{"id":18585},"the-naive-approach-polling","The Naive Approach: Polling",[12,18588,18589],{},"The most common first attempt at presence is a REST endpoint that clients call periodically to report that they are still alive, and a second endpoint that other clients poll to fetch the current status of users they care about.",[135,18591,18592],{},[138,18593,18595],{"className":2564,"code":18594,"filename":142,"language":2566,"meta":144,"style":144},"// Client heartbeat poll every 30 seconds\nsetInterval(async () => {\n  await fetch('/api/presence/heartbeat', { method: 'POST' })\n}, 30_000)\n\n// Other clients poll for status\nsetInterval(async () => {\n  const res = await fetch('/api/presence?userIds=u1,u2,u3')\n  updatePresenceUI(await res.json())\n}, 5_000)\n",[84,18596,18597,18602,18616,18634,18643,18647,18652,18666,18685,18700],{"__ignoreMap":144},[148,18598,18599],{"class":150,"line":151},[148,18600,18601],{"class":182},"// Client heartbeat poll every 30 seconds\n",[148,18603,18604,18606,18608,18610,18612,18614],{"class":150,"line":172},[148,18605,14078],{"class":207},[148,18607,369],{"class":161},[148,18609,1447],{"class":154},[148,18611,4011],{"class":161},[148,18613,636],{"class":154},[148,18615,211],{"class":161},[148,18617,18618,18620,18622,18624,18627,18630,18632],{"class":150,"line":179},[148,18619,13620],{"class":154},[148,18621,3606],{"class":207},[148,18623,369],{"class":161},[148,18625,18626],{"class":168},"'/api/presence/heartbeat'",[148,18628,18629],{"class":161},", { method: ",[148,18631,3622],{"class":168},[148,18633,2557],{"class":161},[148,18635,18636,18638,18641],{"class":150,"line":186},[148,18637,14149],{"class":161},[148,18639,18640],{"class":235},"30_000",[148,18642,375],{"class":161},[148,18644,18645],{"class":150,"line":192},[148,18646,176],{"emptyLinePlaceholder":175},[148,18648,18649],{"class":150,"line":198},[148,18650,18651],{"class":182},"// Other clients poll for status\n",[148,18653,18654,18656,18658,18660,18662,18664],{"class":150,"line":214},[148,18655,14078],{"class":207},[148,18657,369],{"class":161},[148,18659,1447],{"class":154},[148,18661,4011],{"class":161},[148,18663,636],{"class":154},[148,18665,211],{"class":161},[148,18667,18668,18670,18672,18674,18676,18678,18680,18683],{"class":150,"line":227},[148,18669,3596],{"class":154},[148,18671,3599],{"class":235},[148,18673,329],{"class":154},[148,18675,1502],{"class":154},[148,18677,3606],{"class":207},[148,18679,369],{"class":161},[148,18681,18682],{"class":168},"'/api/presence?userIds=u1,u2,u3'",[148,18684,375],{"class":161},[148,18686,18687,18690,18692,18694,18696,18698],{"class":150,"line":239},[148,18688,18689],{"class":207},"  updatePresenceUI",[148,18691,369],{"class":161},[148,18693,343],{"class":154},[148,18695,3722],{"class":161},[148,18697,3725],{"class":207},[148,18699,2531],{"class":161},[148,18701,18702,18704,18707],{"class":150,"line":249},[148,18703,14149],{"class":161},[148,18705,18706],{"class":235},"5_000",[148,18708,375],{"class":161},[12,18710,18711],{},"This works for a demo. It falls apart in production for several reasons.",[12,18713,18714],{},"First, the polling interval is a compromise you can never win. Poll every 5 seconds and you hammer your API with requests. Poll every 30 seconds and the UI feels stale. A user can log out and their presence stays green for half a minute.",[12,18716,18717],{},"Second, the load scales badly. With 1,000 users each polling every 5 seconds, you have 200 requests per second just to maintain a feature that should be passive. Add more users and you add proportionally more load, with zero actual information being delivered most of the time, since status has not changed.",[12,18719,18720],{},"Third, ungraceful disconnects are invisible. If a user closes their laptop, the heartbeat stops. Your server only knows they are gone after the heartbeat timeout expires, which you set at 60 seconds to avoid false negatives. So users stay \"online\" for a minute after they have left.",[35,18722,18724],{"id":18723},"the-event-driven-approach","The Event-Driven Approach",[12,18726,18727],{},"A better model flips the direction. Instead of clients asking \"who is online?\", the server pushes presence changes to subscribers as they happen. This is the pub/sub model applied to presence.",[12,18729,18730],{},"When a user connects, the server emits a join event to the relevant channel. When they disconnect, it emits a leave event. Subscribers receive these events in real time and update their local state accordingly.",[135,18732,18733],{},[138,18734,18736],{"className":2564,"code":18735,"filename":142,"language":2566,"meta":144,"style":144},"// Server emits on connection\npresence.emit('join', {\n  userId: 'u_abc123',\n  status: 'online',\n  metadata: { displayName: 'Alice', avatar: 'https://...' },\n  roomId: 'room_xyz',\n  timestamp: Date.now(),\n})\n\n// Server emits on disconnect\npresence.emit('leave', {\n  userId: 'u_abc123',\n  roomId: 'room_xyz',\n  timestamp: Date.now(),\n})\n",[84,18737,18738,18743,18757,18767,18777,18792,18802,18811,18815,18819,18824,18837,18845,18853,18861],{"__ignoreMap":144},[148,18739,18740],{"class":150,"line":151},[148,18741,18742],{"class":182},"// Server emits on connection\n",[148,18744,18745,18748,18750,18752,18755],{"class":150,"line":172},[148,18746,18747],{"class":161},"presence.",[148,18749,2807],{"class":207},[148,18751,369],{"class":161},[148,18753,18754],{"class":168},"'join'",[148,18756,3614],{"class":161},[148,18758,18759,18762,18765],{"class":150,"line":179},[148,18760,18761],{"class":161},"  userId: ",[148,18763,18764],{"class":168},"'u_abc123'",[148,18766,540],{"class":161},[148,18768,18769,18772,18775],{"class":150,"line":186},[148,18770,18771],{"class":161},"  status: ",[148,18773,18774],{"class":168},"'online'",[148,18776,540],{"class":161},[148,18778,18779,18782,18784,18787,18790],{"class":150,"line":192},[148,18780,18781],{"class":161},"  metadata: { displayName: ",[148,18783,14896],{"class":168},[148,18785,18786],{"class":161},", avatar: ",[148,18788,18789],{"class":168},"'https://...'",[148,18791,6234],{"class":161},[148,18793,18794,18797,18800],{"class":150,"line":198},[148,18795,18796],{"class":161},"  roomId: ",[148,18798,18799],{"class":168},"'room_xyz'",[148,18801,540],{"class":161},[148,18803,18804,18807,18809],{"class":150,"line":214},[148,18805,18806],{"class":161},"  timestamp: Date.",[148,18808,6462],{"class":207},[148,18810,9552],{"class":161},[148,18812,18813],{"class":150,"line":227},[148,18814,853],{"class":161},[148,18816,18817],{"class":150,"line":239},[148,18818,176],{"emptyLinePlaceholder":175},[148,18820,18821],{"class":150,"line":249},[148,18822,18823],{"class":182},"// Server emits on disconnect\n",[148,18825,18826,18828,18830,18832,18835],{"class":150,"line":259},[148,18827,18747],{"class":161},[148,18829,2807],{"class":207},[148,18831,369],{"class":161},[148,18833,18834],{"class":168},"'leave'",[148,18836,3614],{"class":161},[148,18838,18839,18841,18843],{"class":150,"line":654},[148,18840,18761],{"class":161},[148,18842,18764],{"class":168},[148,18844,540],{"class":161},[148,18846,18847,18849,18851],{"class":150,"line":660},[148,18848,18796],{"class":161},[148,18850,18799],{"class":168},[148,18852,540],{"class":161},[148,18854,18855,18857,18859],{"class":150,"line":1289},[148,18856,18806],{"class":161},[148,18858,6462],{"class":207},[148,18860,9552],{"class":161},[148,18862,18863],{"class":150,"line":1295},[148,18864,853],{"class":161},[12,18866,18867],{},"Clients subscribe to the presence channel for the rooms they care about. No polling, no wasted requests, no artificial delay. Updates arrive within milliseconds of the actual state change.",[35,18869,18871],{"id":18870},"per-room-vs-global-presence","Per-Room vs Global Presence",[12,18873,18874],{},"There are two presence scopes you will need to think about separately.",[12,18876,18877,18880],{},[30,18878,18879],{},"Per-room presence"," tracks who is in a specific room right now. This is the right model for chat rooms, collaborative documents, video calls, or any context where membership is bounded. A user joining room A does not need to know about users in rooms B through Z.",[12,18882,18883,18886],{},[30,18884,18885],{},"Global presence"," (sometimes called a lobby) tracks who is online across the entire application, regardless of which room they are in. This is useful for friend lists, direct message availability indicators, or dashboards where you want to see all connected users.",[12,18888,18889],{},"Most applications need both. A chat product might show global presence on a contacts sidebar while showing per-room presence inside a conversation. The architecture handles them differently. Per-room presence is scoped to the room's pub/sub channel. Global presence runs on a separate shared channel that all authenticated connections subscribe to on connect and unsubscribe from on disconnect.",[35,18891,18893],{"id":18892},"edge-cases-that-will-bite-you","Edge Cases That Will Bite You",[7470,18895,18897],{"id":18896},"tab-switching","Tab Switching",[12,18899,18900,18901,18904],{},"When a user switches to another browser tab, the Page Visibility API fires a ",[84,18902,18903],{},"visibilitychange"," event. Most presence systems interpret this as \"away\" after a short delay. The trick is debouncing: a user switching tabs to check Slack and switching back within 10 seconds should not flip to away and back. Set a threshold, typically 30 to 60 seconds of hidden state, before emitting an away transition.",[7470,18906,18908],{"id":18907},"mobile-backgrounding","Mobile Backgrounding",[12,18910,18911],{},"On mobile, backgrounding an app is more aggressive than tab switching. The OS can suspend the JavaScript thread entirely, which means the WebSocket connection stops sending keepalives. The server will see the connection as dead after the heartbeat timeout. When the user foregrounds the app, you need to detect that the socket was dropped and reconnect, then re-emit a join event with updated metadata.",[7470,18913,18915],{"id":18914},"ungraceful-disconnects","Ungraceful Disconnects",[12,18917,18918,18919,18922],{},"A graceful disconnect happens when the client calls ",[84,18920,18921],{},"socket.close()"," and the server receives the close frame. An ungraceful disconnect happens when the network drops, the process is killed, or the device loses power. In the ungraceful case, the server will not know the client is gone until the heartbeat timeout fires.",[12,18924,18925],{},"The standard solution is a server-side heartbeat: the server expects a ping from each client on a regular interval, typically every 20 to 30 seconds. If no ping arrives within the window, the server marks the client as disconnected and emits a leave event. Clients that are truly connected will send their ping and stay online. Clients that have silently dropped will be cleaned up within one heartbeat window.",[7470,18927,18929],{"id":18928},"multiple-connections","Multiple Connections",[12,18931,18932],{},"A single user can be connected from multiple tabs or devices simultaneously. A naive implementation will emit a leave event when one tab closes, even though the user is still connected from another tab. The fix is reference counting at the user level. The server tracks how many active connections exist per user ID. It only emits a leave event when the count reaches zero.",[35,18934,18936],{"id":18935},"how-nolag-handles-presence","How NoLag Handles Presence",[12,18938,18939],{},"NoLag builds presence into the room and lobby primitives so you do not have to wire it yourself. Three things are worth knowing up front, because each one differs from the generic design above.",[12,18941,18942,18945,18946,18948,18949,18951,18952,18954],{},[30,18943,18944],{},"Membership is explicit."," Subscribing to a room's topics does not put you in its presence list. A client joins by calling ",[84,18947,3393],{},", which is also how it updates its metadata later; the first call is the join event other members see. A connection holds presence in one room at a time, so setting presence in a second room moves it there. And presence ",[22,18950,13100],{}," are delivered on the client, not the room: ",[84,18953,16743],{}," is for topic messages.",[135,18956,18957],{},[138,18958,18960],{"className":2564,"code":18959,"filename":142,"language":2566,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\n\nconst client = NoLag(ACTOR_TOKEN)\n\n// Presence events arrive on the client. Each carries actorTokenId and presence.\nclient.on('presence:join', (actor) => addToRoster(actor.actorTokenId, actor.presence))\nclient.on('presence:update', (actor) => updateRoster(actor.actorTokenId, actor.presence))\nclient.on('presence:leave', (actor) => removeFromRoster(actor.actorTokenId))\n\nawait client.connect()\n\n// APP_SLUG is the slug returned when you created the app (it has a random\n// suffix). The room must already exist: rooms are created in the control plane.\nconst room = client.setApp(APP_SLUG).setRoom('room_xyz')\n\n// This is the join. Include whatever metadata other members should see.\nroom.setPresence({ status: 'online', displayName: 'Alice', avatar: 'https://...' })\n\n// Bootstrap the roster with the current members\nconst members = await room.fetchPresence()\n\n// Update at any time; the others receive presence:update\nroom.setPresence({ status: 'away', displayName: 'Alice', avatar: 'https://...' })\n",[84,18961,18962,18972,18976,18992,18996,19001,19026,19050,19075,19079,19089,19093,19097,19102,19128,19132,19137,19160,19164,19169,19187,19191,19196],{"__ignoreMap":144},[148,18963,18964,18966,18968,18970],{"class":150,"line":151},[148,18965,155],{"class":154},[148,18967,2575],{"class":161},[148,18969,165],{"class":154},[148,18971,169],{"class":168},[148,18973,18974],{"class":150,"line":172},[148,18975,176],{"emptyLinePlaceholder":175},[148,18977,18978,18980,18982,18984,18986,18988,18990],{"class":150,"line":179},[148,18979,323],{"class":154},[148,18981,1589],{"class":235},[148,18983,329],{"class":154},[148,18985,1594],{"class":207},[148,18987,369],{"class":161},[148,18989,17398],{"class":235},[148,18991,375],{"class":161},[148,18993,18994],{"class":150,"line":186},[148,18995,176],{"emptyLinePlaceholder":175},[148,18997,18998],{"class":150,"line":192},[148,18999,19000],{"class":182},"// Presence events arrive on the client. Each carries actorTokenId and presence.\n",[148,19002,19003,19005,19007,19009,19012,19014,19016,19018,19020,19023],{"class":150,"line":198},[148,19004,16176],{"class":161},[148,19006,2706],{"class":207},[148,19008,369],{"class":161},[148,19010,19011],{"class":168},"'presence:join'",[148,19013,2713],{"class":161},[148,19015,2839],{"class":217},[148,19017,633],{"class":161},[148,19019,636],{"class":154},[148,19021,19022],{"class":207}," addToRoster",[148,19024,19025],{"class":161},"(actor.actorTokenId, actor.presence))\n",[148,19027,19028,19030,19032,19034,19037,19039,19041,19043,19045,19048],{"class":150,"line":214},[148,19029,16176],{"class":161},[148,19031,2706],{"class":207},[148,19033,369],{"class":161},[148,19035,19036],{"class":168},"'presence:update'",[148,19038,2713],{"class":161},[148,19040,2839],{"class":217},[148,19042,633],{"class":161},[148,19044,636],{"class":154},[148,19046,19047],{"class":207}," updateRoster",[148,19049,19025],{"class":161},[148,19051,19052,19054,19056,19058,19061,19063,19065,19067,19069,19072],{"class":150,"line":227},[148,19053,16176],{"class":161},[148,19055,2706],{"class":207},[148,19057,369],{"class":161},[148,19059,19060],{"class":168},"'presence:leave'",[148,19062,2713],{"class":161},[148,19064,2839],{"class":217},[148,19066,633],{"class":161},[148,19068,636],{"class":154},[148,19070,19071],{"class":207}," removeFromRoster",[148,19073,19074],{"class":161},"(actor.actorTokenId))\n",[148,19076,19077],{"class":150,"line":239},[148,19078,176],{"emptyLinePlaceholder":175},[148,19080,19081,19083,19085,19087],{"class":150,"line":249},[148,19082,343],{"class":154},[148,19084,4099],{"class":161},[148,19086,2647],{"class":207},[148,19088,352],{"class":161},[148,19090,19091],{"class":150,"line":259},[148,19092,176],{"emptyLinePlaceholder":175},[148,19094,19095],{"class":150,"line":654},[148,19096,18213],{"class":182},[148,19098,19099],{"class":150,"line":660},[148,19100,19101],{"class":182},"// suffix). The room must already exist: rooms are created in the control plane.\n",[148,19103,19104,19106,19108,19110,19112,19114,19116,19118,19120,19122,19124,19126],{"class":150,"line":1289},[148,19105,323],{"class":154},[148,19107,359],{"class":235},[148,19109,329],{"class":154},[148,19111,4099],{"class":161},[148,19113,2667],{"class":207},[148,19115,369],{"class":161},[148,19117,2672],{"class":235},[148,19119,2675],{"class":161},[148,19121,2678],{"class":207},[148,19123,369],{"class":161},[148,19125,18799],{"class":168},[148,19127,375],{"class":161},[148,19129,19130],{"class":150,"line":1295},[148,19131,176],{"emptyLinePlaceholder":175},[148,19133,19134],{"class":150,"line":1313},[148,19135,19136],{"class":182},"// This is the join. Include whatever metadata other members should see.\n",[148,19138,19139,19141,19144,19147,19149,19152,19154,19156,19158],{"class":150,"line":1331},[148,19140,380],{"class":161},[148,19142,19143],{"class":207},"setPresence",[148,19145,19146],{"class":161},"({ status: ",[148,19148,18774],{"class":168},[148,19150,19151],{"class":161},", displayName: ",[148,19153,14896],{"class":168},[148,19155,18786],{"class":161},[148,19157,18789],{"class":168},[148,19159,2557],{"class":161},[148,19161,19162],{"class":150,"line":1346},[148,19163,176],{"emptyLinePlaceholder":175},[148,19165,19166],{"class":150,"line":1355},[148,19167,19168],{"class":182},"// Bootstrap the roster with the current members\n",[148,19170,19171,19173,19176,19178,19180,19182,19185],{"class":150,"line":1361},[148,19172,323],{"class":154},[148,19174,19175],{"class":235}," members",[148,19177,329],{"class":154},[148,19179,1502],{"class":154},[148,19181,6701],{"class":161},[148,19183,19184],{"class":207},"fetchPresence",[148,19186,352],{"class":161},[148,19188,19189],{"class":150,"line":1370},[148,19190,176],{"emptyLinePlaceholder":175},[148,19192,19193],{"class":150,"line":1376},[148,19194,19195],{"class":182},"// Update at any time; the others receive presence:update\n",[148,19197,19198,19200,19202,19204,19206,19208,19210,19212,19214],{"class":150,"line":2033},[148,19199,380],{"class":161},[148,19201,19143],{"class":207},[148,19203,19146],{"class":161},[148,19205,15716],{"class":168},[148,19207,19151],{"class":161},[148,19209,14896],{"class":168},[148,19211,18786],{"class":161},[148,19213,18789],{"class":168},[148,19215,2557],{"class":161},[12,19217,19218,19221,19222,19224],{},[30,19219,19220],{},"Heartbeats and drops are handled for you."," The SDK sends a heartbeat every 30 seconds, and the broker drops a connection it has not heard from for 60 seconds and broadcasts the leave, so a silently dropped client is cleaned up within about a minute. A graceful disconnect leaves immediately. Reconnecting restores the connection's subscriptions but not its room presence, so set it again on ",[84,19223,2647],{}," (the blueprint SDKs do this for you).",[12,19226,19227,19230,19231,19233],{},[30,19228,19229],{},"Presence is per connection."," Each entry is identified by ",[84,19232,12310],{},". If one person can be connected from several devices on the same actor, each connection joins and leaves on its own, so do the reference counting in your application, or give each device its own actor.",[12,19235,19236,19237,19241],{},"For global presence, use a ",[952,19238,19240],{"href":19239},"/docs/concepts/lobbies","lobby",". A lobby is an explicit group of rooms, configured in the control plane, and presence set on any of its rooms mirrors into it (a room can belong to at most 10 lobbies). It is observe-only: you cannot set presence on a lobby, and only actors that have set presence on one of its rooms appear in it. Subscribing returns a snapshot to bootstrap a contacts list, then live updates follow:",[135,19243,19244],{},[138,19245,19247],{"className":2564,"code":19246,"filename":142,"language":2566,"meta":144,"style":144},"const lobby = client.setApp(APP_SLUG).setLobby('online')\n\n// Snapshot keyed by room, then by actor: { roomId: { actorId: presence } }\nconst snapshot = await lobby.subscribe()\n\nlobby.on('presence:join', ({ roomId, actorId, data }) => showOnline(actorId, roomId, data))\nlobby.on('presence:leave', ({ actorId }) => showOffline(actorId))\nlobby.on('presence:update', ({ actorId, data }) => showOnline(actorId, undefined, data))\n",[84,19248,19249,19277,19281,19286,19304,19308,19342,19366],{"__ignoreMap":144},[148,19250,19251,19253,19256,19258,19260,19262,19264,19266,19268,19271,19273,19275],{"class":150,"line":151},[148,19252,323],{"class":154},[148,19254,19255],{"class":235}," lobby",[148,19257,329],{"class":154},[148,19259,4099],{"class":161},[148,19261,2667],{"class":207},[148,19263,369],{"class":161},[148,19265,2672],{"class":235},[148,19267,2675],{"class":161},[148,19269,19270],{"class":207},"setLobby",[148,19272,369],{"class":161},[148,19274,18774],{"class":168},[148,19276,375],{"class":161},[148,19278,19279],{"class":150,"line":172},[148,19280,176],{"emptyLinePlaceholder":175},[148,19282,19283],{"class":150,"line":179},[148,19284,19285],{"class":182},"// Snapshot keyed by room, then by actor: { roomId: { actorId: presence } }\n",[148,19287,19288,19290,19293,19295,19297,19300,19302],{"class":150,"line":186},[148,19289,323],{"class":154},[148,19291,19292],{"class":235}," snapshot",[148,19294,329],{"class":154},[148,19296,1502],{"class":154},[148,19298,19299],{"class":161}," lobby.",[148,19301,2692],{"class":207},[148,19303,352],{"class":161},[148,19305,19306],{"class":150,"line":192},[148,19307,176],{"emptyLinePlaceholder":175},[148,19309,19310,19313,19315,19317,19319,19321,19324,19326,19328,19330,19332,19334,19336,19339],{"class":150,"line":198},[148,19311,19312],{"class":161},"lobby.",[148,19314,2706],{"class":207},[148,19316,369],{"class":161},[148,19318,19011],{"class":168},[148,19320,15386],{"class":161},[148,19322,19323],{"class":217},"roomId",[148,19325,87],{"class":161},[148,19327,11913],{"class":217},[148,19329,87],{"class":161},[148,19331,2716],{"class":217},[148,19333,15392],{"class":161},[148,19335,636],{"class":154},[148,19337,19338],{"class":207}," showOnline",[148,19340,19341],{"class":161},"(actorId, roomId, data))\n",[148,19343,19344,19346,19348,19350,19352,19354,19356,19358,19360,19363],{"class":150,"line":214},[148,19345,19312],{"class":161},[148,19347,2706],{"class":207},[148,19349,369],{"class":161},[148,19351,19060],{"class":168},[148,19353,15386],{"class":161},[148,19355,11913],{"class":217},[148,19357,15392],{"class":161},[148,19359,636],{"class":154},[148,19361,19362],{"class":207}," showOffline",[148,19364,19365],{"class":161},"(actorId))\n",[148,19367,19368,19370,19372,19374,19376,19378,19380,19382,19384,19386,19388,19390,19393,19396],{"class":150,"line":227},[148,19369,19312],{"class":161},[148,19371,2706],{"class":207},[148,19373,369],{"class":161},[148,19375,19036],{"class":168},[148,19377,15386],{"class":161},[148,19379,11913],{"class":217},[148,19381,87],{"class":161},[148,19383,2716],{"class":217},[148,19385,15392],{"class":161},[148,19387,636],{"class":154},[148,19389,19338],{"class":207},[148,19391,19392],{"class":161},"(actorId, ",[148,19394,19395],{"class":235},"undefined",[148,19397,19398],{"class":161},", data))\n",[12,19400,19401,19402,19405,19406,19408],{},"Custom metadata is first-class: whatever object you pass to ",[84,19403,19404],{},"room.setPresence"," is what other members receive, and calling it again propagates the change as a ",[84,19407,3403],{},". There is no presence field in the connect options, so the sequence is always connect first, then set presence on a room. No custom presence server, no polling, no heartbeat management code in your application.",[969,19410,19411],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":144,"searchDepth":172,"depth":172,"links":19413},[19414,19415,19416,19417,19418,19424],{"id":18546,"depth":172,"text":18547},{"id":18585,"depth":172,"text":18586},{"id":18723,"depth":172,"text":18724},{"id":18870,"depth":172,"text":18871},{"id":18892,"depth":172,"text":18893,"children":19419},[19420,19421,19422,19423],{"id":18896,"depth":179,"text":18897},{"id":18907,"depth":179,"text":18908},{"id":18914,"depth":179,"text":18915},{"id":18928,"depth":179,"text":18929},{"id":18935,"depth":172,"text":18936},"Engineering","2026-03-24","Presence tracking looks simple until you have 10,000 concurrent users across hundreds of rooms. Learn the architecture behind scalable online/offline status, heartbeat timeouts, and per-room vs global presence.",{},"/blog/presence-tracking-at-scale",{"title":18535,"description":19427},"blog/presence-tracking-at-scale","srMOfwKmW0dLo-c0URLCB6TLVkJp6B5UFHb10_Ytp1k",{"id":19434,"title":19435,"author":7,"body":19436,"category":7894,"date":20094,"description":20095,"excerpt":986,"extension":987,"meta":20096,"navigation":175,"path":20097,"readTime":3502,"seo":20098,"stem":20099,"__hash__":20100},"blog/blog/real-time-dashboard-filters.md","Real-Time Dashboards Without the Firehose: Filtered Subscriptions",{"type":9,"value":19437,"toc":20085},[19438,19441,19444,19448,19455,19458,19461,19465,19468,19681,19684,19687,19791,19798,19808,19812,19815,19818,19821,19825,19828,19831,20007,20016,20029,20033,20036,20039,20043,20046,20049,20052,20056,20079,20082],[12,19439,19440],{},"Real-time dashboards have a fundamental mismatch at their core. The dashboard shows 10 orders at a time. The orders topic has 50,000 active orders. Without any filtering, every subscriber receives every update for every order, all day, even though they only care about the 10 currently on screen.",[12,19442,19443],{},"This is the firehose problem. It wastes bandwidth, taxes the client's rendering thread, and scales terribly. The fix is filtered subscriptions: subscribing to a topic but telling the infrastructure exactly which entities within that topic you care about.",[35,19445,19447],{"id":19446},"the-unfiltered-problem-in-detail","The Unfiltered Problem in Detail",[12,19449,19450,19451,19454],{},"Imagine an e-commerce operations dashboard. The platform processes 500 order updates per second across 50,000 live orders, published by a dozen order services (a single NoLag connection may publish at most 50 messages a second, so volume like this always comes from several publishers). An operator loads the dashboard and subscribes to the ",[84,19452,19453],{},"orders"," topic. Their browser immediately starts receiving all 500 updates per second, rendering changes for orders they cannot even see. The tab uses hundreds of megabytes per hour of bandwidth. On a slower connection, frames drop and the UI stutters.",[12,19456,19457],{},"The typical band-aid is to debounce or throttle on the client side: only re-render every 250ms, discard updates for off-screen items. But you are still transmitting everything over the wire. The client is still doing work to parse and discard messages. The problem is just hidden behind a render optimization.",[12,19459,19460],{},"The right fix is infrastructure-level filtering: only send the client the messages it actually needs.",[35,19462,19464],{"id":19463},"how-topic-filters-work","How Topic Filters Work",[12,19466,19467],{},"A topic filter is a set of routing keys you attach to a subscription. When you subscribe to a topic, you include a list of entity identifiers, and the infrastructure only delivers messages that were published with one of those keys.",[135,19469,19470],{},[138,19471,19473],{"className":140,"code":19472,"filename":142,"language":143,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\n\nconst client = NoLag('YOUR_TOKEN')\nawait client.connect()   // subscribe before connect fails with \"Not connected\"\n\n// APP_SLUG is the slug returned when you created the app (it has a random\n// suffix); create the orders-room room in the control plane first.\nconst room = client.setApp(APP_SLUG).setRoom('orders-room')\n\n// Subscribe to the orders topic with filters,\n// only receive updates for the 10 orders currently on screen\nroom.subscribe('orders', {\n  filters: [\n    'order_abc123',\n    'order_def456',\n    'order_ghi789',\n    // ...up to 100 keys per topic\n  ],\n})\n\nroom.on('orders', (order, meta) => {\n  updateOrderRow(order)          // the payload, as published\n  console.log(meta.filter)       // the key it was published with, e.g. 'order_abc123'\n})\n",[84,19474,19475,19485,19489,19506,19520,19524,19528,19533,19560,19564,19569,19574,19586,19591,19598,19605,19612,19617,19622,19626,19630,19654,19665,19677],{"__ignoreMap":144},[148,19476,19477,19479,19481,19483],{"class":150,"line":151},[148,19478,155],{"class":154},[148,19480,2575],{"class":161},[148,19482,165],{"class":154},[148,19484,169],{"class":168},[148,19486,19487],{"class":150,"line":172},[148,19488,176],{"emptyLinePlaceholder":175},[148,19490,19491,19493,19495,19497,19499,19501,19504],{"class":150,"line":179},[148,19492,323],{"class":154},[148,19494,1589],{"class":235},[148,19496,329],{"class":154},[148,19498,1594],{"class":207},[148,19500,369],{"class":161},[148,19502,19503],{"class":168},"'YOUR_TOKEN'",[148,19505,375],{"class":161},[148,19507,19508,19510,19512,19514,19517],{"class":150,"line":186},[148,19509,343],{"class":154},[148,19511,4099],{"class":161},[148,19513,2647],{"class":207},[148,19515,19516],{"class":161},"()   ",[148,19518,19519],{"class":182},"// subscribe before connect fails with \"Not connected\"\n",[148,19521,19522],{"class":150,"line":192},[148,19523,176],{"emptyLinePlaceholder":175},[148,19525,19526],{"class":150,"line":198},[148,19527,18213],{"class":182},[148,19529,19530],{"class":150,"line":214},[148,19531,19532],{"class":182},"// suffix); create the orders-room room in the control plane first.\n",[148,19534,19535,19537,19539,19541,19543,19545,19547,19549,19551,19553,19555,19558],{"class":150,"line":227},[148,19536,323],{"class":154},[148,19538,359],{"class":235},[148,19540,329],{"class":154},[148,19542,4099],{"class":161},[148,19544,2667],{"class":207},[148,19546,369],{"class":161},[148,19548,2672],{"class":235},[148,19550,2675],{"class":161},[148,19552,2678],{"class":207},[148,19554,369],{"class":161},[148,19556,19557],{"class":168},"'orders-room'",[148,19559,375],{"class":161},[148,19561,19562],{"class":150,"line":239},[148,19563,176],{"emptyLinePlaceholder":175},[148,19565,19566],{"class":150,"line":249},[148,19567,19568],{"class":182},"// Subscribe to the orders topic with filters,\n",[148,19570,19571],{"class":150,"line":259},[148,19572,19573],{"class":182},"// only receive updates for the 10 orders currently on screen\n",[148,19575,19576,19578,19580,19582,19584],{"class":150,"line":654},[148,19577,380],{"class":161},[148,19579,2692],{"class":207},[148,19581,369],{"class":161},[148,19583,17166],{"class":168},[148,19585,3614],{"class":161},[148,19587,19588],{"class":150,"line":660},[148,19589,19590],{"class":161},"  filters: [\n",[148,19592,19593,19596],{"class":150,"line":1289},[148,19594,19595],{"class":168},"    'order_abc123'",[148,19597,540],{"class":161},[148,19599,19600,19603],{"class":150,"line":1295},[148,19601,19602],{"class":168},"    'order_def456'",[148,19604,540],{"class":161},[148,19606,19607,19610],{"class":150,"line":1313},[148,19608,19609],{"class":168},"    'order_ghi789'",[148,19611,540],{"class":161},[148,19613,19614],{"class":150,"line":1331},[148,19615,19616],{"class":182},"    // ...up to 100 keys per topic\n",[148,19618,19619],{"class":150,"line":1346},[148,19620,19621],{"class":161},"  ],\n",[148,19623,19624],{"class":150,"line":1355},[148,19625,853],{"class":161},[148,19627,19628],{"class":150,"line":1361},[148,19629,176],{"emptyLinePlaceholder":175},[148,19631,19632,19634,19636,19638,19640,19642,19644,19646,19648,19650,19652],{"class":150,"line":1370},[148,19633,380],{"class":161},[148,19635,2706],{"class":207},[148,19637,369],{"class":161},[148,19639,17166],{"class":168},[148,19641,2713],{"class":161},[148,19643,17231],{"class":217},[148,19645,87],{"class":161},[148,19647,16855],{"class":217},[148,19649,633],{"class":161},[148,19651,636],{"class":154},[148,19653,211],{"class":161},[148,19655,19656,19659,19662],{"class":150,"line":1376},[148,19657,19658],{"class":207},"  updateOrderRow",[148,19660,19661],{"class":161},"(order)          ",[148,19663,19664],{"class":182},"// the payload, as published\n",[148,19666,19667,19669,19671,19674],{"class":150,"line":2033},[148,19668,2514],{"class":161},[148,19670,2517],{"class":207},[148,19672,19673],{"class":161},"(meta.filter)       ",[148,19675,19676],{"class":182},"// the key it was published with, e.g. 'order_abc123'\n",[148,19678,19679],{"class":150,"line":2039},[148,19680,853],{"class":161},[12,19682,19683],{},"The infrastructure evaluates the filter on the server side before the message is transmitted. Each key is its own routing key on the broker, so there is no payload inspection and no schema for the broker to learn: a subscription with filters receives only messages published with one of its keys, and everything else is never sent to your connection.",[12,19685,19686],{},"Publishers do not change their topic. They publish as normal and name the entity key on the publish:",[135,19688,19689],{},[138,19690,19692],{"className":140,"code":19691,"filename":142,"language":143,"meta":144,"style":144},"// An order service publishes an update, tagged with the order's key.\n// It is a different actor from the dashboard: publishers never receive their own messages.\nconst room = client.setApp(APP_SLUG).setRoom('orders-room')\n\nroom.emit('orders', {\n  orderId: 'order_abc123',\n  status: 'shipped',\n  updatedAt: new Date().toISOString(),\n}, { filter: 'order_abc123' })\n",[84,19693,19694,19699,19704,19730,19734,19746,19756,19765,19782],{"__ignoreMap":144},[148,19695,19696],{"class":150,"line":151},[148,19697,19698],{"class":182},"// An order service publishes an update, tagged with the order's key.\n",[148,19700,19701],{"class":150,"line":172},[148,19702,19703],{"class":182},"// It is a different actor from the dashboard: publishers never receive their own messages.\n",[148,19705,19706,19708,19710,19712,19714,19716,19718,19720,19722,19724,19726,19728],{"class":150,"line":179},[148,19707,323],{"class":154},[148,19709,359],{"class":235},[148,19711,329],{"class":154},[148,19713,4099],{"class":161},[148,19715,2667],{"class":207},[148,19717,369],{"class":161},[148,19719,2672],{"class":235},[148,19721,2675],{"class":161},[148,19723,2678],{"class":207},[148,19725,369],{"class":161},[148,19727,19557],{"class":168},[148,19729,375],{"class":161},[148,19731,19732],{"class":150,"line":186},[148,19733,176],{"emptyLinePlaceholder":175},[148,19735,19736,19738,19740,19742,19744],{"class":150,"line":192},[148,19737,380],{"class":161},[148,19739,2807],{"class":207},[148,19741,369],{"class":161},[148,19743,17166],{"class":168},[148,19745,3614],{"class":161},[148,19747,19748,19751,19754],{"class":150,"line":198},[148,19749,19750],{"class":161},"  orderId: ",[148,19752,19753],{"class":168},"'order_abc123'",[148,19755,540],{"class":161},[148,19757,19758,19760,19763],{"class":150,"line":214},[148,19759,18771],{"class":161},[148,19761,19762],{"class":168},"'shipped'",[148,19764,540],{"class":161},[148,19766,19767,19770,19772,19775,19777,19780],{"class":150,"line":227},[148,19768,19769],{"class":161},"  updatedAt: ",[148,19771,436],{"class":154},[148,19773,19774],{"class":207}," Date",[148,19776,10326],{"class":161},[148,19778,19779],{"class":207},"toISOString",[148,19781,9552],{"class":161},[148,19783,19784,19787,19789],{"class":150,"line":239},[148,19785,19786],{"class":161},"}, { filter: ",[148,19788,19753],{"class":168},[148,19790,2557],{"class":161},[12,19792,19793,19794,19797],{},"Subscribers with ",[84,19795,19796],{},"order_abc123"," in their filter list receive this. Subscribers with a filter list that does not include it do not. The logic lives entirely in the infrastructure, not in application code.",[12,19799,19800,19801,19804,19805,19807],{},"One nuance is worth knowing before you rely on it. A subscription ",[30,19802,19803],{},"without"," filters is a wildcard: it receives every message on the topic, including the tagged ones, which is what an \"all orders\" admin view wants. And a message published ",[30,19806,19803],{}," a filter reaches only those wildcard subscribers, never a filtered one. So filters narrow what a client asks for; they are not a privacy boundary. Use per-topic ACL for that.",[35,19809,19811],{"id":19810},"why-infrastructure-level-filtering-matters","Why Infrastructure-Level Filtering Matters",[12,19813,19814],{},"Client-side filtering is not a real solution. Even if you discard messages in JavaScript before updating the UI, you have still paid for the network transmission of those messages. On a mobile connection, that matters a lot. On a connection with a data cap, it matters even more.",[12,19816,19817],{},"Infrastructure-level filtering means the message is evaluated once, at the server, before any transmission happens. A client subscribed to 10 orders out of 50,000 receives roughly 0.02% of the total message volume on that topic. The savings are proportional to how selective the subscription is.",[12,19819,19820],{},"This also reduces load on your infrastructure. Fewer messages transmitted means fewer bytes processed per connection, which translates to more connections you can serve with the same resources.",[35,19822,19824],{"id":19823},"dynamic-filter-swapping","Dynamic Filter Swapping",[12,19826,19827],{},"Dashboards are not static. Users navigate to a different page, apply a filter, or scroll to a different set of orders. When they do, the set of entities on screen changes, and the subscription filters need to change with it.",[12,19829,19830],{},"You can update filters on an active subscription without tearing it down and reconnecting:",[135,19832,19833],{},[138,19834,19836],{"className":140,"code":19835,"filename":142,"language":143,"meta":144,"style":144},"// Dashboard mounts on page 1 of orders\nconst room = client.setApp(APP_SLUG).setRoom('orders-room')\n\nroom.subscribe('orders', {\n  filters: currentPageOrderIds,\n})\n\nroom.on('orders', handleOrderUpdate)\n\n// Later, when the user changes page: replace the whole set in one call.\n// The broker diffs the old and new keys itself.\nroom.setFilters('orders', newPageOrderIds)\n\n// Or adjust at the edges as rows scroll in and out of view\nroom.addFilters('orders', ['order_jkl012'])\nroom.removeFilters('orders', ['order_abc123'])\n\n// An empty set switches the subscription back to the wildcard (everything)\nroom.setFilters('orders', [])\n",[84,19837,19838,19843,19869,19873,19885,19890,19894,19898,19911,19915,19920,19925,19939,19943,19948,19968,19985,19989,19994],{"__ignoreMap":144},[148,19839,19840],{"class":150,"line":151},[148,19841,19842],{"class":182},"// Dashboard mounts on page 1 of orders\n",[148,19844,19845,19847,19849,19851,19853,19855,19857,19859,19861,19863,19865,19867],{"class":150,"line":172},[148,19846,323],{"class":154},[148,19848,359],{"class":235},[148,19850,329],{"class":154},[148,19852,4099],{"class":161},[148,19854,2667],{"class":207},[148,19856,369],{"class":161},[148,19858,2672],{"class":235},[148,19860,2675],{"class":161},[148,19862,2678],{"class":207},[148,19864,369],{"class":161},[148,19866,19557],{"class":168},[148,19868,375],{"class":161},[148,19870,19871],{"class":150,"line":179},[148,19872,176],{"emptyLinePlaceholder":175},[148,19874,19875,19877,19879,19881,19883],{"class":150,"line":186},[148,19876,380],{"class":161},[148,19878,2692],{"class":207},[148,19880,369],{"class":161},[148,19882,17166],{"class":168},[148,19884,3614],{"class":161},[148,19886,19887],{"class":150,"line":192},[148,19888,19889],{"class":161},"  filters: currentPageOrderIds,\n",[148,19891,19892],{"class":150,"line":198},[148,19893,853],{"class":161},[148,19895,19896],{"class":150,"line":214},[148,19897,176],{"emptyLinePlaceholder":175},[148,19899,19900,19902,19904,19906,19908],{"class":150,"line":227},[148,19901,380],{"class":161},[148,19903,2706],{"class":207},[148,19905,369],{"class":161},[148,19907,17166],{"class":168},[148,19909,19910],{"class":161},", handleOrderUpdate)\n",[148,19912,19913],{"class":150,"line":239},[148,19914,176],{"emptyLinePlaceholder":175},[148,19916,19917],{"class":150,"line":249},[148,19918,19919],{"class":182},"// Later, when the user changes page: replace the whole set in one call.\n",[148,19921,19922],{"class":150,"line":259},[148,19923,19924],{"class":182},"// The broker diffs the old and new keys itself.\n",[148,19926,19927,19929,19932,19934,19936],{"class":150,"line":654},[148,19928,380],{"class":161},[148,19930,19931],{"class":207},"setFilters",[148,19933,369],{"class":161},[148,19935,17166],{"class":168},[148,19937,19938],{"class":161},", newPageOrderIds)\n",[148,19940,19941],{"class":150,"line":660},[148,19942,176],{"emptyLinePlaceholder":175},[148,19944,19945],{"class":150,"line":1289},[148,19946,19947],{"class":182},"// Or adjust at the edges as rows scroll in and out of view\n",[148,19949,19950,19952,19955,19957,19959,19962,19965],{"class":150,"line":1295},[148,19951,380],{"class":161},[148,19953,19954],{"class":207},"addFilters",[148,19956,369],{"class":161},[148,19958,17166],{"class":168},[148,19960,19961],{"class":161},", [",[148,19963,19964],{"class":168},"'order_jkl012'",[148,19966,19967],{"class":161},"])\n",[148,19969,19970,19972,19975,19977,19979,19981,19983],{"class":150,"line":1313},[148,19971,380],{"class":161},[148,19973,19974],{"class":207},"removeFilters",[148,19976,369],{"class":161},[148,19978,17166],{"class":168},[148,19980,19961],{"class":161},[148,19982,19753],{"class":168},[148,19984,19967],{"class":161},[148,19986,19987],{"class":150,"line":1331},[148,19988,176],{"emptyLinePlaceholder":175},[148,19990,19991],{"class":150,"line":1346},[148,19992,19993],{"class":182},"// An empty set switches the subscription back to the wildcard (everything)\n",[148,19995,19996,19998,20000,20002,20004],{"class":150,"line":1355},[148,19997,380],{"class":161},[148,19999,19931],{"class":207},[148,20001,369],{"class":161},[148,20003,17166],{"class":168},[148,20005,20006],{"class":161},", [])\n",[12,20008,20009,20010,20012,20013,20015],{},"This keeps the subscription lifecycle clean. You open one subscription when the dashboard mounts, swap the filters whenever the visible set changes, and unsubscribe when the user navigates away. No reconnections, no subscription bookkeeping beyond the topic name. Do not re-subscribe with new filters instead: a second ",[84,20011,2692],{}," on the same topic unsubscribes first, so there is a gap, whereas ",[84,20014,19931],{}," sends one frame and the broker applies the difference.",[12,20017,20018,20019,87,20021,87,20024,694,20026,20028],{},"Key values may not contain ",[84,20020,10497],{},[84,20022,20023],{},"#",[84,20025,11760],{},[84,20027,3982],{},", and a subscription holds at most 100 keys per topic. If a screen can show more entities than that, page the subscription the same way you page the table.",[35,20030,20032],{"id":20031},"a-concrete-bandwidth-example","A Concrete Bandwidth Example",[12,20034,20035],{},"Suppose the orders topic sends 500 updates per second. Each message is roughly 400 bytes. That is 200KB/s, or 720MB per hour, for an unfiltered subscriber.",[12,20037,20038],{},"A dashboard showing 10 orders with matching filters receives around 10 updates per second (assuming updates are distributed evenly). That is 4KB/s, or about 14MB per hour. A 50x reduction, and it scales. Show 20 orders and you receive roughly 20 updates per second. Show 5 and you receive fewer. The client's bandwidth usage is directly proportional to what it actually needs to show.",[35,20040,20042],{"id":20041},"why-mobile-makes-this-non-negotiable","Why Mobile Makes This Non-Negotiable",[12,20044,20045],{},"On desktop over a fast connection, an unfiltered subscription is annoying but workable. On mobile over LTE or a congested Wi-Fi network, it is a product defect. Users notice when tabs drain their data plan. They notice when the app feels sluggish because it is processing hundreds of messages per second it does not need.",[12,20047,20048],{},"Mobile devices also have tighter CPU constraints. Parsing, deserializing, and discarding messages in JavaScript is not free. Every message your client receives that it does not use is CPU time spent on nothing. Infrastructure-level filtering means those messages are never serialized, transmitted, or parsed in the first place.",[12,20050,20051],{},"If you are building any kind of real-time dashboard that will be used on mobile, filtered subscriptions are not an optimization. They are the baseline architecture.",[35,20053,20055],{"id":20054},"getting-started-with-nolag-filters","Getting Started with NoLag Filters",[12,20057,20058,20059,20062,20063,20066,20067,87,20069,1812,20072,20075,20076,20078],{},"Filtered subscriptions are built into the NoLag client SDKs. You do not need any special configuration or server-side code changes. Publish with ",[84,20060,20061],{},"{ filter: key }",", subscribe with a ",[84,20064,20065],{},"filters"," array, and the infrastructure handles the rest. Update filters as your UI state changes with ",[84,20068,17310],{},[84,20070,20071],{},"room.addFilters",[84,20073,20074],{},"room.removeFilters",". The ",[952,20077,17315],{"href":17314}," covers the same API in Python and Go, and the AND-composite form for keys that must all match.",[12,20080,20081],{},"The dashboard example above, with 50,000 orders and 10 on screen at a time, is what filters exist for. The routing happens on the broker before a message ever reaches your connection, so the client's cost tracks what is on screen rather than what the platform is doing.",[969,20083,20084],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":144,"searchDepth":172,"depth":172,"links":20086},[20087,20088,20089,20090,20091,20092,20093],{"id":19446,"depth":172,"text":19447},{"id":19463,"depth":172,"text":19464},{"id":19810,"depth":172,"text":19811},{"id":19823,"depth":172,"text":19824},{"id":20031,"depth":172,"text":20032},{"id":20041,"depth":172,"text":20042},{"id":20054,"depth":172,"text":20055},"2026-03-17","Most dashboards show a subset of data, but most pub/sub systems deliver everything. Learn how topic filters subscribe only to the entities on screen, saving bandwidth and keeping UIs snappy.",{},"/blog/real-time-dashboard-filters",{"title":19435,"description":20095},"blog/real-time-dashboard-filters","uuWCBE3b4NJcUqFpcSmhlDwz-4VWAVLeEfHZHKQJ5VE",{"id":20102,"title":20103,"author":7,"body":20104,"category":983,"date":21130,"description":21131,"excerpt":986,"extension":987,"meta":21132,"navigation":175,"path":21133,"readTime":17690,"seo":21134,"stem":21135,"__hash__":21136},"blog/blog/iot-fleet-tracking-websockets.md","IoT Fleet Tracking Over WebSockets: Architecture for 100K Devices",{"type":9,"value":20105,"toc":21122},[20106,20109,20112,20116,20119,20125,20131,20137,20143,20147,20150,20160,20174,20183,20534,20537,20541,20547,20553,20559,20690,20693,20697,20700,20703,20706,21038,21059,21063,21073,21076,21102,21106,21109,21116,21119],[12,20107,20108],{},"Fleet tracking is one of the most demanding real-time workloads you can build. You have a large number of devices, each publishing position updates at a high rate, and a much smaller number of dashboards that need to display a subset of those devices in real time. The architecture has to handle constant inbound telemetry, selective outbound delivery, variable device connectivity, and occasional high-priority alert messages, all at the same time.",[12,20110,20111],{},"This post walks through a practical architecture for fleet tracking at the 100,000-device scale, including topic design, QoS choices, reconnection handling, and how to keep your infrastructure from becoming a bottleneck.",[35,20113,20115],{"id":20114},"the-core-challenge","The Core Challenge",[12,20117,20118],{},"Fleet tracking has a few properties that make it harder than most real-time workloads.",[12,20120,20121,20124],{},[30,20122,20123],{},"Volume."," A fleet of 100,000 vehicles publishing GPS positions every 5 seconds generates 20,000 messages per second. That is constant, not bursty. Your infrastructure needs to sustain that throughput indefinitely, not just survive a spike.",[12,20126,20127,20130],{},[30,20128,20129],{},"Fanout asymmetry."," A single dispatcher dashboard might be watching 50 vehicles. Another might be watching 200. A fleet manager wants an overview of all 100,000. The delivery requirements vary enormously between subscribers, but the ingest pipeline is the same for all devices.",[12,20132,20133,20136],{},[30,20134,20135],{},"Variable connectivity."," Fleet devices are not sitting on a stable office Wi-Fi connection. They are in tunnels, switching between cellular towers, parked in areas with poor signal, and sometimes simply powered off for hours at a time. Your architecture needs to handle reconnections gracefully without losing critical events.",[12,20138,20139,20142],{},[30,20140,20141],{},"Mixed message criticality."," A GPS position update that is 10 seconds old is almost worthless. A geofence breach alert from 10 seconds ago is still very much worth delivering. These two message types need different handling.",[35,20144,20146],{"id":20145},"topic-design-fleet-vs-vehicle","Topic Design: Fleet vs Vehicle",[12,20148,20149],{},"The first architecture decision is how to organize the fleet. There are two main patterns.",[12,20151,20152,20155,20156,20159],{},[30,20153,20154],{},"One room per vehicle:"," Each device publishes into its own room, for example ",[84,20157,20158],{},"vehicle-v_abc123",". Dashboards subscribe to the individual rooms for the vehicles they are tracking.",[12,20161,20162,20165,20166,20169,20170,20173],{},[30,20163,20164],{},"One room per fleet with filtered subscriptions:"," All vehicles in a fleet publish to a shared ",[84,20167,20168],{},"positions"," topic in one room, for example ",[84,20171,20172],{},"fleet-f_xyz",", tagging each publish with the vehicle id as a filter. Dashboards subscribe to the topic with a filter list specifying which vehicle ids they want updates for.",[12,20175,20176,20177,20179,20180,20182],{},"For most workloads, the filtered subscription approach is better. It keeps the subscription count manageable. A dashboard watching 200 vehicles opens one subscription with 200 filters, rather than 200 individual subscriptions. Subscription overhead adds up: connection tracking, heartbeat management, and routing table size all scale with subscription count. Topic names are single tokens (no ",[84,20178,10497],{},"), so the hierarchy is exactly ",[84,20181,17090],{}," and the filter carries the per-vehicle routing key. NoLag caps a subscription at 100 filters, so a dashboard watching more than that opens a second subscription or narrows its view.",[135,20184,20185,20378],{},[138,20186,20188],{"className":140,"code":20187,"filename":142,"language":143,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\n\nconst client = NoLag(DEVICE_TOKEN)\nawait client.connect()\n\n// Create the app and the room first; the slug you get back has a random suffix\nconst room = client.setApp(APP_SLUG).setRoom('fleet-f_xyz')\n\n// Device publishes its position, tagged with its vehicle id as the filter\nroom.emit(\n  'positions',\n  {\n    vehicleId: 'v_abc123',\n    lat: -33.8688,\n    lng: 151.2093,\n    speed: 62,\n    heading: 287,\n    timestamp: Date.now(),\n  },\n  { qos: 0, filter: 'v_abc123' }, // fire and forget for position updates\n)\n",[84,20189,20190,20200,20204,20221,20231,20235,20240,20267,20271,20276,20284,20291,20296,20305,20316,20325,20334,20343,20352,20356,20374],{"__ignoreMap":144},[148,20191,20192,20194,20196,20198],{"class":150,"line":151},[148,20193,155],{"class":154},[148,20195,2575],{"class":161},[148,20197,165],{"class":154},[148,20199,169],{"class":168},[148,20201,20202],{"class":150,"line":172},[148,20203,176],{"emptyLinePlaceholder":175},[148,20205,20206,20208,20210,20212,20214,20216,20219],{"class":150,"line":179},[148,20207,323],{"class":154},[148,20209,1589],{"class":235},[148,20211,329],{"class":154},[148,20213,1594],{"class":207},[148,20215,369],{"class":161},[148,20217,20218],{"class":235},"DEVICE_TOKEN",[148,20220,375],{"class":161},[148,20222,20223,20225,20227,20229],{"class":150,"line":186},[148,20224,343],{"class":154},[148,20226,4099],{"class":161},[148,20228,2647],{"class":207},[148,20230,352],{"class":161},[148,20232,20233],{"class":150,"line":192},[148,20234,176],{"emptyLinePlaceholder":175},[148,20236,20237],{"class":150,"line":198},[148,20238,20239],{"class":182},"// Create the app and the room first; the slug you get back has a random suffix\n",[148,20241,20242,20244,20246,20248,20250,20252,20254,20256,20258,20260,20262,20265],{"class":150,"line":214},[148,20243,323],{"class":154},[148,20245,359],{"class":235},[148,20247,329],{"class":154},[148,20249,4099],{"class":161},[148,20251,2667],{"class":207},[148,20253,369],{"class":161},[148,20255,2672],{"class":235},[148,20257,2675],{"class":161},[148,20259,2678],{"class":207},[148,20261,369],{"class":161},[148,20263,20264],{"class":168},"'fleet-f_xyz'",[148,20266,375],{"class":161},[148,20268,20269],{"class":150,"line":227},[148,20270,176],{"emptyLinePlaceholder":175},[148,20272,20273],{"class":150,"line":239},[148,20274,20275],{"class":182},"// Device publishes its position, tagged with its vehicle id as the filter\n",[148,20277,20278,20280,20282],{"class":150,"line":249},[148,20279,380],{"class":161},[148,20281,2807],{"class":207},[148,20283,527],{"class":161},[148,20285,20286,20289],{"class":150,"line":259},[148,20287,20288],{"class":168},"  'positions'",[148,20290,540],{"class":161},[148,20292,20293],{"class":150,"line":654},[148,20294,20295],{"class":161},"  {\n",[148,20297,20298,20301,20303],{"class":150,"line":660},[148,20299,20300],{"class":161},"    vehicleId: ",[148,20302,16525],{"class":168},[148,20304,540],{"class":161},[148,20306,20307,20310,20312,20314],{"class":150,"line":1289},[148,20308,20309],{"class":161},"    lat: ",[148,20311,6468],{"class":154},[148,20313,16533],{"class":235},[148,20315,540],{"class":161},[148,20317,20318,20321,20323],{"class":150,"line":1295},[148,20319,20320],{"class":161},"    lng: ",[148,20322,16539],{"class":235},[148,20324,540],{"class":161},[148,20326,20327,20330,20332],{"class":150,"line":1313},[148,20328,20329],{"class":161},"    speed: ",[148,20331,16545],{"class":235},[148,20333,540],{"class":161},[148,20335,20336,20339,20341],{"class":150,"line":1331},[148,20337,20338],{"class":161},"    heading: ",[148,20340,16551],{"class":235},[148,20342,540],{"class":161},[148,20344,20345,20348,20350],{"class":150,"line":1346},[148,20346,20347],{"class":161},"    timestamp: Date.",[148,20349,6462],{"class":207},[148,20351,9552],{"class":161},[148,20353,20354],{"class":150,"line":1355},[148,20355,6239],{"class":161},[148,20357,20358,20361,20363,20366,20368,20371],{"class":150,"line":1361},[148,20359,20360],{"class":161},"  { qos: ",[148,20362,9227],{"class":235},[148,20364,20365],{"class":161},", filter: ",[148,20367,16525],{"class":168},[148,20369,20370],{"class":161}," }, ",[148,20372,20373],{"class":182},"// fire and forget for position updates\n",[148,20375,20376],{"class":150,"line":1370},[148,20377,375],{"class":161},[138,20379,20381],{"className":140,"code":20380,"filename":66,"language":143,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\n\nconst client = NoLag(DASHBOARD_TOKEN)\nawait client.connect()\nconst room = client.setApp(APP_SLUG).setRoom('fleet-f_xyz')\n\n// Dashboard subscribes to positions for visible vehicles only (max 100 filters)\nroom.subscribe('positions', {\n  filters: visibleVehicleIds,\n})\n\nroom.on('positions', (position, meta) => updateVehicleMarker(position, meta.filter))\n\n// As the map pans, swap the filter set without resubscribing\nroom.setFilters('positions', nowVisibleVehicleIds)\n",[84,20382,20383,20393,20397,20414,20424,20450,20454,20459,20471,20476,20480,20484,20512,20516,20521],{"__ignoreMap":144},[148,20384,20385,20387,20389,20391],{"class":150,"line":151},[148,20386,155],{"class":154},[148,20388,2575],{"class":161},[148,20390,165],{"class":154},[148,20392,169],{"class":168},[148,20394,20395],{"class":150,"line":172},[148,20396,176],{"emptyLinePlaceholder":175},[148,20398,20399,20401,20403,20405,20407,20409,20412],{"class":150,"line":179},[148,20400,323],{"class":154},[148,20402,1589],{"class":235},[148,20404,329],{"class":154},[148,20406,1594],{"class":207},[148,20408,369],{"class":161},[148,20410,20411],{"class":235},"DASHBOARD_TOKEN",[148,20413,375],{"class":161},[148,20415,20416,20418,20420,20422],{"class":150,"line":186},[148,20417,343],{"class":154},[148,20419,4099],{"class":161},[148,20421,2647],{"class":207},[148,20423,352],{"class":161},[148,20425,20426,20428,20430,20432,20434,20436,20438,20440,20442,20444,20446,20448],{"class":150,"line":192},[148,20427,323],{"class":154},[148,20429,359],{"class":235},[148,20431,329],{"class":154},[148,20433,4099],{"class":161},[148,20435,2667],{"class":207},[148,20437,369],{"class":161},[148,20439,2672],{"class":235},[148,20441,2675],{"class":161},[148,20443,2678],{"class":207},[148,20445,369],{"class":161},[148,20447,20264],{"class":168},[148,20449,375],{"class":161},[148,20451,20452],{"class":150,"line":198},[148,20453,176],{"emptyLinePlaceholder":175},[148,20455,20456],{"class":150,"line":214},[148,20457,20458],{"class":182},"// Dashboard subscribes to positions for visible vehicles only (max 100 filters)\n",[148,20460,20461,20463,20465,20467,20469],{"class":150,"line":227},[148,20462,380],{"class":161},[148,20464,2692],{"class":207},[148,20466,369],{"class":161},[148,20468,16833],{"class":168},[148,20470,3614],{"class":161},[148,20472,20473],{"class":150,"line":239},[148,20474,20475],{"class":161},"  filters: visibleVehicleIds,\n",[148,20477,20478],{"class":150,"line":249},[148,20479,853],{"class":161},[148,20481,20482],{"class":150,"line":259},[148,20483,176],{"emptyLinePlaceholder":175},[148,20485,20486,20488,20490,20492,20494,20496,20498,20500,20502,20504,20506,20509],{"class":150,"line":654},[148,20487,380],{"class":161},[148,20489,2706],{"class":207},[148,20491,369],{"class":161},[148,20493,16833],{"class":168},[148,20495,2713],{"class":161},[148,20497,16850],{"class":217},[148,20499,87],{"class":161},[148,20501,16855],{"class":217},[148,20503,633],{"class":161},[148,20505,636],{"class":154},[148,20507,20508],{"class":207}," updateVehicleMarker",[148,20510,20511],{"class":161},"(position, meta.filter))\n",[148,20513,20514],{"class":150,"line":660},[148,20515,176],{"emptyLinePlaceholder":175},[148,20517,20518],{"class":150,"line":1289},[148,20519,20520],{"class":182},"// As the map pans, swap the filter set without resubscribing\n",[148,20522,20523,20525,20527,20529,20531],{"class":150,"line":1295},[148,20524,380],{"class":161},[148,20526,19931],{"class":207},[148,20528,369],{"class":161},[148,20530,16833],{"class":168},[148,20532,20533],{"class":161},", nowVisibleVehicleIds)\n",[12,20535,20536],{},"One rule to keep in mind: a subscriber with no filters is a wildcard and receives everything on the topic, and a publish with no filter reaches only those wildcard subscribers. Filters are routing, not a privacy boundary. The exception to the shared-room design is very large fleets where different parts of the fleet are managed by different teams. In that case, per-sub-fleet rooms make access control simpler, with each team's actors only granted access to their own fleet's room.",[35,20538,20540],{"id":20539},"qos-choices-for-fleet-data","QoS Choices for Fleet Data",[12,20542,20543,20544,20546],{},"Not all fleet messages have the same delivery requirements. Getting QoS right is important both for reliability and for not overloading your infrastructure with unnecessary acknowledgment traffic. On NoLag the QoS level you pass with a publish (0, 1 or 2, default 1) applies to the broker's internal MQTT hop; the WebSocket leg between the device and the broker has an optional ",[84,20545,3431],{}," acknowledgement, surfaced as the emit callback, and no resend.",[12,20548,20549,20552],{},[30,20550,20551],{},"QoS 0 (fire and forget) for position telemetry."," GPS positions are high-frequency and time-sensitive. A position from 30 seconds ago has almost no value. If a packet drops, the next position update will arrive within seconds anyway. Using QoS 1 for position updates would mean acknowledging and retrying thousands of messages per second inside the broker, most of which would be stale by the time they are retried. QoS 0 is the right choice.",[12,20554,20555,20558],{},[30,20556,20557],{},"QoS 1 (at least once) for geofence alerts."," When a vehicle breaches a geofence, that event needs to be delivered. Missing a geofence breach can mean a compliance violation or a missed theft alert. QoS 1 has the broker hop acknowledge the message and retry it if that delivery fails, and the emit callback tells the device the broker accepted it.",[135,20560,20561],{},[138,20562,20564],{"className":140,"code":20563,"filename":142,"language":143,"meta":144,"style":144},"// Geofence breach: use QoS 1 and wait for the broker's acknowledgement\nroom.emit(\n  'alerts',\n  {\n    type: 'geofence_breach',\n    vehicleId: 'v_abc123',\n    geofenceId: 'zone_restricted_01',\n    breachType: 'exit',\n    timestamp: Date.now(),\n  },\n  { qos: 1, filter: 'v_abc123' },\n  (err) => {\n    if (err) console.error('alert not accepted by the broker', err)\n  },\n)\n",[84,20565,20566,20571,20579,20586,20590,20600,20608,20618,20628,20636,20640,20652,20665,20682,20686],{"__ignoreMap":144},[148,20567,20568],{"class":150,"line":151},[148,20569,20570],{"class":182},"// Geofence breach: use QoS 1 and wait for the broker's acknowledgement\n",[148,20572,20573,20575,20577],{"class":150,"line":172},[148,20574,380],{"class":161},[148,20576,2807],{"class":207},[148,20578,527],{"class":161},[148,20580,20581,20584],{"class":150,"line":179},[148,20582,20583],{"class":168},"  'alerts'",[148,20585,540],{"class":161},[148,20587,20588],{"class":150,"line":186},[148,20589,20295],{"class":161},[148,20591,20592,20595,20598],{"class":150,"line":192},[148,20593,20594],{"class":161},"    type: ",[148,20596,20597],{"class":168},"'geofence_breach'",[148,20599,540],{"class":161},[148,20601,20602,20604,20606],{"class":150,"line":198},[148,20603,20300],{"class":161},[148,20605,16525],{"class":168},[148,20607,540],{"class":161},[148,20609,20610,20613,20616],{"class":150,"line":214},[148,20611,20612],{"class":161},"    geofenceId: ",[148,20614,20615],{"class":168},"'zone_restricted_01'",[148,20617,540],{"class":161},[148,20619,20620,20623,20626],{"class":150,"line":227},[148,20621,20622],{"class":161},"    breachType: ",[148,20624,20625],{"class":168},"'exit'",[148,20627,540],{"class":161},[148,20629,20630,20632,20634],{"class":150,"line":239},[148,20631,20347],{"class":161},[148,20633,6462],{"class":207},[148,20635,9552],{"class":161},[148,20637,20638],{"class":150,"line":249},[148,20639,6239],{"class":161},[148,20641,20642,20644,20646,20648,20650],{"class":150,"line":259},[148,20643,20360],{"class":161},[148,20645,12662],{"class":235},[148,20647,20365],{"class":161},[148,20649,16525],{"class":168},[148,20651,6234],{"class":161},[148,20653,20654,20657,20659,20661,20663],{"class":150,"line":654},[148,20655,20656],{"class":161},"  (",[148,20658,18423],{"class":217},[148,20660,633],{"class":161},[148,20662,636],{"class":154},[148,20664,211],{"class":161},[148,20666,20667,20669,20672,20674,20676,20679],{"class":150,"line":660},[148,20668,1694],{"class":154},[148,20670,20671],{"class":161}," (err) console.",[148,20673,693],{"class":207},[148,20675,369],{"class":161},[148,20677,20678],{"class":168},"'alert not accepted by the broker'",[148,20680,20681],{"class":161},", err)\n",[148,20683,20684],{"class":150,"line":1289},[148,20685,6239],{"class":161},[148,20687,20688],{"class":150,"line":1295},[148,20689,375],{"class":161},[12,20691,20692],{},"Separating alerts onto their own topic also lets dashboards manage two subscriptions with different characteristics: a high-volume QoS 0 feed for position updates, and a low-volume QoS 1 feed for alerts that demands attention.",[35,20694,20696],{"id":20695},"reconnection-and-replay-for-spotty-devices","Reconnection and Replay for Spotty Devices",[12,20698,20699],{},"Fleet devices lose connectivity constantly. A delivery truck going through a basement loading dock, a train in a tunnel, a vehicle switching between cellular providers in a rural area. The connection drops and eventually comes back.",[12,20701,20702],{},"For position data, reconnection is simple: the device reconnects and starts publishing again. No replay needed. The dashboard gap-fills naturally as new positions arrive.",[12,20704,20705],{},"For alert data, it is different. If a geofence breach happened while the device was offline, the device needs to publish that event when it reconnects. The device should buffer critical events locally and flush them on reconnect, publishing them with their original timestamps so the backend can process them in the correct order.",[135,20707,20708],{},[138,20709,20711],{"className":140,"code":20710,"filename":142,"language":143,"meta":144,"style":144},"import type { RoomContext } from '@nolag/js-sdk'\n\ntype AlertEvent = {\n  type: string\n  vehicleId: string\n  geofenceId?: string\n  timestamp: number\n}\n\n// Device-side: buffer critical events during outage\nclass EventBuffer {\n  private queue: AlertEvent[] = []\n\n  enqueue(event: AlertEvent) {\n    this.queue.push(event)\n    this.persistToStorage() // survive process restarts\n  }\n\n  flush(room: RoomContext) {\n    while (this.queue.length > 0) {\n      const event = this.queue[0]\n      room.emit('alerts', event, { qos: 1, filter: event.vehicleId })\n      this.queue.shift()\n    }\n  }\n\n  private persistToStorage() {\n    // write this.queue to disk\n  }\n}\n\nconst buffer = new EventBuffer()\nclient.on('reconnect', () => buffer.flush(room))\n",[84,20712,20713,20725,20729,20740,20749,20758,20768,20778,20782,20786,20791,20801,20821,20825,20840,20853,20867,20871,20875,20890,20910,20930,20949,20961,20965,20969,20973,20982,20987,20991,20995,20999,21014],{"__ignoreMap":144},[148,20714,20715,20717,20719,20721,20723],{"class":150,"line":151},[148,20716,155],{"class":154},[148,20718,158],{"class":154},[148,20720,3796],{"class":161},[148,20722,165],{"class":154},[148,20724,169],{"class":168},[148,20726,20727],{"class":150,"line":172},[148,20728,176],{"emptyLinePlaceholder":175},[148,20730,20731,20733,20736,20738],{"class":150,"line":179},[148,20732,3845],{"class":154},[148,20734,20735],{"class":207}," AlertEvent",[148,20737,329],{"class":154},[148,20739,211],{"class":161},[148,20741,20742,20745,20747],{"class":150,"line":186},[148,20743,20744],{"class":217},"  type",[148,20746,221],{"class":154},[148,20748,236],{"class":235},[148,20750,20751,20754,20756],{"class":150,"line":192},[148,20752,20753],{"class":217},"  vehicleId",[148,20755,221],{"class":154},[148,20757,236],{"class":235},[148,20759,20760,20763,20766],{"class":150,"line":198},[148,20761,20762],{"class":217},"  geofenceId",[148,20764,20765],{"class":154},"?:",[148,20767,236],{"class":235},[148,20769,20770,20773,20775],{"class":150,"line":214},[148,20771,20772],{"class":217},"  timestamp",[148,20774,221],{"class":154},[148,20776,20777],{"class":235}," number\n",[148,20779,20780],{"class":150,"line":227},[148,20781,262],{"class":161},[148,20783,20784],{"class":150,"line":239},[148,20785,176],{"emptyLinePlaceholder":175},[148,20787,20788],{"class":150,"line":249},[148,20789,20790],{"class":182},"// Device-side: buffer critical events during outage\n",[148,20792,20793,20796,20799],{"class":150,"line":259},[148,20794,20795],{"class":154},"class",[148,20797,20798],{"class":207}," EventBuffer",[148,20800,211],{"class":161},[148,20802,20803,20806,20809,20811,20813,20816,20818],{"class":150,"line":654},[148,20804,20805],{"class":154},"  private",[148,20807,20808],{"class":217}," queue",[148,20810,221],{"class":154},[148,20812,20735],{"class":207},[148,20814,20815],{"class":161},"[] ",[148,20817,1337],{"class":154},[148,20819,20820],{"class":161}," []\n",[148,20822,20823],{"class":150,"line":660},[148,20824,176],{"emptyLinePlaceholder":175},[148,20826,20827,20830,20832,20834,20836,20838],{"class":150,"line":1289},[148,20828,20829],{"class":207},"  enqueue",[148,20831,369],{"class":161},[148,20833,1683],{"class":217},[148,20835,221],{"class":154},[148,20837,20735],{"class":207},[148,20839,600],{"class":161},[148,20841,20842,20845,20848,20850],{"class":150,"line":1295},[148,20843,20844],{"class":235},"    this",[148,20846,20847],{"class":161},".queue.",[148,20849,2407],{"class":207},[148,20851,20852],{"class":161},"(event)\n",[148,20854,20855,20857,20859,20862,20864],{"class":150,"line":1313},[148,20856,20844],{"class":235},[148,20858,611],{"class":161},[148,20860,20861],{"class":207},"persistToStorage",[148,20863,6465],{"class":161},[148,20865,20866],{"class":182},"// survive process restarts\n",[148,20868,20869],{"class":150,"line":1331},[148,20870,1716],{"class":161},[148,20872,20873],{"class":150,"line":1346},[148,20874,176],{"emptyLinePlaceholder":175},[148,20876,20877,20880,20882,20884,20886,20888],{"class":150,"line":1355},[148,20878,20879],{"class":207},"  flush",[148,20881,369],{"class":161},[148,20883,1421],{"class":217},[148,20885,221],{"class":154},[148,20887,3935],{"class":207},[148,20889,600],{"class":161},[148,20891,20892,20895,20897,20900,20902,20904,20906,20908],{"class":150,"line":1361},[148,20893,20894],{"class":154},"    while",[148,20896,114],{"class":161},[148,20898,20899],{"class":235},"this",[148,20901,20847],{"class":161},[148,20903,6728],{"class":235},[148,20905,15455],{"class":154},[148,20907,11673],{"class":235},[148,20909,600],{"class":161},[148,20911,20912,20914,20917,20919,20922,20925,20927],{"class":150,"line":1370},[148,20913,4020],{"class":154},[148,20915,20916],{"class":235}," event",[148,20918,329],{"class":154},[148,20920,20921],{"class":235}," this",[148,20923,20924],{"class":161},".queue[",[148,20926,9227],{"class":235},[148,20928,20929],{"class":161},"]\n",[148,20931,20932,20934,20936,20938,20941,20944,20946],{"class":150,"line":1376},[148,20933,4138],{"class":161},[148,20935,2807],{"class":207},[148,20937,369],{"class":161},[148,20939,20940],{"class":168},"'alerts'",[148,20942,20943],{"class":161},", event, { qos: ",[148,20945,12662],{"class":235},[148,20947,20948],{"class":161},", filter: event.vehicleId })\n",[148,20950,20951,20954,20956,20959],{"class":150,"line":2033},[148,20952,20953],{"class":235},"      this",[148,20955,20847],{"class":161},[148,20957,20958],{"class":207},"shift",[148,20960,352],{"class":161},[148,20962,20963],{"class":150,"line":2039},[148,20964,4253],{"class":161},[148,20966,20967],{"class":150,"line":2044},[148,20968,1716],{"class":161},[148,20970,20971],{"class":150,"line":2050},[148,20972,176],{"emptyLinePlaceholder":175},[148,20974,20975,20977,20980],{"class":150,"line":2056},[148,20976,20805],{"class":154},[148,20978,20979],{"class":207}," persistToStorage",[148,20981,3591],{"class":161},[148,20983,20984],{"class":150,"line":2062},[148,20985,20986],{"class":182},"    // write this.queue to disk\n",[148,20988,20989],{"class":150,"line":2068},[148,20990,1716],{"class":161},[148,20992,20993],{"class":150,"line":2074},[148,20994,262],{"class":161},[148,20996,20997],{"class":150,"line":2080},[148,20998,176],{"emptyLinePlaceholder":175},[148,21000,21001,21003,21006,21008,21010,21012],{"class":150,"line":2086},[148,21002,323],{"class":154},[148,21004,21005],{"class":235}," buffer",[148,21007,329],{"class":154},[148,21009,332],{"class":154},[148,21011,20798],{"class":207},[148,21013,352],{"class":161},[148,21015,21016,21018,21020,21022,21025,21027,21029,21032,21035],{"class":150,"line":2092},[148,21017,16176],{"class":161},[148,21019,2706],{"class":207},[148,21021,369],{"class":161},[148,21023,21024],{"class":168},"'reconnect'",[148,21026,14945],{"class":161},[148,21028,636],{"class":154},[148,21030,21031],{"class":161}," buffer.",[148,21033,21034],{"class":207},"flush",[148,21036,21037],{"class":161},"(room))\n",[12,21039,21040,21041,21043,21044,21047,21048,21051,21052,21054,21055,21058],{},"On the dashboard side there is no replay to lean on. NoLag replays missed messages only to load-balanced worker groups with persistent sessions on the hosted platform (see ",[952,21042,7734],{"href":2319},"); a dashboard that reconnects gets its subscriptions restored and nothing it missed, and there is no protocol call to request messages from a sequence number. The durable copy of every alert therefore belongs in your backend: a service subscribed to ",[84,21045,21046],{},"alerts"," writes each one to a database, and the dashboard reads the open alerts from there on load. A ",[952,21049,21050],{"href":9765},"hydration webhook"," makes the load step automatic: when a dashboard subscribes to ",[84,21053,21046],{},", the broker calls your webhook and delivers its response to the client as a ",[84,21056,21057],{},"hydration"," event, so the current alert list arrives on the same connection as the live feed.",[35,21060,21062],{"id":21061},"scaling-considerations","Scaling Considerations",[12,21064,21065,21066,21069,21070,21072],{},"At 100,000 devices publishing every 5 seconds, the numbers look like this: 20,000 inbound messages per second, each message fanout to potentially hundreds of dashboard subscriptions. If each dashboard is watching 50 vehicles out of 100,000, the filter match rate is 0.05%. Each filter is its own routing key on the broker, so a position tagged ",[84,21067,21068],{},"v_abc123"," is only delivered to subscriptions that asked for ",[84,21071,21068],{}," (and to wildcard subscribers); nothing is evaluated per message per dashboard. The infrastructure needs to do that routing fast and cheaply.",[12,21074,21075],{},"A few design choices help at this scale:",[882,21077,21078,21084,21090,21096],{},[885,21079,21080,21083],{},[30,21081,21082],{},"Keep payloads small."," A GPS position needs only lat, lng, speed, heading, and a timestamp. That is under 100 bytes. Do not attach full vehicle records or lookup data to telemetry messages.",[885,21085,21086,21089],{},[30,21087,21088],{},"Let the SDK's binary encoding work for you."," NoLag SDKs encode every frame as MessagePack, so a numeric-heavy position is already smaller on the wire than the same object as JSON text. Keep field names short and values numeric to get the most from it.",[885,21091,21092,21095],{},[30,21093,21094],{},"Separate telemetry from alerts."," Position updates and geofence alerts should be on different topics with different QoS. Mixing them makes it impossible to give alerts an acknowledged hop without paying for it on every position.",[885,21097,21098,21101],{},[30,21099,21100],{},"Limit dashboard subscription scope."," A dashboard watching all 100,000 vehicles is the worst case for fanout. Design the UX to encourage focused views, for example by region or by job, so that each subscription filter list stays manageable and under the 100-filter cap.",[35,21103,21105],{"id":21104},"how-nolag-approaches-iot-workloads","How NoLag Approaches IoT Workloads",[12,21107,21108],{},"NoLag's infrastructure is designed for asymmetric fanout workloads like fleet tracking. Filters are routing keys on the broker, so subscribers only receive what they asked for and a message that no dashboard is watching is never transmitted. QoS levels are configurable per message, so you can mix fire-and-forget telemetry and acknowledged alerts on the same connection without any special configuration.",[12,21110,21111,21112,21115],{},"Dashboard reconnects are handled by restoring subscriptions server-side, and a ",[84,21113,21114],{},"reconnect"," event on the client is your cue to refresh anything stateful. For a fleet manager who closes their laptop and comes back in the morning, pair that with a hydration webhook so the open-alerts list arrives with the subscription, and keep the durable record of alerts in your own store.",[12,21117,21118],{},"For device-side connectivity, the SDK handles exponential backoff reconnection automatically. Devices that lose signal and regain it will reconnect and resume publishing without any application-level retry code, and the event buffer above covers the alerts raised while they were dark.",[969,21120,21121],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":144,"searchDepth":172,"depth":172,"links":21123},[21124,21125,21126,21127,21128,21129],{"id":20114,"depth":172,"text":20115},{"id":20145,"depth":172,"text":20146},{"id":20539,"depth":172,"text":20540},{"id":20695,"depth":172,"text":20696},{"id":21061,"depth":172,"text":21062},{"id":21104,"depth":172,"text":21105},"2026-03-10","GPS trackers, sensors, and connected vehicles generate a constant stream of telemetry. Learn how to architect a fleet tracking system that scales to 100,000 devices with WebSockets, filtered subscriptions, and smart QoS choices.",{},"/blog/iot-fleet-tracking-websockets",{"title":20103,"description":21131},"blog/iot-fleet-tracking-websockets","f5RRJ7ETtCYnYMUpYIAqt71htIpiR4HpuuhBZwWwEUI",{"id":21138,"title":21139,"author":7,"body":21140,"category":19425,"date":22070,"description":22071,"excerpt":986,"extension":987,"meta":22072,"navigation":175,"path":14708,"readTime":2903,"seo":22073,"stem":22074,"__hash__":22075},"blog/blog/reconnection-message-replay.md","Handling Disconnects Gracefully: Reconnection and Message Replay",{"type":9,"value":21141,"toc":22057},[21142,21145,21148,21151,21155,21158,21164,21170,21176,21182,21188,21192,21195,21198,21201,21205,21209,21212,21216,21219,21223,21226,21230,21233,21240,21460,21470,21474,21480,21673,21676,21689,21693,21700,21729,21732,22025,22031,22035,22049,22055],[12,21143,21144],{},"Every real-time application eventually has to answer a simple but uncomfortable question: what happens when the connection drops?",[12,21146,21147],{},"The honest answer, for most applications, is: not much good. The client reconnects, but it has no idea what happened while it was offline. It either shows stale data, re-fetches everything from the REST API (expensive and slow), or just leaves the user staring at whatever was on screen when the disconnect happened. None of those are good user experiences.",[12,21149,21150],{},"This post explains why disconnects happen, what the naive recovery approaches get wrong, and then is precise about NoLag: what a reconnect restores for you, what it deliberately does not, how to design for the gap, and the one case where the broker does replay what a client missed.",[35,21152,21154],{"id":21153},"why-disconnects-happen","Why Disconnects Happen",[12,21156,21157],{},"Disconnects are not edge cases. They are a normal part of operating a real-time application in the real world.",[12,21159,21160,21163],{},[30,21161,21162],{},"Network switching."," A user walks from their desk to a conference room. Their laptop drops Wi-Fi and picks up a different access point. That transition takes a few seconds and the WebSocket connection dies mid-handoff. The OS may not even surface this to the application immediately.",[12,21165,21166,21169],{},[30,21167,21168],{},"Mobile sleep."," On iOS and Android, backgrounding an app causes the OS to aggressively suspend network activity to preserve battery. The WebSocket connection is severed. When the user brings the app to the foreground, it needs to reconnect and catch up on what it missed.",[12,21171,21172,21175],{},[30,21173,21174],{},"Train tunnels and dead zones."," Users move through the physical world. Cellular coverage has gaps. A user reading a chat app on a commute will pass through areas with no signal. The connection drops and comes back, potentially multiple times in a single session.",[12,21177,21178,21181],{},[30,21179,21180],{},"Server deploys."," Rolling out a new version of your backend closes existing WebSocket connections. If you deploy daily, every user gets disconnected once per day. If your reconnection story is poor, that is a daily UX degradation for your entire user base.",[12,21183,21184,21187],{},[30,21185,21186],{},"Idle timeouts."," Load balancers, reverse proxies, and NAT gateways close idle connections. A user who has a chat window open but has not sent a message in 10 minutes may find their connection silently dropped by an intermediate piece of network infrastructure.",[35,21189,21191],{"id":21190},"the-reconnection-problem","The Reconnection Problem",[12,21193,21194],{},"Reconnecting the WebSocket is the easy part. Any decent client SDK handles exponential backoff and reconnection automatically. The hard part is what comes after: the gap.",[12,21196,21197],{},"The client was connected, then lost the connection for 90 seconds. During those 90 seconds, 39 messages were published to the topics it was subscribed to. The client reconnects and starts receiving from the next message onward. Those 39 messages never reached it. The client's state is now inconsistent with reality.",[12,21199,21200],{},"In a chat application, those 39 messages might be a whole conversation thread the user missed. In a collaborative document, they are edits from other users that the local copy does not reflect. In a live trading dashboard, they are price updates that left the displayed prices stale.",[35,21202,21204],{"id":21203},"naive-approaches-and-why-they-fall-short","Naive Approaches and Why They Fall Short",[7470,21206,21208],{"id":21207},"re-fetching-everything","Re-fetching Everything",[12,21210,21211],{},"The simplest recovery is to call your REST API on reconnect and reload the full state. This works, but it is slow and expensive. A chat room with 500 messages means fetching all 500 on every reconnect. A dashboard with 200 data points means 200 API calls or one large query. At scale, every server deploy triggers a stampede of re-fetch requests from every connected client simultaneously.",[7470,21213,21215],{"id":21214},"ignoring-the-gap","Ignoring the Gap",[12,21217,21218],{},"Some applications simply pick up from where they reconnect and show nothing for the gap period. This is the worst option. The user sees a discontinuity in their data without any indication of what they missed. In a chat app, conversation threads jump forward inexplicably. In a notification feed, important alerts are silently lost.",[7470,21220,21222],{"id":21221},"client-side-timestamps","Client-Side Timestamps",[12,21224,21225],{},"A slightly better approach is for the client to send its last-received timestamp on reconnect and ask the server to resend anything newer. This is closer to correct but has problems. Client clocks drift. Timestamps can collide or reorder under high throughput. And it requires custom server-side logic per application to query and replay by timestamp.",[35,21227,21229],{"id":21228},"what-a-nolag-reconnect-restores","What a NoLag Reconnect Restores",[12,21231,21232],{},"The NoLag SDK reconnects for you. When the socket drops, it retries with exponential backoff (5 seconds, growing by 1.5x per attempt, capped at 30 seconds, up to 10 attempts by default), and collapses that backoff the moment the device reports that connectivity is back. Client tokens minted by a token provider are refreshed before they expire. You do not write any of that.",[12,21234,21235,21236,21239],{},"When the new connection authenticates, the broker restores every subscription the connection had, with its filters and load-balancing settings intact. You do not resubscribe. Your ",[84,21237,21238],{},"room.on"," handlers are still registered locally, so live messages start arriving again with no code on your side.",[135,21241,21242],{},[138,21243,21245],{"className":140,"code":21244,"filename":142,"language":143,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\n\nconst client = NoLag('YOUR_TOKEN')\n\nclient.on('disconnect', (reason) => showOfflineBanner(reason))\nclient.on('reconnect', () => showReconnecting())   // fires when a retry is scheduled\n\n// Fires on the first connection and again after every successful reconnect.\n// Subscriptions are already restored by the time this runs.\nclient.on('connect', () => {\n  hideBanners()\n})\n\nawait client.connect()\n\n// APP_SLUG is the slug returned when you created the app (it has a random\n// suffix); the room must already exist in the control plane.\nconst room = client.setApp(APP_SLUG).setRoom('room_abc')\nroom.subscribe('chat')\nroom.on('chat', (message) => appendMessage(message))\n",[84,21246,21247,21257,21261,21277,21281,21306,21329,21333,21338,21343,21360,21367,21371,21375,21385,21389,21393,21397,21424,21436],{"__ignoreMap":144},[148,21248,21249,21251,21253,21255],{"class":150,"line":151},[148,21250,155],{"class":154},[148,21252,2575],{"class":161},[148,21254,165],{"class":154},[148,21256,169],{"class":168},[148,21258,21259],{"class":150,"line":172},[148,21260,176],{"emptyLinePlaceholder":175},[148,21262,21263,21265,21267,21269,21271,21273,21275],{"class":150,"line":179},[148,21264,323],{"class":154},[148,21266,1589],{"class":235},[148,21268,329],{"class":154},[148,21270,1594],{"class":207},[148,21272,369],{"class":161},[148,21274,19503],{"class":168},[148,21276,375],{"class":161},[148,21278,21279],{"class":150,"line":186},[148,21280,176],{"emptyLinePlaceholder":175},[148,21282,21283,21285,21287,21289,21292,21294,21296,21298,21300,21303],{"class":150,"line":192},[148,21284,16176],{"class":161},[148,21286,2706],{"class":207},[148,21288,369],{"class":161},[148,21290,21291],{"class":168},"'disconnect'",[148,21293,2713],{"class":161},[148,21295,13581],{"class":217},[148,21297,633],{"class":161},[148,21299,636],{"class":154},[148,21301,21302],{"class":207}," showOfflineBanner",[148,21304,21305],{"class":161},"(reason))\n",[148,21307,21308,21310,21312,21314,21316,21318,21320,21323,21326],{"class":150,"line":198},[148,21309,16176],{"class":161},[148,21311,2706],{"class":207},[148,21313,369],{"class":161},[148,21315,21024],{"class":168},[148,21317,14945],{"class":161},[148,21319,636],{"class":154},[148,21321,21322],{"class":207}," showReconnecting",[148,21324,21325],{"class":161},"())   ",[148,21327,21328],{"class":182},"// fires when a retry is scheduled\n",[148,21330,21331],{"class":150,"line":214},[148,21332,176],{"emptyLinePlaceholder":175},[148,21334,21335],{"class":150,"line":227},[148,21336,21337],{"class":182},"// Fires on the first connection and again after every successful reconnect.\n",[148,21339,21340],{"class":150,"line":239},[148,21341,21342],{"class":182},"// Subscriptions are already restored by the time this runs.\n",[148,21344,21345,21347,21349,21351,21354,21356,21358],{"class":150,"line":249},[148,21346,16176],{"class":161},[148,21348,2706],{"class":207},[148,21350,369],{"class":161},[148,21352,21353],{"class":168},"'connect'",[148,21355,14945],{"class":161},[148,21357,636],{"class":154},[148,21359,211],{"class":161},[148,21361,21362,21365],{"class":150,"line":259},[148,21363,21364],{"class":207},"  hideBanners",[148,21366,352],{"class":161},[148,21368,21369],{"class":150,"line":654},[148,21370,853],{"class":161},[148,21372,21373],{"class":150,"line":660},[148,21374,176],{"emptyLinePlaceholder":175},[148,21376,21377,21379,21381,21383],{"class":150,"line":1289},[148,21378,343],{"class":154},[148,21380,4099],{"class":161},[148,21382,2647],{"class":207},[148,21384,352],{"class":161},[148,21386,21387],{"class":150,"line":1295},[148,21388,176],{"emptyLinePlaceholder":175},[148,21390,21391],{"class":150,"line":1313},[148,21392,18213],{"class":182},[148,21394,21395],{"class":150,"line":1331},[148,21396,18218],{"class":182},[148,21398,21399,21401,21403,21405,21407,21409,21411,21413,21415,21417,21419,21422],{"class":150,"line":1346},[148,21400,323],{"class":154},[148,21402,359],{"class":235},[148,21404,329],{"class":154},[148,21406,4099],{"class":161},[148,21408,2667],{"class":207},[148,21410,369],{"class":161},[148,21412,2672],{"class":235},[148,21414,2675],{"class":161},[148,21416,2678],{"class":207},[148,21418,369],{"class":161},[148,21420,21421],{"class":168},"'room_abc'",[148,21423,375],{"class":161},[148,21425,21426,21428,21430,21432,21434],{"class":150,"line":1355},[148,21427,380],{"class":161},[148,21429,2692],{"class":207},[148,21431,369],{"class":161},[148,21433,3080],{"class":168},[148,21435,375],{"class":161},[148,21437,21438,21440,21442,21444,21446,21448,21450,21452,21454,21457],{"class":150,"line":1361},[148,21439,380],{"class":161},[148,21441,2706],{"class":207},[148,21443,369],{"class":161},[148,21445,3080],{"class":168},[148,21447,2713],{"class":161},[148,21449,14607],{"class":217},[148,21451,633],{"class":161},[148,21453,636],{"class":154},[148,21455,21456],{"class":207}," appendMessage",[148,21458,21459],{"class":161},"(message))\n",[12,21461,21462,21463,21466,21467,21469],{},"Two things are not restored, and both are easy to miss. Room presence is not: if this client had called ",[84,21464,21465],{},"room.setPresence(...)",", call it again in the ",[84,21468,2647],{}," handler, or let a blueprint SDK do it (they re-apply presence after every reconnect). And the messages published during the gap are not: an ordinary reconnect replays nothing, and a fresh subscription receives no history. That is the deliberate part, and the next section is about living with it.",[35,21471,21473],{"id":21472},"designing-for-the-gap","Designing for the Gap",[12,21475,21476,21477,21479],{},"NoLag is a messaging layer, not a database. For an ordinary client, the gap is yours to fill, and the good news is that you know exactly when to fill it: the ",[84,21478,2647],{}," event fires after every successful reconnect, once subscriptions are back, so the sequence \"restore subscriptions, then fetch what I missed, then apply live messages\" falls out naturally.",[135,21481,21482],{},[138,21483,21485],{"className":140,"code":21484,"filename":142,"language":143,"meta":144,"style":144},"let lastSeenAt = 0\nlet firstConnection = true\n\nclient.on('connect', async () => {\n  if (firstConnection) {\n    firstConnection = false\n    return                       // initial load already rendered the room\n  }\n  // Ask your own API only for what was published while we were away.\n  // Subscriptions are already restored, so nothing published from here on is lost.\n  const missed = await fetchMessagesSince(lastSeenAt)\n  for (const message of missed) appendMessageSilently(message)\n})\n\nroom.on('chat', (message: ChatMessage) => {\n  // The broker does not stamp a time on messages: carry one in the payload,\n  // assigned by whichever service writes the message to your store.\n  lastSeenAt = message.sentAt\n  appendMessage(message)\n})\n",[84,21486,21487,21499,21510,21514,21534,21541,21550,21557,21561,21566,21571,21588,21609,21613,21617,21642,21647,21652,21662,21669],{"__ignoreMap":144},[148,21488,21489,21491,21494,21496],{"class":150,"line":151},[148,21490,11665],{"class":154},[148,21492,21493],{"class":161}," lastSeenAt ",[148,21495,1337],{"class":154},[148,21497,21498],{"class":235}," 0\n",[148,21500,21501,21503,21506,21508],{"class":150,"line":172},[148,21502,11665],{"class":154},[148,21504,21505],{"class":161}," firstConnection ",[148,21507,1337],{"class":154},[148,21509,3997],{"class":235},[148,21511,21512],{"class":150,"line":179},[148,21513,176],{"emptyLinePlaceholder":175},[148,21515,21516,21518,21520,21522,21524,21526,21528,21530,21532],{"class":150,"line":186},[148,21517,16176],{"class":161},[148,21519,2706],{"class":207},[148,21521,369],{"class":161},[148,21523,21353],{"class":168},[148,21525,87],{"class":161},[148,21527,1447],{"class":154},[148,21529,4011],{"class":161},[148,21531,636],{"class":154},[148,21533,211],{"class":161},[148,21535,21536,21538],{"class":150,"line":192},[148,21537,10452],{"class":154},[148,21539,21540],{"class":161}," (firstConnection) {\n",[148,21542,21543,21546,21548],{"class":150,"line":198},[148,21544,21545],{"class":161},"    firstConnection ",[148,21547,1337],{"class":154},[148,21549,4229],{"class":235},[148,21551,21552,21554],{"class":150,"line":214},[148,21553,4213],{"class":154},[148,21555,21556],{"class":182},"                       // initial load already rendered the room\n",[148,21558,21559],{"class":150,"line":227},[148,21560,1716],{"class":161},[148,21562,21563],{"class":150,"line":239},[148,21564,21565],{"class":182},"  // Ask your own API only for what was published while we were away.\n",[148,21567,21568],{"class":150,"line":249},[148,21569,21570],{"class":182},"  // Subscriptions are already restored, so nothing published from here on is lost.\n",[148,21572,21573,21575,21578,21580,21582,21585],{"class":150,"line":259},[148,21574,3596],{"class":154},[148,21576,21577],{"class":235}," missed",[148,21579,329],{"class":154},[148,21581,1502],{"class":154},[148,21583,21584],{"class":207}," fetchMessagesSince",[148,21586,21587],{"class":161},"(lastSeenAt)\n",[148,21589,21590,21592,21594,21596,21599,21601,21604,21607],{"class":150,"line":654},[148,21591,7501],{"class":154},[148,21593,114],{"class":161},[148,21595,323],{"class":154},[148,21597,21598],{"class":235}," message",[148,21600,7511],{"class":154},[148,21602,21603],{"class":161}," missed) ",[148,21605,21606],{"class":207},"appendMessageSilently",[148,21608,17051],{"class":161},[148,21610,21611],{"class":150,"line":660},[148,21612,853],{"class":161},[148,21614,21615],{"class":150,"line":1289},[148,21616,176],{"emptyLinePlaceholder":175},[148,21618,21619,21621,21623,21625,21627,21629,21631,21633,21636,21638,21640],{"class":150,"line":1295},[148,21620,380],{"class":161},[148,21622,2706],{"class":207},[148,21624,369],{"class":161},[148,21626,3080],{"class":168},[148,21628,2713],{"class":161},[148,21630,14607],{"class":217},[148,21632,221],{"class":154},[148,21634,21635],{"class":207}," ChatMessage",[148,21637,633],{"class":161},[148,21639,636],{"class":154},[148,21641,211],{"class":161},[148,21643,21644],{"class":150,"line":1313},[148,21645,21646],{"class":182},"  // The broker does not stamp a time on messages: carry one in the payload,\n",[148,21648,21649],{"class":150,"line":1331},[148,21650,21651],{"class":182},"  // assigned by whichever service writes the message to your store.\n",[148,21653,21654,21657,21659],{"class":150,"line":1346},[148,21655,21656],{"class":161},"  lastSeenAt ",[148,21658,1337],{"class":154},[148,21660,21661],{"class":161}," message.sentAt\n",[148,21663,21664,21667],{"class":150,"line":1355},[148,21665,21666],{"class":207},"  appendMessage",[148,21668,17051],{"class":161},[148,21670,21671],{"class":150,"line":1361},[148,21672,853],{"class":161},[12,21674,21675],{},"This is the re-fetch approach from earlier, minus its two problems. It fetches only the gap, not the whole room, and it runs after the subscriptions are live, so a message published during the fetch itself arrives through the subscription rather than falling into a second gap. Deduplicate on your own message ID when merging, since the same message can plausibly arrive both ways.",[12,21677,21678,21679,21681,21682,21685,21686,21688],{},"Two things make this cheaper still. Design your topics so a missed message is superseded rather than required: a dashboard that publishes the full current value of a metric needs no catch-up at all, because the next update makes it whole. And for a subscription that represents state rather than a stream, a ",[952,21680,21050],{"href":9765}," lets the broker hand a client the current state when it subscribes (delivered on ",[84,21683,21684],{},"client.on('hydration', ({ topic, data }) => ...)","), which takes care of the initial load. It does not run again when a reconnect restores the subscription, so the gap fetch above still belongs in the ",[84,21687,2647],{}," handler.",[35,21690,21692],{"id":21691},"the-isreplay-flag","The isReplay Flag",[12,21694,21695,21696,21699],{},"There is one situation where the broker does replay messages, and it is worth understanding both because you may build on it and because it is where the ",[84,21697,21698],{},"isReplay"," flag comes from.",[12,21701,21702,21703,21706,21707,694,21709,21711,21712,21714,21715,1812,21718,21721,21722,21725,21726,21728],{},"Replay exists for ",[30,21704,21705],{},"load-balanced worker groups with persistent sessions"," on the hosted platform: a pool of workers connected as ",[84,21708,2854],{},[84,21710,1254],{}," actors, subscribed with ",[84,21713,12064],{},", where each message is delivered to exactly one member of the group. When such a group has scaled to zero and a member comes back, the broker replays the messages the group missed, claiming each one so that only one worker receives it. Replayed messages arrive between ",[84,21716,21717],{},"replay:start",[84,21719,21720],{},"replay:end"," events, live messages that arrive during the replay are buffered and flushed afterwards, and each replayed message carries ",[84,21723,21724],{},"isReplay: true"," in its metadata. The full contract, including what it does not cover, is on the ",[952,21727,2320],{"href":2319}," page. It is not something an ordinary client subscription can opt into.",[12,21730,21731],{},"If you are writing a worker, the flag is what lets you tell catch-up from live:",[135,21733,21734],{},[138,21735,21737],{"className":140,"code":21736,"filename":142,"language":143,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\nimport type { MessageMeta } from '@nolag/js-sdk'\n\n// A worker in a load-balanced group; the token belongs to an `agent` actor,\n// whose broker session persists while it is away.\nconst worker = NoLag(WORKER_TOKEN, { loadBalance: true, loadBalanceGroup: 'image-workers' })\n\nworker.on('replay:start', ({ count }) => log('catching up on', count, 'jobs'))\nworker.on('replay:end', ({ replayed }) => log('caught up:', replayed))\n\nawait worker.connect()\nconst room = worker.setApp(APP_SLUG).setRoom('image-processing')\nroom.subscribe('jobs')\n\nroom.on('jobs', (job: ImageJob, meta: MessageMeta) => {\n  // Always do the work\n  processJob(job)\n\n  // Only do \"something just happened\" side effects for live jobs\n  if (!meta.isReplay) {\n    metrics.increment('jobs.live')\n    notifyOperators(job)\n  }\n})\n",[84,21738,21739,21749,21762,21766,21771,21776,21801,21805,21840,21870,21874,21884,21911,21923,21927,21962,21967,21975,21979,21984,21995,22010,22017,22021],{"__ignoreMap":144},[148,21740,21741,21743,21745,21747],{"class":150,"line":151},[148,21742,155],{"class":154},[148,21744,2575],{"class":161},[148,21746,165],{"class":154},[148,21748,169],{"class":168},[148,21750,21751,21753,21755,21758,21760],{"class":150,"line":172},[148,21752,155],{"class":154},[148,21754,158],{"class":154},[148,21756,21757],{"class":161}," { MessageMeta } ",[148,21759,165],{"class":154},[148,21761,169],{"class":168},[148,21763,21764],{"class":150,"line":179},[148,21765,176],{"emptyLinePlaceholder":175},[148,21767,21768],{"class":150,"line":186},[148,21769,21770],{"class":182},"// A worker in a load-balanced group; the token belongs to an `agent` actor,\n",[148,21772,21773],{"class":150,"line":192},[148,21774,21775],{"class":182},"// whose broker session persists while it is away.\n",[148,21777,21778,21780,21782,21784,21786,21788,21790,21792,21794,21796,21799],{"class":150,"line":198},[148,21779,323],{"class":154},[148,21781,6141],{"class":235},[148,21783,329],{"class":154},[148,21785,1594],{"class":207},[148,21787,369],{"class":161},[148,21789,6131],{"class":235},[148,21791,7776],{"class":161},[148,21793,648],{"class":235},[148,21795,7781],{"class":161},[148,21797,21798],{"class":168},"'image-workers'",[148,21800,2557],{"class":161},[148,21802,21803],{"class":150,"line":214},[148,21804,176],{"emptyLinePlaceholder":175},[148,21806,21807,21810,21812,21814,21817,21819,21821,21823,21825,21827,21829,21832,21835,21838],{"class":150,"line":227},[148,21808,21809],{"class":161},"worker.",[148,21811,2706],{"class":207},[148,21813,369],{"class":161},[148,21815,21816],{"class":168},"'replay:start'",[148,21818,15386],{"class":161},[148,21820,15880],{"class":217},[148,21822,15392],{"class":161},[148,21824,636],{"class":154},[148,21826,13685],{"class":207},[148,21828,369],{"class":161},[148,21830,21831],{"class":168},"'catching up on'",[148,21833,21834],{"class":161},", count, ",[148,21836,21837],{"class":168},"'jobs'",[148,21839,848],{"class":161},[148,21841,21842,21844,21846,21848,21851,21853,21856,21858,21860,21862,21864,21867],{"class":150,"line":239},[148,21843,21809],{"class":161},[148,21845,2706],{"class":207},[148,21847,369],{"class":161},[148,21849,21850],{"class":168},"'replay:end'",[148,21852,15386],{"class":161},[148,21854,21855],{"class":217},"replayed",[148,21857,15392],{"class":161},[148,21859,636],{"class":154},[148,21861,13685],{"class":207},[148,21863,369],{"class":161},[148,21865,21866],{"class":168},"'caught up:'",[148,21868,21869],{"class":161},", replayed))\n",[148,21871,21872],{"class":150,"line":249},[148,21873,176],{"emptyLinePlaceholder":175},[148,21875,21876,21878,21880,21882],{"class":150,"line":259},[148,21877,343],{"class":154},[148,21879,6266],{"class":161},[148,21881,2647],{"class":207},[148,21883,352],{"class":161},[148,21885,21886,21888,21890,21892,21894,21896,21898,21900,21902,21904,21906,21909],{"class":150,"line":654},[148,21887,323],{"class":154},[148,21889,359],{"class":235},[148,21891,329],{"class":154},[148,21893,6266],{"class":161},[148,21895,2667],{"class":207},[148,21897,369],{"class":161},[148,21899,2672],{"class":235},[148,21901,2675],{"class":161},[148,21903,2678],{"class":207},[148,21905,369],{"class":161},[148,21907,21908],{"class":168},"'image-processing'",[148,21910,375],{"class":161},[148,21912,21913,21915,21917,21919,21921],{"class":150,"line":660},[148,21914,380],{"class":161},[148,21916,2692],{"class":207},[148,21918,369],{"class":161},[148,21920,21837],{"class":168},[148,21922,375],{"class":161},[148,21924,21925],{"class":150,"line":1289},[148,21926,176],{"emptyLinePlaceholder":175},[148,21928,21929,21931,21933,21935,21937,21939,21942,21944,21947,21949,21951,21953,21956,21958,21960],{"class":150,"line":1295},[148,21930,380],{"class":161},[148,21932,2706],{"class":207},[148,21934,369],{"class":161},[148,21936,21837],{"class":168},[148,21938,2713],{"class":161},[148,21940,21941],{"class":217},"job",[148,21943,221],{"class":154},[148,21945,21946],{"class":207}," ImageJob",[148,21948,87],{"class":161},[148,21950,16855],{"class":217},[148,21952,221],{"class":154},[148,21954,21955],{"class":207}," MessageMeta",[148,21957,633],{"class":161},[148,21959,636],{"class":154},[148,21961,211],{"class":161},[148,21963,21964],{"class":150,"line":1313},[148,21965,21966],{"class":182},"  // Always do the work\n",[148,21968,21969,21972],{"class":150,"line":1331},[148,21970,21971],{"class":207},"  processJob",[148,21973,21974],{"class":161},"(job)\n",[148,21976,21977],{"class":150,"line":1346},[148,21978,176],{"emptyLinePlaceholder":175},[148,21980,21981],{"class":150,"line":1355},[148,21982,21983],{"class":182},"  // Only do \"something just happened\" side effects for live jobs\n",[148,21985,21986,21988,21990,21992],{"class":150,"line":1361},[148,21987,10452],{"class":154},[148,21989,114],{"class":161},[148,21991,4069],{"class":154},[148,21993,21994],{"class":161},"meta.isReplay) {\n",[148,21996,21997,22000,22003,22005,22008],{"class":150,"line":1370},[148,21998,21999],{"class":161},"    metrics.",[148,22001,22002],{"class":207},"increment",[148,22004,369],{"class":161},[148,22006,22007],{"class":168},"'jobs.live'",[148,22009,375],{"class":161},[148,22011,22012,22015],{"class":150,"line":1376},[148,22013,22014],{"class":207},"    notifyOperators",[148,22016,21974],{"class":161},[148,22018,22019],{"class":150,"line":2033},[148,22020,1716],{"class":161},[148,22022,22023],{"class":150,"line":2039},[148,22024,853],{"class":161},[12,22026,22027,22028,22030],{},"The same instinct applies to any catch-up you implement yourself, which is why the flag is worth borrowing as a convention. Messages fetched to fill a gap should be added to the UI to restore consistency, but they should not trigger notifications. If a chat app shows a desktop notification for every message fetched after an hour offline, a user comes back to 200 popups for messages they already saw on another device, or from a conversation they have muted. Live messages deserve the full treatment: badge counts increment, notifications fire, scroll behaviour updates. Caught-up messages should just appear in their correct position in the timeline, quietly. ",[84,22029,21606],{}," in the earlier sample is that rule.",[35,22032,22034],{"id":22033},"how-this-works-in-nolag-sdks","How This Works in NoLag SDKs",[12,22036,22037,22038,22041,22042,22045,22046,22048],{},"In the NoLag client SDKs, reconnection is automatic and subscriptions are restored by the broker, with filters and load-balancing intact. Your ",[84,22039,22040],{},"room.on(topic, (data, meta) => ...)"," handlers keep running across reconnects, and ",[84,22043,22044],{},"meta.isReplay"," is set on replayed messages for worker groups. Room presence is re-applied by the blueprint SDKs and is one call in the ",[84,22047,2647],{}," handler if you use the core SDK directly.",[12,22050,22051,22052,22054],{},"You do not write reconnection logic. You do not implement retry backoff. You do not resubscribe. What you do own is the gap: keep the messages you care about in your own store, fetch just the missed window on ",[84,22053,2647],{},", prefer topics whose latest message supersedes the ones before it, and treat caught-up messages quietly. The SDK handles the transport layer and gives you a clean, ordered stream of messages with enough context to handle each one correctly.",[969,22056,5112],{},{"title":144,"searchDepth":172,"depth":172,"links":22058},[22059,22060,22061,22066,22067,22068,22069],{"id":21153,"depth":172,"text":21154},{"id":21190,"depth":172,"text":21191},{"id":21203,"depth":172,"text":21204,"children":22062},[22063,22064,22065],{"id":21207,"depth":179,"text":21208},{"id":21214,"depth":179,"text":21215},{"id":21221,"depth":179,"text":21222},{"id":21228,"depth":172,"text":21229},{"id":21472,"depth":172,"text":21473},{"id":21691,"depth":172,"text":21692},{"id":22033,"depth":172,"text":22034},"2026-03-03","Mobile networks drop, Wi-Fi switches, laptops close. Learn what a reconnect restores, what it does not, how to design for the gap, and when the broker replays missed messages for worker groups.",{},{"title":21139,"description":22071},"blog/reconnection-message-replay","_fxfx8o-OZ1SMM-2tACnKioUbugUf7h6XR29aYNADuo",{"id":22077,"title":22078,"author":7,"body":22079,"category":23050,"date":23051,"description":23052,"excerpt":986,"extension":987,"meta":23053,"navigation":175,"path":960,"readTime":3502,"seo":23054,"stem":23055,"__hash__":23056},"blog/blog/blueprint-sdks-vs-raw-pubsub.md","Blueprint SDKs vs Raw Pub/Sub: Ship in Minutes Instead of Weeks",{"type":9,"value":22080,"toc":23043},[22081,22084,22087,22091,22094,22136,22139,22143,22146,22683,22686,22690,22696,22954,22963,22969,22973,22976,23007,23016,23022,23026,23029,23032,23038,23041],[12,22082,22083],{},"Real-time features are one of the most-rebuilt things in software. Every team building a chat product starts with the same blank canvas, makes the same decisions about how to structure rooms, how to track presence, how to handle typing indicators, how to catch a client up after a gap, and how to manage reconnection state. Then they spend two to four weeks wiring it together before they can show a product demo.",[12,22085,22086],{},"This is the build vs buy problem for real-time infrastructure. Raw pub/sub gives you complete flexibility at the cost of building everything yourself. Blueprint SDKs give you a domain-specific API that handles the common patterns, so you can ship a working feature on day one.",[35,22088,22090],{"id":22089},"what-every-real-time-app-rebuilds","What Every Real-Time App Rebuilds",[12,22092,22093],{},"Before getting into the comparison, it is worth being specific about what \"wiring it yourself\" actually means. Here is the list of concerns that every chat application, every notification feed, and every live dashboard rebuilds from scratch:",[882,22095,22096,22102,22108,22113,22119,22125,22130],{},[885,22097,22098,22101],{},[30,22099,22100],{},"Room management."," Creating rooms, joining and leaving them, listing members, enforcing membership rules.",[885,22103,22104,22107],{},[30,22105,22106],{},"Presence tracking."," Who is online, detecting disconnects, heartbeat timeouts, multi-tab handling, custom status metadata.",[885,22109,22110,22112],{},[30,22111,17812],{}," Starting and stopping a typing state, broadcasting it to other room members, timing out stale typing states.",[885,22114,22115,22118],{},[30,22116,22117],{},"Catch-up after a gap."," Deciding what a client that was away should see, and rendering it differently from live traffic.",[885,22120,22121,22124],{},[30,22122,22123],{},"Reconnection state."," Detecting drops, exponential backoff, re-subscribing to channels, re-joining rooms, flushing any locally queued messages.",[885,22126,22127,22129],{},[30,22128,15971],{}," Tracking which messages each user has seen, computing unread badges, marking conversations as read.",[885,22131,22132,22135],{},[30,22133,22134],{},"Message history."," Paginating through past messages, loading older messages on scroll, merging history with the live stream.",[12,22137,22138],{},"Each of these is a real engineering problem with edge cases. Typing indicators need a timeout: if a user starts typing and then closes their tab, the \"Alice is typing\" indicator should not stick around forever. Presence needs reference counting for multi-tab users. Reconnection needs to re-join rooms, not just re-open the socket. None of this is difficult individually, but together it is weeks of work before your core feature is even started.",[35,22140,22142],{"id":22141},"the-raw-pubsub-approach","The Raw Pub/Sub Approach",[12,22144,22145],{},"Here is roughly what it takes to wire a minimal chat room with raw pub/sub. No history, no unread counts, just connecting, joining a room, sending messages, and showing typing indicators.",[135,22147,22148],{},[138,22149,22151],{"className":140,"code":22150,"filename":142,"language":143,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\n\nconst client = NoLag(token)\nawait client.connect()\n\n// Create the app and the room first; the slug you get back has a random suffix\nconst room = client.setApp(APP_SLUG).setRoom(roomId)\nlet typingTimeout: ReturnType\u003Ctypeof setTimeout> | null = null\n\n// Subscribe to the room's message and typing topics\nroom.subscribe('messages')\nroom.on('messages', (msg: any) => renderMessage(msg))\n\nroom.subscribe('typing')\nroom.on('typing', (evt: any) => {\n  if (evt.type === 'typing_start') showTypingIndicator(evt.userId)\n  if (evt.type === 'typing_stop') hideTypingIndicator(evt.userId)\n})\n\n// Presence: announce ourselves in the room, watch everyone else.\n// Presence events are client-level, so they arrive for every room this\n// connection is in and you filter them yourself.\nroom.setPresence({ userId, displayName })\nclient.on('presence:join', (actor) => addMember(actor.actorTokenId, actor.presence))\nclient.on('presence:update', (actor) => updateMember(actor.actorTokenId, actor.presence))\nclient.on('presence:leave', (actor) => removeMember(actor.actorTokenId))\n\n// Send a message. Publishers never receive their own messages,\n// so render it locally as well.\nfunction sendMessage(text: string) {\n  const msg = { userId, displayName, text, timestamp: Date.now() }\n  room.emit('messages', msg)\n  renderMessage(msg)\n}\n\n// Track typing with debounce\nfunction onInputChange() {\n  room.emit('typing', { type: 'typing_start', userId }, { qos: 0 })\n  if (typingTimeout) clearTimeout(typingTimeout)\n  typingTimeout = setTimeout(() => {\n    room.emit('typing', { type: 'typing_stop', userId }, { qos: 0 })\n  }, 3000)\n}\n\n// The broker clears presence when the socket closes, but it does not\n// restore room presence after a reconnect. You have to set it again.\nclient.on('reconnect', () => room.setPresence({ userId, displayName }))\n",[84,22152,22153,22163,22167,22179,22189,22193,22197,22220,22246,22250,22255,22267,22295,22299,22311,22336,22356,22374,22378,22382,22387,22392,22397,22406,22429,22452,22475,22479,22484,22489,22505,22521,22533,22539,22543,22547,22552,22561,22584,22597,22613,22635,22644,22648,22652,22657,22662],{"__ignoreMap":144},[148,22154,22155,22157,22159,22161],{"class":150,"line":151},[148,22156,155],{"class":154},[148,22158,2575],{"class":161},[148,22160,165],{"class":154},[148,22162,169],{"class":168},[148,22164,22165],{"class":150,"line":172},[148,22166,176],{"emptyLinePlaceholder":175},[148,22168,22169,22171,22173,22175,22177],{"class":150,"line":179},[148,22170,323],{"class":154},[148,22172,1589],{"class":235},[148,22174,329],{"class":154},[148,22176,1594],{"class":207},[148,22178,4091],{"class":161},[148,22180,22181,22183,22185,22187],{"class":150,"line":186},[148,22182,343],{"class":154},[148,22184,4099],{"class":161},[148,22186,2647],{"class":207},[148,22188,352],{"class":161},[148,22190,22191],{"class":150,"line":192},[148,22192,176],{"emptyLinePlaceholder":175},[148,22194,22195],{"class":150,"line":198},[148,22196,20239],{"class":182},[148,22198,22199,22201,22203,22205,22207,22209,22211,22213,22215,22217],{"class":150,"line":214},[148,22200,323],{"class":154},[148,22202,359],{"class":235},[148,22204,329],{"class":154},[148,22206,4099],{"class":161},[148,22208,2667],{"class":207},[148,22210,369],{"class":161},[148,22212,2672],{"class":235},[148,22214,2675],{"class":161},[148,22216,2678],{"class":207},[148,22218,22219],{"class":161},"(roomId)\n",[148,22221,22222,22224,22227,22229,22231,22233,22235,22238,22240,22242,22244],{"class":150,"line":227},[148,22223,11665],{"class":154},[148,22225,22226],{"class":161}," typingTimeout",[148,22228,221],{"class":154},[148,22230,3971],{"class":207},[148,22232,572],{"class":161},[148,22234,3976],{"class":154},[148,22236,22237],{"class":161}," setTimeout> ",[148,22239,3982],{"class":154},[148,22241,3941],{"class":235},[148,22243,329],{"class":154},[148,22245,4238],{"class":235},[148,22247,22248],{"class":150,"line":239},[148,22249,176],{"emptyLinePlaceholder":175},[148,22251,22252],{"class":150,"line":249},[148,22253,22254],{"class":182},"// Subscribe to the room's message and typing topics\n",[148,22256,22257,22259,22261,22263,22265],{"class":150,"line":259},[148,22258,380],{"class":161},[148,22260,2692],{"class":207},[148,22262,369],{"class":161},[148,22264,2697],{"class":168},[148,22266,375],{"class":161},[148,22268,22269,22271,22273,22275,22277,22279,22281,22283,22286,22288,22290,22293],{"class":150,"line":654},[148,22270,380],{"class":161},[148,22272,2706],{"class":207},[148,22274,369],{"class":161},[148,22276,2697],{"class":168},[148,22278,2713],{"class":161},[148,22280,3103],{"class":217},[148,22282,221],{"class":154},[148,22284,22285],{"class":235}," any",[148,22287,633],{"class":161},[148,22289,636],{"class":154},[148,22291,22292],{"class":207}," renderMessage",[148,22294,17589],{"class":161},[148,22296,22297],{"class":150,"line":660},[148,22298,176],{"emptyLinePlaceholder":175},[148,22300,22301,22303,22305,22307,22309],{"class":150,"line":1289},[148,22302,380],{"class":161},[148,22304,2692],{"class":207},[148,22306,369],{"class":161},[148,22308,15383],{"class":168},[148,22310,375],{"class":161},[148,22312,22313,22315,22317,22319,22321,22323,22326,22328,22330,22332,22334],{"class":150,"line":1295},[148,22314,380],{"class":161},[148,22316,2706],{"class":207},[148,22318,369],{"class":161},[148,22320,15383],{"class":168},[148,22322,2713],{"class":161},[148,22324,22325],{"class":217},"evt",[148,22327,221],{"class":154},[148,22329,22285],{"class":235},[148,22331,633],{"class":161},[148,22333,636],{"class":154},[148,22335,211],{"class":161},[148,22337,22338,22340,22343,22345,22348,22350,22353],{"class":150,"line":1313},[148,22339,10452],{"class":154},[148,22341,22342],{"class":161}," (evt.type ",[148,22344,1700],{"class":154},[148,22346,22347],{"class":168}," 'typing_start'",[148,22349,633],{"class":161},[148,22351,22352],{"class":207},"showTypingIndicator",[148,22354,22355],{"class":161},"(evt.userId)\n",[148,22357,22358,22360,22362,22364,22367,22369,22372],{"class":150,"line":1331},[148,22359,10452],{"class":154},[148,22361,22342],{"class":161},[148,22363,1700],{"class":154},[148,22365,22366],{"class":168}," 'typing_stop'",[148,22368,633],{"class":161},[148,22370,22371],{"class":207},"hideTypingIndicator",[148,22373,22355],{"class":161},[148,22375,22376],{"class":150,"line":1346},[148,22377,853],{"class":161},[148,22379,22380],{"class":150,"line":1355},[148,22381,176],{"emptyLinePlaceholder":175},[148,22383,22384],{"class":150,"line":1361},[148,22385,22386],{"class":182},"// Presence: announce ourselves in the room, watch everyone else.\n",[148,22388,22389],{"class":150,"line":1370},[148,22390,22391],{"class":182},"// Presence events are client-level, so they arrive for every room this\n",[148,22393,22394],{"class":150,"line":1376},[148,22395,22396],{"class":182},"// connection is in and you filter them yourself.\n",[148,22398,22399,22401,22403],{"class":150,"line":2033},[148,22400,380],{"class":161},[148,22402,19143],{"class":207},[148,22404,22405],{"class":161},"({ userId, displayName })\n",[148,22407,22408,22410,22412,22414,22416,22418,22420,22422,22424,22427],{"class":150,"line":2039},[148,22409,16176],{"class":161},[148,22411,2706],{"class":207},[148,22413,369],{"class":161},[148,22415,19011],{"class":168},[148,22417,2713],{"class":161},[148,22419,2839],{"class":217},[148,22421,633],{"class":161},[148,22423,636],{"class":154},[148,22425,22426],{"class":207}," addMember",[148,22428,19025],{"class":161},[148,22430,22431,22433,22435,22437,22439,22441,22443,22445,22447,22450],{"class":150,"line":2044},[148,22432,16176],{"class":161},[148,22434,2706],{"class":207},[148,22436,369],{"class":161},[148,22438,19036],{"class":168},[148,22440,2713],{"class":161},[148,22442,2839],{"class":217},[148,22444,633],{"class":161},[148,22446,636],{"class":154},[148,22448,22449],{"class":207}," updateMember",[148,22451,19025],{"class":161},[148,22453,22454,22456,22458,22460,22462,22464,22466,22468,22470,22473],{"class":150,"line":2050},[148,22455,16176],{"class":161},[148,22457,2706],{"class":207},[148,22459,369],{"class":161},[148,22461,19060],{"class":168},[148,22463,2713],{"class":161},[148,22465,2839],{"class":217},[148,22467,633],{"class":161},[148,22469,636],{"class":154},[148,22471,22472],{"class":207}," removeMember",[148,22474,19074],{"class":161},[148,22476,22477],{"class":150,"line":2056},[148,22478,176],{"emptyLinePlaceholder":175},[148,22480,22481],{"class":150,"line":2062},[148,22482,22483],{"class":182},"// Send a message. Publishers never receive their own messages,\n",[148,22485,22486],{"class":150,"line":2068},[148,22487,22488],{"class":182},"// so render it locally as well.\n",[148,22490,22491,22493,22495,22497,22499,22501,22503],{"class":150,"line":2074},[148,22492,5682],{"class":154},[148,22494,14524],{"class":207},[148,22496,369],{"class":161},[148,22498,1114],{"class":217},[148,22500,221],{"class":154},[148,22502,558],{"class":235},[148,22504,600],{"class":161},[148,22506,22507,22509,22511,22513,22516,22518],{"class":150,"line":2080},[148,22508,3596],{"class":154},[148,22510,4295],{"class":235},[148,22512,329],{"class":154},[148,22514,22515],{"class":161}," { userId, displayName, text, timestamp: Date.",[148,22517,6462],{"class":207},[148,22519,22520],{"class":161},"() }\n",[148,22522,22523,22525,22527,22529,22531],{"class":150,"line":2086},[148,22524,15355],{"class":161},[148,22526,2807],{"class":207},[148,22528,369],{"class":161},[148,22530,2697],{"class":168},[148,22532,4319],{"class":161},[148,22534,22535,22537],{"class":150,"line":2092},[148,22536,17048],{"class":207},[148,22538,14504],{"class":161},[148,22540,22541],{"class":150,"line":2097},[148,22542,262],{"class":161},[148,22544,22545],{"class":150,"line":2103},[148,22546,176],{"emptyLinePlaceholder":175},[148,22548,22549],{"class":150,"line":2108},[148,22550,22551],{"class":182},"// Track typing with debounce\n",[148,22553,22554,22556,22559],{"class":150,"line":2114},[148,22555,5682],{"class":154},[148,22557,22558],{"class":207}," onInputChange",[148,22560,3591],{"class":161},[148,22562,22563,22565,22567,22569,22571,22574,22577,22580,22582],{"class":150,"line":2120},[148,22564,15355],{"class":161},[148,22566,2807],{"class":207},[148,22568,369],{"class":161},[148,22570,15383],{"class":168},[148,22572,22573],{"class":161},", { type: ",[148,22575,22576],{"class":168},"'typing_start'",[148,22578,22579],{"class":161},", userId }, { qos: ",[148,22581,9227],{"class":235},[148,22583,2557],{"class":161},[148,22585,22586,22588,22591,22594],{"class":150,"line":2125},[148,22587,10452],{"class":154},[148,22589,22590],{"class":161}," (typingTimeout) ",[148,22592,22593],{"class":207},"clearTimeout",[148,22595,22596],{"class":161},"(typingTimeout)\n",[148,22598,22599,22602,22604,22607,22609,22611],{"class":150,"line":4261},[148,22600,22601],{"class":161},"  typingTimeout ",[148,22603,1337],{"class":154},[148,22605,22606],{"class":207}," setTimeout",[148,22608,3955],{"class":161},[148,22610,636],{"class":154},[148,22612,211],{"class":161},[148,22614,22615,22618,22620,22622,22624,22626,22629,22631,22633],{"class":150,"line":4266},[148,22616,22617],{"class":161},"    room.",[148,22619,2807],{"class":207},[148,22621,369],{"class":161},[148,22623,15383],{"class":168},[148,22625,22573],{"class":161},[148,22627,22628],{"class":168},"'typing_stop'",[148,22630,22579],{"class":161},[148,22632,9227],{"class":235},[148,22634,2557],{"class":161},[148,22636,22637,22640,22642],{"class":150,"line":4290},[148,22638,22639],{"class":161},"  }, ",[148,22641,14152],{"class":235},[148,22643,375],{"class":161},[148,22645,22646],{"class":150,"line":4307},[148,22647,262],{"class":161},[148,22649,22650],{"class":150,"line":4322},[148,22651,176],{"emptyLinePlaceholder":175},[148,22653,22654],{"class":150,"line":4328},[148,22655,22656],{"class":182},"// The broker clears presence when the socket closes, but it does not\n",[148,22658,22659],{"class":150,"line":4349},[148,22660,22661],{"class":182},"// restore room presence after a reconnect. You have to set it again.\n",[148,22663,22664,22666,22668,22670,22672,22674,22676,22678,22680],{"class":150,"line":4354},[148,22665,16176],{"class":161},[148,22667,2706],{"class":207},[148,22669,369],{"class":161},[148,22671,21024],{"class":168},[148,22673,14945],{"class":161},[148,22675,636],{"class":154},[148,22677,6701],{"class":161},[148,22679,19143],{"class":207},[148,22681,22682],{"class":161},"({ userId, displayName }))\n",[12,22684,22685],{},"That is already over 40 lines and it is missing: multi-tab presence deduplication, mapping actor ids to user profiles, the receiver-side timeout that clears a stale typing indicator, message history loading, unread count tracking, and any notion of which messages a reconnecting client missed. Each of those adds another chunk of code and another set of edge cases to manage.",[35,22687,22689],{"id":22688},"the-blueprint-sdk-approach","The Blueprint SDK Approach",[12,22691,22692,22693,22695],{},"A Blueprint SDK wraps the pub/sub infrastructure with a domain-specific API designed for a particular use case. Here is the same chat room with the ",[84,22694,9799],{}," Blueprint:",[135,22697,22698],{},[138,22699,22701],{"className":140,"code":22700,"filename":142,"language":143,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\nimport { NoLagChat } from '@nolag/chat'\n\nconst client = NoLag(token)\n// appName is the suffixed slug returned when you created the app from the chat blueprint\nconst chat = new NoLagChat({ client, appName: APP_SLUG, username: displayName })\nawait client.connect()\nawait chat.ready()\n\n// Join a room. Presence and typing indicators are automatic.\nconst room = chat.joinRoom(roomId)\nroom.on('message', (msg) => renderMessage(msg))\nroom.on('userJoined', () => updateMemberList(room.getUsers()))\nroom.on('userLeft', () => updateMemberList(room.getUsers()))\nroom.on('typing', ({ users }) => updateTypingIndicator(users))\n\n// Send a message (returns an optimistic ChatMessage for your own UI)\nroom.sendMessage('Hello!')\n\n// Start typing indicator (auto-stops after inactivity)\nroom.startTyping()\n\n// Leave the room (presence updates automatically)\nchat.leaveRoom(roomId)\n",[84,22702,22703,22713,22723,22727,22739,22744,22763,22773,22783,22787,22792,22806,22828,22853,22875,22899,22903,22908,22920,22924,22929,22937,22941,22946],{"__ignoreMap":144},[148,22704,22705,22707,22709,22711],{"class":150,"line":151},[148,22706,155],{"class":154},[148,22708,2575],{"class":161},[148,22710,165],{"class":154},[148,22712,169],{"class":168},[148,22714,22715,22717,22719,22721],{"class":150,"line":172},[148,22716,155],{"class":154},[148,22718,304],{"class":161},[148,22720,165],{"class":154},[148,22722,309],{"class":168},[148,22724,22725],{"class":150,"line":179},[148,22726,176],{"emptyLinePlaceholder":175},[148,22728,22729,22731,22733,22735,22737],{"class":150,"line":186},[148,22730,323],{"class":154},[148,22732,1589],{"class":235},[148,22734,329],{"class":154},[148,22736,1594],{"class":207},[148,22738,4091],{"class":161},[148,22740,22741],{"class":150,"line":192},[148,22742,22743],{"class":182},"// appName is the suffixed slug returned when you created the app from the chat blueprint\n",[148,22745,22746,22748,22750,22752,22754,22756,22758,22760],{"class":150,"line":198},[148,22747,323],{"class":154},[148,22749,326],{"class":235},[148,22751,329],{"class":154},[148,22753,332],{"class":154},[148,22755,335],{"class":207},[148,22757,12544],{"class":161},[148,22759,2672],{"class":235},[148,22761,22762],{"class":161},", username: displayName })\n",[148,22764,22765,22767,22769,22771],{"class":150,"line":214},[148,22766,343],{"class":154},[148,22768,4099],{"class":161},[148,22770,2647],{"class":207},[148,22772,352],{"class":161},[148,22774,22775,22777,22779,22781],{"class":150,"line":227},[148,22776,343],{"class":154},[148,22778,346],{"class":161},[148,22780,349],{"class":207},[148,22782,352],{"class":161},[148,22784,22785],{"class":150,"line":239},[148,22786,176],{"emptyLinePlaceholder":175},[148,22788,22789],{"class":150,"line":249},[148,22790,22791],{"class":182},"// Join a room. Presence and typing indicators are automatic.\n",[148,22793,22794,22796,22798,22800,22802,22804],{"class":150,"line":259},[148,22795,323],{"class":154},[148,22797,359],{"class":235},[148,22799,329],{"class":154},[148,22801,346],{"class":161},[148,22803,366],{"class":207},[148,22805,22219],{"class":161},[148,22807,22808,22810,22812,22814,22816,22818,22820,22822,22824,22826],{"class":150,"line":654},[148,22809,380],{"class":161},[148,22811,2706],{"class":207},[148,22813,369],{"class":161},[148,22815,3098],{"class":168},[148,22817,2713],{"class":161},[148,22819,3103],{"class":217},[148,22821,633],{"class":161},[148,22823,636],{"class":154},[148,22825,22292],{"class":207},[148,22827,17589],{"class":161},[148,22829,22830,22832,22834,22836,22838,22840,22842,22845,22848,22850],{"class":150,"line":660},[148,22831,380],{"class":161},[148,22833,2706],{"class":207},[148,22835,369],{"class":161},[148,22837,15626],{"class":168},[148,22839,14945],{"class":161},[148,22841,636],{"class":154},[148,22843,22844],{"class":207}," updateMemberList",[148,22846,22847],{"class":161},"(room.",[148,22849,15604],{"class":207},[148,22851,22852],{"class":161},"()))\n",[148,22854,22855,22857,22859,22861,22863,22865,22867,22869,22871,22873],{"class":150,"line":1289},[148,22856,380],{"class":161},[148,22858,2706],{"class":207},[148,22860,369],{"class":161},[148,22862,15668],{"class":168},[148,22864,14945],{"class":161},[148,22866,636],{"class":154},[148,22868,22844],{"class":207},[148,22870,22847],{"class":161},[148,22872,15604],{"class":207},[148,22874,22852],{"class":161},[148,22876,22877,22879,22881,22883,22885,22887,22889,22891,22893,22896],{"class":150,"line":1295},[148,22878,380],{"class":161},[148,22880,2706],{"class":207},[148,22882,369],{"class":161},[148,22884,15383],{"class":168},[148,22886,15386],{"class":161},[148,22888,15389],{"class":217},[148,22890,15392],{"class":161},[148,22892,636],{"class":154},[148,22894,22895],{"class":207}," updateTypingIndicator",[148,22897,22898],{"class":161},"(users))\n",[148,22900,22901],{"class":150,"line":1313},[148,22902,176],{"emptyLinePlaceholder":175},[148,22904,22905],{"class":150,"line":1331},[148,22906,22907],{"class":182},"// Send a message (returns an optimistic ChatMessage for your own UI)\n",[148,22909,22910,22912,22914,22916,22918],{"class":150,"line":1346},[148,22911,380],{"class":161},[148,22913,383],{"class":207},[148,22915,369],{"class":161},[148,22917,388],{"class":168},[148,22919,375],{"class":161},[148,22921,22922],{"class":150,"line":1355},[148,22923,176],{"emptyLinePlaceholder":175},[148,22925,22926],{"class":150,"line":1361},[148,22927,22928],{"class":182},"// Start typing indicator (auto-stops after inactivity)\n",[148,22930,22931,22933,22935],{"class":150,"line":1370},[148,22932,380],{"class":161},[148,22934,10302],{"class":207},[148,22936,352],{"class":161},[148,22938,22939],{"class":150,"line":1376},[148,22940,176],{"emptyLinePlaceholder":175},[148,22942,22943],{"class":150,"line":2033},[148,22944,22945],{"class":182},"// Leave the room (presence updates automatically)\n",[148,22947,22948,22950,22952],{"class":150,"line":2039},[148,22949,14935],{"class":161},[148,22951,15811],{"class":207},[148,22953,22219],{"class":161},[12,22955,22956,22957,22959,22960,22962],{},"About fifteen lines of application code. Presence, typing indicators, user identity, and unread counts are all handled by the Blueprint. The ",[84,22958,14607],{}," handler receives other users' messages in order, and your own sends come back through ",[84,22961,15990],{}," with an optimistic status so you never have to special-case the self-echo rule. Reconnection re-joins the room and re-sets presence automatically. Typing states time out on their own.",[12,22964,22965,22966,22968],{},"One thing no blueprint does for you is catch-up after a gap. NoLag replays missed messages only to load-balanced worker groups (see ",[952,22967,7734],{"href":2319},"); a chat client that reconnects gets its subscriptions back and nothing else. The chat SDK keeps an in-memory cache of what it has seen, so a product that needs history across sessions stores it itself and reads it on join.",[35,22970,22972],{"id":22971},"what-a-blueprint-includes","What a Blueprint Includes",[12,22974,22975],{},"A Blueprint is not just a client SDK. It is a complete package with three parts:",[12,22977,22978,22981,22982,87,22984,87,22986,87,22988,22990,22991,87,22993,87,22995,22998,22999,87,23002,87,23005,611],{},[30,22979,22980],{},"A purpose-built SDK."," The client library with the domain-specific API, covering the common patterns for that use case. For chat: ",[84,22983,366],{},[84,22985,383],{},[84,22987,10302],{},[84,22989,10323],{},". For notifications: ",[84,22992,2692],{},[84,22994,15932],{},[84,22996,22997],{},"markAllRead",". For dashboards: ",[84,23000,23001],{},"joinPanel",[84,23003,23004],{},"leavePanel",[84,23006,19931],{},[12,23008,23009,23012,23013,23015],{},[30,23010,23011],{},"Infrastructure configuration."," The rooms, topic structure, lobby, and retention settings that the SDK expects. Creating an app from the blueprint seeds all of it into your NoLag project, and the suffixed app slug you get back is the ",[84,23014,5995],{}," you hand to the SDK.",[12,23017,23018,23021],{},[30,23019,23020],{},"A working demo."," A runnable example showing the Blueprint in action, with a UI you can use as a starting point or just as a reference.",[35,23023,23025],{"id":23024},"when-to-use-raw-pubsub-vs-a-blueprint","When to Use Raw Pub/Sub vs a Blueprint",[12,23027,23028],{},"Blueprints are the right choice when your use case matches one of the available templates. Chat, notifications, live dashboards, and collaborative features are all well-understood problem spaces with settled patterns. Using a Blueprint means you get a working implementation of those patterns on day one, with the edge cases already handled.",[12,23030,23031],{},"Raw pub/sub is the right choice when your use case is genuinely novel or when you need full control over the message structure, topic design, and delivery semantics. A real-time multiplayer game with custom synchronization logic, a live auction platform with specific bid sequencing requirements, or a specialized IoT telemetry pipeline all benefit from starting with raw pub/sub and building exactly what they need.",[12,23033,23034,23035,23037],{},"The two are not mutually exclusive. You can use the ",[84,23036,9799],{}," Blueprint for your chat feature while using raw pub/sub for a custom activity feed with unusual filtering requirements. Blueprints attach to the same core client, so they compose with raw pub/sub subscriptions in the same application without any conflicts.",[12,23039,23040],{},"The question to ask is: am I building something that has been built many times before, or something genuinely new? If the answer is \"I need a chat feature\", use the Blueprint and spend your engineering time on the parts of your product that are actually differentiated. If the answer is \"I need a message delivery system with custom routing logic tied to our domain model\", reach for raw pub/sub.",[969,23042,20084],{},{"title":144,"searchDepth":172,"depth":172,"links":23044},[23045,23046,23047,23048,23049],{"id":22089,"depth":172,"text":22090},{"id":22141,"depth":172,"text":22142},{"id":22688,"depth":172,"text":22689},{"id":22971,"depth":172,"text":22972},{"id":23024,"depth":172,"text":23025},"Product","2026-02-24","Every real-time app rebuilds the same plumbing: rooms, presence, typing indicators, replay, reconnection state, unread counts. Blueprint SDKs give you a domain-specific API so you can skip the wiring entirely.",{},{"title":22078,"description":23052},"blog/blueprint-sdks-vs-raw-pubsub","1E3gDNhPWfXC1bF4fGBv12tN7FwyiU5FU0tTvrzDJHo",1789869422004]