[{"data":1,"prerenderedAt":18214},["ShallowReactive",2],{"blog-list":3},[4,710,1526,1892,2392,2868,3677,4131,4826,6516,8259,9677,10305,10917,11599,12950,13653,14399,15072,15511,16063,16823,17294],{"id":5,"title":6,"author":7,"body":8,"category":698,"date":699,"description":700,"excerpt":701,"extension":702,"meta":703,"navigation":704,"path":705,"readTime":706,"seo":707,"stem":708,"__hash__":709},"blog/blog/one-connection-many-realtime-features.md","Stop Buying Realtime One Scenario at a Time","Henco Burger",{"type":9,"value":10,"toc":687},"minimark",[11,15,18,26,34,39,131,134,231,242,248,252,255,258,325,328,331,335,338,341,351,358,362,365,376,379,382,386,389,392,516,519,529,542,545,549,555,586,589,593,596,618,621,625,628,655,658,662,683],[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 delivery guarantees, 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","// One core client per tenant carries ALL realtime apps.\n// Feature stores no longer own sockets; they attach a wrapper to this client.\nexport interface RealtimeHandle {\n  client: NoLagSocket\n  chatAppName: string\n  notifyAppName: string\n  dashboardAppName: string\n}\n","TypeScript","typescript","",[84,146,147,156,162,180,193,205,215,225],{"__ignoreMap":144},[148,149,152],"span",{"class":150,"line":151},"line",1,[148,153,155],{"class":154},"sJ8bj","// One core client per tenant carries ALL realtime apps.\n",[148,157,159],{"class":150,"line":158},2,[148,160,161],{"class":154},"// Feature stores no longer own sockets; they attach a wrapper to this client.\n",[148,163,165,169,172,176],{"class":150,"line":164},3,[148,166,168],{"class":167},"szBVR","export",[148,170,171],{"class":167}," interface",[148,173,175],{"class":174},"sScJk"," RealtimeHandle",[148,177,179],{"class":178},"sVt8B"," {\n",[148,181,183,187,190],{"class":150,"line":182},4,[148,184,186],{"class":185},"s4XuR","  client",[148,188,189],{"class":167},":",[148,191,192],{"class":174}," NoLagSocket\n",[148,194,196,199,201],{"class":150,"line":195},5,[148,197,198],{"class":185},"  chatAppName",[148,200,189],{"class":167},[148,202,204],{"class":203},"sj4cs"," string\n",[148,206,208,211,213],{"class":150,"line":207},6,[148,209,210],{"class":185},"  notifyAppName",[148,212,189],{"class":167},[148,214,204],{"class":203},[148,216,218,221,223],{"class":150,"line":217},7,[148,219,220],{"class":185},"  dashboardAppName",[148,222,189],{"class":167},[148,224,204],{"class":203},[148,226,228],{"class":150,"line":227},8,[148,229,230],{"class":178},"}\n",[12,232,233,234,237,238,241],{},"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,235,236],{},"connect()"," and never calls ",[84,239,240],{},"disconnect()",", so there is exactly one place that owns the socket lifecycle.",[12,243,244,245],{},"That is worth stating as a rule, because it is the thing that makes the whole pattern hold together: ",[30,246,247],{},"one core client, many wrappers, and only the owner touches the socket.",[35,249,251],{"id":250},"why-this-is-not-just-one-connection","Why this is not just \"one connection\"",[12,253,254],{},"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,256,257],{},"That is what Blueprints are for. Each one is a use case that has already been thought through: an app schema with its rooms, topics, presence and replay pre-configured, and an SDK whose methods are the domain rather than the transport.",[135,259,260,304],{},[138,261,264],{"className":140,"code":262,"filename":263,"language":143,"meta":144,"style":144},"const chat = new NoLagChat({ client, username })\nroom.sendMessage('Hello!')\n","With a Blueprint",[84,265,266,286],{"__ignoreMap":144},[148,267,268,271,274,277,280,283],{"class":150,"line":151},[148,269,270],{"class":167},"const",[148,272,273],{"class":203}," chat",[148,275,276],{"class":167}," =",[148,278,279],{"class":167}," new",[148,281,282],{"class":174}," NoLagChat",[148,284,285],{"class":178},"({ client, username })\n",[148,287,288,291,294,297,301],{"class":150,"line":158},[148,289,290],{"class":178},"room.",[148,292,293],{"class":174},"sendMessage",[148,295,296],{"class":178},"(",[148,298,300],{"class":299},"sZZnC","'Hello!'",[148,302,303],{"class":178},")\n",[138,305,308],{"className":140,"code":306,"filename":307,"language":143,"meta":144,"style":144},"// pick topic names, decide a room convention, configure replay depth,\n// design a presence payload, define a message envelope, handle ordering,\n// and write all of it again for notifications\n","Without one",[84,309,310,315,320],{"__ignoreMap":144},[148,311,312],{"class":150,"line":151},[148,313,314],{"class":154},"// pick topic names, decide a room convention, configure replay depth,\n",[148,316,317],{"class":150,"line":158},[148,318,319],{"class":154},"// design a presence payload, define a message envelope, handle ordering,\n",[148,321,322],{"class":150,"line":164},[148,323,324],{"class":154},"// and write all of it again for notifications\n",[12,326,327],{},"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,329,330],{},"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,332,334],{"id":333},"scoping-to-a-tenant","Scoping to a tenant",[12,336,337],{},"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,339,340],{},"Here is the first thing that cost us real time.",[12,342,343,346,347,350],{},[30,344,345],{},"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,348,349],{},"new"," client: tear the old one down, mint a token for the new tenant, connect fresh.",[12,352,353,354,357],{},"Feature stores detect the identity change and re-attach. In Vue that is a ",[84,355,356],{},"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,359,361],{"id":360},"the-second-thing-that-cost-us-time","The second thing that cost us time",[12,363,364],{},"There is a subtle tenancy bug worth describing, because the shape of it generalises well beyond our domain.",[12,366,367,368,371,372,375],{},"An endpoint took a tenant id in the URL and used it for two different things: deciding ",[22,369,370],{},"which tenant's data"," the caller was allowed to touch, and deciding ",[22,373,374],{},"whose conversation"," to create. Those look like the same question and are not. The tenant scopes the data. The caller scopes the conversation.",[12,377,378],{},"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,380,381],{},"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,383,385],{"id":384},"where-the-agents-come-in","Where the agents come in",[12,387,388],{},"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,390,391],{},"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,393,394],{},[138,395,397],{"className":140,"code":396,"filename":142,"language":143,"meta":144,"style":144},"// Fan work out to a worker pool and await each result.\n// Per-task failures are isolated: one bad task never rejects the batch.\nexport async function dispatchSubAgents(\n  tasks: SubAgentTask[],\n  { timeoutMs = 120_000, onResult }: DispatchOptions = {},\n): Promise\u003CSubAgentResult[]> {\n  const handoff = await getHandoff()\n  // ... dispatch each task by capability, settle independently\n}\n",[84,398,399,404,409,425,438,469,487,506,511],{"__ignoreMap":144},[148,400,401],{"class":150,"line":151},[148,402,403],{"class":154},"// Fan work out to a worker pool and await each result.\n",[148,405,406],{"class":150,"line":158},[148,407,408],{"class":154},"// Per-task failures are isolated: one bad task never rejects the batch.\n",[148,410,411,413,416,419,422],{"class":150,"line":164},[148,412,168],{"class":167},[148,414,415],{"class":167}," async",[148,417,418],{"class":167}," function",[148,420,421],{"class":174}," dispatchSubAgents",[148,423,424],{"class":178},"(\n",[148,426,427,430,432,435],{"class":150,"line":182},[148,428,429],{"class":185},"  tasks",[148,431,189],{"class":167},[148,433,434],{"class":174}," SubAgentTask",[148,436,437],{"class":178},"[],\n",[148,439,440,443,446,448,451,453,456,459,461,464,466],{"class":150,"line":195},[148,441,442],{"class":178},"  { ",[148,444,445],{"class":185},"timeoutMs",[148,447,276],{"class":167},[148,449,450],{"class":203}," 120_000",[148,452,87],{"class":178},[148,454,455],{"class":185},"onResult",[148,457,458],{"class":178}," }",[148,460,189],{"class":167},[148,462,463],{"class":174}," DispatchOptions",[148,465,276],{"class":167},[148,467,468],{"class":178}," {},\n",[148,470,471,473,475,478,481,484],{"class":150,"line":207},[148,472,130],{"class":178},[148,474,189],{"class":167},[148,476,477],{"class":174}," Promise",[148,479,480],{"class":178},"\u003C",[148,482,483],{"class":174},"SubAgentResult",[148,485,486],{"class":178},"[]> {\n",[148,488,489,492,495,497,500,503],{"class":150,"line":217},[148,490,491],{"class":167},"  const",[148,493,494],{"class":203}," handoff",[148,496,276],{"class":167},[148,498,499],{"class":167}," await",[148,501,502],{"class":174}," getHandoff",[148,504,505],{"class":178},"()\n",[148,507,508],{"class":150,"line":227},[148,509,510],{"class":154},"  // ... dispatch each task by capability, settle independently\n",[148,512,514],{"class":150,"line":513},9,[148,515,230],{"class":178},[12,517,518],{},"Two details in there are the whole lesson.",[12,520,521,524,525,528],{},[30,522,523],{},"Per-task failure isolation."," One sub-agent failing must not reject the batch. If you await a plain ",[84,526,527],{},"Promise.all"," over model calls, a single timeout throws away eleven good answers. Each task settles on its own and the caller is told which ones came back.",[12,530,531,537,538,541],{},[30,532,533,534,536],{},"One long-lived ",[84,535,117],{}," per room."," Constructing a new one per call leaks a ",[84,539,540],{},"result"," listener every time, which is invisible until a long-running process gets slow for no apparent reason. Build it once, rebuild only if the underlying room changes on reconnect.",[12,543,544],{},"Neither of those is in a getting-started guide. Both are the kind of thing you learn by running something for a while.",[35,546,548],{"id":547},"humans-in-the-loop","Humans in the loop",[12,550,551,552,554],{},"Some agent actions should not happen without a person saying yes. The admin console attaches ",[84,553,126],{}," to the relevant room and renders pending requests:",[135,556,557],{},[138,558,560],{"className":140,"code":559,"filename":142,"language":143,"meta":144,"style":144},"const approve = new agents.Approve(room, 'admin-dashboard')\n",[84,561,562],{"__ignoreMap":144},[148,563,564,566,569,571,573,576,578,581,584],{"class":150,"line":151},[148,565,270],{"class":167},[148,567,568],{"class":203}," approve",[148,570,276],{"class":167},[148,572,279],{"class":167},[148,574,575],{"class":178}," agents.",[148,577,126],{"class":174},[148,579,580],{"class":178},"(room, ",[148,582,583],{"class":299},"'admin-dashboard'",[148,585,303],{"class":178},[12,587,588],{},"The agent asks, 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,590,592],{"id":591},"what-this-collapses","What this collapses",[12,594,595],{},"Counted against buying realtime a scenario at a time, one tenant-scoped connection on one platform replaces:",[597,598,599,603,606,609,612,615],"ul",{},[600,601,602],"li",{},"Three sockets in the dashboard, each with its own reconnect and backoff behaviour",[600,604,605],{},"Three vendors, three SDKs, three auth models and three bills",[600,607,608],{},"Three places that had to be told about a tenant switch, and three chances to get it wrong",[600,610,611],{},"A separate queue or job system for agent task dispatch",[600,613,614],{},"A separate mechanism for human approval gates",[600,616,617],{},"A separate transport for device telemetry",[12,619,620],{},"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,622,624],{"id":623},"if-you-are-building-something-similar","If you are building something similar",[12,626,627],{},"Four things we would tell ourselves at the start:",[629,630,631,637,643,649],"ol",{},[600,632,633,636],{},[30,634,635],{},"Put the connection behind one owner."," A store, a service, a module, whatever your framework calls it. Features attach and detach; they never connect.",[600,638,639,642],{},[30,640,641],{},"Make a tenant switch a new client."," Never re-scope a live one.",[600,644,645,648],{},[30,646,647],{},"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.",[600,650,651,654],{},[30,652,653],{},"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,656,657],{},"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,659,661],{"id":660},"read-next","Read next",[597,663,664,671,677],{},[600,665,666],{},[667,668,670],"a",{"href":669},"/blog/multi-agent-coordination-layer","Why multi-agent systems need a coordination layer",[600,672,673],{},[667,674,676],{"href":675},"/blog/blueprint-sdks-vs-raw-pubsub","Blueprint SDKs vs raw pub/sub",[600,678,679],{},[667,680,682],{"href":681},"/docs/agents","The agents SDK and its coordination patterns",[684,685,686],"style",{},"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":158,"depth":158,"links":688},[689,690,691,692,693,694,695,696,697],{"id":37,"depth":158,"text":38},{"id":250,"depth":158,"text":251},{"id":333,"depth":158,"text":334},{"id":360,"depth":158,"text":361},{"id":384,"depth":158,"text":385},{"id":547,"depth":158,"text":548},{"id":591,"depth":158,"text":592},{"id":623,"depth":158,"text":624},{"id":660,"depth":158,"text":661},"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",{},true,"/blog/one-connection-many-realtime-features","10 min read",{"title":6,"description":700},"blog/one-connection-many-realtime-features","5e8RmKFp3YPqJPqNJXGQcsMU6zIB1XH1bLtCwUBF8AI",{"id":711,"title":712,"author":7,"body":713,"category":1518,"date":699,"description":1519,"excerpt":701,"extension":702,"meta":1520,"navigation":704,"path":1521,"readTime":1522,"seo":1523,"stem":1524,"__hash__":1525},"blog/blog/voice-agent-with-tools-twilio.md","Building a Voice AI Agent That Can Actually Do Things",{"type":9,"value":714,"toc":1507},[715,718,721,725,728,731,734,737,741,744,747,806,809,812,816,819,822,833,836,839,877,880,884,887,890,1217,1224,1228,1233,1236,1239,1243,1246,1292,1295,1302,1305,1308,1312,1315,1446,1454,1457,1461,1464,1467,1471,1501,1504],[12,716,717],{},"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,719,720],{},"This is not a prompting problem. It is a budget problem, and the budget is time.",[35,722,724],{"id":723},"why-the-model-on-the-phone-is-small","Why the model on the phone is small",[12,726,727],{},"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,729,730],{},"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,732,733],{},"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,735,736],{},"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,738,740],{"id":739},"the-split","The split",[12,742,743],{},"NoLag is realtime messaging infrastructure and a coordination layer for AI agents: pub/sub topics, rooms, presence and delivery guarantees, 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,745,746],{},"Three pieces, each doing one job:",[40,748,749,761],{},[43,750,751],{},[46,752,753,755,758],{},[49,754,51],{},[49,756,757],{},"Job",[49,759,760],{},"Clock",[59,762,763,779,795],{},[46,764,765,773,776],{},[64,766,767],{},[667,768,770],{"href":769},"/docs/sdks/voice-engine",[84,771,772],{},"@nolag/voice-engine",[64,774,775],{},"Turn taking, interruption, voice activity detection, filler speech",[64,777,778],{},"Milliseconds",[46,780,781,789,792],{},[64,782,783],{},[667,784,786],{"href":785},"/docs/high-level-sdks/voice",[84,787,788],{},"@nolag/voice",[64,790,791],{},"Puts the call in a NoLag room, carries the ask and the answer",[64,793,794],{},"Per turn",[46,796,797,800,803],{},[64,798,799],{},"Your orchestrator",[64,801,802],{},"Bigger model, real tools, database credentials",[64,804,805],{},"Seconds to minutes",[12,807,808],{},"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,810,811],{},"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,813,815],{"id":814},"waiting-out-loud-is-the-feature","Waiting out loud is the feature",[12,817,818],{},"Here is the part that took the longest to get right, and it is a design decision rather than an implementation detail.",[12,820,821],{},"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,823,824],{},[138,825,831],{"className":826,"code":828,"filename":829,"language":830,"meta":144},[827],"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,832,828],{"__ignoreMap":144},[12,834,835],{},"That transcript is from a real call against our example app, transcribed back from the audio the caller would actually have heard. The two tool calls behind it, looking the booking up and checking availability, took about eight seconds. Nothing survives being nested inside a turn for eight seconds, so it was not nested inside one.",[12,837,838],{},"The behaviour has three tiers, and only the first one is invisible to the caller:",[40,840,841,851],{},[43,842,843],{},[46,844,845,848],{},[49,846,847],{},"Answer arrives",[49,849,850],{},"What the caller experiences",[59,852,853,861,869],{},[46,854,855,858],{},[64,856,857],{},"Within the grace window (1200ms by default)",[64,859,860],{},"Folded into the reply. They never learn anything was asked",[46,862,863,866],{},[64,864,865],{},"Later",[64,867,868],{},"An acknowledgement now, the answer spoken in the next gap",[46,870,871,874],{},[64,872,873],{},"Never",[64,875,876],{},"A spoken apology, never silence",[12,878,879],{},"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,881,883],{"id":882},"one-tool-not-twenty","One tool, not twenty",[12,885,886],{},"The agent on the phone gets exactly one tool: ask the orchestrator. That is deliberate.",[12,888,889],{},"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,891,892,1056],{},[138,893,896],{"className":140,"code":894,"filename":895,"language":143,"meta":144,"style":144},"import { NoLagVoice, orchestratedModel } from '@nolag/voice'\n\nconst bridge = new NoLagVoice({ agents }).orchestrator()\nawait bridge.ready()\n\nconst session = new VoiceSession({\n  transport,\n  providers: {\n    ...providers,\n    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  },\n  systemPrompt\n})\n","On the call server",[84,897,898,912,917,939,952,956,973,978,983,991,1002,1011,1017,1026,1032,1038,1044,1050],{"__ignoreMap":144},[148,899,900,903,906,909],{"class":150,"line":151},[148,901,902],{"class":167},"import",[148,904,905],{"class":178}," { NoLagVoice, orchestratedModel } ",[148,907,908],{"class":167},"from",[148,910,911],{"class":299}," '@nolag/voice'\n",[148,913,914],{"class":150,"line":158},[148,915,916],{"emptyLinePlaceholder":704},"\n",[148,918,919,921,924,926,928,931,934,937],{"class":150,"line":164},[148,920,270],{"class":167},[148,922,923],{"class":203}," bridge",[148,925,276],{"class":167},[148,927,279],{"class":167},[148,929,930],{"class":174}," NoLagVoice",[148,932,933],{"class":178},"({ agents }).",[148,935,936],{"class":174},"orchestrator",[148,938,505],{"class":178},[148,940,941,944,947,950],{"class":150,"line":182},[148,942,943],{"class":167},"await",[148,945,946],{"class":178}," bridge.",[148,948,949],{"class":174},"ready",[148,951,505],{"class":178},[148,953,954],{"class":150,"line":195},[148,955,916],{"emptyLinePlaceholder":704},[148,957,958,960,963,965,967,970],{"class":150,"line":207},[148,959,270],{"class":167},[148,961,962],{"class":203}," session",[148,964,276],{"class":167},[148,966,279],{"class":167},[148,968,969],{"class":174}," VoiceSession",[148,971,972],{"class":178},"({\n",[148,974,975],{"class":150,"line":217},[148,976,977],{"class":178},"  transport,\n",[148,979,980],{"class":150,"line":227},[148,981,982],{"class":178},"  providers: {\n",[148,984,985,988],{"class":150,"line":513},[148,986,987],{"class":167},"    ...",[148,989,990],{"class":178},"providers,\n",[148,992,994,997,1000],{"class":150,"line":993},10,[148,995,996],{"class":178},"    llm: ",[148,998,999],{"class":174},"orchestratedModel",[148,1001,972],{"class":178},[148,1003,1005,1008],{"class":150,"line":1004},11,[148,1006,1007],{"class":178},"      model: providers.llm,   ",[148,1009,1010],{"class":154},"// the fast model that carries the conversation\n",[148,1012,1014],{"class":150,"line":1013},12,[148,1015,1016],{"class":178},"      bridge,\n",[148,1018,1020,1023],{"class":150,"line":1019},13,[148,1021,1022],{"class":178},"      floor: session,         ",[148,1024,1025],{"class":154},"// so an answer can be spoken outside a turn\n",[148,1027,1029],{"class":150,"line":1028},14,[148,1030,1031],{"class":178},"      callId\n",[148,1033,1035],{"class":150,"line":1034},15,[148,1036,1037],{"class":178},"    })\n",[148,1039,1041],{"class":150,"line":1040},16,[148,1042,1043],{"class":178},"  },\n",[148,1045,1047],{"class":150,"line":1046},17,[148,1048,1049],{"class":178},"  systemPrompt\n",[148,1051,1053],{"class":150,"line":1052},18,[148,1054,1055],{"class":178},"})\n",[138,1057,1060],{"className":140,"code":1058,"filename":1059,"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,1061,1062,1074,1085,1089,1110,1148,1178,1195,1199,1212],{"__ignoreMap":144},[148,1063,1064,1066,1069,1071],{"class":150,"line":151},[148,1065,902],{"class":167},[148,1067,1068],{"class":178}," { Handoff } ",[148,1070,908],{"class":167},[148,1072,1073],{"class":299}," '@nolag/agents'\n",[148,1075,1076,1078,1081,1083],{"class":150,"line":158},[148,1077,902],{"class":167},[148,1079,1080],{"class":178}," { ORCHESTRATOR_CAPABILITY, ORCHESTRATOR_ROOM } ",[148,1082,908],{"class":167},[148,1084,911],{"class":299},[148,1086,1087],{"class":150,"line":164},[148,1088,916],{"emptyLinePlaceholder":704},[148,1090,1091,1093,1096,1099,1102,1104,1107],{"class":150,"line":182},[148,1092,349],{"class":167},[148,1094,1095],{"class":174}," Handoff",[148,1097,1098],{"class":178},"(agents.",[148,1100,1101],{"class":174},"room",[148,1103,296],{"class":178},[148,1105,1106],{"class":203},"ORCHESTRATOR_ROOM",[148,1108,1109],{"class":178},"))\n",[148,1111,1112,1115,1118,1121,1124,1127,1130,1132,1135,1137,1140,1143,1146],{"class":150,"line":195},[148,1113,1114],{"class":178},"  .",[148,1116,1117],{"class":174},"onTask",[148,1119,1120],{"class":178},"([",[148,1122,1123],{"class":203},"ORCHESTRATOR_CAPABILITY",[148,1125,1126],{"class":178},"], ",[148,1128,1129],{"class":167},"async",[148,1131,114],{"class":178},[148,1133,1134],{"class":185},"task",[148,1136,87],{"class":178},[148,1138,1139],{"class":185},"respond",[148,1141,1142],{"class":178},") ",[148,1144,1145],{"class":167},"=>",[148,1147,179],{"class":178},[148,1149,1150,1153,1156,1159,1161,1164,1166,1169,1172,1175],{"class":150,"line":207},[148,1151,1152],{"class":167},"    const",[148,1154,1155],{"class":178}," { ",[148,1157,1158],{"class":203},"question",[148,1160,87],{"class":178},[148,1162,1163],{"class":203},"context",[148,1165,87],{"class":178},[148,1167,1168],{"class":203},"callId",[148,1170,1171],{"class":178}," } ",[148,1173,1174],{"class":167},"=",[148,1176,1177],{"class":178}," task.payload\n",[148,1179,1180,1182,1185,1187,1189,1192],{"class":150,"line":217},[148,1181,1152],{"class":167},[148,1183,1184],{"class":203}," answer",[148,1186,276],{"class":167},[148,1188,499],{"class":167},[148,1190,1191],{"class":174}," yourBigModelWithTools",[148,1193,1194],{"class":178},"(question, context)\n",[148,1196,1197],{"class":150,"line":227},[148,1198,916],{"emptyLinePlaceholder":704},[148,1200,1201,1204,1206,1209],{"class":150,"line":513},[148,1202,1203],{"class":174},"    respond",[148,1205,296],{"class":178},[148,1207,1208],{"class":299},"'success'",[148,1210,1211],{"class":178},", { speech: answer.speech, detail: answer.detail })\n",[148,1213,1214],{"class":150,"line":993},[148,1215,1216],{"class":178},"  })\n",[12,1218,1219,1220,1223],{},"The ",[84,1221,1222],{},"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,1225,1227],{"id":1226},"why-the-ask-is-text-and-not-a-function-call","Why the ask is text and not a function call",[12,1229,1230,1232],{},[84,1231,999],{}," 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,1234,1235],{},"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,1237,1238],{},"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,1240,1242],{"id":1241},"scaling-the-orchestrator","Scaling the orchestrator",[12,1244,1245],{},"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,1247,1248],{},[138,1249,1251],{"className":140,"code":1250,"filename":142,"language":143,"meta":144,"style":144},"import { orchestratorPoolOptions } from '@nolag/voice'\n\nconst client = NoLag(token, { url, ...orchestratorPoolOptions() })\n",[84,1252,1253,1264,1268],{"__ignoreMap":144},[148,1254,1255,1257,1260,1262],{"class":150,"line":151},[148,1256,902],{"class":167},[148,1258,1259],{"class":178}," { orchestratorPoolOptions } ",[148,1261,908],{"class":167},[148,1263,911],{"class":299},[148,1265,1266],{"class":150,"line":158},[148,1267,916],{"emptyLinePlaceholder":704},[148,1269,1270,1272,1275,1277,1280,1283,1286,1289],{"class":150,"line":164},[148,1271,270],{"class":167},[148,1273,1274],{"class":203}," client",[148,1276,276],{"class":167},[148,1278,1279],{"class":174}," NoLag",[148,1281,1282],{"class":178},"(token, { url, ",[148,1284,1285],{"class":167},"...",[148,1287,1288],{"class":174},"orchestratorPoolOptions",[148,1290,1291],{"class":178},"() })\n",[12,1293,1294],{},"Two details are worth knowing, because both fail quietly rather than loudly.",[12,1296,1297,1298,1301],{},"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,1299,1300],{},"orchestratorPoolOptions()"," exists: it sets both halves.",[12,1303,1304],{},"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,1306,1307],{},"In our own testing 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,1309,1311],{"id":1310},"watching-a-call-while-it-happens","Watching a call while it happens",[12,1313,1314],{},"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,1316,1317],{},[138,1318,1320],{"className":140,"code":1319,"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,1321,1322,1340,1360,1376,1398,1403,1407,1411,1426],{"__ignoreMap":144},[148,1323,1324,1326,1329,1331,1334,1337],{"class":150,"line":151},[148,1325,270],{"class":167},[148,1327,1328],{"class":203}," watcher",[148,1330,276],{"class":167},[148,1332,1333],{"class":178}," voice.",[148,1335,1336],{"class":174},"watchCall",[148,1338,1339],{"class":178},"(callId, {\n",[148,1341,1342,1345,1348,1350,1352,1354,1357],{"class":150,"line":158},[148,1343,1344],{"class":174},"  onTranscript",[148,1346,1347],{"class":178},": (",[148,1349,150],{"class":185},[148,1351,1142],{"class":178},[148,1353,1145],{"class":167},[148,1355,1356],{"class":174}," render",[148,1358,1359],{"class":178},"(line.role, line.text),\n",[148,1361,1362,1365,1367,1370,1372,1374],{"class":150,"line":164},[148,1363,1364],{"class":174},"  onEvent",[148,1366,1347],{"class":178},[148,1368,1369],{"class":185},"event",[148,1371,1142],{"class":178},[148,1373,1145],{"class":167},[148,1375,179],{"class":178},[148,1377,1378,1381,1384,1387,1390,1392,1395],{"class":150,"line":182},[148,1379,1380],{"class":167},"    if",[148,1382,1383],{"class":178}," (event.event ",[148,1385,1386],{"class":167},"===",[148,1388,1389],{"class":299}," 'turn-complete'",[148,1391,1142],{"class":178},[148,1393,1394],{"class":174},"showLatency",[148,1396,1397],{"class":178},"(event.totalMs)\n",[148,1399,1400],{"class":150,"line":195},[148,1401,1402],{"class":178},"  }\n",[148,1404,1405],{"class":150,"line":207},[148,1406,1055],{"class":178},[148,1408,1409],{"class":150,"line":217},[148,1410,916],{"emptyLinePlaceholder":704},[148,1412,1413,1416,1419,1421,1424],{"class":150,"line":227},[148,1414,1415],{"class":178},"watcher.",[148,1417,1418],{"class":174},"say",[148,1420,296],{"class":178},[148,1422,1423],{"class":299},"'Let them know we can hold the car until two.'",[148,1425,303],{"class":178},[148,1427,1428,1430,1433,1435,1438,1441,1444],{"class":150,"line":513},[148,1429,1415],{"class":178},[148,1431,1432],{"class":174},"instruct",[148,1434,296],{"class":178},[148,1436,1437],{"class":299},"'Only discuss today",[148,1439,1440],{"class":203},"\\'",[148,1442,1443],{"class":299},"s booking.'",[148,1445,303],{"class":178},[12,1447,1448,1450,1451,1453],{},[84,1449,1418],{}," is spoken to the caller immediately. ",[84,1452,1432],{}," 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,1455,1456],{},"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,1458,1460],{"id":1459},"building-without-a-phone-number","Building without a phone number",[12,1462,1463],{},"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,1465,1466],{},"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,1468,1470],{"id":1469},"what-to-read-next","What to read next",[597,1472,1473,1480,1487],{},[600,1474,1475,1479],{},[667,1476,1477],{"href":769},[84,1478,772],{}," for the real-time half: turn taking, barge-in, filler speech, call screener and voicemail handling, recording",[600,1481,1482,1486],{},[667,1483,1484],{"href":785},[84,1485,788],{}," for the coordination half: the room, the orchestrator bridge, supervisor steering",[600,1488,1489,1493,1494,87,1496,1498,1499],{},[667,1490,1491],{"href":681},[84,1492,90],{}," for the six coordination patterns the orchestrator side is built from, including ",[84,1495,117],{},[84,1497,123],{}," and ",[84,1500,126],{},[12,1502,1503],{},"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.",[684,1505,1506],{},"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":158,"depth":158,"links":1508},[1509,1510,1511,1512,1513,1514,1515,1516,1517],{"id":723,"depth":158,"text":724},{"id":739,"depth":158,"text":740},{"id":814,"depth":158,"text":815},{"id":882,"depth":158,"text":883},{"id":1226,"depth":158,"text":1227},{"id":1241,"depth":158,"text":1242},{"id":1310,"depth":158,"text":1311},{"id":1459,"depth":158,"text":1460},{"id":1469,"depth":158,"text":1470},"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":712,"description":1519},"blog/voice-agent-with-tools-twilio","y8mf49CkbZuQdwbtt8QexrImKvHl8Ne7YqwJaRWdt8Y",{"id":1527,"title":1528,"author":7,"body":1529,"category":1883,"date":1884,"description":1885,"excerpt":701,"extension":702,"meta":1886,"navigation":704,"path":1887,"readTime":1888,"seo":1889,"stem":1890,"__hash__":1891},"blog/blog/coordinate-langchain-agents-with-nolag.md","Coordinate LangChain Agents with NoLag",{"type":9,"value":1530,"toc":1874},[1531,1534,1536,1569,1572,1576,1600,1604,1778,1783,1787,1790,1814,1820,1824,1839,1843,1846,1850,1871],[12,1532,1533],{},"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,1535,38],{"id":37},[597,1537,1538,1549,1563],{},[600,1539,1540,1541,1544,1545,1548],{},"A ",[30,1542,1543],{},"dispatcher"," publishes tasks to a ",[84,1546,1547],{},"tasks"," topic.",[600,1550,1551,1552,1555,1556,1558,1559,1562],{},"One or more ",[30,1553,1554],{},"workers",", each wrapping a LangChain chain, subscribe to ",[84,1557,1547],{},", run the chain, and publish to ",[84,1560,1561],{},"results",".",[600,1564,1565,1566,1568],{},"Anything can watch ",[84,1567,1561],{},": a UI, a supervisor agent, or a human.",[12,1570,1571],{},"Because it is pub/sub, you add workers by starting more subscribers. No queue to run, no orchestration server to operate.",[35,1573,1575],{"id":1574},"_1-install","1. Install",[138,1577,1581],{"className":1578,"code":1579,"language":1580,"meta":144,"style":144},"language-bash shiki shiki-themes github-light github-dark","pip install nolag langchain langchain-openai\n","bash",[84,1582,1583],{"__ignoreMap":144},[148,1584,1585,1588,1591,1594,1597],{"class":150,"line":151},[148,1586,1587],{"class":174},"pip",[148,1589,1590],{"class":299}," install",[148,1592,1593],{"class":299}," nolag",[148,1595,1596],{"class":299}," langchain",[148,1598,1599],{"class":299}," langchain-openai\n",[35,1601,1603],{"id":1602},"_2-a-langchain-worker-on-a-nolag-room","2. A LangChain worker on a NoLag room",[138,1605,1609],{"className":1606,"code":1607,"language":1608,"meta":144,"style":144},"language-python shiki shiki-themes github-light github-dark","import asyncio\nfrom nolag import NoLag\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    client = NoLag(\"your_access_token\")\n    await client.connect()\n\n    room = client.set_app(\"agents\").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,1610,1611,1616,1621,1626,1631,1635,1640,1645,1650,1655,1659,1664,1669,1674,1678,1683,1688,1692,1697,1703,1709,1715,1721,1727,1733,1739,1744,1750,1755,1761,1767,1772],{"__ignoreMap":144},[148,1612,1613],{"class":150,"line":151},[148,1614,1615],{},"import asyncio\n",[148,1617,1618],{"class":150,"line":158},[148,1619,1620],{},"from nolag import NoLag\n",[148,1622,1623],{"class":150,"line":164},[148,1624,1625],{},"from langchain_openai import ChatOpenAI\n",[148,1627,1628],{"class":150,"line":182},[148,1629,1630],{},"from langchain_core.prompts import ChatPromptTemplate\n",[148,1632,1633],{"class":150,"line":195},[148,1634,916],{"emptyLinePlaceholder":704},[148,1636,1637],{"class":150,"line":207},[148,1638,1639],{},"# Build the LangChain agent (a simple summariser here)\n",[148,1641,1642],{"class":150,"line":217},[148,1643,1644],{},"llm = ChatOpenAI(model=\"gpt-4o-mini\")\n",[148,1646,1647],{"class":150,"line":227},[148,1648,1649],{},"prompt = ChatPromptTemplate.from_template(\"Summarise this in one sentence:\\n\\n{input}\")\n",[148,1651,1652],{"class":150,"line":513},[148,1653,1654],{},"chain = prompt | llm\n",[148,1656,1657],{"class":150,"line":993},[148,1658,916],{"emptyLinePlaceholder":704},[148,1660,1661],{"class":150,"line":1004},[148,1662,1663],{},"async def main():\n",[148,1665,1666],{"class":150,"line":1013},[148,1667,1668],{},"    client = NoLag(\"your_access_token\")\n",[148,1670,1671],{"class":150,"line":1019},[148,1672,1673],{},"    await client.connect()\n",[148,1675,1676],{"class":150,"line":1028},[148,1677,916],{"emptyLinePlaceholder":704},[148,1679,1680],{"class":150,"line":1034},[148,1681,1682],{},"    room = client.set_app(\"agents\").set_room(\"workflow\")\n",[148,1684,1685],{"class":150,"line":1040},[148,1686,1687],{},"    await room.subscribe(\"tasks\")\n",[148,1689,1690],{"class":150,"line":1046},[148,1691,916],{"emptyLinePlaceholder":704},[148,1693,1694],{"class":150,"line":1052},[148,1695,1696],{},"    def on_task(data, meta):\n",[148,1698,1700],{"class":150,"line":1699},19,[148,1701,1702],{},"        async def run():\n",[148,1704,1706],{"class":150,"line":1705},20,[148,1707,1708],{},"            result = await chain.ainvoke({\"input\": data[\"input\"]})\n",[148,1710,1712],{"class":150,"line":1711},21,[148,1713,1714],{},"            await room.emit(\"results\", {\n",[148,1716,1718],{"class":150,"line":1717},22,[148,1719,1720],{},"                \"task_id\": data[\"task_id\"],\n",[148,1722,1724],{"class":150,"line":1723},23,[148,1725,1726],{},"                \"output\": result.content,\n",[148,1728,1730],{"class":150,"line":1729},24,[148,1731,1732],{},"            })\n",[148,1734,1736],{"class":150,"line":1735},25,[148,1737,1738],{},"        asyncio.create_task(run())\n",[148,1740,1742],{"class":150,"line":1741},26,[148,1743,916],{"emptyLinePlaceholder":704},[148,1745,1747],{"class":150,"line":1746},27,[148,1748,1749],{},"    room.on(\"tasks\", on_task)\n",[148,1751,1753],{"class":150,"line":1752},28,[148,1754,916],{"emptyLinePlaceholder":704},[148,1756,1758],{"class":150,"line":1757},29,[148,1759,1760],{},"    # Keep the worker alive\n",[148,1762,1764],{"class":150,"line":1763},30,[148,1765,1766],{},"    await asyncio.Event().wait()\n",[148,1768,1770],{"class":150,"line":1769},31,[148,1771,916],{"emptyLinePlaceholder":704},[148,1773,1775],{"class":150,"line":1774},32,[148,1776,1777],{},"asyncio.run(main())\n",[12,1779,1780,1781,1548],{},"Start this process on as many machines as you like. Each instance is another worker pulling from the same ",[84,1782,1547],{},[35,1784,1786],{"id":1785},"_3-dispatch-work","3. Dispatch work",[12,1788,1789],{},"Any authorised client can publish a task:",[138,1791,1793],{"className":1606,"code":1792,"language":1608,"meta":144,"style":144},"await room.emit(\"tasks\", {\n    \"task_id\": \"t-001\",\n    \"input\": \"NoLag is realtime messaging infrastructure with a coordination layer for agents.\",\n})\n",[84,1794,1795,1800,1805,1810],{"__ignoreMap":144},[148,1796,1797],{"class":150,"line":151},[148,1798,1799],{},"await room.emit(\"tasks\", {\n",[148,1801,1802],{"class":150,"line":158},[148,1803,1804],{},"    \"task_id\": \"t-001\",\n",[148,1806,1807],{"class":150,"line":164},[148,1808,1809],{},"    \"input\": \"NoLag is realtime messaging infrastructure with a coordination layer for agents.\",\n",[148,1811,1812],{"class":150,"line":182},[148,1813,1055],{},[12,1815,1816,1817,1819],{},"Results arrive on the ",[84,1818,1561],{}," topic for anyone subscribed, so a dashboard or a supervising agent can react as they land.",[35,1821,1823],{"id":1822},"keeping-a-human-in-the-loop","Keeping a human in the loop",[12,1825,1826,1827,1830,1831,1834,1835,1838],{},"For steps that need sign-off, NoLag's coordination patterns include ",[30,1828,1829],{},"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 ",[667,1832,1833],{"href":681},"AI agents guide"," covers approval gates, shared blackboard state, and observability in depth. For higher-level Python ergonomics, the ",[84,1836,1837],{},"nolag-agents"," package wraps these patterns so you do not hand-roll the topics.",[35,1840,1842],{"id":1841},"why-coordinate-over-pubsub","Why coordinate over pub/sub",[12,1844,1845],{},"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,1847,1849],{"id":1848},"next-steps","Next steps",[597,1851,1852,1858,1865],{},[600,1853,1854,1855,1562],{},"Read ",[667,1856,1857],{"href":669},"why multi-agent systems need a coordination layer",[600,1859,1860,1861,1562],{},"Start with the ",[667,1862,1864],{"href":1863},"/docs/getting-started","5-minute quick start",[600,1866,1867,1868,1562],{},"Go deeper on the six ",[667,1869,1870],{"href":681},"coordination patterns",[684,1872,1873],{},"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":158,"depth":158,"links":1875},[1876,1877,1878,1879,1880,1881,1882],{"id":37,"depth":158,"text":38},{"id":1574,"depth":158,"text":1575},{"id":1602,"depth":158,"text":1603},{"id":1785,"depth":158,"text":1786},{"id":1822,"depth":158,"text":1823},{"id":1841,"depth":158,"text":1842},{"id":1848,"depth":158,"text":1849},"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":1528,"description":1885},"blog/coordinate-langchain-agents-with-nolag","P-de1Q94YFbLBJDwWjAflmnjP7W5omy8zKgqRExQjX4",{"id":1893,"title":1894,"author":7,"body":1895,"category":2383,"date":2384,"description":2385,"excerpt":701,"extension":702,"meta":2386,"navigation":704,"path":2387,"readTime":2388,"seo":2389,"stem":2390,"__hash__":2391},"blog/blog/migrate-from-firebase-to-nolag.md","Migrate from Firebase to NoLag",{"type":9,"value":1896,"toc":2374},[1897,1900,1904,1915,1919,1945,1949,2022,2026,2151,2155,2328,2331,2335,2351,2353,2371],[12,1898,1899],{},"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,1901,1903],{"id":1902},"what-to-migrate-and-what-not-to","What to migrate, and what not to",[12,1905,1906,1907,1910,1911,1914],{},"NoLag is a ",[30,1908,1909],{},"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,1912,1913],{},"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.",[35,1916,1918],{"id":1917},"why-teams-move-the-realtime-layer","Why teams move the realtime layer",[597,1920,1921,1927,1933,1939],{},[600,1922,1923,1926],{},[30,1924,1925],{},"Explicit pub/sub"," instead of modelling every realtime feature as a change on a stored document.",[600,1928,1929,1932],{},[30,1930,1931],{},"Database-agnostic",": add realtime to any stack rather than standardising on Firestore.",[600,1934,1935,1938],{},[30,1936,1937],{},"Blueprint SDKs"," for chat, notifications, dashboards, and tracking.",[600,1940,1941,1944],{},[30,1942,1943],{},"A coordination layer for AI agents"," on the same platform.",[35,1946,1948],{"id":1947},"concept-map","Concept map",[40,1950,1951,1961],{},[43,1952,1953],{},[46,1954,1955,1958],{},[49,1956,1957],{},"Firebase",[49,1959,1960],{},"NoLag",[59,1962,1963,1971,1988,2006,2014],{},[46,1964,1965,1968],{},[64,1966,1967],{},"Realtime Database ref / Firestore collection",[64,1969,1970],{},"Room + topic",[46,1972,1973,1983],{},[64,1974,1975,1978,1979,1982],{},[84,1976,1977],{},"onValue"," / ",[84,1980,1981],{},"onSnapshot"," listener",[64,1984,1985],{},[84,1986,1987],{},"room.on('topic', cb)",[46,1989,1990,2001],{},[64,1991,1992,1978,1995,1978,1998],{},[84,1993,1994],{},"set",[84,1996,1997],{},"update",[84,1999,2000],{},"push",[64,2002,2003],{},[84,2004,2005],{},"room.emit('topic', data)",[46,2007,2008,2011],{},[64,2009,2010],{},"Security rules",[64,2012,2013],{},"Actors, access tokens, per-topic ACL",[46,2015,2016,2019],{},[64,2017,2018],{},"Presence via connection state",[64,2020,2021],{},"Presence (per room)",[35,2023,2025],{"id":2024},"before-firebase-realtime-database","Before: Firebase Realtime Database",[138,2027,2031],{"className":2028,"code":2029,"language":2030,"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,2032,2033,2045,2049,2063,2083,2087,2103,2125,2129,2133],{"__ignoreMap":144},[148,2034,2035,2037,2040,2042],{"class":150,"line":151},[148,2036,902],{"class":167},[148,2038,2039],{"class":178}," { getDatabase, ref, onValue, push } ",[148,2041,908],{"class":167},[148,2043,2044],{"class":299}," 'firebase/database'\n",[148,2046,2047],{"class":150,"line":158},[148,2048,916],{"emptyLinePlaceholder":704},[148,2050,2051,2053,2056,2058,2061],{"class":150,"line":164},[148,2052,270],{"class":167},[148,2054,2055],{"class":203}," db",[148,2057,276],{"class":167},[148,2059,2060],{"class":174}," getDatabase",[148,2062,505],{"class":178},[148,2064,2065,2067,2070,2072,2075,2078,2081],{"class":150,"line":182},[148,2066,270],{"class":167},[148,2068,2069],{"class":203}," messagesRef",[148,2071,276],{"class":167},[148,2073,2074],{"class":174}," ref",[148,2076,2077],{"class":178},"(db, ",[148,2079,2080],{"class":299},"'chat/general/messages'",[148,2082,303],{"class":178},[148,2084,2085],{"class":150,"line":195},[148,2086,916],{"emptyLinePlaceholder":704},[148,2088,2089,2091,2094,2097,2099,2101],{"class":150,"line":207},[148,2090,1977],{"class":174},[148,2092,2093],{"class":178},"(messagesRef, (",[148,2095,2096],{"class":185},"snapshot",[148,2098,1142],{"class":178},[148,2100,1145],{"class":167},[148,2102,179],{"class":178},[148,2104,2105,2108,2111,2113,2116,2119,2122],{"class":150,"line":217},[148,2106,2107],{"class":178},"  console.",[148,2109,2110],{"class":174},"log",[148,2112,296],{"class":178},[148,2114,2115],{"class":299},"'Data changed:'",[148,2117,2118],{"class":178},", snapshot.",[148,2120,2121],{"class":174},"val",[148,2123,2124],{"class":178},"())\n",[148,2126,2127],{"class":150,"line":227},[148,2128,1055],{"class":178},[148,2130,2131],{"class":150,"line":513},[148,2132,916],{"emptyLinePlaceholder":704},[148,2134,2135,2137,2140,2142,2145,2148],{"class":150,"line":993},[148,2136,2000],{"class":174},[148,2138,2139],{"class":178},"(messagesRef, { text: ",[148,2141,300],{"class":299},[148,2143,2144],{"class":178},", sender: ",[148,2146,2147],{"class":299},"'user-123'",[148,2149,2150],{"class":178}," })\n",[35,2152,2154],{"id":2153},"after-nolag","After: NoLag",[138,2156,2160],{"className":2157,"code":2158,"language":2159,"meta":144,"style":144},"language-ts shiki shiki-themes github-light github-dark","import { NoLag } from '@nolag/js-sdk'\n\nconst client = NoLag('your_access_token')\nawait client.connect()\n\nconst room = client.setApp('chat').setRoom('general')\nroom.subscribe('messages')\n\nroom.on('messages', (data) => {\n  console.log('Message received:', data)\n})\n\nroom.emit('messages', { text: 'Hello!', sender: 'user-123' })\n","ts",[84,2161,2162,2174,2178,2195,2207,2211,2243,2257,2261,2284,2298,2302,2306],{"__ignoreMap":144},[148,2163,2164,2166,2169,2171],{"class":150,"line":151},[148,2165,902],{"class":167},[148,2167,2168],{"class":178}," { NoLag } ",[148,2170,908],{"class":167},[148,2172,2173],{"class":299}," '@nolag/js-sdk'\n",[148,2175,2176],{"class":150,"line":158},[148,2177,916],{"emptyLinePlaceholder":704},[148,2179,2180,2182,2184,2186,2188,2190,2193],{"class":150,"line":164},[148,2181,270],{"class":167},[148,2183,1274],{"class":203},[148,2185,276],{"class":167},[148,2187,1279],{"class":174},[148,2189,296],{"class":178},[148,2191,2192],{"class":299},"'your_access_token'",[148,2194,303],{"class":178},[148,2196,2197,2199,2202,2205],{"class":150,"line":182},[148,2198,943],{"class":167},[148,2200,2201],{"class":178}," client.",[148,2203,2204],{"class":174},"connect",[148,2206,505],{"class":178},[148,2208,2209],{"class":150,"line":195},[148,2210,916],{"emptyLinePlaceholder":704},[148,2212,2213,2215,2218,2220,2222,2225,2227,2230,2233,2236,2238,2241],{"class":150,"line":207},[148,2214,270],{"class":167},[148,2216,2217],{"class":203}," room",[148,2219,276],{"class":167},[148,2221,2201],{"class":178},[148,2223,2224],{"class":174},"setApp",[148,2226,296],{"class":178},[148,2228,2229],{"class":299},"'chat'",[148,2231,2232],{"class":178},").",[148,2234,2235],{"class":174},"setRoom",[148,2237,296],{"class":178},[148,2239,2240],{"class":299},"'general'",[148,2242,303],{"class":178},[148,2244,2245,2247,2250,2252,2255],{"class":150,"line":217},[148,2246,290],{"class":178},[148,2248,2249],{"class":174},"subscribe",[148,2251,296],{"class":178},[148,2253,2254],{"class":299},"'messages'",[148,2256,303],{"class":178},[148,2258,2259],{"class":150,"line":227},[148,2260,916],{"emptyLinePlaceholder":704},[148,2262,2263,2265,2268,2270,2272,2275,2278,2280,2282],{"class":150,"line":513},[148,2264,290],{"class":178},[148,2266,2267],{"class":174},"on",[148,2269,296],{"class":178},[148,2271,2254],{"class":299},[148,2273,2274],{"class":178},", (",[148,2276,2277],{"class":185},"data",[148,2279,1142],{"class":178},[148,2281,1145],{"class":167},[148,2283,179],{"class":178},[148,2285,2286,2288,2290,2292,2295],{"class":150,"line":993},[148,2287,2107],{"class":178},[148,2289,2110],{"class":174},[148,2291,296],{"class":178},[148,2293,2294],{"class":299},"'Message received:'",[148,2296,2297],{"class":178},", data)\n",[148,2299,2300],{"class":150,"line":1004},[148,2301,1055],{"class":178},[148,2303,2304],{"class":150,"line":1013},[148,2305,916],{"emptyLinePlaceholder":704},[148,2307,2308,2310,2313,2315,2317,2320,2322,2324,2326],{"class":150,"line":1019},[148,2309,290],{"class":178},[148,2311,2312],{"class":174},"emit",[148,2314,296],{"class":178},[148,2316,2254],{"class":299},[148,2318,2319],{"class":178},", { text: ",[148,2321,300],{"class":299},[148,2323,2144],{"class":178},[148,2325,2147],{"class":299},[148,2327,2150],{"class":178},[12,2329,2330],{},"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.",[35,2332,2334],{"id":2333},"access-control","Access control",[12,2336,2337,2338,2341,2342,2345,2346,2350],{},"Firebase security rules become ",[30,2339,2340],{},"per-topic ACL"," in NoLag, tied to a typed ",[30,2343,2344],{},"actor"," 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 (",[667,2347,2349],{"href":2348},"/docs/client-tokens","client tokens",") your backend signs with a project signing key.",[35,2352,1849],{"id":1848},[597,2354,2355,2360,2368],{},[600,2356,2357,2358,1562],{},"Follow the ",[667,2359,1864],{"href":1863},[600,2361,2362,2363,2367],{},"See the full ",[667,2364,2366],{"href":2365},"/compare/firebase","NoLag vs Firebase"," comparison.",[600,2369,2370],{},"If you are keeping a database, use NoLag alongside it for the live layer only.",[684,2372,2373],{},"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);}",{"title":144,"searchDepth":158,"depth":158,"links":2375},[2376,2377,2378,2379,2380,2381,2382],{"id":1902,"depth":158,"text":1903},{"id":1917,"depth":158,"text":1918},{"id":1947,"depth":158,"text":1948},{"id":2024,"depth":158,"text":2025},{"id":2153,"depth":158,"text":2154},{"id":2333,"depth":158,"text":2334},{"id":1848,"depth":158,"text":1849},"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":1894,"description":2385},"blog/migrate-from-firebase-to-nolag","ZroLgFe_IFWSfstS-obzl_61VfNpGLwgt61yHYHinYk",{"id":2393,"title":2394,"author":7,"body":2395,"category":2383,"date":2860,"description":2861,"excerpt":701,"extension":702,"meta":2862,"navigation":704,"path":2863,"readTime":2864,"seo":2865,"stem":2866,"__hash__":2867},"blog/blog/migrate-from-ably-to-nolag.md","Migrate from Ably to NoLag",{"type":9,"value":2396,"toc":2850},[2397,2400,2404,2407,2422,2424,2491,2495,2633,2635,2782,2786,2799,2803,2810,2814,2824,2826,2847],[12,2398,2399],{},"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,2401,2403],{"id":2402},"why-teams-move","Why teams move",[12,2405,2406],{},"NoLag keeps the realtime transport you rely on and adds:",[597,2408,2409,2414,2419],{},[600,2410,2411,2413],{},[30,2412,1937],{}," for chat, notifications, dashboards, tracking, and more.",[600,2415,2416,2418],{},[30,2417,1943],{},": dispatch, share state, observe, and approve, on the same platform as your app features.",[600,2420,2421],{},"One place for human-facing and agent-facing realtime.",[35,2423,1948],{"id":1947},[40,2425,2426,2435],{},[43,2427,2428],{},[46,2429,2430,2433],{},[49,2431,2432],{},"Ably",[49,2434,1960],{},[59,2436,2437,2445,2452,2463,2474,2483],{},[46,2438,2439,2442],{},[64,2440,2441],{},"API key / token auth",[64,2443,2444],{},"Actor access tokens",[46,2446,2447,2450],{},[64,2448,2449],{},"Channel",[64,2451,1970],{},[46,2453,2454,2459],{},[64,2455,2456],{},[84,2457,2458],{},"channel.subscribe('event', cb)",[64,2460,2461],{},[84,2462,1987],{},[46,2464,2465,2470],{},[64,2466,2467],{},[84,2468,2469],{},"channel.publish('event', data)",[64,2471,2472],{},[84,2473,2005],{},[46,2475,2476,2481],{},[64,2477,2478],{},[84,2479,2480],{},"channel.presence",[64,2482,2021],{},[46,2484,2485,2488],{},[64,2486,2487],{},"History",[64,2489,2490],{},"Message logging and replay",[35,2492,2494],{"id":2493},"before-ably","Before: Ably",[138,2496,2498],{"className":2028,"code":2497,"language":2030,"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,2499,2500,2518,2522,2546,2567,2571,2594,2608,2612,2616],{"__ignoreMap":144},[148,2501,2502,2504,2507,2510,2513,2515],{"class":150,"line":151},[148,2503,902],{"class":167},[148,2505,2506],{"class":203}," *",[148,2508,2509],{"class":167}," as",[148,2511,2512],{"class":178}," Ably ",[148,2514,908],{"class":167},[148,2516,2517],{"class":299}," 'ably'\n",[148,2519,2520],{"class":150,"line":158},[148,2521,916],{"emptyLinePlaceholder":704},[148,2523,2524,2526,2529,2531,2533,2536,2539,2541,2544],{"class":150,"line":164},[148,2525,270],{"class":167},[148,2527,2528],{"class":203}," ably",[148,2530,276],{"class":167},[148,2532,279],{"class":167},[148,2534,2535],{"class":178}," Ably.",[148,2537,2538],{"class":174},"Realtime",[148,2540,296],{"class":178},[148,2542,2543],{"class":299},"'API_KEY'",[148,2545,303],{"class":178},[148,2547,2548,2550,2553,2555,2558,2561,2563,2565],{"class":150,"line":182},[148,2549,270],{"class":167},[148,2551,2552],{"class":203}," channel",[148,2554,276],{"class":167},[148,2556,2557],{"class":178}," ably.channels.",[148,2559,2560],{"class":174},"get",[148,2562,296],{"class":178},[148,2564,2229],{"class":299},[148,2566,303],{"class":178},[148,2568,2569],{"class":150,"line":195},[148,2570,916],{"emptyLinePlaceholder":704},[148,2572,2573,2576,2578,2580,2583,2585,2588,2590,2592],{"class":150,"line":207},[148,2574,2575],{"class":178},"channel.",[148,2577,2249],{"class":174},[148,2579,296],{"class":178},[148,2581,2582],{"class":299},"'message'",[148,2584,2274],{"class":178},[148,2586,2587],{"class":185},"msg",[148,2589,1142],{"class":178},[148,2591,1145],{"class":167},[148,2593,179],{"class":178},[148,2595,2596,2598,2600,2602,2605],{"class":150,"line":217},[148,2597,2107],{"class":178},[148,2599,2110],{"class":174},[148,2601,296],{"class":178},[148,2603,2604],{"class":299},"'Received:'",[148,2606,2607],{"class":178},", msg.data)\n",[148,2609,2610],{"class":150,"line":227},[148,2611,1055],{"class":178},[148,2613,2614],{"class":150,"line":513},[148,2615,916],{"emptyLinePlaceholder":704},[148,2617,2618,2620,2623,2625,2627,2629,2631],{"class":150,"line":993},[148,2619,2575],{"class":178},[148,2621,2622],{"class":174},"publish",[148,2624,296],{"class":178},[148,2626,2582],{"class":299},[148,2628,2319],{"class":178},[148,2630,300],{"class":299},[148,2632,2150],{"class":178},[35,2634,2154],{"id":2153},[138,2636,2638],{"className":2157,"code":2637,"language":2159,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\n\nconst client = NoLag('your_access_token')\nawait client.connect()\n\nconst room = client.setApp('chat').setRoom('general')\nroom.subscribe('message')\n\nroom.on('message', (data) => {\n  console.log('Received:', data)\n})\n\nroom.emit('message', { text: 'Hello!' })\n",[84,2639,2640,2650,2654,2670,2680,2684,2710,2722,2726,2746,2758,2762,2766],{"__ignoreMap":144},[148,2641,2642,2644,2646,2648],{"class":150,"line":151},[148,2643,902],{"class":167},[148,2645,2168],{"class":178},[148,2647,908],{"class":167},[148,2649,2173],{"class":299},[148,2651,2652],{"class":150,"line":158},[148,2653,916],{"emptyLinePlaceholder":704},[148,2655,2656,2658,2660,2662,2664,2666,2668],{"class":150,"line":164},[148,2657,270],{"class":167},[148,2659,1274],{"class":203},[148,2661,276],{"class":167},[148,2663,1279],{"class":174},[148,2665,296],{"class":178},[148,2667,2192],{"class":299},[148,2669,303],{"class":178},[148,2671,2672,2674,2676,2678],{"class":150,"line":182},[148,2673,943],{"class":167},[148,2675,2201],{"class":178},[148,2677,2204],{"class":174},[148,2679,505],{"class":178},[148,2681,2682],{"class":150,"line":195},[148,2683,916],{"emptyLinePlaceholder":704},[148,2685,2686,2688,2690,2692,2694,2696,2698,2700,2702,2704,2706,2708],{"class":150,"line":207},[148,2687,270],{"class":167},[148,2689,2217],{"class":203},[148,2691,276],{"class":167},[148,2693,2201],{"class":178},[148,2695,2224],{"class":174},[148,2697,296],{"class":178},[148,2699,2229],{"class":299},[148,2701,2232],{"class":178},[148,2703,2235],{"class":174},[148,2705,296],{"class":178},[148,2707,2240],{"class":299},[148,2709,303],{"class":178},[148,2711,2712,2714,2716,2718,2720],{"class":150,"line":217},[148,2713,290],{"class":178},[148,2715,2249],{"class":174},[148,2717,296],{"class":178},[148,2719,2582],{"class":299},[148,2721,303],{"class":178},[148,2723,2724],{"class":150,"line":227},[148,2725,916],{"emptyLinePlaceholder":704},[148,2727,2728,2730,2732,2734,2736,2738,2740,2742,2744],{"class":150,"line":513},[148,2729,290],{"class":178},[148,2731,2267],{"class":174},[148,2733,296],{"class":178},[148,2735,2582],{"class":299},[148,2737,2274],{"class":178},[148,2739,2277],{"class":185},[148,2741,1142],{"class":178},[148,2743,1145],{"class":167},[148,2745,179],{"class":178},[148,2747,2748,2750,2752,2754,2756],{"class":150,"line":993},[148,2749,2107],{"class":178},[148,2751,2110],{"class":174},[148,2753,296],{"class":178},[148,2755,2604],{"class":299},[148,2757,2297],{"class":178},[148,2759,2760],{"class":150,"line":1004},[148,2761,1055],{"class":178},[148,2763,2764],{"class":150,"line":1013},[148,2765,916],{"emptyLinePlaceholder":704},[148,2767,2768,2770,2772,2774,2776,2778,2780],{"class":150,"line":1019},[148,2769,290],{"class":178},[148,2771,2312],{"class":174},[148,2773,296],{"class":178},[148,2775,2582],{"class":299},[148,2777,2319],{"class":178},[148,2779,300],{"class":299},[148,2781,2150],{"class":178},[35,2783,2785],{"id":2784},"presence-and-history","Presence and history",[12,2787,2788,2789,2793,2794,2798],{},"Ably presence maps to NoLag ",[667,2790,2792],{"href":2791},"/docs/concepts/presence","presence",", tracked per room with join and leave events and a live member list. If you use Ably history to fetch missed messages, NoLag covers that with ",[667,2795,2797],{"href":2796},"/docs/concepts/qos","message logging and replay",", so a client that reconnects can catch up.",[35,2800,2802],{"id":2801},"delivery-guarantees","Delivery guarantees",[12,2804,2805,2806,2809],{},"Ably is known for its delivery semantics. NoLag offers ",[667,2807,2808],{"href":2796},"quality-of-service levels"," 0, 1, and 2 (at most once, at least once, exactly once) so you choose the guarantee per message rather than paying for the strongest everywhere.",[35,2811,2813],{"id":2812},"authentication","Authentication",[12,2815,2816,2817,2819,2820,2823],{},"Ably authenticates with API keys and tokens, often carrying capabilities in the token itself. NoLag centres permissions on the ",[30,2818,2344],{},": every credential resolves to a typed actor (user, device, server, session), 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 (",[667,2821,2822],{"href":2348},"client token",") signed with a project signing key, so long-lived credentials never leave your servers.",[35,2825,1849],{"id":1848},[597,2827,2828,2832,2839],{},[600,2829,1860,2830,1562],{},[667,2831,1864],{"href":1863},[600,2833,2834,2835,2367],{},"Read the deeper ",[667,2836,2838],{"href":2837},"/compare/ably","NoLag vs Ably",[600,2840,2841,2842,2846],{},"Explore the ",[667,2843,2845],{"href":2844},"/docs/high-level-sdks","high-level SDKs"," if you would rather not rebuild chat or dashboards by hand.",[684,2848,2849],{},"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);}",{"title":144,"searchDepth":158,"depth":158,"links":2851},[2852,2853,2854,2855,2856,2857,2858,2859],{"id":2402,"depth":158,"text":2403},{"id":1947,"depth":158,"text":1948},{"id":2493,"depth":158,"text":2494},{"id":2153,"depth":158,"text":2154},{"id":2784,"depth":158,"text":2785},{"id":2801,"depth":158,"text":2802},{"id":2812,"depth":158,"text":2813},{"id":1848,"depth":158,"text":1849},"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":2394,"description":2861},"blog/migrate-from-ably-to-nolag","fMdsO2gmldXWSVk5eiIxPhcWl8h5QIDANfIiH6OYYfY",{"id":2869,"title":2870,"author":7,"body":2871,"category":1883,"date":3670,"description":3671,"excerpt":701,"extension":702,"meta":3672,"navigation":704,"path":3673,"readTime":2388,"seo":3674,"stem":3675,"__hash__":3676},"blog/blog/add-realtime-to-nextjs.md","Add Realtime to a Next.js App with NoLag",{"type":9,"value":2872,"toc":3663},[2873,2880,2884,2899,2903,2910,3103,3109,3113,3611,3617,3621,3636,3638,3660],[12,2874,2875,2876,2879],{},"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,2877,2878],{},"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,2881,2883],{"id":2882},"_1-install-the-sdk","1. Install the SDK",[138,2885,2887],{"className":1578,"code":2886,"language":1580,"meta":144,"style":144},"npm install @nolag/js-sdk\n",[84,2888,2889],{"__ignoreMap":144},[148,2890,2891,2894,2896],{"class":150,"line":151},[148,2892,2893],{"class":174},"npm",[148,2895,1590],{"class":299},[148,2897,2898],{"class":299}," @nolag/js-sdk\n",[35,2900,2902],{"id":2901},"_2-mint-a-token-on-the-server","2. Mint a token on the server",[12,2904,2905,2906,2909],{},"Never put a project key in browser code. Add a route handler that creates a token server-side and returns it. The ",[84,2907,2908],{},"NOLAG_API_KEY"," stays in your server environment.",[138,2911,2913],{"className":2157,"code":2912,"language":2159,"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: 'device' }),\n  })\n\n  const actor = await res.json()\n  return NextResponse.json({ token: actor.accessToken })\n}\n",[84,2914,2915,2920,2932,2936,2950,2972,2983,2988,3013,3026,3031,3059,3063,3067,3086,3099],{"__ignoreMap":144},[148,2916,2917],{"class":150,"line":151},[148,2918,2919],{"class":154},"// app/api/nolag-token/route.ts\n",[148,2921,2922,2924,2927,2929],{"class":150,"line":158},[148,2923,902],{"class":167},[148,2925,2926],{"class":178}," { NextResponse } ",[148,2928,908],{"class":167},[148,2930,2931],{"class":299}," 'next/server'\n",[148,2933,2934],{"class":150,"line":164},[148,2935,916],{"emptyLinePlaceholder":704},[148,2937,2938,2940,2942,2944,2947],{"class":150,"line":182},[148,2939,168],{"class":167},[148,2941,415],{"class":167},[148,2943,418],{"class":167},[148,2945,2946],{"class":174}," GET",[148,2948,2949],{"class":178},"() {\n",[148,2951,2952,2954,2957,2959,2961,2964,2966,2969],{"class":150,"line":195},[148,2953,491],{"class":167},[148,2955,2956],{"class":203}," res",[148,2958,276],{"class":167},[148,2960,499],{"class":167},[148,2962,2963],{"class":174}," fetch",[148,2965,296],{"class":178},[148,2967,2968],{"class":299},"'https://api.nolag.app/v1/actors'",[148,2970,2971],{"class":178},", {\n",[148,2973,2974,2977,2980],{"class":150,"line":207},[148,2975,2976],{"class":178},"    method: ",[148,2978,2979],{"class":299},"'POST'",[148,2981,2982],{"class":178},",\n",[148,2984,2985],{"class":150,"line":217},[148,2986,2987],{"class":178},"    headers: {\n",[148,2989,2990,2993,2996,2999,3001,3004,3006,3008,3011],{"class":150,"line":227},[148,2991,2992],{"class":178},"      Authorization: ",[148,2994,2995],{"class":299},"`Bearer ${",[148,2997,2998],{"class":178},"process",[148,3000,1562],{"class":299},[148,3002,3003],{"class":178},"env",[148,3005,1562],{"class":299},[148,3007,2908],{"class":203},[148,3009,3010],{"class":299},"}`",[148,3012,2982],{"class":178},[148,3014,3015,3018,3021,3024],{"class":150,"line":513},[148,3016,3017],{"class":299},"      'Content-Type'",[148,3019,3020],{"class":178},": ",[148,3022,3023],{"class":299},"'application/json'",[148,3025,2982],{"class":178},[148,3027,3028],{"class":150,"line":993},[148,3029,3030],{"class":178},"    },\n",[148,3032,3033,3036,3039,3041,3044,3047,3050,3053,3056],{"class":150,"line":1004},[148,3034,3035],{"class":178},"    body: ",[148,3037,3038],{"class":203},"JSON",[148,3040,1562],{"class":178},[148,3042,3043],{"class":174},"stringify",[148,3045,3046],{"class":178},"({ name: ",[148,3048,3049],{"class":299},"'Web Client'",[148,3051,3052],{"class":178},", actorType: ",[148,3054,3055],{"class":299},"'device'",[148,3057,3058],{"class":178}," }),\n",[148,3060,3061],{"class":150,"line":1013},[148,3062,1216],{"class":178},[148,3064,3065],{"class":150,"line":1019},[148,3066,916],{"emptyLinePlaceholder":704},[148,3068,3069,3071,3074,3076,3078,3081,3084],{"class":150,"line":1028},[148,3070,491],{"class":167},[148,3072,3073],{"class":203}," actor",[148,3075,276],{"class":167},[148,3077,499],{"class":167},[148,3079,3080],{"class":178}," res.",[148,3082,3083],{"class":174},"json",[148,3085,505],{"class":178},[148,3087,3088,3091,3094,3096],{"class":150,"line":1034},[148,3089,3090],{"class":167},"  return",[148,3092,3093],{"class":178}," NextResponse.",[148,3095,3083],{"class":174},[148,3097,3098],{"class":178},"({ token: actor.accessToken })\n",[148,3100,3101],{"class":150,"line":1040},[148,3102,230],{"class":178},[12,3104,3105,3106,3108],{},"For production, prefer short-lived ",[667,3107,2349],{"href":2348}," minted from your project signing keys so browser sessions get scoped, expiring credentials.",[35,3110,3112],{"id":3111},"_3-connect-from-a-client-component","3. Connect from a client component",[138,3114,3118],{"className":3115,"code":3116,"language":3117,"meta":144,"style":144},"language-tsx shiki shiki-themes github-light github-dark","'use client'\nimport { useEffect, useState } from 'react'\nimport { NoLag } from '@nolag/js-sdk'\n\nexport default function Chat() {\n  const [messages, setMessages] = useState\u003Cany[]>([])\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('chat').setRoom('general')\n      room.subscribe('message')\n      room.on('message', (data) => setMessages((m) => [...m, data]))\n    })()\n\n    return () => {\n      active = false\n      client?.disconnect?.()\n    }\n  }, [])\n\n  const send = () => {\n    // room.emit('message', { text: 'Hello!' })\n  }\n\n  return (\n    \u003Cul>\n      {messages.map((m, i) => (\n        \u003Cli key={i}>{m.text}\u003C/li>\n      ))}\n    \u003C/ul>\n  )\n}\n","tsx",[84,3119,3120,3125,3137,3147,3151,3165,3196,3200,3212,3238,3250,3254,3268,3313,3329,3333,3345,3356,3360,3386,3399,3436,3441,3445,3456,3466,3477,3482,3487,3491,3506,3511,3515,3520,3528,3540,3564,3584,3590,3600,3606],{"__ignoreMap":144},[148,3121,3122],{"class":150,"line":151},[148,3123,3124],{"class":299},"'use client'\n",[148,3126,3127,3129,3132,3134],{"class":150,"line":158},[148,3128,902],{"class":167},[148,3130,3131],{"class":178}," { useEffect, useState } ",[148,3133,908],{"class":167},[148,3135,3136],{"class":299}," 'react'\n",[148,3138,3139,3141,3143,3145],{"class":150,"line":164},[148,3140,902],{"class":167},[148,3142,2168],{"class":178},[148,3144,908],{"class":167},[148,3146,2173],{"class":299},[148,3148,3149],{"class":150,"line":182},[148,3150,916],{"emptyLinePlaceholder":704},[148,3152,3153,3155,3158,3160,3163],{"class":150,"line":195},[148,3154,168],{"class":167},[148,3156,3157],{"class":167}," default",[148,3159,418],{"class":167},[148,3161,3162],{"class":174}," Chat",[148,3164,2949],{"class":178},[148,3166,3167,3169,3172,3175,3177,3180,3183,3185,3188,3190,3193],{"class":150,"line":207},[148,3168,491],{"class":167},[148,3170,3171],{"class":178}," [",[148,3173,3174],{"class":203},"messages",[148,3176,87],{"class":178},[148,3178,3179],{"class":203},"setMessages",[148,3181,3182],{"class":178},"] ",[148,3184,1174],{"class":167},[148,3186,3187],{"class":174}," useState",[148,3189,480],{"class":178},[148,3191,3192],{"class":203},"any",[148,3194,3195],{"class":178},"[]>([])\n",[148,3197,3198],{"class":150,"line":217},[148,3199,916],{"emptyLinePlaceholder":704},[148,3201,3202,3205,3208,3210],{"class":150,"line":227},[148,3203,3204],{"class":174},"  useEffect",[148,3206,3207],{"class":178},"(() ",[148,3209,1145],{"class":167},[148,3211,179],{"class":178},[148,3213,3214,3217,3219,3221,3224,3226,3229,3232,3235],{"class":150,"line":513},[148,3215,3216],{"class":167},"    let",[148,3218,1274],{"class":178},[148,3220,189],{"class":167},[148,3222,3223],{"class":174}," ReturnType",[148,3225,480],{"class":178},[148,3227,3228],{"class":167},"typeof",[148,3230,3231],{"class":178}," NoLag> ",[148,3233,3234],{"class":167},"|",[148,3236,3237],{"class":203}," undefined\n",[148,3239,3240,3242,3245,3247],{"class":150,"line":993},[148,3241,3216],{"class":167},[148,3243,3244],{"class":178}," active ",[148,3246,1174],{"class":167},[148,3248,3249],{"class":203}," true\n",[148,3251,3252],{"class":150,"line":1004},[148,3253,916],{"emptyLinePlaceholder":704},[148,3255,3256,3259,3261,3264,3266],{"class":150,"line":1013},[148,3257,3258],{"class":178},"    ;(",[148,3260,1129],{"class":167},[148,3262,3263],{"class":178}," () ",[148,3265,1145],{"class":167},[148,3267,179],{"class":178},[148,3269,3270,3273,3275,3278,3280,3282,3284,3286,3288,3291,3293,3296,3299,3302,3304,3306,3309,3311],{"class":150,"line":1019},[148,3271,3272],{"class":167},"      const",[148,3274,1155],{"class":178},[148,3276,3277],{"class":203},"token",[148,3279,1171],{"class":178},[148,3281,1174],{"class":167},[148,3283,499],{"class":167},[148,3285,2963],{"class":174},[148,3287,296],{"class":178},[148,3289,3290],{"class":299},"'/api/nolag-token'",[148,3292,2232],{"class":178},[148,3294,3295],{"class":174},"then",[148,3297,3298],{"class":178},"((",[148,3300,3301],{"class":185},"r",[148,3303,1142],{"class":178},[148,3305,1145],{"class":167},[148,3307,3308],{"class":178}," r.",[148,3310,3083],{"class":174},[148,3312,2124],{"class":178},[148,3314,3315,3318,3320,3323,3326],{"class":150,"line":1028},[148,3316,3317],{"class":167},"      if",[148,3319,114],{"class":178},[148,3321,3322],{"class":167},"!",[148,3324,3325],{"class":178},"active) ",[148,3327,3328],{"class":167},"return\n",[148,3330,3331],{"class":150,"line":1034},[148,3332,916],{"emptyLinePlaceholder":704},[148,3334,3335,3338,3340,3342],{"class":150,"line":1040},[148,3336,3337],{"class":178},"      client ",[148,3339,1174],{"class":167},[148,3341,1279],{"class":174},[148,3343,3344],{"class":178},"(token)\n",[148,3346,3347,3350,3352,3354],{"class":150,"line":1046},[148,3348,3349],{"class":167},"      await",[148,3351,2201],{"class":178},[148,3353,2204],{"class":174},[148,3355,505],{"class":178},[148,3357,3358],{"class":150,"line":1052},[148,3359,916],{"emptyLinePlaceholder":704},[148,3361,3362,3364,3366,3368,3370,3372,3374,3376,3378,3380,3382,3384],{"class":150,"line":1699},[148,3363,3272],{"class":167},[148,3365,2217],{"class":203},[148,3367,276],{"class":167},[148,3369,2201],{"class":178},[148,3371,2224],{"class":174},[148,3373,296],{"class":178},[148,3375,2229],{"class":299},[148,3377,2232],{"class":178},[148,3379,2235],{"class":174},[148,3381,296],{"class":178},[148,3383,2240],{"class":299},[148,3385,303],{"class":178},[148,3387,3388,3391,3393,3395,3397],{"class":150,"line":1705},[148,3389,3390],{"class":178},"      room.",[148,3392,2249],{"class":174},[148,3394,296],{"class":178},[148,3396,2582],{"class":299},[148,3398,303],{"class":178},[148,3400,3401,3403,3405,3407,3409,3411,3413,3415,3417,3420,3422,3425,3427,3429,3431,3433],{"class":150,"line":1711},[148,3402,3390],{"class":178},[148,3404,2267],{"class":174},[148,3406,296],{"class":178},[148,3408,2582],{"class":299},[148,3410,2274],{"class":178},[148,3412,2277],{"class":185},[148,3414,1142],{"class":178},[148,3416,1145],{"class":167},[148,3418,3419],{"class":174}," setMessages",[148,3421,3298],{"class":178},[148,3423,3424],{"class":185},"m",[148,3426,1142],{"class":178},[148,3428,1145],{"class":167},[148,3430,3171],{"class":178},[148,3432,1285],{"class":167},[148,3434,3435],{"class":178},"m, data]))\n",[148,3437,3438],{"class":150,"line":1717},[148,3439,3440],{"class":178},"    })()\n",[148,3442,3443],{"class":150,"line":1723},[148,3444,916],{"emptyLinePlaceholder":704},[148,3446,3447,3450,3452,3454],{"class":150,"line":1729},[148,3448,3449],{"class":167},"    return",[148,3451,3263],{"class":178},[148,3453,1145],{"class":167},[148,3455,179],{"class":178},[148,3457,3458,3461,3463],{"class":150,"line":1735},[148,3459,3460],{"class":178},"      active ",[148,3462,1174],{"class":167},[148,3464,3465],{"class":203}," false\n",[148,3467,3468,3471,3474],{"class":150,"line":1741},[148,3469,3470],{"class":178},"      client?.",[148,3472,3473],{"class":174},"disconnect",[148,3475,3476],{"class":178},"?.()\n",[148,3478,3479],{"class":150,"line":1746},[148,3480,3481],{"class":178},"    }\n",[148,3483,3484],{"class":150,"line":1752},[148,3485,3486],{"class":178},"  }, [])\n",[148,3488,3489],{"class":150,"line":1757},[148,3490,916],{"emptyLinePlaceholder":704},[148,3492,3493,3495,3498,3500,3502,3504],{"class":150,"line":1763},[148,3494,491],{"class":167},[148,3496,3497],{"class":174}," send",[148,3499,276],{"class":167},[148,3501,3263],{"class":178},[148,3503,1145],{"class":167},[148,3505,179],{"class":178},[148,3507,3508],{"class":150,"line":1769},[148,3509,3510],{"class":154},"    // room.emit('message', { text: 'Hello!' })\n",[148,3512,3513],{"class":150,"line":1774},[148,3514,1402],{"class":178},[148,3516,3518],{"class":150,"line":3517},33,[148,3519,916],{"emptyLinePlaceholder":704},[148,3521,3523,3525],{"class":150,"line":3522},34,[148,3524,3090],{"class":167},[148,3526,3527],{"class":178}," (\n",[148,3529,3531,3534,3537],{"class":150,"line":3530},35,[148,3532,3533],{"class":178},"    \u003C",[148,3535,597],{"class":3536},"s9eBZ",[148,3538,3539],{"class":178},">\n",[148,3541,3543,3546,3549,3551,3553,3555,3558,3560,3562],{"class":150,"line":3542},36,[148,3544,3545],{"class":178},"      {messages.",[148,3547,3548],{"class":174},"map",[148,3550,3298],{"class":178},[148,3552,3424],{"class":185},[148,3554,87],{"class":178},[148,3556,3557],{"class":185},"i",[148,3559,1142],{"class":178},[148,3561,1145],{"class":167},[148,3563,3527],{"class":178},[148,3565,3567,3570,3572,3575,3577,3580,3582],{"class":150,"line":3566},37,[148,3568,3569],{"class":178},"        \u003C",[148,3571,600],{"class":3536},[148,3573,3574],{"class":174}," key",[148,3576,1174],{"class":167},[148,3578,3579],{"class":178},"{i}>{m.text}\u003C/",[148,3581,600],{"class":3536},[148,3583,3539],{"class":178},[148,3585,3587],{"class":150,"line":3586},38,[148,3588,3589],{"class":178},"      ))}\n",[148,3591,3593,3596,3598],{"class":150,"line":3592},39,[148,3594,3595],{"class":178},"    \u003C/",[148,3597,597],{"class":3536},[148,3599,3539],{"class":178},[148,3601,3603],{"class":150,"line":3602},40,[148,3604,3605],{"class":178},"  )\n",[148,3607,3609],{"class":150,"line":3608},41,[148,3610,230],{"class":178},[12,3612,1219,3613,3616],{},[84,3614,3615],{},"active"," flag guards against React running effects twice in development and against a connection resolving after the component unmounts.",[35,3618,3620],{"id":3619},"why-the-client-component-rule-matters","Why the client-component rule matters",[12,3622,3623,3624,3627,3628,3631,3632,3635],{},"The NoLag SDK opens a WebSocket, which only exists in the browser. Importing and connecting it in a server component or during SSR will fail. Keep the ",[84,3625,3626],{},"NoLag(...)"," call inside ",[84,3629,3630],{},"useEffect"," (or an event handler) in a ",[84,3633,3634],{},"'use client'"," file and you avoid the whole class of hydration and \"window is not defined\" errors.",[35,3637,1849],{"id":1848},[597,3639,3640,3646,3653],{},[600,3641,3642,3643,3645],{},"Read the ",[667,3644,1864],{"href":1863}," for the full connect, subscribe, and publish flow.",[600,3647,3648,3649,3652],{},"Skip the plumbing with the ",[667,3650,3651],{"href":2844},"chat blueprint",", which wraps rooms, typing, and history.",[600,3654,3655,3656,3659],{},"Building agent features into your app? See the ",[667,3657,3658],{"href":681},"AI agents"," guide.",[684,3661,3662],{},"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":158,"depth":158,"links":3664},[3665,3666,3667,3668,3669],{"id":2882,"depth":158,"text":2883},{"id":2901,"depth":158,"text":2902},{"id":3111,"depth":158,"text":3112},{"id":3619,"depth":158,"text":3620},{"id":1848,"depth":158,"text":1849},"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":2870,"description":3671},"blog/add-realtime-to-nextjs","SdconHkiWGBrol7et6eneH0UVPuPjJyGU3cnsynv1Ok",{"id":3678,"title":3679,"author":7,"body":3680,"category":2383,"date":4124,"description":4125,"excerpt":701,"extension":702,"meta":4126,"navigation":704,"path":4127,"readTime":2864,"seo":4128,"stem":4129,"__hash__":4130},"blog/blog/migrate-from-pusher-to-nolag.md","Migrate from Pusher to NoLag",{"type":9,"value":3681,"toc":4115},[3682,3685,3687,3690,3708,3710,3774,3778,3900,3902,4054,4064,4068,4075,4081,4084,4090,4092,4112],[12,3683,3684],{},"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,3686,2403],{"id":2402},[12,3688,3689],{},"NoLag keeps the pub/sub foundation you already rely on and adds a few things Pusher does not focus on:",[597,3691,3692,3697,3702],{},[600,3693,3694,3696],{},[30,3695,1937],{}," for chat, notifications, dashboards, tracking, and more, so common patterns are a few lines instead of a from-scratch build.",[600,3698,3699,3701],{},[30,3700,1943],{},": dispatch work, share state, observe decisions, and gate actions with human approval, on the same infrastructure as your app.",[600,3703,3704,3707],{},[30,3705,3706],{},"Quality-of-service levels"," and presence built in.",[35,3709,1948],{"id":1947},[40,3711,3712,3721],{},[43,3713,3714],{},[46,3715,3716,3719],{},[49,3717,3718],{},"Pusher",[49,3720,1960],{},[59,3722,3723,3731,3737,3748,3759,3766],{},[46,3724,3725,3728],{},[64,3726,3727],{},"App key + cluster",[64,3729,3730],{},"App (a container for rooms and topics)",[46,3732,3733,3735],{},[64,3734,2449],{},[64,3736,1970],{},[46,3738,3739,3744],{},[64,3740,3741],{},[84,3742,3743],{},"channel.bind('event', cb)",[64,3745,3746],{},[84,3747,1987],{},[46,3749,3750,3755],{},[64,3751,3752],{},[84,3753,3754],{},"pusher.trigger('channel', 'event', data)",[64,3756,3757],{},[84,3758,2005],{},[46,3760,3761,3764],{},[64,3762,3763],{},"Presence channel",[64,3765,2021],{},[46,3767,3768,3771],{},[64,3769,3770],{},"Private channel + auth endpoint",[64,3772,3773],{},"Actors, access tokens, and per-topic ACL",[35,3775,3777],{"id":3776},"before-pusher","Before: Pusher",[138,3779,3781],{"className":2028,"code":3780,"language":2030,"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,3782,3783,3795,3799,3826,3830,3849,3870,3882,3886,3890,3895],{"__ignoreMap":144},[148,3784,3785,3787,3790,3792],{"class":150,"line":151},[148,3786,902],{"class":167},[148,3788,3789],{"class":178}," Pusher ",[148,3791,908],{"class":167},[148,3793,3794],{"class":299}," 'pusher-js'\n",[148,3796,3797],{"class":150,"line":158},[148,3798,916],{"emptyLinePlaceholder":704},[148,3800,3801,3803,3806,3808,3810,3813,3815,3818,3821,3824],{"class":150,"line":164},[148,3802,270],{"class":167},[148,3804,3805],{"class":203}," pusher",[148,3807,276],{"class":167},[148,3809,279],{"class":167},[148,3811,3812],{"class":174}," Pusher",[148,3814,296],{"class":178},[148,3816,3817],{"class":299},"'APP_KEY'",[148,3819,3820],{"class":178},", { cluster: ",[148,3822,3823],{"class":299},"'eu'",[148,3825,2150],{"class":178},[148,3827,3828],{"class":150,"line":182},[148,3829,916],{"emptyLinePlaceholder":704},[148,3831,3832,3834,3836,3838,3841,3843,3845,3847],{"class":150,"line":195},[148,3833,270],{"class":167},[148,3835,2552],{"class":203},[148,3837,276],{"class":167},[148,3839,3840],{"class":178}," pusher.",[148,3842,2249],{"class":174},[148,3844,296],{"class":178},[148,3846,2229],{"class":299},[148,3848,303],{"class":178},[148,3850,3851,3853,3856,3858,3860,3862,3864,3866,3868],{"class":150,"line":207},[148,3852,2575],{"class":178},[148,3854,3855],{"class":174},"bind",[148,3857,296],{"class":178},[148,3859,2582],{"class":299},[148,3861,2274],{"class":178},[148,3863,2277],{"class":185},[148,3865,1142],{"class":178},[148,3867,1145],{"class":167},[148,3869,179],{"class":178},[148,3871,3872,3874,3876,3878,3880],{"class":150,"line":217},[148,3873,2107],{"class":178},[148,3875,2110],{"class":174},[148,3877,296],{"class":178},[148,3879,2604],{"class":299},[148,3881,2297],{"class":178},[148,3883,3884],{"class":150,"line":227},[148,3885,1055],{"class":178},[148,3887,3888],{"class":150,"line":513},[148,3889,916],{"emptyLinePlaceholder":704},[148,3891,3892],{"class":150,"line":993},[148,3893,3894],{"class":154},"// On your server, using the pusher server SDK:\n",[148,3896,3897],{"class":150,"line":1004},[148,3898,3899],{"class":154},"// pusher.trigger('chat', 'message', { text: 'Hello!' })\n",[35,3901,2154],{"id":2153},[138,3903,3905],{"className":2157,"code":3904,"language":2159,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\n\nconst client = NoLag('your_access_token')\nawait client.connect()\n\nconst room = client.setApp('chat').setRoom('general')\nroom.subscribe('message')\n\nroom.on('message', (data) => {\n  console.log('Received:', data)\n})\n\n// Publish from any authorised client:\nroom.emit('message', { text: 'Hello!' })\n",[84,3906,3907,3917,3921,3937,3947,3951,3977,3989,3993,4013,4025,4029,4033,4038],{"__ignoreMap":144},[148,3908,3909,3911,3913,3915],{"class":150,"line":151},[148,3910,902],{"class":167},[148,3912,2168],{"class":178},[148,3914,908],{"class":167},[148,3916,2173],{"class":299},[148,3918,3919],{"class":150,"line":158},[148,3920,916],{"emptyLinePlaceholder":704},[148,3922,3923,3925,3927,3929,3931,3933,3935],{"class":150,"line":164},[148,3924,270],{"class":167},[148,3926,1274],{"class":203},[148,3928,276],{"class":167},[148,3930,1279],{"class":174},[148,3932,296],{"class":178},[148,3934,2192],{"class":299},[148,3936,303],{"class":178},[148,3938,3939,3941,3943,3945],{"class":150,"line":182},[148,3940,943],{"class":167},[148,3942,2201],{"class":178},[148,3944,2204],{"class":174},[148,3946,505],{"class":178},[148,3948,3949],{"class":150,"line":195},[148,3950,916],{"emptyLinePlaceholder":704},[148,3952,3953,3955,3957,3959,3961,3963,3965,3967,3969,3971,3973,3975],{"class":150,"line":207},[148,3954,270],{"class":167},[148,3956,2217],{"class":203},[148,3958,276],{"class":167},[148,3960,2201],{"class":178},[148,3962,2224],{"class":174},[148,3964,296],{"class":178},[148,3966,2229],{"class":299},[148,3968,2232],{"class":178},[148,3970,2235],{"class":174},[148,3972,296],{"class":178},[148,3974,2240],{"class":299},[148,3976,303],{"class":178},[148,3978,3979,3981,3983,3985,3987],{"class":150,"line":217},[148,3980,290],{"class":178},[148,3982,2249],{"class":174},[148,3984,296],{"class":178},[148,3986,2582],{"class":299},[148,3988,303],{"class":178},[148,3990,3991],{"class":150,"line":227},[148,3992,916],{"emptyLinePlaceholder":704},[148,3994,3995,3997,3999,4001,4003,4005,4007,4009,4011],{"class":150,"line":513},[148,3996,290],{"class":178},[148,3998,2267],{"class":174},[148,4000,296],{"class":178},[148,4002,2582],{"class":299},[148,4004,2274],{"class":178},[148,4006,2277],{"class":185},[148,4008,1142],{"class":178},[148,4010,1145],{"class":167},[148,4012,179],{"class":178},[148,4014,4015,4017,4019,4021,4023],{"class":150,"line":993},[148,4016,2107],{"class":178},[148,4018,2110],{"class":174},[148,4020,296],{"class":178},[148,4022,2604],{"class":299},[148,4024,2297],{"class":178},[148,4026,4027],{"class":150,"line":1004},[148,4028,1055],{"class":178},[148,4030,4031],{"class":150,"line":1013},[148,4032,916],{"emptyLinePlaceholder":704},[148,4034,4035],{"class":150,"line":1019},[148,4036,4037],{"class":154},"// Publish from any authorised client:\n",[148,4039,4040,4042,4044,4046,4048,4050,4052],{"class":150,"line":1028},[148,4041,290],{"class":178},[148,4043,2312],{"class":174},[148,4045,296],{"class":178},[148,4047,2582],{"class":299},[148,4049,2319],{"class":178},[148,4051,300],{"class":299},[148,4053,2150],{"class":178},[12,4055,4056,4057,4059,4060,4063],{},"The shape is the same. The main differences are that NoLag groups topics inside a ",[30,4058,1101],{}," (so you get natural namespacing and presence per room), and access is controlled with ",[30,4061,4062],{},"actors and tokens"," rather than a per-channel auth endpoint.",[35,4065,4067],{"id":4066},"auth-delete-your-auth-endpoint","Auth: delete your auth endpoint",[12,4069,4070,4071,4074],{},"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,4072,4073],{},"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.",[12,4076,4077,4078,4080],{},"For browser clients, mint a short-lived JWT (",[667,4079,2822],{"href":2348},") 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,4082,4083],{"id":2792},"Presence",[12,4085,4086,4087,4089],{},"Pusher presence channels map to NoLag ",[667,4088,2792],{"href":2791},", tracked per room. You get join and leave events and the current member list without standing up your own tracking.",[35,4091,1849],{"id":1848},[597,4093,4094,4099,4105],{},[600,4095,2357,4096,4098],{},[667,4097,1864],{"href":1863}," to connect for the first time.",[600,4100,4101,4102,4104],{},"If you are building chat, the ",[667,4103,3651],{"href":2844}," gives you rooms, typing, and history out of the box.",[600,4106,4107,4108,1562],{},"Compare the platforms in more detail: ",[667,4109,4111],{"href":4110},"/compare/pusher","NoLag vs Pusher",[684,4113,4114],{},"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":158,"depth":158,"links":4116},[4117,4118,4119,4120,4121,4122,4123],{"id":2402,"depth":158,"text":2403},{"id":1947,"depth":158,"text":1948},{"id":3776,"depth":158,"text":3777},{"id":2153,"depth":158,"text":2154},{"id":4066,"depth":158,"text":4067},{"id":2792,"depth":158,"text":4083},{"id":1848,"depth":158,"text":1849},"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":3679,"description":4125},"blog/migrate-from-pusher-to-nolag","wb75aaJuOZueIaEiMORktOwIB4bVVlAxDEzR_toHp3M",{"id":4132,"title":4133,"author":7,"body":4134,"category":1883,"date":4819,"description":4820,"excerpt":701,"extension":702,"meta":4821,"navigation":704,"path":4822,"readTime":2864,"seo":4823,"stem":4824,"__hash__":4825},"blog/blog/add-realtime-to-react.md","Add Realtime to a React App with NoLag",{"type":9,"value":4135,"toc":4811},[4136,4143,4145,4157,4161,4576,4580,4772,4776,4785,4789,4792,4794,4808],[12,4137,4138,4139,4142],{},"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 ",[667,4140,4141],{"href":3673},"Next.js guide"," for the extra server-token step.",[35,4144,2883],{"id":2882},[138,4146,4147],{"className":1578,"code":2886,"language":1580,"meta":144,"style":144},[84,4148,4149],{"__ignoreMap":144},[148,4150,4151,4153,4155],{"class":150,"line":151},[148,4152,2893],{"class":174},[148,4154,1590],{"class":299},[148,4156,2898],{"class":299},[35,4158,4160],{"id":4159},"_2-a-reusable-hook","2. A reusable hook",[138,4162,4164],{"className":3115,"code":4163,"language":3117,"meta":144,"style":144},"import { useEffect, useRef, useState, useCallback } from 'react'\nimport { NoLag } from '@nolag/js-sdk'\n\nexport function useNoLagRoom(token: string, app: string, roomName: string, topic: string) {\n  const [messages, setMessages] = useState\u003Cany[]>([])\n  const roomRef = useRef\u003Cany>(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(topic, (data: any) => 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: unknown) => roomRef.current?.emit(topic, data),\n    [topic],\n  )\n\n  return { messages, publish }\n}\n",[84,4165,4166,4177,4187,4191,4239,4263,4287,4291,4301,4321,4331,4335,4347,4357,4367,4379,4383,4403,4412,4448,4458,4462,4466,4476,4484,4493,4501,4505,4510,4514,4528,4552,4557,4561,4565,4572],{"__ignoreMap":144},[148,4167,4168,4170,4173,4175],{"class":150,"line":151},[148,4169,902],{"class":167},[148,4171,4172],{"class":178}," { useEffect, useRef, useState, useCallback } ",[148,4174,908],{"class":167},[148,4176,3136],{"class":299},[148,4178,4179,4181,4183,4185],{"class":150,"line":158},[148,4180,902],{"class":167},[148,4182,2168],{"class":178},[148,4184,908],{"class":167},[148,4186,2173],{"class":299},[148,4188,4189],{"class":150,"line":164},[148,4190,916],{"emptyLinePlaceholder":704},[148,4192,4193,4195,4197,4200,4202,4204,4206,4209,4211,4214,4216,4218,4220,4223,4225,4227,4229,4232,4234,4236],{"class":150,"line":182},[148,4194,168],{"class":167},[148,4196,418],{"class":167},[148,4198,4199],{"class":174}," useNoLagRoom",[148,4201,296],{"class":178},[148,4203,3277],{"class":185},[148,4205,189],{"class":167},[148,4207,4208],{"class":203}," string",[148,4210,87],{"class":178},[148,4212,4213],{"class":185},"app",[148,4215,189],{"class":167},[148,4217,4208],{"class":203},[148,4219,87],{"class":178},[148,4221,4222],{"class":185},"roomName",[148,4224,189],{"class":167},[148,4226,4208],{"class":203},[148,4228,87],{"class":178},[148,4230,4231],{"class":185},"topic",[148,4233,189],{"class":167},[148,4235,4208],{"class":203},[148,4237,4238],{"class":178},") {\n",[148,4240,4241,4243,4245,4247,4249,4251,4253,4255,4257,4259,4261],{"class":150,"line":195},[148,4242,491],{"class":167},[148,4244,3171],{"class":178},[148,4246,3174],{"class":203},[148,4248,87],{"class":178},[148,4250,3179],{"class":203},[148,4252,3182],{"class":178},[148,4254,1174],{"class":167},[148,4256,3187],{"class":174},[148,4258,480],{"class":178},[148,4260,3192],{"class":203},[148,4262,3195],{"class":178},[148,4264,4265,4267,4270,4272,4275,4277,4279,4282,4285],{"class":150,"line":207},[148,4266,491],{"class":167},[148,4268,4269],{"class":203}," roomRef",[148,4271,276],{"class":167},[148,4273,4274],{"class":174}," useRef",[148,4276,480],{"class":178},[148,4278,3192],{"class":203},[148,4280,4281],{"class":178},">(",[148,4283,4284],{"class":203},"null",[148,4286,303],{"class":178},[148,4288,4289],{"class":150,"line":217},[148,4290,916],{"emptyLinePlaceholder":704},[148,4292,4293,4295,4297,4299],{"class":150,"line":227},[148,4294,3204],{"class":174},[148,4296,3207],{"class":178},[148,4298,1145],{"class":167},[148,4300,179],{"class":178},[148,4302,4303,4305,4307,4309,4311,4313,4315,4317,4319],{"class":150,"line":513},[148,4304,3216],{"class":167},[148,4306,1274],{"class":178},[148,4308,189],{"class":167},[148,4310,3223],{"class":174},[148,4312,480],{"class":178},[148,4314,3228],{"class":167},[148,4316,3231],{"class":178},[148,4318,3234],{"class":167},[148,4320,3237],{"class":203},[148,4322,4323,4325,4327,4329],{"class":150,"line":993},[148,4324,3216],{"class":167},[148,4326,3244],{"class":178},[148,4328,1174],{"class":167},[148,4330,3249],{"class":203},[148,4332,4333],{"class":150,"line":1004},[148,4334,916],{"emptyLinePlaceholder":704},[148,4336,4337,4339,4341,4343,4345],{"class":150,"line":1013},[148,4338,3258],{"class":178},[148,4340,1129],{"class":167},[148,4342,3263],{"class":178},[148,4344,1145],{"class":167},[148,4346,179],{"class":178},[148,4348,4349,4351,4353,4355],{"class":150,"line":1019},[148,4350,3337],{"class":178},[148,4352,1174],{"class":167},[148,4354,1279],{"class":174},[148,4356,3344],{"class":178},[148,4358,4359,4361,4363,4365],{"class":150,"line":1028},[148,4360,3349],{"class":167},[148,4362,2201],{"class":178},[148,4364,2204],{"class":174},[148,4366,505],{"class":178},[148,4368,4369,4371,4373,4375,4377],{"class":150,"line":1034},[148,4370,3317],{"class":167},[148,4372,114],{"class":178},[148,4374,3322],{"class":167},[148,4376,3325],{"class":178},[148,4378,3328],{"class":167},[148,4380,4381],{"class":150,"line":1040},[148,4382,916],{"emptyLinePlaceholder":704},[148,4384,4385,4387,4389,4391,4393,4395,4398,4400],{"class":150,"line":1046},[148,4386,3272],{"class":167},[148,4388,2217],{"class":203},[148,4390,276],{"class":167},[148,4392,2201],{"class":178},[148,4394,2224],{"class":174},[148,4396,4397],{"class":178},"(app).",[148,4399,2235],{"class":174},[148,4401,4402],{"class":178},"(roomName)\n",[148,4404,4405,4407,4409],{"class":150,"line":1052},[148,4406,3390],{"class":178},[148,4408,2249],{"class":174},[148,4410,4411],{"class":178},"(topic)\n",[148,4413,4414,4416,4418,4421,4423,4425,4428,4430,4432,4434,4436,4438,4440,4442,4444,4446],{"class":150,"line":1699},[148,4415,3390],{"class":178},[148,4417,2267],{"class":174},[148,4419,4420],{"class":178},"(topic, (",[148,4422,2277],{"class":185},[148,4424,189],{"class":167},[148,4426,4427],{"class":203}," any",[148,4429,1142],{"class":178},[148,4431,1145],{"class":167},[148,4433,3419],{"class":174},[148,4435,3298],{"class":178},[148,4437,3424],{"class":185},[148,4439,1142],{"class":178},[148,4441,1145],{"class":167},[148,4443,3171],{"class":178},[148,4445,1285],{"class":167},[148,4447,3435],{"class":178},[148,4449,4450,4453,4455],{"class":150,"line":1705},[148,4451,4452],{"class":178},"      roomRef.current ",[148,4454,1174],{"class":167},[148,4456,4457],{"class":178}," room\n",[148,4459,4460],{"class":150,"line":1711},[148,4461,3440],{"class":178},[148,4463,4464],{"class":150,"line":1717},[148,4465,916],{"emptyLinePlaceholder":704},[148,4467,4468,4470,4472,4474],{"class":150,"line":1723},[148,4469,3449],{"class":167},[148,4471,3263],{"class":178},[148,4473,1145],{"class":167},[148,4475,179],{"class":178},[148,4477,4478,4480,4482],{"class":150,"line":1729},[148,4479,3460],{"class":178},[148,4481,1174],{"class":167},[148,4483,3465],{"class":203},[148,4485,4486,4488,4490],{"class":150,"line":1735},[148,4487,4452],{"class":178},[148,4489,1174],{"class":167},[148,4491,4492],{"class":203}," null\n",[148,4494,4495,4497,4499],{"class":150,"line":1741},[148,4496,3470],{"class":178},[148,4498,3473],{"class":174},[148,4500,3476],{"class":178},[148,4502,4503],{"class":150,"line":1746},[148,4504,3481],{"class":178},[148,4506,4507],{"class":150,"line":1752},[148,4508,4509],{"class":178},"  }, [token, app, roomName, topic])\n",[148,4511,4512],{"class":150,"line":1757},[148,4513,916],{"emptyLinePlaceholder":704},[148,4515,4516,4518,4521,4523,4526],{"class":150,"line":1763},[148,4517,491],{"class":167},[148,4519,4520],{"class":203}," publish",[148,4522,276],{"class":167},[148,4524,4525],{"class":174}," useCallback",[148,4527,424],{"class":178},[148,4529,4530,4533,4535,4537,4540,4542,4544,4547,4549],{"class":150,"line":1769},[148,4531,4532],{"class":178},"    (",[148,4534,2277],{"class":185},[148,4536,189],{"class":167},[148,4538,4539],{"class":203}," unknown",[148,4541,1142],{"class":178},[148,4543,1145],{"class":167},[148,4545,4546],{"class":178}," roomRef.current?.",[148,4548,2312],{"class":174},[148,4550,4551],{"class":178},"(topic, data),\n",[148,4553,4554],{"class":150,"line":1774},[148,4555,4556],{"class":178},"    [topic],\n",[148,4558,4559],{"class":150,"line":3517},[148,4560,3605],{"class":178},[148,4562,4563],{"class":150,"line":3522},[148,4564,916],{"emptyLinePlaceholder":704},[148,4566,4567,4569],{"class":150,"line":3530},[148,4568,3090],{"class":167},[148,4570,4571],{"class":178}," { messages, publish }\n",[148,4573,4574],{"class":150,"line":3542},[148,4575,230],{"class":178},[35,4577,4579],{"id":4578},"_3-use-it-in-a-component","3. Use it in a component",[138,4581,4583],{"className":3115,"code":4582,"language":3117,"meta":144,"style":144},"function Chat({ token }: { token: string }) {\n  const { messages, publish } = useNoLagRoom(token, 'chat', '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,4584,4585,4612,4645,4649,4655,4664,4673,4694,4711,4716,4725,4756,4764,4768],{"__ignoreMap":144},[148,4586,4587,4590,4592,4595,4597,4599,4601,4603,4605,4607,4609],{"class":150,"line":151},[148,4588,4589],{"class":167},"function",[148,4591,3162],{"class":174},[148,4593,4594],{"class":178},"({ ",[148,4596,3277],{"class":185},[148,4598,458],{"class":178},[148,4600,189],{"class":167},[148,4602,1155],{"class":178},[148,4604,3277],{"class":185},[148,4606,189],{"class":167},[148,4608,4208],{"class":203},[148,4610,4611],{"class":178}," }) {\n",[148,4613,4614,4616,4618,4620,4622,4624,4626,4628,4630,4633,4635,4637,4639,4641,4643],{"class":150,"line":158},[148,4615,491],{"class":167},[148,4617,1155],{"class":178},[148,4619,3174],{"class":203},[148,4621,87],{"class":178},[148,4623,2622],{"class":203},[148,4625,1171],{"class":178},[148,4627,1174],{"class":167},[148,4629,4199],{"class":174},[148,4631,4632],{"class":178},"(token, ",[148,4634,2229],{"class":299},[148,4636,87],{"class":178},[148,4638,2240],{"class":299},[148,4640,87],{"class":178},[148,4642,2582],{"class":299},[148,4644,303],{"class":178},[148,4646,4647],{"class":150,"line":164},[148,4648,916],{"emptyLinePlaceholder":704},[148,4650,4651,4653],{"class":150,"line":182},[148,4652,3090],{"class":167},[148,4654,3527],{"class":178},[148,4656,4657,4659,4662],{"class":150,"line":195},[148,4658,3533],{"class":178},[148,4660,4661],{"class":3536},"div",[148,4663,3539],{"class":178},[148,4665,4666,4669,4671],{"class":150,"line":207},[148,4667,4668],{"class":178},"      \u003C",[148,4670,597],{"class":3536},[148,4672,3539],{"class":178},[148,4674,4675,4678,4680,4682,4684,4686,4688,4690,4692],{"class":150,"line":217},[148,4676,4677],{"class":178},"        {messages.",[148,4679,3548],{"class":174},[148,4681,3298],{"class":178},[148,4683,3424],{"class":185},[148,4685,87],{"class":178},[148,4687,3557],{"class":185},[148,4689,1142],{"class":178},[148,4691,1145],{"class":167},[148,4693,3527],{"class":178},[148,4695,4696,4699,4701,4703,4705,4707,4709],{"class":150,"line":227},[148,4697,4698],{"class":178},"          \u003C",[148,4700,600],{"class":3536},[148,4702,3574],{"class":174},[148,4704,1174],{"class":167},[148,4706,3579],{"class":178},[148,4708,600],{"class":3536},[148,4710,3539],{"class":178},[148,4712,4713],{"class":150,"line":513},[148,4714,4715],{"class":178},"        ))}\n",[148,4717,4718,4721,4723],{"class":150,"line":993},[148,4719,4720],{"class":178},"      \u003C/",[148,4722,597],{"class":3536},[148,4724,3539],{"class":178},[148,4726,4727,4729,4732,4735,4737,4740,4742,4744,4747,4749,4752,4754],{"class":150,"line":1004},[148,4728,4668],{"class":178},[148,4730,4731],{"class":3536},"button",[148,4733,4734],{"class":174}," onClick",[148,4736,1174],{"class":167},[148,4738,4739],{"class":178},"{() ",[148,4741,1145],{"class":167},[148,4743,4520],{"class":174},[148,4745,4746],{"class":178},"({ text: ",[148,4748,300],{"class":299},[148,4750,4751],{"class":178}," })}>Send\u003C/",[148,4753,4731],{"class":3536},[148,4755,3539],{"class":178},[148,4757,4758,4760,4762],{"class":150,"line":1013},[148,4759,3595],{"class":178},[148,4761,4661],{"class":3536},[148,4763,3539],{"class":178},[148,4765,4766],{"class":150,"line":1019},[148,4767,3605],{"class":178},[148,4769,4770],{"class":150,"line":1028},[148,4771,230],{"class":178},[35,4773,4775],{"id":4774},"keep-the-token-off-the-client","Keep the token off the client",[12,4777,4778,4779,4781,4782,4784],{},"Fetch the ",[84,4780,3277],{}," 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 ",[667,4783,2822],{"href":2348}," scoped to what that user may do.",[35,4786,4788],{"id":4787},"why-the-cleanup-matters","Why the cleanup matters",[12,4790,4791],{},"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,4793,1849],{"id":1848},[597,4795,4796,4801],{},[600,4797,4798,4799,1562],{},"Walk through the full flow in the ",[667,4800,1864],{"href":1863},[600,4802,4803,4804,4807],{},"Use a ",[667,4805,4806],{"href":2844},"blueprint SDK"," if you would rather not wire chat, notifications, or dashboards by hand.",[684,4809,4810],{},"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 .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":144,"searchDepth":158,"depth":158,"links":4812},[4813,4814,4815,4816,4817,4818],{"id":2882,"depth":158,"text":2883},{"id":4159,"depth":158,"text":4160},{"id":4578,"depth":158,"text":4579},{"id":4774,"depth":158,"text":4775},{"id":4787,"depth":158,"text":4788},{"id":1848,"depth":158,"text":1849},"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":4133,"description":4820},"blog/add-realtime-to-react","36HeBOje0HLI8QV7oMYvtJ8ScahqfdCfVq8x8z2uIRU",{"id":4827,"title":4828,"author":7,"body":4829,"category":6508,"date":6509,"description":6510,"excerpt":701,"extension":702,"meta":6511,"navigation":704,"path":6512,"readTime":1888,"seo":6513,"stem":6514,"__hash__":6515},"blog/blog/connect-local-llm.md","Connect Your Local LLM to NoLag in 5 Minutes",{"type":9,"value":4830,"toc":6493},[4831,4834,4837,4841,4844,4858,4861,4865,4900,4904,4907,4913,4916,5330,5339,5343,5346,5644,5651,5655,5658,5664,6005,6008,6168,6179,6183,6186,6189,6391,6394,6398,6401,6405,6408,6412,6415,6419,6422,6436,6439,6442,6476,6479,6490],[12,4832,4833],{},"You have a local model running. Ollama, llama.cpp, vLLM -- 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,4835,4836],{},"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,4838,4840],{"id":4839},"what-youll-build","What You'll Build",[12,4842,4843],{},"A local Ollama worker that:",[597,4845,4846,4849,4852,4855],{},[600,4847,4848],{},"Connects to NoLag and advertises its capabilities via presence",[600,4850,4851],{},"Picks up tasks dispatched by any other agent in the system",[600,4853,4854],{},"Calls your local model and sends results back",[600,4856,4857],{},"Shows up in the NoLag portal dashboard so you can see it's alive",[12,4859,4860],{},"Then you'll add a second model with different capabilities and watch NoLag route tasks to the right worker automatically. Zero changes to the dispatching code.",[35,4862,4864],{"id":4863},"prerequisites","Prerequisites",[629,4866,4867,4876,4884,4895],{},[600,4868,4869,4872,4873],{},[30,4870,4871],{},"Ollama installed"," -- ",[84,4874,4875],{},"curl -fsSL https://ollama.com/install.sh | sh",[600,4877,4878,4872,4881],{},[30,4879,4880],{},"A model pulled",[84,4882,4883],{},"ollama pull llama3.2",[600,4885,4886,4889,4890,4894],{},[30,4887,4888],{},"A NoLag account"," -- create an app in the ",[667,4891,4893],{"href":4892},"/pricing","portal"," and grab an actor token",[600,4896,4897],{},[30,4898,4899],{},"Node.js 18+",[35,4901,4903],{"id":4902},"step-1-create-the-worker","Step 1: Create the Worker",[12,4905,4906],{},"Install the dependencies:",[138,4908,4911],{"className":4909,"code":4910,"language":830,"meta":144},[827],"npm install @nolag/agents ollama\n",[84,4912,4910],{"__ignoreMap":144},[12,4914,4915],{},"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,4917,4918],{},[138,4919,4922],{"className":140,"code":4920,"filename":4921,"language":143,"meta":144,"style":144},"import { NoLagAgents, Handoff } from \"@nolag/agents\";\nimport { Ollama } from \"ollama\";\n\nconst ollama = new Ollama({ host: \"http://localhost:11434\" });\n\nconst worker = new NoLagAgents(TOKEN, {\n  appName: \"my-agents-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 worker.connect();\nconst room = worker.room(\"tasks\");\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.type} agent.` },\n      { role: \"user\", content: 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,4923,4924,4939,4953,4957,4980,4984,5005,5015,5025,5030,5039,5049,5065,5082,5086,5091,5095,5107,5127,5142,5146,5179,5198,5207,5212,5238,5248,5253,5258,5262,5274,5279,5287,5304,5308,5312,5316],{"__ignoreMap":144},[148,4925,4926,4928,4931,4933,4936],{"class":150,"line":151},[148,4927,902],{"class":167},[148,4929,4930],{"class":178}," { NoLagAgents, Handoff } ",[148,4932,908],{"class":167},[148,4934,4935],{"class":299}," \"@nolag/agents\"",[148,4937,4938],{"class":178},";\n",[148,4940,4941,4943,4946,4948,4951],{"class":150,"line":158},[148,4942,902],{"class":167},[148,4944,4945],{"class":178}," { Ollama } ",[148,4947,908],{"class":167},[148,4949,4950],{"class":299}," \"ollama\"",[148,4952,4938],{"class":178},[148,4954,4955],{"class":150,"line":164},[148,4956,916],{"emptyLinePlaceholder":704},[148,4958,4959,4961,4964,4966,4968,4971,4974,4977],{"class":150,"line":182},[148,4960,270],{"class":167},[148,4962,4963],{"class":203}," ollama",[148,4965,276],{"class":167},[148,4967,279],{"class":167},[148,4969,4970],{"class":174}," Ollama",[148,4972,4973],{"class":178},"({ host: ",[148,4975,4976],{"class":299},"\"http://localhost:11434\"",[148,4978,4979],{"class":178}," });\n",[148,4981,4982],{"class":150,"line":195},[148,4983,916],{"emptyLinePlaceholder":704},[148,4985,4986,4988,4991,4993,4995,4998,5000,5003],{"class":150,"line":207},[148,4987,270],{"class":167},[148,4989,4990],{"class":203}," worker",[148,4992,276],{"class":167},[148,4994,279],{"class":167},[148,4996,4997],{"class":174}," NoLagAgents",[148,4999,296],{"class":178},[148,5001,5002],{"class":203},"TOKEN",[148,5004,2971],{"class":178},[148,5006,5007,5010,5013],{"class":150,"line":217},[148,5008,5009],{"class":178},"  appName: ",[148,5011,5012],{"class":299},"\"my-agents-app\"",[148,5014,2982],{"class":178},[148,5016,5017,5020,5023],{"class":150,"line":227},[148,5018,5019],{"class":178},"  agentId: ",[148,5021,5022],{"class":299},"\"llama-worker\"",[148,5024,2982],{"class":178},[148,5026,5027],{"class":150,"line":513},[148,5028,5029],{"class":178},"  presence: {\n",[148,5031,5032,5035,5037],{"class":150,"line":993},[148,5033,5034],{"class":178},"    name: ",[148,5036,5022],{"class":299},[148,5038,2982],{"class":178},[148,5040,5041,5044,5047],{"class":150,"line":1004},[148,5042,5043],{"class":178},"    role: ",[148,5045,5046],{"class":299},"\"agent\"",[148,5048,2982],{"class":178},[148,5050,5051,5054,5057,5059,5062],{"class":150,"line":1013},[148,5052,5053],{"class":178},"    capabilities: [",[148,5055,5056],{"class":299},"\"summarize\"",[148,5058,87],{"class":178},[148,5060,5061],{"class":299},"\"classify\"",[148,5063,5064],{"class":178},"],\n",[148,5066,5067,5070,5073,5076,5079],{"class":150,"line":1019},[148,5068,5069],{"class":178},"    metadata: { model: ",[148,5071,5072],{"class":299},"\"llama3.2\"",[148,5074,5075],{"class":178},", provider: ",[148,5077,5078],{"class":299},"\"local\"",[148,5080,5081],{"class":178}," },\n",[148,5083,5084],{"class":150,"line":1028},[148,5085,1043],{"class":178},[148,5087,5088],{"class":150,"line":1034},[148,5089,5090],{"class":178},"});\n",[148,5092,5093],{"class":150,"line":1040},[148,5094,916],{"emptyLinePlaceholder":704},[148,5096,5097,5099,5102,5104],{"class":150,"line":1046},[148,5098,943],{"class":167},[148,5100,5101],{"class":178}," worker.",[148,5103,2204],{"class":174},[148,5105,5106],{"class":178},"();\n",[148,5108,5109,5111,5113,5115,5117,5119,5121,5124],{"class":150,"line":1052},[148,5110,270],{"class":167},[148,5112,2217],{"class":203},[148,5114,276],{"class":167},[148,5116,5101],{"class":178},[148,5118,1101],{"class":174},[148,5120,296],{"class":178},[148,5122,5123],{"class":299},"\"tasks\"",[148,5125,5126],{"class":178},");\n",[148,5128,5129,5131,5133,5135,5137,5139],{"class":150,"line":1699},[148,5130,270],{"class":167},[148,5132,494],{"class":203},[148,5134,276],{"class":167},[148,5136,279],{"class":167},[148,5138,1095],{"class":174},[148,5140,5141],{"class":178},"(room);\n",[148,5143,5144],{"class":150,"line":1705},[148,5145,916],{"emptyLinePlaceholder":704},[148,5147,5148,5151,5153,5155,5157,5159,5161,5163,5165,5167,5169,5171,5173,5175,5177],{"class":150,"line":1711},[148,5149,5150],{"class":178},"handoff.",[148,5152,1117],{"class":174},[148,5154,1120],{"class":178},[148,5156,5056],{"class":299},[148,5158,87],{"class":178},[148,5160,5061],{"class":299},[148,5162,1126],{"class":178},[148,5164,1129],{"class":167},[148,5166,114],{"class":178},[148,5168,1134],{"class":185},[148,5170,87],{"class":178},[148,5172,1139],{"class":185},[148,5174,1142],{"class":178},[148,5176,1145],{"class":167},[148,5178,179],{"class":178},[148,5180,5181,5183,5186,5188,5190,5193,5196],{"class":150,"line":1717},[148,5182,491],{"class":167},[148,5184,5185],{"class":203}," response",[148,5187,276],{"class":167},[148,5189,499],{"class":167},[148,5191,5192],{"class":178}," ollama.",[148,5194,5195],{"class":174},"chat",[148,5197,972],{"class":178},[148,5199,5200,5203,5205],{"class":150,"line":1723},[148,5201,5202],{"class":178},"    model: ",[148,5204,5072],{"class":299},[148,5206,2982],{"class":178},[148,5208,5209],{"class":150,"line":1729},[148,5210,5211],{"class":178},"    messages: [\n",[148,5213,5214,5217,5220,5223,5226,5228,5230,5233,5236],{"class":150,"line":1735},[148,5215,5216],{"class":178},"      { role: ",[148,5218,5219],{"class":299},"\"system\"",[148,5221,5222],{"class":178},", content: ",[148,5224,5225],{"class":299},"`You are a ${",[148,5227,1134],{"class":178},[148,5229,1562],{"class":299},[148,5231,5232],{"class":178},"type",[148,5234,5235],{"class":299},"} agent.`",[148,5237,5081],{"class":178},[148,5239,5240,5242,5245],{"class":150,"line":1741},[148,5241,5216],{"class":178},[148,5243,5244],{"class":299},"\"user\"",[148,5246,5247],{"class":178},", content: task.payload.text },\n",[148,5249,5250],{"class":150,"line":1746},[148,5251,5252],{"class":178},"    ],\n",[148,5254,5255],{"class":150,"line":1752},[148,5256,5257],{"class":178},"  });\n",[148,5259,5260],{"class":150,"line":1757},[148,5261,916],{"emptyLinePlaceholder":704},[148,5263,5264,5267,5269,5272],{"class":150,"line":1763},[148,5265,5266],{"class":174},"  respond",[148,5268,296],{"class":178},[148,5270,5271],{"class":299},"\"success\"",[148,5273,2971],{"class":178},[148,5275,5276],{"class":150,"line":1769},[148,5277,5278],{"class":178},"    result: response.message.content,\n",[148,5280,5281,5283,5285],{"class":150,"line":1774},[148,5282,5202],{"class":178},[148,5284,5072],{"class":299},[148,5286,2982],{"class":178},[148,5288,5289,5292,5295,5298,5301],{"class":150,"line":3517},[148,5290,5291],{"class":178},"    latencyMs: Date.",[148,5293,5294],{"class":174},"now",[148,5296,5297],{"class":178},"() ",[148,5299,5300],{"class":167},"-",[148,5302,5303],{"class":178}," task.createdAt,\n",[148,5305,5306],{"class":150,"line":3522},[148,5307,5257],{"class":178},[148,5309,5310],{"class":150,"line":3530},[148,5311,5090],{"class":178},[148,5313,5314],{"class":150,"line":3542},[148,5315,916],{"emptyLinePlaceholder":704},[148,5317,5318,5321,5323,5325,5328],{"class":150,"line":3566},[148,5319,5320],{"class":178},"console.",[148,5322,2110],{"class":174},[148,5324,296],{"class":178},[148,5326,5327],{"class":299},"\"Worker online. Waiting for tasks...\"",[148,5329,5126],{"class":178},[12,5331,5332,5333,5335,5336,5338],{},"That's the entire worker. No routing logic, no queue management, no health checks. The ",[84,5334,2792],{}," block tells the system what this worker can do. The ",[84,5337,1117],{}," callback handles incoming work. NoLag manages everything in between.",[35,5340,5342],{"id":5341},"step-2-dispatch-a-task","Step 2: Dispatch a Task",[12,5344,5345],{},"From any other process -- a backend service, a CLI script, another agent -- dispatch a task by capability:",[135,5347,5348],{},[138,5349,5352],{"className":140,"code":5350,"filename":5351,"language":143,"meta":144,"style":144},"import { NoLagAgents, Handoff } from \"@nolag/agents\";\n\nconst orchestrator = new NoLagAgents(TOKEN, {\n  appName: \"my-agents-app\",\n  agentId: \"orchestrator\",\n  presence: { name: \"orchestrator\", role: \"orchestrator\" },\n});\n\nawait orchestrator.connect();\nconst room = orchestrator.room(\"tasks\");\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 -- NoLag routes it to a capable worker\nconst result = await handoff.dispatch(\"summarize\", {\n  text: \"NoLag is a real-time messaging infrastructure...\"\n}, {\n  waitForResult: true,\n  timeout: 30000,\n});\n\nconsole.log(\"Result:\", result.payload.result);\nconsole.log(\"Model:\", result.payload.model);\nconsole.log(\"Latency:\", result.payload.latencyMs, \"ms\");\n","dispatch.ts",[84,5353,5354,5366,5370,5389,5397,5406,5420,5424,5428,5439,5457,5471,5475,5480,5501,5524,5528,5533,5556,5564,5569,5579,5589,5593,5597,5611,5625],{"__ignoreMap":144},[148,5355,5356,5358,5360,5362,5364],{"class":150,"line":151},[148,5357,902],{"class":167},[148,5359,4930],{"class":178},[148,5361,908],{"class":167},[148,5363,4935],{"class":299},[148,5365,4938],{"class":178},[148,5367,5368],{"class":150,"line":158},[148,5369,916],{"emptyLinePlaceholder":704},[148,5371,5372,5374,5377,5379,5381,5383,5385,5387],{"class":150,"line":164},[148,5373,270],{"class":167},[148,5375,5376],{"class":203}," orchestrator",[148,5378,276],{"class":167},[148,5380,279],{"class":167},[148,5382,4997],{"class":174},[148,5384,296],{"class":178},[148,5386,5002],{"class":203},[148,5388,2971],{"class":178},[148,5390,5391,5393,5395],{"class":150,"line":182},[148,5392,5009],{"class":178},[148,5394,5012],{"class":299},[148,5396,2982],{"class":178},[148,5398,5399,5401,5404],{"class":150,"line":195},[148,5400,5019],{"class":178},[148,5402,5403],{"class":299},"\"orchestrator\"",[148,5405,2982],{"class":178},[148,5407,5408,5411,5413,5416,5418],{"class":150,"line":207},[148,5409,5410],{"class":178},"  presence: { name: ",[148,5412,5403],{"class":299},[148,5414,5415],{"class":178},", role: ",[148,5417,5403],{"class":299},[148,5419,5081],{"class":178},[148,5421,5422],{"class":150,"line":217},[148,5423,5090],{"class":178},[148,5425,5426],{"class":150,"line":227},[148,5427,916],{"emptyLinePlaceholder":704},[148,5429,5430,5432,5435,5437],{"class":150,"line":513},[148,5431,943],{"class":167},[148,5433,5434],{"class":178}," orchestrator.",[148,5436,2204],{"class":174},[148,5438,5106],{"class":178},[148,5440,5441,5443,5445,5447,5449,5451,5453,5455],{"class":150,"line":993},[148,5442,270],{"class":167},[148,5444,2217],{"class":203},[148,5446,276],{"class":167},[148,5448,5434],{"class":178},[148,5450,1101],{"class":174},[148,5452,296],{"class":178},[148,5454,5123],{"class":299},[148,5456,5126],{"class":178},[148,5458,5459,5461,5463,5465,5467,5469],{"class":150,"line":1004},[148,5460,270],{"class":167},[148,5462,494],{"class":203},[148,5464,276],{"class":167},[148,5466,279],{"class":167},[148,5468,1095],{"class":174},[148,5470,5141],{"class":178},[148,5472,5473],{"class":150,"line":1013},[148,5474,916],{"emptyLinePlaceholder":704},[148,5476,5477],{"class":150,"line":1019},[148,5478,5479],{"class":154},"// Check who's online\n",[148,5481,5482,5484,5487,5489,5492,5495,5497,5499],{"class":150,"line":1028},[148,5483,270],{"class":167},[148,5485,5486],{"class":203}," workers",[148,5488,276],{"class":167},[148,5490,5491],{"class":178}," room.",[148,5493,5494],{"class":174},"findAgents",[148,5496,296],{"class":178},[148,5498,5056],{"class":299},[148,5500,5126],{"class":178},[148,5502,5503,5505,5507,5509,5512,5514,5516,5519,5522],{"class":150,"line":1034},[148,5504,5320],{"class":178},[148,5506,2110],{"class":174},[148,5508,296],{"class":178},[148,5510,5511],{"class":299},"`${",[148,5513,1554],{"class":178},[148,5515,1562],{"class":299},[148,5517,5518],{"class":203},"length",[148,5520,5521],{"class":299},"} summarize worker(s) online`",[148,5523,5126],{"class":178},[148,5525,5526],{"class":150,"line":1040},[148,5527,916],{"emptyLinePlaceholder":704},[148,5529,5530],{"class":150,"line":1046},[148,5531,5532],{"class":154},"// Dispatch a task -- NoLag routes it to a capable worker\n",[148,5534,5535,5537,5540,5542,5544,5547,5550,5552,5554],{"class":150,"line":1052},[148,5536,270],{"class":167},[148,5538,5539],{"class":203}," result",[148,5541,276],{"class":167},[148,5543,499],{"class":167},[148,5545,5546],{"class":178}," handoff.",[148,5548,5549],{"class":174},"dispatch",[148,5551,296],{"class":178},[148,5553,5056],{"class":299},[148,5555,2971],{"class":178},[148,5557,5558,5561],{"class":150,"line":1699},[148,5559,5560],{"class":178},"  text: ",[148,5562,5563],{"class":299},"\"NoLag is a real-time messaging infrastructure...\"\n",[148,5565,5566],{"class":150,"line":1705},[148,5567,5568],{"class":178},"}, {\n",[148,5570,5571,5574,5577],{"class":150,"line":1711},[148,5572,5573],{"class":178},"  waitForResult: ",[148,5575,5576],{"class":203},"true",[148,5578,2982],{"class":178},[148,5580,5581,5584,5587],{"class":150,"line":1717},[148,5582,5583],{"class":178},"  timeout: ",[148,5585,5586],{"class":203},"30000",[148,5588,2982],{"class":178},[148,5590,5591],{"class":150,"line":1723},[148,5592,5090],{"class":178},[148,5594,5595],{"class":150,"line":1729},[148,5596,916],{"emptyLinePlaceholder":704},[148,5598,5599,5601,5603,5605,5608],{"class":150,"line":1735},[148,5600,5320],{"class":178},[148,5602,2110],{"class":174},[148,5604,296],{"class":178},[148,5606,5607],{"class":299},"\"Result:\"",[148,5609,5610],{"class":178},", result.payload.result);\n",[148,5612,5613,5615,5617,5619,5622],{"class":150,"line":1741},[148,5614,5320],{"class":178},[148,5616,2110],{"class":174},[148,5618,296],{"class":178},[148,5620,5621],{"class":299},"\"Model:\"",[148,5623,5624],{"class":178},", result.payload.model);\n",[148,5626,5627,5629,5631,5633,5636,5639,5642],{"class":150,"line":1746},[148,5628,5320],{"class":178},[148,5630,2110],{"class":174},[148,5632,296],{"class":178},[148,5634,5635],{"class":299},"\"Latency:\"",[148,5637,5638],{"class":178},", result.payload.latencyMs, ",[148,5640,5641],{"class":299},"\"ms\"",[148,5643,5126],{"class":178},[12,5645,5646,5647,5650],{},"The orchestrator doesn't import ",[84,5648,5649],{},"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.",[35,5652,5654],{"id":5653},"step-3-add-a-second-model","Step 3: Add a Second Model",[12,5656,5657],{},"Pull another model and spin up a second worker with different capabilities:",[138,5659,5662],{"className":5660,"code":5661,"language":830,"meta":144},[827],"ollama pull mistral\n",[84,5663,5661],{"__ignoreMap":144},[135,5665,5666],{},[138,5667,5670],{"className":140,"code":5668,"filename":5669,"language":143,"meta":144,"style":144},"import { NoLagAgents, Handoff } from \"@nolag/agents\";\nimport { Ollama } from \"ollama\";\n\nconst ollama = new Ollama({ host: \"http://localhost:11434\" });\n\nconst worker = new NoLagAgents(TOKEN, {\n  appName: \"my-agents-app\",\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 worker.connect();\nconst room = worker.room(\"tasks\");\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.type} agent.` },\n      { role: \"user\", content: 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,5671,5672,5684,5696,5700,5718,5722,5740,5748,5757,5761,5769,5777,5791,5804,5808,5812,5816,5826,5844,5858,5862,5894,5910,5918,5922,5943,5951,5955,5959,5963,5973,5977,5985,5997,6001],{"__ignoreMap":144},[148,5673,5674,5676,5678,5680,5682],{"class":150,"line":151},[148,5675,902],{"class":167},[148,5677,4930],{"class":178},[148,5679,908],{"class":167},[148,5681,4935],{"class":299},[148,5683,4938],{"class":178},[148,5685,5686,5688,5690,5692,5694],{"class":150,"line":158},[148,5687,902],{"class":167},[148,5689,4945],{"class":178},[148,5691,908],{"class":167},[148,5693,4950],{"class":299},[148,5695,4938],{"class":178},[148,5697,5698],{"class":150,"line":164},[148,5699,916],{"emptyLinePlaceholder":704},[148,5701,5702,5704,5706,5708,5710,5712,5714,5716],{"class":150,"line":182},[148,5703,270],{"class":167},[148,5705,4963],{"class":203},[148,5707,276],{"class":167},[148,5709,279],{"class":167},[148,5711,4970],{"class":174},[148,5713,4973],{"class":178},[148,5715,4976],{"class":299},[148,5717,4979],{"class":178},[148,5719,5720],{"class":150,"line":195},[148,5721,916],{"emptyLinePlaceholder":704},[148,5723,5724,5726,5728,5730,5732,5734,5736,5738],{"class":150,"line":207},[148,5725,270],{"class":167},[148,5727,4990],{"class":203},[148,5729,276],{"class":167},[148,5731,279],{"class":167},[148,5733,4997],{"class":174},[148,5735,296],{"class":178},[148,5737,5002],{"class":203},[148,5739,2971],{"class":178},[148,5741,5742,5744,5746],{"class":150,"line":217},[148,5743,5009],{"class":178},[148,5745,5012],{"class":299},[148,5747,2982],{"class":178},[148,5749,5750,5752,5755],{"class":150,"line":227},[148,5751,5019],{"class":178},[148,5753,5754],{"class":299},"\"mistral-worker\"",[148,5756,2982],{"class":178},[148,5758,5759],{"class":150,"line":513},[148,5760,5029],{"class":178},[148,5762,5763,5765,5767],{"class":150,"line":993},[148,5764,5034],{"class":178},[148,5766,5754],{"class":299},[148,5768,2982],{"class":178},[148,5770,5771,5773,5775],{"class":150,"line":1004},[148,5772,5043],{"class":178},[148,5774,5046],{"class":299},[148,5776,2982],{"class":178},[148,5778,5779,5781,5784,5786,5789],{"class":150,"line":1013},[148,5780,5053],{"class":178},[148,5782,5783],{"class":299},"\"extract-entities\"",[148,5785,87],{"class":178},[148,5787,5788],{"class":299},"\"translate\"",[148,5790,5064],{"class":178},[148,5792,5793,5795,5798,5800,5802],{"class":150,"line":1019},[148,5794,5069],{"class":178},[148,5796,5797],{"class":299},"\"mistral\"",[148,5799,5075],{"class":178},[148,5801,5078],{"class":299},[148,5803,5081],{"class":178},[148,5805,5806],{"class":150,"line":1028},[148,5807,1043],{"class":178},[148,5809,5810],{"class":150,"line":1034},[148,5811,5090],{"class":178},[148,5813,5814],{"class":150,"line":1040},[148,5815,916],{"emptyLinePlaceholder":704},[148,5817,5818,5820,5822,5824],{"class":150,"line":1046},[148,5819,943],{"class":167},[148,5821,5101],{"class":178},[148,5823,2204],{"class":174},[148,5825,5106],{"class":178},[148,5827,5828,5830,5832,5834,5836,5838,5840,5842],{"class":150,"line":1052},[148,5829,270],{"class":167},[148,5831,2217],{"class":203},[148,5833,276],{"class":167},[148,5835,5101],{"class":178},[148,5837,1101],{"class":174},[148,5839,296],{"class":178},[148,5841,5123],{"class":299},[148,5843,5126],{"class":178},[148,5845,5846,5848,5850,5852,5854,5856],{"class":150,"line":1699},[148,5847,270],{"class":167},[148,5849,494],{"class":203},[148,5851,276],{"class":167},[148,5853,279],{"class":167},[148,5855,1095],{"class":174},[148,5857,5141],{"class":178},[148,5859,5860],{"class":150,"line":1705},[148,5861,916],{"emptyLinePlaceholder":704},[148,5863,5864,5866,5868,5870,5872,5874,5876,5878,5880,5882,5884,5886,5888,5890,5892],{"class":150,"line":1711},[148,5865,5150],{"class":178},[148,5867,1117],{"class":174},[148,5869,1120],{"class":178},[148,5871,5783],{"class":299},[148,5873,87],{"class":178},[148,5875,5788],{"class":299},[148,5877,1126],{"class":178},[148,5879,1129],{"class":167},[148,5881,114],{"class":178},[148,5883,1134],{"class":185},[148,5885,87],{"class":178},[148,5887,1139],{"class":185},[148,5889,1142],{"class":178},[148,5891,1145],{"class":167},[148,5893,179],{"class":178},[148,5895,5896,5898,5900,5902,5904,5906,5908],{"class":150,"line":1717},[148,5897,491],{"class":167},[148,5899,5185],{"class":203},[148,5901,276],{"class":167},[148,5903,499],{"class":167},[148,5905,5192],{"class":178},[148,5907,5195],{"class":174},[148,5909,972],{"class":178},[148,5911,5912,5914,5916],{"class":150,"line":1723},[148,5913,5202],{"class":178},[148,5915,5797],{"class":299},[148,5917,2982],{"class":178},[148,5919,5920],{"class":150,"line":1729},[148,5921,5211],{"class":178},[148,5923,5924,5926,5928,5930,5933,5935,5937,5939,5941],{"class":150,"line":1735},[148,5925,5216],{"class":178},[148,5927,5219],{"class":299},[148,5929,5222],{"class":178},[148,5931,5932],{"class":299},"`You are an ${",[148,5934,1134],{"class":178},[148,5936,1562],{"class":299},[148,5938,5232],{"class":178},[148,5940,5235],{"class":299},[148,5942,5081],{"class":178},[148,5944,5945,5947,5949],{"class":150,"line":1741},[148,5946,5216],{"class":178},[148,5948,5244],{"class":299},[148,5950,5247],{"class":178},[148,5952,5953],{"class":150,"line":1746},[148,5954,5252],{"class":178},[148,5956,5957],{"class":150,"line":1752},[148,5958,5257],{"class":178},[148,5960,5961],{"class":150,"line":1757},[148,5962,916],{"emptyLinePlaceholder":704},[148,5964,5965,5967,5969,5971],{"class":150,"line":1763},[148,5966,5266],{"class":174},[148,5968,296],{"class":178},[148,5970,5271],{"class":299},[148,5972,2971],{"class":178},[148,5974,5975],{"class":150,"line":1769},[148,5976,5278],{"class":178},[148,5978,5979,5981,5983],{"class":150,"line":1774},[148,5980,5202],{"class":178},[148,5982,5797],{"class":299},[148,5984,2982],{"class":178},[148,5986,5987,5989,5991,5993,5995],{"class":150,"line":3517},[148,5988,5291],{"class":178},[148,5990,5294],{"class":174},[148,5992,5297],{"class":178},[148,5994,5300],{"class":167},[148,5996,5303],{"class":178},[148,5998,5999],{"class":150,"line":3522},[148,6000,5257],{"class":178},[148,6002,6003],{"class":150,"line":3530},[148,6004,5090],{"class":178},[12,6006,6007],{},"Now dispatch tasks that route to each model automatically:",[135,6009,6010],{},[138,6011,6014],{"className":140,"code":6012,"filename":6013,"language":143,"meta":144,"style":144},"// The orchestrator doesn't change.\n// Dispatch by capability -- NoLag routes to the right worker.\n\nconst summary = await handoff.dispatch(\"summarize\", {\n  text: document\n}, { waitForResult: true });\n// -> routed to llama-worker\n\nconst entities = await handoff.dispatch(\"extract-entities\", {\n  text: document\n}, { waitForResult: true });\n// -> routed to mistral-worker\n\nconst translation = await handoff.dispatch(\"translate\", {\n  text: summary.payload.result\n}, { waitForResult: true });\n// -> routed to 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,6015,6016,6021,6026,6030,6051,6056,6065,6070,6074,6095,6099,6107,6112,6116,6137,6142,6150,6154,6158,6163],{"__ignoreMap":144},[148,6017,6018],{"class":150,"line":151},[148,6019,6020],{"class":154},"// The orchestrator doesn't change.\n",[148,6022,6023],{"class":150,"line":158},[148,6024,6025],{"class":154},"// Dispatch by capability -- NoLag routes to the right worker.\n",[148,6027,6028],{"class":150,"line":164},[148,6029,916],{"emptyLinePlaceholder":704},[148,6031,6032,6034,6037,6039,6041,6043,6045,6047,6049],{"class":150,"line":182},[148,6033,270],{"class":167},[148,6035,6036],{"class":203}," summary",[148,6038,276],{"class":167},[148,6040,499],{"class":167},[148,6042,5546],{"class":178},[148,6044,5549],{"class":174},[148,6046,296],{"class":178},[148,6048,5056],{"class":299},[148,6050,2971],{"class":178},[148,6052,6053],{"class":150,"line":195},[148,6054,6055],{"class":178},"  text: document\n",[148,6057,6058,6061,6063],{"class":150,"line":207},[148,6059,6060],{"class":178},"}, { waitForResult: ",[148,6062,5576],{"class":203},[148,6064,4979],{"class":178},[148,6066,6067],{"class":150,"line":217},[148,6068,6069],{"class":154},"// -> routed to llama-worker\n",[148,6071,6072],{"class":150,"line":227},[148,6073,916],{"emptyLinePlaceholder":704},[148,6075,6076,6078,6081,6083,6085,6087,6089,6091,6093],{"class":150,"line":513},[148,6077,270],{"class":167},[148,6079,6080],{"class":203}," entities",[148,6082,276],{"class":167},[148,6084,499],{"class":167},[148,6086,5546],{"class":178},[148,6088,5549],{"class":174},[148,6090,296],{"class":178},[148,6092,5783],{"class":299},[148,6094,2971],{"class":178},[148,6096,6097],{"class":150,"line":993},[148,6098,6055],{"class":178},[148,6100,6101,6103,6105],{"class":150,"line":1004},[148,6102,6060],{"class":178},[148,6104,5576],{"class":203},[148,6106,4979],{"class":178},[148,6108,6109],{"class":150,"line":1013},[148,6110,6111],{"class":154},"// -> routed to mistral-worker\n",[148,6113,6114],{"class":150,"line":1019},[148,6115,916],{"emptyLinePlaceholder":704},[148,6117,6118,6120,6123,6125,6127,6129,6131,6133,6135],{"class":150,"line":1028},[148,6119,270],{"class":167},[148,6121,6122],{"class":203}," translation",[148,6124,276],{"class":167},[148,6126,499],{"class":167},[148,6128,5546],{"class":178},[148,6130,5549],{"class":174},[148,6132,296],{"class":178},[148,6134,5788],{"class":299},[148,6136,2971],{"class":178},[148,6138,6139],{"class":150,"line":1034},[148,6140,6141],{"class":178},"  text: summary.payload.result\n",[148,6143,6144,6146,6148],{"class":150,"line":1040},[148,6145,6060],{"class":178},[148,6147,5576],{"class":203},[148,6149,4979],{"class":178},[148,6151,6152],{"class":150,"line":1046},[148,6153,6111],{"class":154},[148,6155,6156],{"class":150,"line":1052},[148,6157,916],{"emptyLinePlaceholder":704},[148,6159,6160],{"class":150,"line":1699},[148,6161,6162],{"class":154},"// All three tasks ran on local models.\n",[148,6164,6165],{"class":150,"line":1705},[148,6166,6167],{"class":154},"// No API keys. No egress. No per-token billing.\n",[12,6169,6170,6171,6174,6175,6178],{},"The orchestrator dispatches by ",[22,6172,6173],{},"what needs doing",", not by ",[22,6176,6177],{},"which model does it",". Add a third model, remove one, restart a worker mid-task -- the system adapts because routing is based on presence, not hardcoded addresses.",[35,6180,6182],{"id":6181},"what-you-get-for-free","What You Get for Free",[6184,6185,4083],"h3",{"id":2792},[12,6187,6188],{},"Every connected worker shows up in real time. You always know which models are online and what they can do.",[135,6190,6191],{},[138,6192,6195],{"className":140,"code":6193,"filename":6194,"language":143,"meta":144,"style":144},"// See every connected agent in real time\nroom.onPresenceChange((agents) => {\n  for (const agent of agents) {\n    console.log(`${agent.name} [${agent.status}]`);\n    console.log(`  capabilities: ${agent.capabilities.join(\", \")}`);\n    console.log(`  model: ${agent.metadata?.model}`);\n  }\n});\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,6196,6197,6202,6220,6238,6272,6306,6334,6338,6342,6346,6351,6356,6361,6366,6371,6376,6381,6386],{"__ignoreMap":144},[148,6198,6199],{"class":150,"line":151},[148,6200,6201],{"class":154},"// See every connected agent in real time\n",[148,6203,6204,6206,6209,6211,6214,6216,6218],{"class":150,"line":158},[148,6205,290],{"class":178},[148,6207,6208],{"class":174},"onPresenceChange",[148,6210,3298],{"class":178},[148,6212,6213],{"class":185},"agents",[148,6215,1142],{"class":178},[148,6217,1145],{"class":167},[148,6219,179],{"class":178},[148,6221,6222,6225,6227,6229,6232,6235],{"class":150,"line":164},[148,6223,6224],{"class":167},"  for",[148,6226,114],{"class":178},[148,6228,270],{"class":167},[148,6230,6231],{"class":203}," agent",[148,6233,6234],{"class":167}," of",[148,6236,6237],{"class":178}," agents) {\n",[148,6239,6240,6243,6245,6247,6249,6252,6254,6257,6260,6262,6264,6267,6270],{"class":150,"line":182},[148,6241,6242],{"class":178},"    console.",[148,6244,2110],{"class":174},[148,6246,296],{"class":178},[148,6248,5511],{"class":299},[148,6250,6251],{"class":178},"agent",[148,6253,1562],{"class":299},[148,6255,6256],{"class":178},"name",[148,6258,6259],{"class":299},"} [${",[148,6261,6251],{"class":178},[148,6263,1562],{"class":299},[148,6265,6266],{"class":178},"status",[148,6268,6269],{"class":299},"}]`",[148,6271,5126],{"class":178},[148,6273,6274,6276,6278,6280,6283,6285,6287,6290,6292,6295,6297,6300,6302,6304],{"class":150,"line":195},[148,6275,6242],{"class":178},[148,6277,2110],{"class":174},[148,6279,296],{"class":178},[148,6281,6282],{"class":299},"`  capabilities: ${",[148,6284,6251],{"class":178},[148,6286,1562],{"class":299},[148,6288,6289],{"class":178},"capabilities",[148,6291,1562],{"class":299},[148,6293,6294],{"class":174},"join",[148,6296,296],{"class":299},[148,6298,6299],{"class":299},"\", \"",[148,6301,130],{"class":299},[148,6303,3010],{"class":299},[148,6305,5126],{"class":178},[148,6307,6308,6310,6312,6314,6317,6319,6321,6324,6327,6330,6332],{"class":150,"line":207},[148,6309,6242],{"class":178},[148,6311,2110],{"class":174},[148,6313,296],{"class":178},[148,6315,6316],{"class":299},"`  model: ${",[148,6318,6251],{"class":178},[148,6320,1562],{"class":299},[148,6322,6323],{"class":178},"metadata",[148,6325,6326],{"class":299},"?.",[148,6328,6329],{"class":178},"model",[148,6331,3010],{"class":299},[148,6333,5126],{"class":178},[148,6335,6336],{"class":150,"line":217},[148,6337,1402],{"class":178},[148,6339,6340],{"class":150,"line":227},[148,6341,5090],{"class":178},[148,6343,6344],{"class":150,"line":513},[148,6345,916],{"emptyLinePlaceholder":704},[148,6347,6348],{"class":150,"line":993},[148,6349,6350],{"class":154},"// Output:\n",[148,6352,6353],{"class":150,"line":1004},[148,6354,6355],{"class":154},"// llama-worker [online]\n",[148,6357,6358],{"class":150,"line":1013},[148,6359,6360],{"class":154},"//   capabilities: summarize, classify\n",[148,6362,6363],{"class":150,"line":1019},[148,6364,6365],{"class":154},"//   model: llama3.2\n",[148,6367,6368],{"class":150,"line":1028},[148,6369,6370],{"class":154},"// mistral-worker [online]\n",[148,6372,6373],{"class":150,"line":1034},[148,6374,6375],{"class":154},"//   capabilities: extract-entities, translate\n",[148,6377,6378],{"class":150,"line":1040},[148,6379,6380],{"class":154},"//   model: mistral\n",[148,6382,6383],{"class":150,"line":1046},[148,6384,6385],{"class":154},"// orchestrator [online]\n",[148,6387,6388],{"class":150,"line":1052},[148,6389,6390],{"class":154},"//   capabilities: (none)\n",[12,6392,6393],{},"The NoLag portal dashboard shows this same view graphically -- no custom monitoring needed.",[6184,6395,6397],{"id":6396},"reconnection","Reconnection",[12,6399,6400],{},"If a worker disconnects (machine reboots, network blip, Ollama restart), the SDK reconnects automatically. Tasks dispatched while the worker was offline are held in the queue and delivered when it comes back. No messages lost.",[6184,6402,6404],{"id":6403},"load-balancing","Load Balancing",[12,6406,6407],{},"Run two instances of the same worker on two machines. NoLag distributes tasks across them with load-balanced subscriptions. Scale by adding processes, not by changing code.",[6184,6409,6411],{"id":6410},"observability","Observability",[12,6413,6414],{},"Every task dispatch and result flows through NoLag's event system. The portal's Agents tab shows the topology: which agents are connected, task flow between them, and aggregate metrics. Attach an observer agent to log everything to your own system if you need it.",[35,6416,6418],{"id":6417},"why-not-a-framework","Why Not a Framework?",[12,6420,6421],{},"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:",[597,6423,6424,6427,6430,6433],{},[600,6425,6426],{},"Agents can't run on different machines",[600,6428,6429],{},"You can't scale workers independently",[600,6431,6432],{},"If the coordinator crashes, everything stops",[600,6434,6435],{},"Adding a human approval step means hacking the framework's internals",[12,6437,6438],{},"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,6440,6441],{"id":1848},"Next Steps",[597,6443,6444,6455,6461,6467],{},[600,6445,6446,6449,6450,6454],{},[30,6447,6448],{},"Add a cloud model"," -- see ",[667,6451,6453],{"href":6452},"/blog/hybrid-llm-orchestration","Hybrid LLM Coordination"," for mixing local and proprietary models",[600,6456,6457,6460],{},[30,6458,6459],{},"Add human approval"," -- gate sensitive actions with the Approve pattern before they execute",[600,6462,6463,6466],{},[30,6464,6465],{},"Share state"," -- use the Blackboard pattern to track model performance metrics across all workers",[600,6468,6469,4872,6472],{},[30,6470,6471],{},"Read the docs",[667,6473,6475],{"href":6474},"/docs/agents/getting-started","@nolag/agents getting started guide",[12,6477,6478],{},"Your local model is the smart part. NoLag is the dumb pipe that makes it reachable. That's the whole idea.",[597,6480,6481,6485],{},[600,6482,6483],{},[667,6484,6453],{"href":6452},[600,6486,6487],{},[667,6488,6489],{"href":6474},"Agents Docs",[684,6491,6492],{},"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":158,"depth":158,"links":6494},[6495,6496,6497,6498,6499,6500,6506,6507],{"id":4839,"depth":158,"text":4840},{"id":4863,"depth":158,"text":4864},{"id":4902,"depth":158,"text":4903},{"id":5341,"depth":158,"text":5342},{"id":5653,"depth":158,"text":5654},{"id":6181,"depth":158,"text":6182,"children":6501},[6502,6503,6504,6505],{"id":2792,"depth":164,"text":4083},{"id":6396,"depth":164,"text":6397},{"id":6403,"depth":164,"text":6404},{"id":6410,"depth":164,"text":6411},{"id":6417,"depth":158,"text":6418},{"id":1848,"depth":158,"text":6441},"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":4828,"description":6510},"blog/connect-local-llm","9yUuHuux27hjwowssSH1Ne9bEL8aaChoNwQ15exzBE4",{"id":6517,"title":6518,"author":7,"body":6519,"category":1518,"date":8253,"description":8254,"excerpt":701,"extension":702,"meta":8255,"navigation":704,"path":6452,"readTime":1522,"seo":8256,"stem":8257,"__hash__":8258},"blog/blog/hybrid-llm-orchestration.md","Hybrid LLM Coordination: Mixing Local and Proprietary Models",{"type":9,"value":6520,"toc":8240},[6521,6524,6527,6531,6534,6721,6724,6756,6760,6770,7022,7032,7036,7039,7359,7362,7388,7392,7395,7733,7736,7740,7743,7837,7841,7844,7847,8035,8038,8042,8045,8125,8128,8132,8135,8161,8165,8168,8182,8185,8189,8212,8215,8219,8222,8225,8238],[12,6522,6523],{},"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,6525,6526],{},"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,6528,6530],{"id":6529},"why-hybrid-is-hard","Why Hybrid Is Hard",[12,6532,6533],{},"The naive approach hardcodes model assignments into the orchestrator:",[135,6535,6536],{},[138,6537,6539],{"className":140,"code":6538,"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,6540,6541,6546,6566,6571,6591,6595,6600,6617,6621,6626,6644,6648,6653,6672,6676,6683,6687,6691,6696,6701,6706,6711,6716],{"__ignoreMap":144},[148,6542,6543],{"class":150,"line":151},[148,6544,6545],{"class":154},"// The tightly-coupled approach\n",[148,6547,6548,6550,6552,6555,6557,6560,6562,6564],{"class":150,"line":158},[148,6549,1129],{"class":167},[148,6551,418],{"class":167},[148,6553,6554],{"class":174}," processDocument",[148,6556,296],{"class":178},[148,6558,6559],{"class":185},"doc",[148,6561,189],{"class":167},[148,6563,4208],{"class":203},[148,6565,4238],{"class":178},[148,6567,6568],{"class":150,"line":164},[148,6569,6570],{"class":154},"  // Classification - fast, no data leaves your network\n",[148,6572,6573,6575,6578,6580,6582,6585,6588],{"class":150,"line":182},[148,6574,491],{"class":167},[148,6576,6577],{"class":203}," category",[148,6579,276],{"class":167},[148,6581,499],{"class":167},[148,6583,6584],{"class":178}," localLlama.",[148,6586,6587],{"class":174},"classify",[148,6589,6590],{"class":178},"(doc);\n",[148,6592,6593],{"class":150,"line":195},[148,6594,916],{"emptyLinePlaceholder":704},[148,6596,6597],{"class":150,"line":207},[148,6598,6599],{"class":154},"  // Summarization - needs strong reasoning\n",[148,6601,6602,6604,6606,6608,6610,6613,6615],{"class":150,"line":217},[148,6603,491],{"class":167},[148,6605,6036],{"class":203},[148,6607,276],{"class":167},[148,6609,499],{"class":167},[148,6611,6612],{"class":178}," openai.",[148,6614,5195],{"class":174},[148,6616,6590],{"class":178},[148,6618,6619],{"class":150,"line":227},[148,6620,916],{"emptyLinePlaceholder":704},[148,6622,6623],{"class":150,"line":513},[148,6624,6625],{"class":154},"  // Entity extraction - privacy-sensitive, run locally\n",[148,6627,6628,6630,6632,6634,6636,6639,6642],{"class":150,"line":993},[148,6629,491],{"class":167},[148,6631,6080],{"class":203},[148,6633,276],{"class":167},[148,6635,499],{"class":167},[148,6637,6638],{"class":178}," localMistral.",[148,6640,6641],{"class":174},"extract",[148,6643,6590],{"class":178},[148,6645,6646],{"class":150,"line":1004},[148,6647,916],{"emptyLinePlaceholder":704},[148,6649,6650],{"class":150,"line":1013},[148,6651,6652],{"class":154},"  // Final synthesis - best model wins\n",[148,6654,6655,6657,6660,6662,6664,6667,6669],{"class":150,"line":1019},[148,6656,491],{"class":167},[148,6658,6659],{"class":203}," report",[148,6661,276],{"class":167},[148,6663,499],{"class":167},[148,6665,6666],{"class":178}," anthropic.",[148,6668,3174],{"class":174},[148,6670,6671],{"class":178},"(summary, entities);\n",[148,6673,6674],{"class":150,"line":1028},[148,6675,916],{"emptyLinePlaceholder":704},[148,6677,6678,6680],{"class":150,"line":1034},[148,6679,3090],{"class":167},[148,6681,6682],{"class":178}," report;\n",[148,6684,6685],{"class":150,"line":1040},[148,6686,230],{"class":178},[148,6688,6689],{"class":150,"line":1046},[148,6690,916],{"emptyLinePlaceholder":704},[148,6692,6693],{"class":150,"line":1052},[148,6694,6695],{"class":154},"// Problems:\n",[148,6697,6698],{"class":150,"line":1699},[148,6699,6700],{"class":154},"// - Every model is hardcoded in the orchestrator\n",[148,6702,6703],{"class":150,"line":1705},[148,6704,6705],{"class":154},"// - Swapping a model means changing orchestration logic\n",[148,6707,6708],{"class":150,"line":1711},[148,6709,6710],{"class":154},"// - Can't scale local and cloud independently\n",[148,6712,6713],{"class":150,"line":1717},[148,6714,6715],{"class":154},"// - No visibility into which model handled what\n",[148,6717,6718],{"class":150,"line":1723},[148,6719,6720],{"class":154},"// - If the local model server goes down, everything stops\n",[12,6722,6723],{},"This works in a prototype. In production it falls apart:",[597,6725,6726,6732,6738,6744,6750],{},[600,6727,6728,6731],{},[30,6729,6730],{},"Model coupling"," -- swapping Llama for Mistral means changing orchestration code, not just a config value",[600,6733,6734,6737],{},[30,6735,6736],{},"No independent scaling"," -- your local GPU box and your cloud API calls are locked to the same process",[600,6739,6740,6743],{},[30,6741,6742],{},"No fault isolation"," -- if Ollama crashes, the entire pipeline stops, even for tasks that use cloud models",[600,6745,6746,6749],{},[30,6747,6748],{},"No visibility"," -- you can't see which model handled which task, what the latency was, or where errors occurred",[600,6751,6752,6755],{},[30,6753,6754],{},"No dynamic routing"," -- you can't shift traffic between local and cloud based on load, latency, or error rate",[35,6757,6759],{"id":6758},"the-decoupled-architecture","The Decoupled Architecture",[12,6761,6762,6763,6765,6766,6769],{},"The fix is to separate ",[22,6764,6173],{}," from ",[22,6767,6768],{},"who does it",". The orchestrator dispatches tasks by capability (\"classify\", \"summarize\", \"extract-entities\"). Workers advertise their capabilities via presence. NoLag routes tasks to capable workers automatically.",[135,6771,6772],{},[138,6773,6775],{"className":140,"code":6774,"filename":142,"language":143,"meta":144,"style":144},"import { NoLagAgents, Handoff } from \"@nolag/agents\";\n\n// Orchestrator - dispatches by capability, not by model\nconst orchestrator = new NoLagAgents(TOKEN, {\n  appName: \"doc-pipeline\",\n  agentId: \"orchestrator\",\n  presence: { name: \"orchestrator\", role: \"orchestrator\" },\n});\nawait orchestrator.connect();\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 to verify a capable agent is connected.\nconst result = await handoff.dispatch(\"classify\", { document: doc }, {\n  priority: \"high\",\n  waitForResult: true,\n  timeout: 30000,\n});\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,6776,6777,6789,6793,6798,6816,6825,6833,6845,6849,6859,6878,6892,6896,6901,6920,6938,6942,6947,6952,6973,6983,6991,6999,7003,7007,7012,7017],{"__ignoreMap":144},[148,6778,6779,6781,6783,6785,6787],{"class":150,"line":151},[148,6780,902],{"class":167},[148,6782,4930],{"class":178},[148,6784,908],{"class":167},[148,6786,4935],{"class":299},[148,6788,4938],{"class":178},[148,6790,6791],{"class":150,"line":158},[148,6792,916],{"emptyLinePlaceholder":704},[148,6794,6795],{"class":150,"line":164},[148,6796,6797],{"class":154},"// Orchestrator - dispatches by capability, not by model\n",[148,6799,6800,6802,6804,6806,6808,6810,6812,6814],{"class":150,"line":182},[148,6801,270],{"class":167},[148,6803,5376],{"class":203},[148,6805,276],{"class":167},[148,6807,279],{"class":167},[148,6809,4997],{"class":174},[148,6811,296],{"class":178},[148,6813,5002],{"class":203},[148,6815,2971],{"class":178},[148,6817,6818,6820,6823],{"class":150,"line":195},[148,6819,5009],{"class":178},[148,6821,6822],{"class":299},"\"doc-pipeline\"",[148,6824,2982],{"class":178},[148,6826,6827,6829,6831],{"class":150,"line":207},[148,6828,5019],{"class":178},[148,6830,5403],{"class":299},[148,6832,2982],{"class":178},[148,6834,6835,6837,6839,6841,6843],{"class":150,"line":217},[148,6836,5410],{"class":178},[148,6838,5403],{"class":299},[148,6840,5415],{"class":178},[148,6842,5403],{"class":299},[148,6844,5081],{"class":178},[148,6846,6847],{"class":150,"line":227},[148,6848,5090],{"class":178},[148,6850,6851,6853,6855,6857],{"class":150,"line":513},[148,6852,943],{"class":167},[148,6854,5434],{"class":178},[148,6856,2204],{"class":174},[148,6858,5106],{"class":178},[148,6860,6861,6863,6865,6867,6869,6871,6873,6876],{"class":150,"line":993},[148,6862,270],{"class":167},[148,6864,2217],{"class":203},[148,6866,276],{"class":167},[148,6868,5434],{"class":178},[148,6870,1101],{"class":174},[148,6872,296],{"class":178},[148,6874,6875],{"class":299},"\"processing\"",[148,6877,5126],{"class":178},[148,6879,6880,6882,6884,6886,6888,6890],{"class":150,"line":1004},[148,6881,270],{"class":167},[148,6883,494],{"class":203},[148,6885,276],{"class":167},[148,6887,279],{"class":167},[148,6889,1095],{"class":174},[148,6891,5141],{"class":178},[148,6893,6894],{"class":150,"line":1013},[148,6895,916],{"emptyLinePlaceholder":704},[148,6897,6898],{"class":150,"line":1019},[148,6899,6900],{"class":154},"// Service discovery: see what's available\n",[148,6902,6903,6905,6908,6910,6912,6914,6916,6918],{"class":150,"line":1028},[148,6904,270],{"class":167},[148,6906,6907],{"class":203}," classifiers",[148,6909,276],{"class":167},[148,6911,5491],{"class":178},[148,6913,5494],{"class":174},[148,6915,296],{"class":178},[148,6917,5061],{"class":299},[148,6919,5126],{"class":178},[148,6921,6922,6924,6926,6928,6931,6934,6936],{"class":150,"line":1034},[148,6923,5320],{"class":178},[148,6925,2110],{"class":174},[148,6927,296],{"class":178},[148,6929,6930],{"class":299},"\"Classifiers online:\"",[148,6932,6933],{"class":178},", classifiers.",[148,6935,5518],{"class":203},[148,6937,5126],{"class":178},[148,6939,6940],{"class":150,"line":1040},[148,6941,916],{"emptyLinePlaceholder":704},[148,6943,6944],{"class":150,"line":1046},[148,6945,6946],{"class":154},"// Dispatch tasks by what needs doing, not how.\n",[148,6948,6949],{"class":150,"line":1052},[148,6950,6951],{"class":154},"// The SDK checks presence to verify a capable agent is connected.\n",[148,6953,6954,6956,6958,6960,6962,6964,6966,6968,6970],{"class":150,"line":1699},[148,6955,270],{"class":167},[148,6957,5539],{"class":203},[148,6959,276],{"class":167},[148,6961,499],{"class":167},[148,6963,5546],{"class":178},[148,6965,5549],{"class":174},[148,6967,296],{"class":178},[148,6969,5061],{"class":299},[148,6971,6972],{"class":178},", { document: doc }, {\n",[148,6974,6975,6978,6981],{"class":150,"line":1705},[148,6976,6977],{"class":178},"  priority: ",[148,6979,6980],{"class":299},"\"high\"",[148,6982,2982],{"class":178},[148,6984,6985,6987,6989],{"class":150,"line":1711},[148,6986,5573],{"class":178},[148,6988,5576],{"class":203},[148,6990,2982],{"class":178},[148,6992,6993,6995,6997],{"class":150,"line":1717},[148,6994,5583],{"class":178},[148,6996,5586],{"class":203},[148,6998,2982],{"class":178},[148,7000,7001],{"class":150,"line":1723},[148,7002,5090],{"class":178},[148,7004,7005],{"class":150,"line":1729},[148,7006,916],{"emptyLinePlaceholder":704},[148,7008,7009],{"class":150,"line":1735},[148,7010,7011],{"class":154},"// A local Llama worker picks up \"classify\" tasks.\n",[148,7013,7014],{"class":150,"line":1741},[148,7015,7016],{"class":154},"// A Claude worker picks up \"synthesize\" tasks.\n",[148,7018,7019],{"class":150,"line":1746},[148,7020,7021],{"class":154},"// Workers self-register with their capabilities via presence.\n",[12,7023,7024,7025,7027,7028,7031],{},"The orchestrator never imports ",[84,7026,5649],{}," or ",[84,7029,7030],{},"@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.",[35,7033,7035],{"id":7034},"the-local-worker","The Local Worker",[12,7037,7038],{},"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,7040,7041],{},[138,7042,7044],{"className":140,"code":7043,"filename":142,"language":143,"meta":144,"style":144},"import { NoLagAgents, Handoff } from \"@nolag/agents\";\nimport { Ollama } from \"ollama\";\n\nconst ollama = new Ollama({ host: \"http://localhost:11434\" });\n\nconst worker = new NoLagAgents(TOKEN, {\n  appName: \"doc-pipeline\",\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 worker.connect();\nconst room = worker.room(\"processing\");\nconst handoff = new Handoff(room);\n\n// The SDK auto-filters: only tasks matching these capabilities arrive\nhandoff.onTask([\"classify\", \"extract-entities\"], async (task, respond) => {\n  const response = await ollama.chat({\n    model: \"llama3.2\",\n    messages: [{ role: \"user\", content: 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,7045,7046,7058,7070,7074,7092,7096,7114,7122,7131,7135,7143,7151,7163,7175,7179,7183,7193,7211,7225,7229,7234,7266,7282,7290,7300,7304,7308,7318,7322,7330,7339,7351,7355],{"__ignoreMap":144},[148,7047,7048,7050,7052,7054,7056],{"class":150,"line":151},[148,7049,902],{"class":167},[148,7051,4930],{"class":178},[148,7053,908],{"class":167},[148,7055,4935],{"class":299},[148,7057,4938],{"class":178},[148,7059,7060,7062,7064,7066,7068],{"class":150,"line":158},[148,7061,902],{"class":167},[148,7063,4945],{"class":178},[148,7065,908],{"class":167},[148,7067,4950],{"class":299},[148,7069,4938],{"class":178},[148,7071,7072],{"class":150,"line":164},[148,7073,916],{"emptyLinePlaceholder":704},[148,7075,7076,7078,7080,7082,7084,7086,7088,7090],{"class":150,"line":182},[148,7077,270],{"class":167},[148,7079,4963],{"class":203},[148,7081,276],{"class":167},[148,7083,279],{"class":167},[148,7085,4970],{"class":174},[148,7087,4973],{"class":178},[148,7089,4976],{"class":299},[148,7091,4979],{"class":178},[148,7093,7094],{"class":150,"line":195},[148,7095,916],{"emptyLinePlaceholder":704},[148,7097,7098,7100,7102,7104,7106,7108,7110,7112],{"class":150,"line":207},[148,7099,270],{"class":167},[148,7101,4990],{"class":203},[148,7103,276],{"class":167},[148,7105,279],{"class":167},[148,7107,4997],{"class":174},[148,7109,296],{"class":178},[148,7111,5002],{"class":203},[148,7113,2971],{"class":178},[148,7115,7116,7118,7120],{"class":150,"line":217},[148,7117,5009],{"class":178},[148,7119,6822],{"class":299},[148,7121,2982],{"class":178},[148,7123,7124,7126,7129],{"class":150,"line":227},[148,7125,5019],{"class":178},[148,7127,7128],{"class":299},"\"local-classifier\"",[148,7130,2982],{"class":178},[148,7132,7133],{"class":150,"line":513},[148,7134,5029],{"class":178},[148,7136,7137,7139,7141],{"class":150,"line":993},[148,7138,5034],{"class":178},[148,7140,7128],{"class":299},[148,7142,2982],{"class":178},[148,7144,7145,7147,7149],{"class":150,"line":1004},[148,7146,5043],{"class":178},[148,7148,5046],{"class":299},[148,7150,2982],{"class":178},[148,7152,7153,7155,7157,7159,7161],{"class":150,"line":1013},[148,7154,5053],{"class":178},[148,7156,5061],{"class":299},[148,7158,87],{"class":178},[148,7160,5783],{"class":299},[148,7162,5064],{"class":178},[148,7164,7165,7167,7169,7171,7173],{"class":150,"line":1019},[148,7166,5069],{"class":178},[148,7168,5072],{"class":299},[148,7170,5075],{"class":178},[148,7172,5078],{"class":299},[148,7174,5081],{"class":178},[148,7176,7177],{"class":150,"line":1028},[148,7178,1043],{"class":178},[148,7180,7181],{"class":150,"line":1034},[148,7182,5090],{"class":178},[148,7184,7185,7187,7189,7191],{"class":150,"line":1040},[148,7186,943],{"class":167},[148,7188,5101],{"class":178},[148,7190,2204],{"class":174},[148,7192,5106],{"class":178},[148,7194,7195,7197,7199,7201,7203,7205,7207,7209],{"class":150,"line":1046},[148,7196,270],{"class":167},[148,7198,2217],{"class":203},[148,7200,276],{"class":167},[148,7202,5101],{"class":178},[148,7204,1101],{"class":174},[148,7206,296],{"class":178},[148,7208,6875],{"class":299},[148,7210,5126],{"class":178},[148,7212,7213,7215,7217,7219,7221,7223],{"class":150,"line":1052},[148,7214,270],{"class":167},[148,7216,494],{"class":203},[148,7218,276],{"class":167},[148,7220,279],{"class":167},[148,7222,1095],{"class":174},[148,7224,5141],{"class":178},[148,7226,7227],{"class":150,"line":1699},[148,7228,916],{"emptyLinePlaceholder":704},[148,7230,7231],{"class":150,"line":1705},[148,7232,7233],{"class":154},"// The SDK auto-filters: only tasks matching these capabilities arrive\n",[148,7235,7236,7238,7240,7242,7244,7246,7248,7250,7252,7254,7256,7258,7260,7262,7264],{"class":150,"line":1711},[148,7237,5150],{"class":178},[148,7239,1117],{"class":174},[148,7241,1120],{"class":178},[148,7243,5061],{"class":299},[148,7245,87],{"class":178},[148,7247,5783],{"class":299},[148,7249,1126],{"class":178},[148,7251,1129],{"class":167},[148,7253,114],{"class":178},[148,7255,1134],{"class":185},[148,7257,87],{"class":178},[148,7259,1139],{"class":185},[148,7261,1142],{"class":178},[148,7263,1145],{"class":167},[148,7265,179],{"class":178},[148,7267,7268,7270,7272,7274,7276,7278,7280],{"class":150,"line":1717},[148,7269,491],{"class":167},[148,7271,5185],{"class":203},[148,7273,276],{"class":167},[148,7275,499],{"class":167},[148,7277,5192],{"class":178},[148,7279,5195],{"class":174},[148,7281,972],{"class":178},[148,7283,7284,7286,7288],{"class":150,"line":1723},[148,7285,5202],{"class":178},[148,7287,5072],{"class":299},[148,7289,2982],{"class":178},[148,7291,7292,7295,7297],{"class":150,"line":1729},[148,7293,7294],{"class":178},"    messages: [{ role: ",[148,7296,5244],{"class":299},[148,7298,7299],{"class":178},", content: task.payload.document }],\n",[148,7301,7302],{"class":150,"line":1735},[148,7303,5257],{"class":178},[148,7305,7306],{"class":150,"line":1741},[148,7307,916],{"emptyLinePlaceholder":704},[148,7309,7310,7312,7314,7316],{"class":150,"line":1746},[148,7311,5266],{"class":174},[148,7313,296],{"class":178},[148,7315,5271],{"class":299},[148,7317,2971],{"class":178},[148,7319,7320],{"class":150,"line":1752},[148,7321,5278],{"class":178},[148,7323,7324,7326,7328],{"class":150,"line":1757},[148,7325,5202],{"class":178},[148,7327,5072],{"class":299},[148,7329,2982],{"class":178},[148,7331,7332,7335,7337],{"class":150,"line":1763},[148,7333,7334],{"class":178},"    provider: ",[148,7336,5078],{"class":299},[148,7338,2982],{"class":178},[148,7340,7341,7343,7345,7347,7349],{"class":150,"line":1769},[148,7342,5291],{"class":178},[148,7344,5294],{"class":174},[148,7346,5297],{"class":178},[148,7348,5300],{"class":167},[148,7350,5303],{"class":178},[148,7352,7353],{"class":150,"line":1774},[148,7354,5257],{"class":178},[148,7356,7357],{"class":150,"line":3517},[148,7358,5090],{"class":178},[12,7360,7361],{},"Key properties of this worker:",[597,7363,7364,7370,7376,7382],{},[600,7365,7366,7369],{},[30,7367,7368],{},"Data never leaves your network"," -- the document is sent to Ollama on localhost, not to an external API",[600,7371,7372,7375],{},[30,7373,7374],{},"Presence-based discovery"," -- the orchestrator (and the portal dashboard) see this worker appear the moment it connects, with its model and capabilities visible",[600,7377,7378,7381],{},[30,7379,7380],{},"Selective task claiming"," -- the worker only processes tasks matching its capabilities, ignoring others",[600,7383,7384,7387],{},[30,7385,7386],{},"Result metadata"," -- every result includes the model, provider, and latency for observability",[35,7389,7391],{"id":7390},"the-cloud-worker","The Cloud Worker",[12,7393,7394],{},"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,7396,7397],{},[138,7398,7400],{"className":140,"code":7399,"filename":142,"language":143,"meta":144,"style":144},"import { NoLagAgents, Handoff } from \"@nolag/agents\";\nimport Anthropic from \"@anthropic-ai/sdk\";\n\nconst anthropic = new Anthropic();\n\nconst worker = new NoLagAgents(TOKEN, {\n  appName: \"doc-pipeline\",\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 worker.connect();\nconst room = worker.room(\"processing\");\nconst handoff = new Handoff(room);\n\n// The SDK auto-filters: only tasks matching these capabilities arrive\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: 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,7401,7402,7414,7428,7432,7448,7452,7470,7478,7487,7491,7499,7507,7520,7534,7538,7542,7552,7570,7584,7588,7592,7624,7642,7650,7660,7668,7672,7676,7686,7697,7705,7713,7725,7729],{"__ignoreMap":144},[148,7403,7404,7406,7408,7410,7412],{"class":150,"line":151},[148,7405,902],{"class":167},[148,7407,4930],{"class":178},[148,7409,908],{"class":167},[148,7411,4935],{"class":299},[148,7413,4938],{"class":178},[148,7415,7416,7418,7421,7423,7426],{"class":150,"line":158},[148,7417,902],{"class":167},[148,7419,7420],{"class":178}," Anthropic ",[148,7422,908],{"class":167},[148,7424,7425],{"class":299}," \"@anthropic-ai/sdk\"",[148,7427,4938],{"class":178},[148,7429,7430],{"class":150,"line":164},[148,7431,916],{"emptyLinePlaceholder":704},[148,7433,7434,7436,7439,7441,7443,7446],{"class":150,"line":182},[148,7435,270],{"class":167},[148,7437,7438],{"class":203}," anthropic",[148,7440,276],{"class":167},[148,7442,279],{"class":167},[148,7444,7445],{"class":174}," Anthropic",[148,7447,5106],{"class":178},[148,7449,7450],{"class":150,"line":195},[148,7451,916],{"emptyLinePlaceholder":704},[148,7453,7454,7456,7458,7460,7462,7464,7466,7468],{"class":150,"line":207},[148,7455,270],{"class":167},[148,7457,4990],{"class":203},[148,7459,276],{"class":167},[148,7461,279],{"class":167},[148,7463,4997],{"class":174},[148,7465,296],{"class":178},[148,7467,5002],{"class":203},[148,7469,2971],{"class":178},[148,7471,7472,7474,7476],{"class":150,"line":217},[148,7473,5009],{"class":178},[148,7475,6822],{"class":299},[148,7477,2982],{"class":178},[148,7479,7480,7482,7485],{"class":150,"line":227},[148,7481,5019],{"class":178},[148,7483,7484],{"class":299},"\"cloud-synthesizer\"",[148,7486,2982],{"class":178},[148,7488,7489],{"class":150,"line":513},[148,7490,5029],{"class":178},[148,7492,7493,7495,7497],{"class":150,"line":993},[148,7494,5034],{"class":178},[148,7496,7484],{"class":299},[148,7498,2982],{"class":178},[148,7500,7501,7503,7505],{"class":150,"line":1004},[148,7502,5043],{"class":178},[148,7504,5046],{"class":299},[148,7506,2982],{"class":178},[148,7508,7509,7511,7513,7515,7518],{"class":150,"line":1013},[148,7510,5053],{"class":178},[148,7512,5056],{"class":299},[148,7514,87],{"class":178},[148,7516,7517],{"class":299},"\"synthesize\"",[148,7519,5064],{"class":178},[148,7521,7522,7524,7527,7529,7532],{"class":150,"line":1019},[148,7523,5069],{"class":178},[148,7525,7526],{"class":299},"\"claude-sonnet-4-6\"",[148,7528,5075],{"class":178},[148,7530,7531],{"class":299},"\"anthropic\"",[148,7533,5081],{"class":178},[148,7535,7536],{"class":150,"line":1028},[148,7537,1043],{"class":178},[148,7539,7540],{"class":150,"line":1034},[148,7541,5090],{"class":178},[148,7543,7544,7546,7548,7550],{"class":150,"line":1040},[148,7545,943],{"class":167},[148,7547,5101],{"class":178},[148,7549,2204],{"class":174},[148,7551,5106],{"class":178},[148,7553,7554,7556,7558,7560,7562,7564,7566,7568],{"class":150,"line":1046},[148,7555,270],{"class":167},[148,7557,2217],{"class":203},[148,7559,276],{"class":167},[148,7561,5101],{"class":178},[148,7563,1101],{"class":174},[148,7565,296],{"class":178},[148,7567,6875],{"class":299},[148,7569,5126],{"class":178},[148,7571,7572,7574,7576,7578,7580,7582],{"class":150,"line":1052},[148,7573,270],{"class":167},[148,7575,494],{"class":203},[148,7577,276],{"class":167},[148,7579,279],{"class":167},[148,7581,1095],{"class":174},[148,7583,5141],{"class":178},[148,7585,7586],{"class":150,"line":1699},[148,7587,916],{"emptyLinePlaceholder":704},[148,7589,7590],{"class":150,"line":1705},[148,7591,7233],{"class":154},[148,7593,7594,7596,7598,7600,7602,7604,7606,7608,7610,7612,7614,7616,7618,7620,7622],{"class":150,"line":1711},[148,7595,5150],{"class":178},[148,7597,1117],{"class":174},[148,7599,1120],{"class":178},[148,7601,5056],{"class":299},[148,7603,87],{"class":178},[148,7605,7517],{"class":299},[148,7607,1126],{"class":178},[148,7609,1129],{"class":167},[148,7611,114],{"class":178},[148,7613,1134],{"class":185},[148,7615,87],{"class":178},[148,7617,1139],{"class":185},[148,7619,1142],{"class":178},[148,7621,1145],{"class":167},[148,7623,179],{"class":178},[148,7625,7626,7628,7630,7632,7634,7637,7640],{"class":150,"line":1717},[148,7627,491],{"class":167},[148,7629,5185],{"class":203},[148,7631,276],{"class":167},[148,7633,499],{"class":167},[148,7635,7636],{"class":178}," anthropic.messages.",[148,7638,7639],{"class":174},"create",[148,7641,972],{"class":178},[148,7643,7644,7646,7648],{"class":150,"line":1723},[148,7645,5202],{"class":178},[148,7647,7526],{"class":299},[148,7649,2982],{"class":178},[148,7651,7652,7655,7658],{"class":150,"line":1729},[148,7653,7654],{"class":178},"    max_tokens: ",[148,7656,7657],{"class":203},"4096",[148,7659,2982],{"class":178},[148,7661,7662,7664,7666],{"class":150,"line":1735},[148,7663,7294],{"class":178},[148,7665,5244],{"class":299},[148,7667,7299],{"class":178},[148,7669,7670],{"class":150,"line":1741},[148,7671,5257],{"class":178},[148,7673,7674],{"class":150,"line":1746},[148,7675,916],{"emptyLinePlaceholder":704},[148,7677,7678,7680,7682,7684],{"class":150,"line":1752},[148,7679,5266],{"class":174},[148,7681,296],{"class":178},[148,7683,5271],{"class":299},[148,7685,2971],{"class":178},[148,7687,7688,7691,7694],{"class":150,"line":1757},[148,7689,7690],{"class":178},"    result: response.content[",[148,7692,7693],{"class":203},"0",[148,7695,7696],{"class":178},"].text,\n",[148,7698,7699,7701,7703],{"class":150,"line":1763},[148,7700,5202],{"class":178},[148,7702,7526],{"class":299},[148,7704,2982],{"class":178},[148,7706,7707,7709,7711],{"class":150,"line":1769},[148,7708,7334],{"class":178},[148,7710,7531],{"class":299},[148,7712,2982],{"class":178},[148,7714,7715,7717,7719,7721,7723],{"class":150,"line":1774},[148,7716,5291],{"class":178},[148,7718,5294],{"class":174},[148,7720,5297],{"class":178},[148,7722,5300],{"class":167},[148,7724,5303],{"class":178},[148,7726,7727],{"class":150,"line":3517},[148,7728,5257],{"class":178},[148,7730,7731],{"class":150,"line":3522},[148,7732,5090],{"class":178},[12,7734,7735],{},"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,7737,7739],{"id":7738},"when-to-use-which-model","When to Use Which Model",[12,7741,7742],{},"Here's a practical routing guide:",[40,7744,7745,7758],{},[43,7746,7747],{},[46,7748,7749,7752,7755],{},[49,7750,7751],{},"Task Type",[49,7753,7754],{},"Model",[49,7756,7757],{},"Why",[59,7759,7760,7771,7782,7793,7804,7815,7826],{},[46,7761,7762,7765,7768],{},[64,7763,7764],{},"Classification / tagging",[64,7766,7767],{},"Local (Llama 3, Mistral)",[64,7769,7770],{},"Fast, cheap, accurate enough for categorical outputs",[46,7772,7773,7776,7779],{},[64,7774,7775],{},"Entity extraction",[64,7777,7778],{},"Local (fine-tuned)",[64,7780,7781],{},"Privacy-sensitive data stays on-prem",[46,7783,7784,7787,7790],{},[64,7785,7786],{},"Summarization",[64,7788,7789],{},"Cloud (Claude, GPT-4)",[64,7791,7792],{},"Needs strong reasoning and coherent long-form output",[46,7794,7795,7798,7801],{},[64,7796,7797],{},"Code generation",[64,7799,7800],{},"Cloud (Claude, Codex)",[64,7802,7803],{},"Frontier models significantly outperform local for complex code",[46,7805,7806,7809,7812],{},[64,7807,7808],{},"Translation",[64,7810,7811],{},"Local (NLLB, Madlad)",[64,7813,7814],{},"Specialized translation models are fast and good",[46,7816,7817,7820,7823],{},[64,7818,7819],{},"Embedding / search",[64,7821,7822],{},"Local (Nomic, BGE)",[64,7824,7825],{},"Embedding models are small and latency-sensitive",[46,7827,7828,7831,7834],{},[64,7829,7830],{},"Complex reasoning",[64,7832,7833],{},"Cloud (Claude Opus, o1)",[64,7835,7836],{},"Multi-step reasoning is where frontier models shine",[35,7838,7840],{"id":7839},"shared-state-the-blackboard","Shared State: The Blackboard",[12,7842,7843],{},"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,7845,7846],{},"The NoLag blackboard pattern gives every agent a shared key-value store, updated in real time:",[135,7848,7849],{},[138,7850,7852],{"className":140,"code":7851,"filename":142,"language":143,"meta":144,"style":144},"import { Blackboard, createStateEnvelope } from \"@nolag/agents\";\n\nconst blackboard = new Blackboard(room);\n\n// Orchestrator tracks model performance on the blackboard\nroom.on(\"result\", (result) => {\n  const { model, provider, latencyMs } = result.payload;\n  const key = `model-stats:${provider}:${model}`;\n\n  // Publish to shared state (visible to all agents in real time)\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\"\n",[84,7853,7854,7867,7871,7887,7891,7896,7917,7942,7964,7968,7973,7983,7988,7993,7998,8008,8012,8016,8020,8025,8030],{"__ignoreMap":144},[148,7855,7856,7858,7861,7863,7865],{"class":150,"line":151},[148,7857,902],{"class":167},[148,7859,7860],{"class":178}," { Blackboard, createStateEnvelope } ",[148,7862,908],{"class":167},[148,7864,4935],{"class":299},[148,7866,4938],{"class":178},[148,7868,7869],{"class":150,"line":158},[148,7870,916],{"emptyLinePlaceholder":704},[148,7872,7873,7875,7878,7880,7882,7885],{"class":150,"line":164},[148,7874,270],{"class":167},[148,7876,7877],{"class":203}," blackboard",[148,7879,276],{"class":167},[148,7881,279],{"class":167},[148,7883,7884],{"class":174}," Blackboard",[148,7886,5141],{"class":178},[148,7888,7889],{"class":150,"line":182},[148,7890,916],{"emptyLinePlaceholder":704},[148,7892,7893],{"class":150,"line":195},[148,7894,7895],{"class":154},"// Orchestrator tracks model performance on the blackboard\n",[148,7897,7898,7900,7902,7904,7907,7909,7911,7913,7915],{"class":150,"line":207},[148,7899,290],{"class":178},[148,7901,2267],{"class":174},[148,7903,296],{"class":178},[148,7905,7906],{"class":299},"\"result\"",[148,7908,2274],{"class":178},[148,7910,540],{"class":185},[148,7912,1142],{"class":178},[148,7914,1145],{"class":167},[148,7916,179],{"class":178},[148,7918,7919,7921,7923,7925,7927,7930,7932,7935,7937,7939],{"class":150,"line":217},[148,7920,491],{"class":167},[148,7922,1155],{"class":178},[148,7924,6329],{"class":203},[148,7926,87],{"class":178},[148,7928,7929],{"class":203},"provider",[148,7931,87],{"class":178},[148,7933,7934],{"class":203},"latencyMs",[148,7936,1171],{"class":178},[148,7938,1174],{"class":167},[148,7940,7941],{"class":178}," result.payload;\n",[148,7943,7944,7946,7948,7950,7953,7955,7958,7960,7962],{"class":150,"line":227},[148,7945,491],{"class":167},[148,7947,3574],{"class":203},[148,7949,276],{"class":167},[148,7951,7952],{"class":299}," `model-stats:${",[148,7954,7929],{"class":178},[148,7956,7957],{"class":299},"}:${",[148,7959,6329],{"class":178},[148,7961,3010],{"class":299},[148,7963,4938],{"class":178},[148,7965,7966],{"class":150,"line":513},[148,7967,916],{"emptyLinePlaceholder":704},[148,7969,7970],{"class":150,"line":993},[148,7971,7972],{"class":154},"  // Publish to shared state (visible to all agents in real time)\n",[148,7974,7975,7978,7980],{"class":150,"line":1004},[148,7976,7977],{"class":178},"  blackboard.",[148,7979,1994],{"class":174},[148,7981,7982],{"class":178},"(key, {\n",[148,7984,7985],{"class":150,"line":1013},[148,7986,7987],{"class":178},"    latencyMs,\n",[148,7989,7990],{"class":150,"line":1019},[148,7991,7992],{"class":178},"    provider,\n",[148,7994,7995],{"class":150,"line":1028},[148,7996,7997],{"class":178},"    model,\n",[148,7999,8000,8003,8005],{"class":150,"line":1034},[148,8001,8002],{"class":178},"    lastUsed: Date.",[148,8004,5294],{"class":174},[148,8006,8007],{"class":178},"(),\n",[148,8009,8010],{"class":150,"line":1040},[148,8011,5257],{"class":178},[148,8013,8014],{"class":150,"line":1046},[148,8015,5090],{"class":178},[148,8017,8018],{"class":150,"line":1052},[148,8019,916],{"emptyLinePlaceholder":704},[148,8021,8022],{"class":150,"line":1699},[148,8023,8024],{"class":154},"// Any agent can read the blackboard to make routing decisions:\n",[148,8026,8027],{"class":150,"line":1705},[148,8028,8029],{"class":154},"// \"If local model latency > 500ms, fall back to cloud\"\n",[148,8031,8032],{"class":150,"line":1711},[148,8033,8034],{"class":154},"// \"If cloud error rate > 5%, route to backup provider\"\n",[12,8036,8037],{},"This enables dynamic routing decisions. If your local Llama instance starts responding slowly (GPU thermal throttling, memory pressure), the orchestrator can read the blackboard and temporarily route classification tasks to a cloud model instead. When the local model recovers, traffic shifts back automatically.",[35,8039,8041],{"id":8040},"observability-see-everything","Observability: See Everything",[12,8043,8044],{},"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,8046,8047],{},[138,8048,8050],{"className":140,"code":8049,"filename":142,"language":143,"meta":144,"style":144},"// The observer (portal dashboard) sees everything in real time:\n// - Which models are online (presence)\n// - Task dispatch and completion (events)\n// - Model performance metrics (blackboard)\n// - Approval requests for sensitive operations\n\n// In the NoLag portal, the Agents tab shows:\n//\n//   [orchestrator] ---handoff---> [local-classifier]\n//         |                           (llama3.2)\n//         |\n//         +--------handoff---> [cloud-synthesizer]\n//                                   (claude-sonnet-4-6)\n//\n// Metrics: 142 tasks | 97% success | avg 340ms local | avg 1.2s cloud\n",[84,8051,8052,8057,8062,8067,8072,8077,8081,8086,8091,8096,8101,8106,8111,8116,8120],{"__ignoreMap":144},[148,8053,8054],{"class":150,"line":151},[148,8055,8056],{"class":154},"// The observer (portal dashboard) sees everything in real time:\n",[148,8058,8059],{"class":150,"line":158},[148,8060,8061],{"class":154},"// - Which models are online (presence)\n",[148,8063,8064],{"class":150,"line":164},[148,8065,8066],{"class":154},"// - Task dispatch and completion (events)\n",[148,8068,8069],{"class":150,"line":182},[148,8070,8071],{"class":154},"// - Model performance metrics (blackboard)\n",[148,8073,8074],{"class":150,"line":195},[148,8075,8076],{"class":154},"// - Approval requests for sensitive operations\n",[148,8078,8079],{"class":150,"line":207},[148,8080,916],{"emptyLinePlaceholder":704},[148,8082,8083],{"class":150,"line":217},[148,8084,8085],{"class":154},"// In the NoLag portal, the Agents tab shows:\n",[148,8087,8088],{"class":150,"line":227},[148,8089,8090],{"class":154},"//\n",[148,8092,8093],{"class":150,"line":513},[148,8094,8095],{"class":154},"//   [orchestrator] ---handoff---> [local-classifier]\n",[148,8097,8098],{"class":150,"line":993},[148,8099,8100],{"class":154},"//         |                           (llama3.2)\n",[148,8102,8103],{"class":150,"line":1004},[148,8104,8105],{"class":154},"//         |\n",[148,8107,8108],{"class":150,"line":1013},[148,8109,8110],{"class":154},"//         +--------handoff---> [cloud-synthesizer]\n",[148,8112,8113],{"class":150,"line":1019},[148,8114,8115],{"class":154},"//                                   (claude-sonnet-4-6)\n",[148,8117,8118],{"class":150,"line":1028},[148,8119,8090],{"class":154},[148,8121,8122],{"class":150,"line":1034},[148,8123,8124],{"class":154},"// Metrics: 142 tasks | 97% success | avg 340ms local | avg 1.2s cloud\n",[12,8126,8127],{},"The NoLag portal's agent dashboard shows the full topology in real time. You see every connected worker, their model and provider, task flow between agents, and aggregate performance metrics. No custom dashboards, no separate monitoring stack.",[35,8129,8131],{"id":8130},"scaling-independently","Scaling Independently",[12,8133,8134],{},"Because workers are independent processes that connect to NoLag, you can scale each tier independently:",[597,8136,8137,8143,8149,8155],{},[600,8138,8139,8142],{},[30,8140,8141],{},"Scale local horizontally"," -- add more GPU boxes, each running a worker. NoLag's load-balanced subscriptions distribute tasks across them automatically.",[600,8144,8145,8148],{},[30,8146,8147],{},"Scale cloud vertically"," -- increase API rate limits and concurrency. Cloud workers can run as serverless functions that auto-scale with demand.",[600,8150,8151,8154],{},[30,8152,8153],{},"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.",[600,8156,8157,8160],{},[30,8158,8159],{},"Mix providers freely"," -- run three workers with different cloud providers (Anthropic, OpenAI, Google) for the same capability. If one provider has an outage, the others keep processing.",[35,8162,8164],{"id":8163},"the-privacy-boundary","The Privacy Boundary",[12,8166,8167],{},"For regulated industries (healthcare, finance, legal), hybrid orchestration solves a real compliance problem. Sensitive data stays on your infrastructure:",[629,8169,8170,8173,8176,8179],{},[600,8171,8172],{},"Documents arrive at your on-prem worker via NoLag (encrypted WebSocket)",[600,8174,8175],{},"The local worker extracts entities, classifies, and redacts PII",[600,8177,8178],{},"Only the redacted summary is sent to a cloud model for synthesis",[600,8180,8181],{},"The cloud model never sees raw PII, medical records, or financial data",[12,8183,8184],{},"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,8186,8188],{"id":8187},"getting-started","Getting Started",[629,8190,8191,8197,8200,8203,8206,8209],{},[600,8192,8193,8194],{},"Install the agents SDK: ",[84,8195,8196],{},"npm install @nolag/agents",[600,8198,8199],{},"Create an app in the NoLag dashboard with a room for your workflow",[600,8201,8202],{},"Write an orchestrator that dispatches tasks by capability",[600,8204,8205],{},"Write a local worker with Ollama for fast, private tasks",[600,8207,8208],{},"Write a cloud worker with your preferred API for reasoning tasks",[600,8210,8211],{},"Open the Agents tab in the portal to watch the topology live",[12,8213,8214],{},"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,8216,8218],{"id":8217},"whats-next","What's Next",[12,8220,8221],{},"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,8223,8224],{},"The models will keep getting better. The coordination infrastructure shouldn't need to change every time they do.",[597,8226,8227,8232],{},[600,8228,8229],{},[667,8230,8231],{"href":669},"Coordination Layer",[600,8233,8234],{},[667,8235,8237],{"href":8236},"/docs/concepts/webhooks","Webhooks",[684,8239,686],{},{"title":144,"searchDepth":158,"depth":158,"links":8241},[8242,8243,8244,8245,8246,8247,8248,8249,8250,8251,8252],{"id":6529,"depth":158,"text":6530},{"id":6758,"depth":158,"text":6759},{"id":7034,"depth":158,"text":7035},{"id":7390,"depth":158,"text":7391},{"id":7738,"depth":158,"text":7739},{"id":7839,"depth":158,"text":7840},{"id":8040,"depth":158,"text":8041},{"id":8130,"depth":158,"text":8131},{"id":8163,"depth":158,"text":8164},{"id":8187,"depth":158,"text":8188},{"id":8217,"depth":158,"text":8218},"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":6518,"description":8254},"blog/hybrid-llm-orchestration","afx6h5VhA3l1qGR2OGIH_yNW-KF2aPva_jAXPRh2_Lc",{"id":8260,"title":8261,"author":7,"body":8262,"category":1518,"date":9669,"description":9670,"excerpt":701,"extension":702,"meta":9671,"navigation":704,"path":9672,"readTime":9673,"seo":9674,"stem":9675,"__hash__":9676},"blog/blog/building-agentic-chatbot.md","Building an Agentic Chatbot with NoLag",{"type":9,"value":8263,"toc":9659},[8264,8274,8278,8281,8304,8308,8330,8334,8337,8357,8360,8380,8384,8387,8790,8793,8797,8800,9117,9120,9124,9127,9368,9372,9375,9624,9628,9648,9651,9656],[12,8265,8266,8267,8270,8271,8273],{},"This tutorial walks through building a customer support chatbot that combines two NoLag Blueprints: ",[84,8268,8269],{},"@nolag/chat"," for the user-facing interface and ",[84,8272,90],{}," for AI-powered response generation. The result is a production-ready chatbot with typing indicators, conversation history, sentiment-based escalation, and full observability.",[35,8275,8277],{"id":8276},"architecture-overview","Architecture Overview",[12,8279,8280],{},"The system has three components:",[629,8282,8283,8292,8298],{},[600,8284,8285,8288,8289,8291],{},[30,8286,8287],{},"Chat frontend"," - Users interact with a standard chat room powered by ",[84,8290,8269],{},". They see typing indicators, message history, and presence.",[600,8293,8294,8297],{},[30,8295,8296],{},"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.",[600,8299,8300,8303],{},[30,8301,8302],{},"AI workers"," - Agent processes that receive tasks via the Handoff pattern, call an LLM, and return responses. They can be scaled horizontally.",[35,8305,8307],{"id":8306},"step-1-install-dependencies","Step 1: Install Dependencies",[135,8309,8310],{},[138,8311,8314],{"className":1578,"code":8312,"filename":8313,"language":1580,"meta":144,"style":144},"npm install @nolag/chat @nolag/agents @nolag/js-sdk\n","Terminal",[84,8315,8316],{"__ignoreMap":144},[148,8317,8318,8320,8322,8325,8328],{"class":150,"line":151},[148,8319,2893],{"class":174},[148,8321,1590],{"class":299},[148,8323,8324],{"class":299}," @nolag/chat",[148,8326,8327],{"class":299}," @nolag/agents",[148,8329,2898],{"class":299},[35,8331,8333],{"id":8332},"step-2-set-up-apps-in-the-portal","Step 2: Set Up Apps in the Portal",[12,8335,8336],{},"Create two apps in your NoLag project:",[629,8338,8339,8348],{},[600,8340,1540,8341,8344,8345,130],{},[30,8342,8343],{},"Chat"," Blueprint app (slug: ",[84,8346,8347],{},"support-chat",[600,8349,8350,8351,8344,8354,130],{},"An ",[30,8352,8353],{},"Agents",[84,8355,8356],{},"support-agents",[12,8358,8359],{},"Create three actors:",[597,8361,8362,8368,8374],{},[600,8363,8364,8367],{},[30,8365,8366],{},"Bridge actor"," - granted access to both apps",[600,8369,8370,8373],{},[30,8371,8372],{},"Worker actor"," - granted access to the agents app",[600,8375,8376,8379],{},[30,8377,8378],{},"User actors"," - granted access to the chat app (one per user, or use dynamic tokens)",[35,8381,8383],{"id":8382},"step-3-build-the-bridge","Step 3: Build the Bridge",[12,8385,8386],{},"The bridge is the key piece. It connects to both apps simultaneously and translates between chat messages and agent tasks:",[135,8388,8389],{},[138,8390,8393],{"className":140,"code":8391,"filename":8392,"language":143,"meta":144,"style":144},"import { NoLagChat } from \"@nolag/chat\";\nimport { NoLagAgents } from \"@nolag/agents\";\n\n// The bridge actor connects to both apps\nconst chat = new NoLagChat(BRIDGE_CHAT_TOKEN, {\n  user: { id: \"bot\", name: \"Support Bot\", avatar: \"/bot.png\" },\n});\nconst agents = new NoLagAgents(BRIDGE_AGENT_TOKEN);\n\nawait Promise.all([chat.connect(), agents.connect()]);\n\nconst chatRoom = chat.joinRoom(\"support\");\nconst agentRoom = agents.joinRoom(\"support-workflow\");\n\n// Forward user messages to the agent workflow\nchatRoom.on(\"message\", async (msg) => {\n  // Don't process our own messages\n  if (msg.sender.id === \"bot\") return;\n\n  // Show typing indicator while agent works\n  chatRoom.startTyping();\n\n  await agentRoom.handoff.dispatch({\n    type: \"respond\",\n    payload: {\n      userMessage: msg.text,\n      userId: msg.sender.id,\n      history: chatRoom.getMessages().slice(-10),\n    },\n    capabilities: [\"customer-support\"],\n  });\n});\n\n// Forward agent responses back to chat\nagentRoom.handoff.onResult(async (result) => {\n  chatRoom.stopTyping();\n  await chatRoom.sendMessage(result.data.response);\n});\n","bridge.ts",[84,8394,8395,8409,8422,8426,8431,8450,8472,8476,8496,8500,8524,8528,8550,8570,8574,8579,8605,8610,8630,8634,8639,8649,8653,8665,8675,8680,8685,8690,8714,8718,8727,8731,8735,8739,8744,8765,8774,8786],{"__ignoreMap":144},[148,8396,8397,8399,8402,8404,8407],{"class":150,"line":151},[148,8398,902],{"class":167},[148,8400,8401],{"class":178}," { NoLagChat } ",[148,8403,908],{"class":167},[148,8405,8406],{"class":299}," \"@nolag/chat\"",[148,8408,4938],{"class":178},[148,8410,8411,8413,8416,8418,8420],{"class":150,"line":158},[148,8412,902],{"class":167},[148,8414,8415],{"class":178}," { NoLagAgents } ",[148,8417,908],{"class":167},[148,8419,4935],{"class":299},[148,8421,4938],{"class":178},[148,8423,8424],{"class":150,"line":164},[148,8425,916],{"emptyLinePlaceholder":704},[148,8427,8428],{"class":150,"line":182},[148,8429,8430],{"class":154},"// The bridge actor connects to both apps\n",[148,8432,8433,8435,8437,8439,8441,8443,8445,8448],{"class":150,"line":195},[148,8434,270],{"class":167},[148,8436,273],{"class":203},[148,8438,276],{"class":167},[148,8440,279],{"class":167},[148,8442,282],{"class":174},[148,8444,296],{"class":178},[148,8446,8447],{"class":203},"BRIDGE_CHAT_TOKEN",[148,8449,2971],{"class":178},[148,8451,8452,8455,8458,8461,8464,8467,8470],{"class":150,"line":207},[148,8453,8454],{"class":178},"  user: { id: ",[148,8456,8457],{"class":299},"\"bot\"",[148,8459,8460],{"class":178},", name: ",[148,8462,8463],{"class":299},"\"Support Bot\"",[148,8465,8466],{"class":178},", avatar: ",[148,8468,8469],{"class":299},"\"/bot.png\"",[148,8471,5081],{"class":178},[148,8473,8474],{"class":150,"line":217},[148,8475,5090],{"class":178},[148,8477,8478,8480,8483,8485,8487,8489,8491,8494],{"class":150,"line":227},[148,8479,270],{"class":167},[148,8481,8482],{"class":203}," agents",[148,8484,276],{"class":167},[148,8486,279],{"class":167},[148,8488,4997],{"class":174},[148,8490,296],{"class":178},[148,8492,8493],{"class":203},"BRIDGE_AGENT_TOKEN",[148,8495,5126],{"class":178},[148,8497,8498],{"class":150,"line":513},[148,8499,916],{"emptyLinePlaceholder":704},[148,8501,8502,8504,8506,8508,8511,8514,8516,8519,8521],{"class":150,"line":993},[148,8503,943],{"class":167},[148,8505,477],{"class":203},[148,8507,1562],{"class":178},[148,8509,8510],{"class":174},"all",[148,8512,8513],{"class":178},"([chat.",[148,8515,2204],{"class":174},[148,8517,8518],{"class":178},"(), agents.",[148,8520,2204],{"class":174},[148,8522,8523],{"class":178},"()]);\n",[148,8525,8526],{"class":150,"line":1004},[148,8527,916],{"emptyLinePlaceholder":704},[148,8529,8530,8532,8535,8537,8540,8543,8545,8548],{"class":150,"line":1013},[148,8531,270],{"class":167},[148,8533,8534],{"class":203}," chatRoom",[148,8536,276],{"class":167},[148,8538,8539],{"class":178}," chat.",[148,8541,8542],{"class":174},"joinRoom",[148,8544,296],{"class":178},[148,8546,8547],{"class":299},"\"support\"",[148,8549,5126],{"class":178},[148,8551,8552,8554,8557,8559,8561,8563,8565,8568],{"class":150,"line":1019},[148,8553,270],{"class":167},[148,8555,8556],{"class":203}," agentRoom",[148,8558,276],{"class":167},[148,8560,575],{"class":178},[148,8562,8542],{"class":174},[148,8564,296],{"class":178},[148,8566,8567],{"class":299},"\"support-workflow\"",[148,8569,5126],{"class":178},[148,8571,8572],{"class":150,"line":1028},[148,8573,916],{"emptyLinePlaceholder":704},[148,8575,8576],{"class":150,"line":1034},[148,8577,8578],{"class":154},"// Forward user messages to the agent workflow\n",[148,8580,8581,8584,8586,8588,8591,8593,8595,8597,8599,8601,8603],{"class":150,"line":1040},[148,8582,8583],{"class":178},"chatRoom.",[148,8585,2267],{"class":174},[148,8587,296],{"class":178},[148,8589,8590],{"class":299},"\"message\"",[148,8592,87],{"class":178},[148,8594,1129],{"class":167},[148,8596,114],{"class":178},[148,8598,2587],{"class":185},[148,8600,1142],{"class":178},[148,8602,1145],{"class":167},[148,8604,179],{"class":178},[148,8606,8607],{"class":150,"line":1046},[148,8608,8609],{"class":154},"  // Don't process our own messages\n",[148,8611,8612,8615,8618,8620,8623,8625,8628],{"class":150,"line":1052},[148,8613,8614],{"class":167},"  if",[148,8616,8617],{"class":178}," (msg.sender.id ",[148,8619,1386],{"class":167},[148,8621,8622],{"class":299}," \"bot\"",[148,8624,1142],{"class":178},[148,8626,8627],{"class":167},"return",[148,8629,4938],{"class":178},[148,8631,8632],{"class":150,"line":1699},[148,8633,916],{"emptyLinePlaceholder":704},[148,8635,8636],{"class":150,"line":1705},[148,8637,8638],{"class":154},"  // Show typing indicator while agent works\n",[148,8640,8641,8644,8647],{"class":150,"line":1711},[148,8642,8643],{"class":178},"  chatRoom.",[148,8645,8646],{"class":174},"startTyping",[148,8648,5106],{"class":178},[148,8650,8651],{"class":150,"line":1717},[148,8652,916],{"emptyLinePlaceholder":704},[148,8654,8655,8658,8661,8663],{"class":150,"line":1723},[148,8656,8657],{"class":167},"  await",[148,8659,8660],{"class":178}," agentRoom.handoff.",[148,8662,5549],{"class":174},[148,8664,972],{"class":178},[148,8666,8667,8670,8673],{"class":150,"line":1729},[148,8668,8669],{"class":178},"    type: ",[148,8671,8672],{"class":299},"\"respond\"",[148,8674,2982],{"class":178},[148,8676,8677],{"class":150,"line":1735},[148,8678,8679],{"class":178},"    payload: {\n",[148,8681,8682],{"class":150,"line":1741},[148,8683,8684],{"class":178},"      userMessage: msg.text,\n",[148,8686,8687],{"class":150,"line":1746},[148,8688,8689],{"class":178},"      userId: msg.sender.id,\n",[148,8691,8692,8695,8698,8701,8704,8706,8708,8711],{"class":150,"line":1752},[148,8693,8694],{"class":178},"      history: chatRoom.",[148,8696,8697],{"class":174},"getMessages",[148,8699,8700],{"class":178},"().",[148,8702,8703],{"class":174},"slice",[148,8705,296],{"class":178},[148,8707,5300],{"class":167},[148,8709,8710],{"class":203},"10",[148,8712,8713],{"class":178},"),\n",[148,8715,8716],{"class":150,"line":1757},[148,8717,3030],{"class":178},[148,8719,8720,8722,8725],{"class":150,"line":1763},[148,8721,5053],{"class":178},[148,8723,8724],{"class":299},"\"customer-support\"",[148,8726,5064],{"class":178},[148,8728,8729],{"class":150,"line":1769},[148,8730,5257],{"class":178},[148,8732,8733],{"class":150,"line":1774},[148,8734,5090],{"class":178},[148,8736,8737],{"class":150,"line":3517},[148,8738,916],{"emptyLinePlaceholder":704},[148,8740,8741],{"class":150,"line":3522},[148,8742,8743],{"class":154},"// Forward agent responses back to chat\n",[148,8745,8746,8749,8751,8753,8755,8757,8759,8761,8763],{"class":150,"line":3530},[148,8747,8748],{"class":178},"agentRoom.handoff.",[148,8750,455],{"class":174},[148,8752,296],{"class":178},[148,8754,1129],{"class":167},[148,8756,114],{"class":178},[148,8758,540],{"class":185},[148,8760,1142],{"class":178},[148,8762,1145],{"class":167},[148,8764,179],{"class":178},[148,8766,8767,8769,8772],{"class":150,"line":3542},[148,8768,8643],{"class":178},[148,8770,8771],{"class":174},"stopTyping",[148,8773,5106],{"class":178},[148,8775,8776,8778,8781,8783],{"class":150,"line":3566},[148,8777,8657],{"class":167},[148,8779,8780],{"class":178}," chatRoom.",[148,8782,293],{"class":174},[148,8784,8785],{"class":178},"(result.data.response);\n",[148,8787,8788],{"class":150,"line":3586},[148,8789,5090],{"class":178},[12,8791,8792],{},"The bridge shows typing indicators while the agent is processing, giving users a natural chat experience. Message history is passed to the agent so it has conversation context.",[35,8794,8796],{"id":8795},"step-4-build-the-ai-worker","Step 4: Build the AI Worker",[12,8798,8799],{},"Workers register their capabilities and process incoming tasks. You can use any LLM - OpenAI, Anthropic, a local model, or a custom pipeline:",[135,8801,8802],{},[138,8803,8805],{"className":140,"code":8804,"filename":4921,"language":143,"meta":144,"style":144},"import { NoLagAgents } from \"@nolag/agents\";\n\nconst agents = new NoLagAgents(WORKER_TOKEN);\nawait agents.connect();\n\nconst room = agents.joinRoom(\"support-workflow\");\n\nroom.handoff.register({\n  capabilities: [\"customer-support\"],\n  concurrency: 5,\n});\n\nroom.handoff.onTask(async (task) => {\n  const { userMessage, history } = task.payload;\n\n  // Call your LLM of choice\n  const response = await openai.chat.completions.create({\n    model: \"gpt-4\",\n    messages: [\n      { role: \"system\", content: \"You are a helpful customer support agent.\" },\n      ...history.map((m: any) => ({\n        role: m.sender.id === \"bot\" ? \"assistant\" : \"user\",\n        content: m.text,\n      })),\n      { role: \"user\", content: userMessage },\n    ],\n  });\n\n  await task.complete({\n    response: response.choices[0].message.content,\n  });\n});\n",[84,8806,8807,8819,8823,8842,8852,8856,8874,8878,8888,8897,8907,8911,8915,8935,8956,8960,8965,8982,8991,8995,9008,9033,9056,9061,9066,9075,9079,9083,9087,9099,9109,9113],{"__ignoreMap":144},[148,8808,8809,8811,8813,8815,8817],{"class":150,"line":151},[148,8810,902],{"class":167},[148,8812,8415],{"class":178},[148,8814,908],{"class":167},[148,8816,4935],{"class":299},[148,8818,4938],{"class":178},[148,8820,8821],{"class":150,"line":158},[148,8822,916],{"emptyLinePlaceholder":704},[148,8824,8825,8827,8829,8831,8833,8835,8837,8840],{"class":150,"line":164},[148,8826,270],{"class":167},[148,8828,8482],{"class":203},[148,8830,276],{"class":167},[148,8832,279],{"class":167},[148,8834,4997],{"class":174},[148,8836,296],{"class":178},[148,8838,8839],{"class":203},"WORKER_TOKEN",[148,8841,5126],{"class":178},[148,8843,8844,8846,8848,8850],{"class":150,"line":182},[148,8845,943],{"class":167},[148,8847,575],{"class":178},[148,8849,2204],{"class":174},[148,8851,5106],{"class":178},[148,8853,8854],{"class":150,"line":195},[148,8855,916],{"emptyLinePlaceholder":704},[148,8857,8858,8860,8862,8864,8866,8868,8870,8872],{"class":150,"line":207},[148,8859,270],{"class":167},[148,8861,2217],{"class":203},[148,8863,276],{"class":167},[148,8865,575],{"class":178},[148,8867,8542],{"class":174},[148,8869,296],{"class":178},[148,8871,8567],{"class":299},[148,8873,5126],{"class":178},[148,8875,8876],{"class":150,"line":217},[148,8877,916],{"emptyLinePlaceholder":704},[148,8879,8880,8883,8886],{"class":150,"line":227},[148,8881,8882],{"class":178},"room.handoff.",[148,8884,8885],{"class":174},"register",[148,8887,972],{"class":178},[148,8889,8890,8893,8895],{"class":150,"line":513},[148,8891,8892],{"class":178},"  capabilities: [",[148,8894,8724],{"class":299},[148,8896,5064],{"class":178},[148,8898,8899,8902,8905],{"class":150,"line":993},[148,8900,8901],{"class":178},"  concurrency: ",[148,8903,8904],{"class":203},"5",[148,8906,2982],{"class":178},[148,8908,8909],{"class":150,"line":1004},[148,8910,5090],{"class":178},[148,8912,8913],{"class":150,"line":1013},[148,8914,916],{"emptyLinePlaceholder":704},[148,8916,8917,8919,8921,8923,8925,8927,8929,8931,8933],{"class":150,"line":1019},[148,8918,8882],{"class":178},[148,8920,1117],{"class":174},[148,8922,296],{"class":178},[148,8924,1129],{"class":167},[148,8926,114],{"class":178},[148,8928,1134],{"class":185},[148,8930,1142],{"class":178},[148,8932,1145],{"class":167},[148,8934,179],{"class":178},[148,8936,8937,8939,8941,8944,8946,8949,8951,8953],{"class":150,"line":1028},[148,8938,491],{"class":167},[148,8940,1155],{"class":178},[148,8942,8943],{"class":203},"userMessage",[148,8945,87],{"class":178},[148,8947,8948],{"class":203},"history",[148,8950,1171],{"class":178},[148,8952,1174],{"class":167},[148,8954,8955],{"class":178}," task.payload;\n",[148,8957,8958],{"class":150,"line":1034},[148,8959,916],{"emptyLinePlaceholder":704},[148,8961,8962],{"class":150,"line":1040},[148,8963,8964],{"class":154},"  // Call your LLM of choice\n",[148,8966,8967,8969,8971,8973,8975,8978,8980],{"class":150,"line":1046},[148,8968,491],{"class":167},[148,8970,5185],{"class":203},[148,8972,276],{"class":167},[148,8974,499],{"class":167},[148,8976,8977],{"class":178}," openai.chat.completions.",[148,8979,7639],{"class":174},[148,8981,972],{"class":178},[148,8983,8984,8986,8989],{"class":150,"line":1052},[148,8985,5202],{"class":178},[148,8987,8988],{"class":299},"\"gpt-4\"",[148,8990,2982],{"class":178},[148,8992,8993],{"class":150,"line":1699},[148,8994,5211],{"class":178},[148,8996,8997,8999,9001,9003,9006],{"class":150,"line":1705},[148,8998,5216],{"class":178},[148,9000,5219],{"class":299},[148,9002,5222],{"class":178},[148,9004,9005],{"class":299},"\"You are a helpful customer support agent.\"",[148,9007,5081],{"class":178},[148,9009,9010,9013,9016,9018,9020,9022,9024,9026,9028,9030],{"class":150,"line":1711},[148,9011,9012],{"class":167},"      ...",[148,9014,9015],{"class":178},"history.",[148,9017,3548],{"class":174},[148,9019,3298],{"class":178},[148,9021,3424],{"class":185},[148,9023,189],{"class":167},[148,9025,4427],{"class":203},[148,9027,1142],{"class":178},[148,9029,1145],{"class":167},[148,9031,9032],{"class":178}," ({\n",[148,9034,9035,9038,9040,9042,9045,9048,9051,9054],{"class":150,"line":1717},[148,9036,9037],{"class":178},"        role: m.sender.id ",[148,9039,1386],{"class":167},[148,9041,8622],{"class":299},[148,9043,9044],{"class":167}," ?",[148,9046,9047],{"class":299}," \"assistant\"",[148,9049,9050],{"class":167}," :",[148,9052,9053],{"class":299}," \"user\"",[148,9055,2982],{"class":178},[148,9057,9058],{"class":150,"line":1723},[148,9059,9060],{"class":178},"        content: m.text,\n",[148,9062,9063],{"class":150,"line":1729},[148,9064,9065],{"class":178},"      })),\n",[148,9067,9068,9070,9072],{"class":150,"line":1735},[148,9069,5216],{"class":178},[148,9071,5244],{"class":299},[148,9073,9074],{"class":178},", content: userMessage },\n",[148,9076,9077],{"class":150,"line":1741},[148,9078,5252],{"class":178},[148,9080,9081],{"class":150,"line":1746},[148,9082,5257],{"class":178},[148,9084,9085],{"class":150,"line":1752},[148,9086,916],{"emptyLinePlaceholder":704},[148,9088,9089,9091,9094,9097],{"class":150,"line":1757},[148,9090,8657],{"class":167},[148,9092,9093],{"class":178}," task.",[148,9095,9096],{"class":174},"complete",[148,9098,972],{"class":178},[148,9100,9101,9104,9106],{"class":150,"line":1763},[148,9102,9103],{"class":178},"    response: response.choices[",[148,9105,7693],{"class":203},[148,9107,9108],{"class":178},"].message.content,\n",[148,9110,9111],{"class":150,"line":1769},[148,9112,5257],{"class":178},[148,9114,9115],{"class":150,"line":1774},[148,9116,5090],{"class":178},[12,9118,9119],{},"Workers are stateless and horizontally scalable. Run multiple instances and they'll automatically load-balance tasks via the Handoff pattern.",[35,9121,9123],{"id":9122},"step-5-add-escalation","Step 5: Add Escalation",[12,9125,9126],{},"Use the Approve pattern to escalate sensitive conversations to a human agent. The worker detects negative sentiment and requests approval before responding:",[135,9128,9129],{},[138,9130,9133],{"className":140,"code":9131,"filename":9132,"language":143,"meta":144,"style":144},"// In the worker, detect when to escalate\nroom.handoff.onTask(async (task) => {\n  const { userMessage } = task.payload;\n  const sentiment = await analyzeSentiment(userMessage);\n\n  if (sentiment.score \u003C -0.7) {\n    // Escalate: request human approval before responding\n    const approval = await room.approve.request({\n      action: \"escalate-to-human\",\n      description: \"Negative sentiment detected. Review before responding.\",\n      payload: { userMessage, suggestedResponse: \"...\" },\n    });\n\n    if (approval.approved) {\n      // Human may have modified the response\n      await task.complete({\n        response: approval.modifications?.response || approval.payload.suggestedResponse,\n      });\n    }\n  } else {\n    // Normal LLM response\n    const response = await generateResponse(task.payload);\n    await task.complete({ response });\n  }\n});\n","escalation.ts",[84,9134,9135,9140,9160,9174,9191,9195,9212,9217,9236,9246,9256,9266,9271,9275,9282,9287,9297,9308,9313,9317,9327,9332,9348,9360,9364],{"__ignoreMap":144},[148,9136,9137],{"class":150,"line":151},[148,9138,9139],{"class":154},"// In the worker, detect when to escalate\n",[148,9141,9142,9144,9146,9148,9150,9152,9154,9156,9158],{"class":150,"line":158},[148,9143,8882],{"class":178},[148,9145,1117],{"class":174},[148,9147,296],{"class":178},[148,9149,1129],{"class":167},[148,9151,114],{"class":178},[148,9153,1134],{"class":185},[148,9155,1142],{"class":178},[148,9157,1145],{"class":167},[148,9159,179],{"class":178},[148,9161,9162,9164,9166,9168,9170,9172],{"class":150,"line":164},[148,9163,491],{"class":167},[148,9165,1155],{"class":178},[148,9167,8943],{"class":203},[148,9169,1171],{"class":178},[148,9171,1174],{"class":167},[148,9173,8955],{"class":178},[148,9175,9176,9178,9181,9183,9185,9188],{"class":150,"line":182},[148,9177,491],{"class":167},[148,9179,9180],{"class":203}," sentiment",[148,9182,276],{"class":167},[148,9184,499],{"class":167},[148,9186,9187],{"class":174}," analyzeSentiment",[148,9189,9190],{"class":178},"(userMessage);\n",[148,9192,9193],{"class":150,"line":195},[148,9194,916],{"emptyLinePlaceholder":704},[148,9196,9197,9199,9202,9204,9207,9210],{"class":150,"line":207},[148,9198,8614],{"class":167},[148,9200,9201],{"class":178}," (sentiment.score ",[148,9203,480],{"class":167},[148,9205,9206],{"class":167}," -",[148,9208,9209],{"class":203},"0.7",[148,9211,4238],{"class":178},[148,9213,9214],{"class":150,"line":217},[148,9215,9216],{"class":154},"    // Escalate: request human approval before responding\n",[148,9218,9219,9221,9224,9226,9228,9231,9234],{"class":150,"line":227},[148,9220,1152],{"class":167},[148,9222,9223],{"class":203}," approval",[148,9225,276],{"class":167},[148,9227,499],{"class":167},[148,9229,9230],{"class":178}," room.approve.",[148,9232,9233],{"class":174},"request",[148,9235,972],{"class":178},[148,9237,9238,9241,9244],{"class":150,"line":513},[148,9239,9240],{"class":178},"      action: ",[148,9242,9243],{"class":299},"\"escalate-to-human\"",[148,9245,2982],{"class":178},[148,9247,9248,9251,9254],{"class":150,"line":993},[148,9249,9250],{"class":178},"      description: ",[148,9252,9253],{"class":299},"\"Negative sentiment detected. Review before responding.\"",[148,9255,2982],{"class":178},[148,9257,9258,9261,9264],{"class":150,"line":1004},[148,9259,9260],{"class":178},"      payload: { userMessage, suggestedResponse: ",[148,9262,9263],{"class":299},"\"...\"",[148,9265,5081],{"class":178},[148,9267,9268],{"class":150,"line":1013},[148,9269,9270],{"class":178},"    });\n",[148,9272,9273],{"class":150,"line":1019},[148,9274,916],{"emptyLinePlaceholder":704},[148,9276,9277,9279],{"class":150,"line":1028},[148,9278,1380],{"class":167},[148,9280,9281],{"class":178}," (approval.approved) {\n",[148,9283,9284],{"class":150,"line":1034},[148,9285,9286],{"class":154},"      // Human may have modified the response\n",[148,9288,9289,9291,9293,9295],{"class":150,"line":1040},[148,9290,3349],{"class":167},[148,9292,9093],{"class":178},[148,9294,9096],{"class":174},[148,9296,972],{"class":178},[148,9298,9299,9302,9305],{"class":150,"line":1046},[148,9300,9301],{"class":178},"        response: approval.modifications?.response ",[148,9303,9304],{"class":167},"||",[148,9306,9307],{"class":178}," approval.payload.suggestedResponse,\n",[148,9309,9310],{"class":150,"line":1052},[148,9311,9312],{"class":178},"      });\n",[148,9314,9315],{"class":150,"line":1699},[148,9316,3481],{"class":178},[148,9318,9319,9322,9325],{"class":150,"line":1705},[148,9320,9321],{"class":178},"  } ",[148,9323,9324],{"class":167},"else",[148,9326,179],{"class":178},[148,9328,9329],{"class":150,"line":1711},[148,9330,9331],{"class":154},"    // Normal LLM response\n",[148,9333,9334,9336,9338,9340,9342,9345],{"class":150,"line":1717},[148,9335,1152],{"class":167},[148,9337,5185],{"class":203},[148,9339,276],{"class":167},[148,9341,499],{"class":167},[148,9343,9344],{"class":174}," generateResponse",[148,9346,9347],{"class":178},"(task.payload);\n",[148,9349,9350,9353,9355,9357],{"class":150,"line":1723},[148,9351,9352],{"class":167},"    await",[148,9354,9093],{"class":178},[148,9356,9096],{"class":174},[148,9358,9359],{"class":178},"({ response });\n",[148,9361,9362],{"class":150,"line":1729},[148,9363,1402],{"class":178},[148,9365,9366],{"class":150,"line":1735},[148,9367,5090],{"class":178},[35,9369,9371],{"id":9370},"step-6-monitor-everything","Step 6: Monitor Everything",[12,9373,9374],{},"The Observe pattern gives you full visibility into the chatbot's performance:",[135,9376,9377],{},[138,9378,9381],{"className":140,"code":9379,"filename":9380,"language":143,"meta":144,"style":144},"const room = agents.joinRoom(\"support-workflow\");\n\n// Track metrics\nlet totalQueries = 0;\nlet avgResponseTime = 0;\n\nroom.observe.on(\"task:completed\", (event) => {\n  totalQueries++;\n  avgResponseTime = (avgResponseTime * (totalQueries - 1) + event.data.duration) / totalQueries;\n  console.log(`Avg response: ${avgResponseTime}ms | Total: ${totalQueries}`);\n});\n\nroom.observe.on(\"task:failed\", (event) => {\n  console.error(\"Agent failed:\", event.data.error);\n});\n\n// Monitor agent health\nconst onlineAgents = room.getPresence();\nconsole.log(\"Workers online:\", onlineAgents.length);\n","monitor.ts",[84,9382,9383,9401,9405,9410,9425,9438,9442,9464,9474,9509,9533,9537,9541,9562,9577,9581,9585,9590,9606],{"__ignoreMap":144},[148,9384,9385,9387,9389,9391,9393,9395,9397,9399],{"class":150,"line":151},[148,9386,270],{"class":167},[148,9388,2217],{"class":203},[148,9390,276],{"class":167},[148,9392,575],{"class":178},[148,9394,8542],{"class":174},[148,9396,296],{"class":178},[148,9398,8567],{"class":299},[148,9400,5126],{"class":178},[148,9402,9403],{"class":150,"line":158},[148,9404,916],{"emptyLinePlaceholder":704},[148,9406,9407],{"class":150,"line":164},[148,9408,9409],{"class":154},"// Track metrics\n",[148,9411,9412,9415,9418,9420,9423],{"class":150,"line":182},[148,9413,9414],{"class":167},"let",[148,9416,9417],{"class":178}," totalQueries ",[148,9419,1174],{"class":167},[148,9421,9422],{"class":203}," 0",[148,9424,4938],{"class":178},[148,9426,9427,9429,9432,9434,9436],{"class":150,"line":195},[148,9428,9414],{"class":167},[148,9430,9431],{"class":178}," avgResponseTime ",[148,9433,1174],{"class":167},[148,9435,9422],{"class":203},[148,9437,4938],{"class":178},[148,9439,9440],{"class":150,"line":207},[148,9441,916],{"emptyLinePlaceholder":704},[148,9443,9444,9447,9449,9451,9454,9456,9458,9460,9462],{"class":150,"line":217},[148,9445,9446],{"class":178},"room.observe.",[148,9448,2267],{"class":174},[148,9450,296],{"class":178},[148,9452,9453],{"class":299},"\"task:completed\"",[148,9455,2274],{"class":178},[148,9457,1369],{"class":185},[148,9459,1142],{"class":178},[148,9461,1145],{"class":167},[148,9463,179],{"class":178},[148,9465,9466,9469,9472],{"class":150,"line":227},[148,9467,9468],{"class":178},"  totalQueries",[148,9470,9471],{"class":167},"++",[148,9473,4938],{"class":178},[148,9475,9476,9479,9481,9484,9487,9490,9492,9495,9497,9500,9503,9506],{"class":150,"line":513},[148,9477,9478],{"class":178},"  avgResponseTime ",[148,9480,1174],{"class":167},[148,9482,9483],{"class":178}," (avgResponseTime ",[148,9485,9486],{"class":167},"*",[148,9488,9489],{"class":178}," (totalQueries ",[148,9491,5300],{"class":167},[148,9493,9494],{"class":203}," 1",[148,9496,1142],{"class":178},[148,9498,9499],{"class":167},"+",[148,9501,9502],{"class":178}," event.data.duration) ",[148,9504,9505],{"class":167},"/",[148,9507,9508],{"class":178}," totalQueries;\n",[148,9510,9511,9513,9515,9517,9520,9523,9526,9529,9531],{"class":150,"line":993},[148,9512,2107],{"class":178},[148,9514,2110],{"class":174},[148,9516,296],{"class":178},[148,9518,9519],{"class":299},"`Avg response: ${",[148,9521,9522],{"class":178},"avgResponseTime",[148,9524,9525],{"class":299},"}ms | Total: ${",[148,9527,9528],{"class":178},"totalQueries",[148,9530,3010],{"class":299},[148,9532,5126],{"class":178},[148,9534,9535],{"class":150,"line":1004},[148,9536,5090],{"class":178},[148,9538,9539],{"class":150,"line":1013},[148,9540,916],{"emptyLinePlaceholder":704},[148,9542,9543,9545,9547,9549,9552,9554,9556,9558,9560],{"class":150,"line":1019},[148,9544,9446],{"class":178},[148,9546,2267],{"class":174},[148,9548,296],{"class":178},[148,9550,9551],{"class":299},"\"task:failed\"",[148,9553,2274],{"class":178},[148,9555,1369],{"class":185},[148,9557,1142],{"class":178},[148,9559,1145],{"class":167},[148,9561,179],{"class":178},[148,9563,9564,9566,9569,9571,9574],{"class":150,"line":1028},[148,9565,2107],{"class":178},[148,9567,9568],{"class":174},"error",[148,9570,296],{"class":178},[148,9572,9573],{"class":299},"\"Agent failed:\"",[148,9575,9576],{"class":178},", event.data.error);\n",[148,9578,9579],{"class":150,"line":1034},[148,9580,5090],{"class":178},[148,9582,9583],{"class":150,"line":1040},[148,9584,916],{"emptyLinePlaceholder":704},[148,9586,9587],{"class":150,"line":1046},[148,9588,9589],{"class":154},"// Monitor agent health\n",[148,9591,9592,9594,9597,9599,9601,9604],{"class":150,"line":1052},[148,9593,270],{"class":167},[148,9595,9596],{"class":203}," onlineAgents",[148,9598,276],{"class":167},[148,9600,5491],{"class":178},[148,9602,9603],{"class":174},"getPresence",[148,9605,5106],{"class":178},[148,9607,9608,9610,9612,9614,9617,9620,9622],{"class":150,"line":1699},[148,9609,5320],{"class":178},[148,9611,2110],{"class":174},[148,9613,296],{"class":178},[148,9615,9616],{"class":299},"\"Workers online:\"",[148,9618,9619],{"class":178},", onlineAgents.",[148,9621,5518],{"class":203},[148,9623,5126],{"class":178},[35,9625,9627],{"id":9626},"what-you-get","What You Get",[597,9629,9630,9633,9636,9639,9642,9645],{},[600,9631,9632],{},"A chat interface with typing indicators and presence - users don't know they're talking to an AI",[600,9634,9635],{},"Horizontally scalable AI workers with automatic load balancing",[600,9637,9638],{},"Human escalation for sensitive conversations",[600,9640,9641],{},"Full observability: response times, error rates, agent health",[600,9643,9644],{},"Persistent message history with replay on reconnect",[600,9646,9647],{},"Per-topic access control separating user traffic from agent traffic",[12,9649,9650],{},"The entire system runs on NoLag's real-time infrastructure. No Redis queues, no custom WebSocket servers, no separate monitoring stack.",[12,9652,9653],{},[667,9654,9655],{"href":6474},"Get started with @nolag/agents →",[684,9657,9658],{},"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":158,"depth":158,"links":9660},[9661,9662,9663,9664,9665,9666,9667,9668],{"id":8276,"depth":158,"text":8277},{"id":8306,"depth":158,"text":8307},{"id":8332,"depth":158,"text":8333},{"id":8382,"depth":158,"text":8383},{"id":8795,"depth":158,"text":8796},{"id":9122,"depth":158,"text":9123},{"id":9370,"depth":158,"text":9371},{"id":9626,"depth":158,"text":9627},"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":8261,"description":9670},"blog/building-agentic-chatbot","th_JnRxqIUb9J-nBlj4eu8v5yRhnJmVtDmRV7k5dT6c",{"id":9678,"title":9679,"author":7,"body":9680,"category":1518,"date":9669,"description":10299,"excerpt":701,"extension":702,"meta":10300,"navigation":704,"path":10301,"readTime":706,"seo":10302,"stem":10303,"__hash__":10304},"blog/blog/human-to-agent-realtime.md","From Human Realtime to Agent Realtime",{"type":9,"value":9681,"toc":10289},[9682,9685,9688,9692,9695,9837,9840,9844,9851,9854,9857,9861,9864,10041,10044,10048,10051,10143,10146,10150,10161,10164,10168,10171,10254,10257,10261,10264,10267,10271,10274,10280,10286],[12,9683,9684],{},"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,9686,9687],{},"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,9689,9691],{"id":9690},"the-mapping","The Mapping",[12,9693,9694],{},"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,9696,9697],{},[138,9698,9701],{"className":140,"code":9699,"filename":9700,"language":143,"meta":144,"style":144},"// Human chat: messages flow between users in a room\nroom:general:messages    // QoS 2, retained, 7-day replay\nroom:general:typing      // QoS 0, ephemeral\nroom:general:presence    // QoS 1, retained\n\n// Agent coordination: same primitives, different semantics\n_handoff/dispatch        // QoS 2, retained - task queue\n_handoff/result          // QoS 2, retained - result queue\n_blackboard/state        // QoS 2, retained - shared state\n_observe/events          // QoS 1 - event stream\n_approve/request         // QoS 2, retained - approval queue\n_tools/invoke            // QoS 2 - tool calls\n","Topic Mapping",[84,9702,9703,9708,9723,9737,9751,9755,9760,9773,9785,9798,9811,9824],{"__ignoreMap":144},[148,9704,9705],{"class":150,"line":151},[148,9706,9707],{"class":154},"// Human chat: messages flow between users in a room\n",[148,9709,9710,9712,9714,9717,9720],{"class":150,"line":158},[148,9711,1101],{"class":174},[148,9713,189],{"class":178},[148,9715,9716],{"class":174},"general",[148,9718,9719],{"class":178},":messages    ",[148,9721,9722],{"class":154},"// QoS 2, retained, 7-day replay\n",[148,9724,9725,9727,9729,9731,9734],{"class":150,"line":164},[148,9726,1101],{"class":174},[148,9728,189],{"class":178},[148,9730,9716],{"class":174},[148,9732,9733],{"class":178},":typing      ",[148,9735,9736],{"class":154},"// QoS 0, ephemeral\n",[148,9738,9739,9741,9743,9745,9748],{"class":150,"line":182},[148,9740,1101],{"class":174},[148,9742,189],{"class":178},[148,9744,9716],{"class":174},[148,9746,9747],{"class":178},":presence    ",[148,9749,9750],{"class":154},"// QoS 1, retained\n",[148,9752,9753],{"class":150,"line":195},[148,9754,916],{"emptyLinePlaceholder":704},[148,9756,9757],{"class":150,"line":207},[148,9758,9759],{"class":154},"// Agent coordination: same primitives, different semantics\n",[148,9761,9762,9765,9767,9770],{"class":150,"line":217},[148,9763,9764],{"class":178},"_handoff",[148,9766,9505],{"class":167},[148,9768,9769],{"class":178},"dispatch        ",[148,9771,9772],{"class":154},"// QoS 2, retained - task queue\n",[148,9774,9775,9777,9779,9782],{"class":150,"line":227},[148,9776,9764],{"class":178},[148,9778,9505],{"class":167},[148,9780,9781],{"class":178},"result          ",[148,9783,9784],{"class":154},"// QoS 2, retained - result queue\n",[148,9786,9787,9790,9792,9795],{"class":150,"line":513},[148,9788,9789],{"class":178},"_blackboard",[148,9791,9505],{"class":167},[148,9793,9794],{"class":178},"state        ",[148,9796,9797],{"class":154},"// QoS 2, retained - shared state\n",[148,9799,9800,9803,9805,9808],{"class":150,"line":993},[148,9801,9802],{"class":178},"_observe",[148,9804,9505],{"class":167},[148,9806,9807],{"class":178},"events          ",[148,9809,9810],{"class":154},"// QoS 1 - event stream\n",[148,9812,9813,9816,9818,9821],{"class":150,"line":1004},[148,9814,9815],{"class":178},"_approve",[148,9817,9505],{"class":167},[148,9819,9820],{"class":178},"request         ",[148,9822,9823],{"class":154},"// QoS 2, retained - approval queue\n",[148,9825,9826,9829,9831,9834],{"class":150,"line":1013},[148,9827,9828],{"class":178},"_tools",[148,9830,9505],{"class":167},[148,9832,9833],{"class":178},"invoke            ",[148,9835,9836],{"class":154},"// QoS 2 - tool calls\n",[12,9838,9839],{},"The topic names change. The semantics are different. But the underlying infrastructure - message routing, delivery guarantees, persistence, and fan-out - is identical.",[35,9841,9843],{"id":9842},"topics-as-task-queues","Topics as Task Queues",[12,9845,9846,9847,9850],{},"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,9848,9849],{},"_handoff/dispatch"," as a work queue: the orchestrator publishes tasks, workers subscribe and claim them.",[12,9852,9853],{},"The key property that makes this work is QoS 2 (exactly-once delivery). In chat, QoS 2 prevents duplicate messages. In agent coordination, QoS 2 prevents duplicate task execution. A task dispatched once must be claimed by exactly one worker and completed exactly once.",[12,9855,9856],{},"Retained messages serve a different purpose in each context. For chat, retention means message replay - catch up on history after reconnecting. For agents, retention means task persistence - if no worker is online when a task is dispatched, it waits until one connects.",[35,9858,9860],{"id":9859},"presence-as-health-monitoring","Presence as Health Monitoring",[12,9862,9863],{},"Human presence is simple: online, away, or offline. Agent presence needs more metadata: capabilities, current load, and health status. We extended the presence payload to carry this information without changing the underlying protocol.",[135,9865,9866],{},[138,9867,9870],{"className":140,"code":9868,"filename":9869,"language":143,"meta":144,"style":144},"// Human: \"Alice is online in #general\"\n{\n  actorId: \"user-alice\",\n  rooms: [\"general\", \"random\"],\n  status: \"online\",\n  lastSeen: \"2024-01-15T10:30:00Z\"\n}\n\n// Agent: \"research-worker-3 is available with 2/5 task slots\"\n{\n  actorId: \"research-worker-3\",\n  rooms: [\"research-workflow\"],\n  status: \"available\",\n  capabilities: [\"research\", \"finance\"],\n  concurrency: { current: 3, max: 5 },\n  lastHeartbeat: \"2024-01-15T10:30:00Z\"\n}\n","Presence Comparison",[84,9871,9872,9877,9882,9894,9912,9924,9934,9938,9942,9947,9951,9962,9973,9984,10001,10028,10037],{"__ignoreMap":144},[148,9873,9874],{"class":150,"line":151},[148,9875,9876],{"class":154},"// Human: \"Alice is online in #general\"\n",[148,9878,9879],{"class":150,"line":158},[148,9880,9881],{"class":178},"{\n",[148,9883,9884,9887,9889,9892],{"class":150,"line":164},[148,9885,9886],{"class":174},"  actorId",[148,9888,3020],{"class":178},[148,9890,9891],{"class":299},"\"user-alice\"",[148,9893,2982],{"class":178},[148,9895,9896,9899,9902,9905,9907,9910],{"class":150,"line":182},[148,9897,9898],{"class":174},"  rooms",[148,9900,9901],{"class":178},": [",[148,9903,9904],{"class":299},"\"general\"",[148,9906,87],{"class":178},[148,9908,9909],{"class":299},"\"random\"",[148,9911,5064],{"class":178},[148,9913,9914,9917,9919,9922],{"class":150,"line":195},[148,9915,9916],{"class":174},"  status",[148,9918,3020],{"class":178},[148,9920,9921],{"class":299},"\"online\"",[148,9923,2982],{"class":178},[148,9925,9926,9929,9931],{"class":150,"line":207},[148,9927,9928],{"class":174},"  lastSeen",[148,9930,3020],{"class":178},[148,9932,9933],{"class":299},"\"2024-01-15T10:30:00Z\"\n",[148,9935,9936],{"class":150,"line":217},[148,9937,230],{"class":178},[148,9939,9940],{"class":150,"line":227},[148,9941,916],{"emptyLinePlaceholder":704},[148,9943,9944],{"class":150,"line":513},[148,9945,9946],{"class":154},"// Agent: \"research-worker-3 is available with 2/5 task slots\"\n",[148,9948,9949],{"class":150,"line":993},[148,9950,9881],{"class":178},[148,9952,9953,9955,9957,9960],{"class":150,"line":1004},[148,9954,9886],{"class":174},[148,9956,3020],{"class":178},[148,9958,9959],{"class":299},"\"research-worker-3\"",[148,9961,2982],{"class":178},[148,9963,9964,9966,9968,9971],{"class":150,"line":1013},[148,9965,9898],{"class":174},[148,9967,9901],{"class":178},[148,9969,9970],{"class":299},"\"research-workflow\"",[148,9972,5064],{"class":178},[148,9974,9975,9977,9979,9982],{"class":150,"line":1019},[148,9976,9916],{"class":174},[148,9978,3020],{"class":178},[148,9980,9981],{"class":299},"\"available\"",[148,9983,2982],{"class":178},[148,9985,9986,9989,9991,9994,9996,9999],{"class":150,"line":1028},[148,9987,9988],{"class":174},"  capabilities",[148,9990,9901],{"class":178},[148,9992,9993],{"class":299},"\"research\"",[148,9995,87],{"class":178},[148,9997,9998],{"class":299},"\"finance\"",[148,10000,5064],{"class":178},[148,10002,10003,10006,10009,10012,10014,10017,10019,10022,10024,10026],{"class":150,"line":1034},[148,10004,10005],{"class":174},"  concurrency",[148,10007,10008],{"class":178},": { ",[148,10010,10011],{"class":174},"current",[148,10013,3020],{"class":178},[148,10015,10016],{"class":203},"3",[148,10018,87],{"class":178},[148,10020,10021],{"class":174},"max",[148,10023,3020],{"class":178},[148,10025,8904],{"class":203},[148,10027,5081],{"class":178},[148,10029,10030,10033,10035],{"class":150,"line":1040},[148,10031,10032],{"class":174},"  lastHeartbeat",[148,10034,3020],{"class":178},[148,10036,9933],{"class":299},[148,10038,10039],{"class":150,"line":1046},[148,10040,230],{"class":178},[12,10042,10043],{},"The Handoff pattern uses presence to make routing decisions. When a task arrives with required capabilities, the system checks which workers are present, which have matching capabilities, and which have available capacity. This is the same fan-out logic that routes chat messages to room members, extended with capability-based filtering.",[35,10045,10047],{"id":10046},"qos-levels-for-different-patterns","QoS Levels for Different Patterns",[12,10049,10050],{},"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 or duplicated message.",[135,10052,10053],{},[138,10054,10057],{"className":140,"code":10055,"filename":10056,"language":143,"meta":144,"style":144},"// QoS 0 (fire and forget): typing indicators, cursor positions\n// Lost messages are harmless - next update overwrites anyway\nroom.observe.on(\"cursor:move\", handler);  // Agent equivalent: ephemeral status\n\n// QoS 1 (at least once): notifications, event stream\n// Duplicate delivery is acceptable - events are idempotent\nroom.observe.on(\"task:completed\", handler);\n\n// QoS 2 (exactly once): chat messages, task dispatch\n// Critical path - duplicates or missed messages break the workflow\nroom.handoff.dispatch({ ... });  // Must be delivered exactly once\n","QoS Choices",[84,10058,10059,10064,10069,10086,10090,10095,10100,10113,10117,10122,10127],{"__ignoreMap":144},[148,10060,10061],{"class":150,"line":151},[148,10062,10063],{"class":154},"// QoS 0 (fire and forget): typing indicators, cursor positions\n",[148,10065,10066],{"class":150,"line":158},[148,10067,10068],{"class":154},"// Lost messages are harmless - next update overwrites anyway\n",[148,10070,10071,10073,10075,10077,10080,10083],{"class":150,"line":164},[148,10072,9446],{"class":178},[148,10074,2267],{"class":174},[148,10076,296],{"class":178},[148,10078,10079],{"class":299},"\"cursor:move\"",[148,10081,10082],{"class":178},", handler);  ",[148,10084,10085],{"class":154},"// Agent equivalent: ephemeral status\n",[148,10087,10088],{"class":150,"line":182},[148,10089,916],{"emptyLinePlaceholder":704},[148,10091,10092],{"class":150,"line":195},[148,10093,10094],{"class":154},"// QoS 1 (at least once): notifications, event stream\n",[148,10096,10097],{"class":150,"line":207},[148,10098,10099],{"class":154},"// Duplicate delivery is acceptable - events are idempotent\n",[148,10101,10102,10104,10106,10108,10110],{"class":150,"line":217},[148,10103,9446],{"class":178},[148,10105,2267],{"class":174},[148,10107,296],{"class":178},[148,10109,9453],{"class":299},[148,10111,10112],{"class":178},", handler);\n",[148,10114,10115],{"class":150,"line":227},[148,10116,916],{"emptyLinePlaceholder":704},[148,10118,10119],{"class":150,"line":513},[148,10120,10121],{"class":154},"// QoS 2 (exactly once): chat messages, task dispatch\n",[148,10123,10124],{"class":150,"line":993},[148,10125,10126],{"class":154},"// Critical path - duplicates or missed messages break the workflow\n",[148,10128,10129,10131,10133,10135,10137,10140],{"class":150,"line":1004},[148,10130,8882],{"class":178},[148,10132,5549],{"class":174},[148,10134,4594],{"class":178},[148,10136,1285],{"class":167},[148,10138,10139],{"class":178}," });  ",[148,10141,10142],{"class":154},"// Must be delivered exactly once\n",[12,10144,10145],{},"This is one of the advantages of building on real messaging infrastructure rather than a custom agent framework. QoS levels are a solved problem in messaging. We don't need to reinvent delivery guarantees - we just map each pattern to the appropriate level.",[35,10147,10149],{"id":10148},"rooms-as-workflow-boundaries","Rooms as Workflow Boundaries",[12,10151,10152,10153,10156,10157,10160],{},"In chat, rooms group related conversations. In agent coordination, rooms group related workflows. A room called ",[84,10154,10155],{},"research-pipeline"," contains all the topics for that pipeline: task dispatch, results, shared state, and events. A separate room called ",[84,10158,10159],{},"content-review"," contains its own isolated set of topics.",[12,10162,10163],{},"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.",[35,10165,10167],{"id":10166},"performance-characteristics","Performance Characteristics",[12,10169,10170],{},"Agent workloads have different performance profiles than human real-time:",[40,10172,10173,10186],{},[43,10174,10175],{},[46,10176,10177,10180,10183],{},[49,10178,10179],{},"Metric",[49,10181,10182],{},"Human Realtime",[49,10184,10185],{},"Agent Realtime",[59,10187,10188,10199,10210,10221,10232,10243],{},[46,10189,10190,10193,10196],{},[64,10191,10192],{},"Message size",[64,10194,10195],{},"Small (chat text, GPS coords)",[64,10197,10198],{},"Medium-large (task payloads, LLM outputs)",[46,10200,10201,10204,10207],{},[64,10202,10203],{},"Message rate",[64,10205,10206],{},"Bursty (typing, idle cycles)",[64,10208,10209],{},"Steady (continuous task dispatch)",[46,10211,10212,10215,10218],{},[64,10213,10214],{},"Latency sensitivity",[64,10216,10217],{},"High (users notice 200ms+)",[64,10219,10220],{},"Moderate (agents tolerate seconds)",[46,10222,10223,10226,10229],{},[64,10224,10225],{},"Delivery guarantee",[64,10227,10228],{},"Mixed (QoS 0-2)",[64,10230,10231],{},"Mostly QoS 2 (task integrity)",[46,10233,10234,10237,10240],{},[64,10235,10236],{},"Connection count",[64,10238,10239],{},"Many (one per user/tab)",[64,10241,10242],{},"Few (one per agent process)",[46,10244,10245,10248,10251],{},[64,10246,10247],{},"Fan-out ratio",[64,10249,10250],{},"High (1:1000 in large rooms)",[64,10252,10253],{},"Low (1:1 task dispatch, 1:N observe)",[12,10255,10256],{},"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 QoS 2 with message persistence ensures no task is lost, even during worker restarts.",[35,10258,10260],{"id":10259},"access-control-carries-over","Access Control Carries Over",[12,10262,10263],{},"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,10265,10266],{},"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,10268,10270],{"id":10269},"the-insight","The Insight",[12,10272,10273],{},"Agent coordination is a real-time messaging problem wearing a different hat. The primitives are the same: publish, subscribe, presence, persistence, 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,10275,10276,10277,10279],{},"That's why we built ",[84,10278,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,10281,10282],{},[667,10283,10285],{"href":10284},"/agents","Explore @nolag/agents →",[684,10287,10288],{},"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 .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 .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":144,"searchDepth":158,"depth":158,"links":10290},[10291,10292,10293,10294,10295,10296,10297,10298],{"id":9690,"depth":158,"text":9691},{"id":9842,"depth":158,"text":9843},{"id":9859,"depth":158,"text":9860},{"id":10046,"depth":158,"text":10047},{"id":10148,"depth":158,"text":10149},{"id":10166,"depth":158,"text":10167},{"id":10259,"depth":158,"text":10260},{"id":10269,"depth":158,"text":10270},"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":9679,"description":10299},"blog/human-to-agent-realtime","RTqalqcxys-Dh94mHytEqyxD0V2ZZeed-YRIpoc543c",{"id":10306,"title":10307,"author":7,"body":10308,"category":1518,"date":9669,"description":10911,"excerpt":701,"extension":702,"meta":10912,"navigation":704,"path":669,"readTime":10913,"seo":10914,"stem":10915,"__hash__":10916},"blog/blog/multi-agent-coordination-layer.md","Why Multi-Agent Systems Need a Coordination Layer",{"type":9,"value":10309,"toc":10904},[10310,10313,10316,10320,10323,10450,10453,10456,10460,10463,10469,10475,10481,10487,10491,10494,10527,10530,10534,10537,10878,10881,10885,10888,10893,10896,10901],[12,10311,10312],{},"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,10314,10315],{},"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,10317,10319],{"id":10318},"the-problem-with-direct-orchestration","The Problem with Direct Orchestration",[12,10321,10322],{},"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,10324,10325],{},[138,10326,10328],{"className":140,"code":10327,"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 the orchestrator crashes, everything is lost\n",[84,10329,10330,10335,10355,10374,10393,10412,10416,10420,10425,10430,10435,10440,10445],{"__ignoreMap":144},[148,10331,10332],{"class":150,"line":151},[148,10333,10334],{"class":154},"// The naive approach: direct function calls\n",[148,10336,10337,10339,10342,10344,10346,10349,10352],{"class":150,"line":158},[148,10338,270],{"class":167},[148,10340,10341],{"class":203}," research",[148,10343,276],{"class":167},[148,10345,499],{"class":167},[148,10347,10348],{"class":178}," researchAgent.",[148,10350,10351],{"class":174},"run",[148,10353,10354],{"class":178},"(query);\n",[148,10356,10357,10359,10362,10364,10366,10369,10371],{"class":150,"line":164},[148,10358,270],{"class":167},[148,10360,10361],{"class":203}," draft",[148,10363,276],{"class":167},[148,10365,499],{"class":167},[148,10367,10368],{"class":178}," draftAgent.",[148,10370,10351],{"class":174},[148,10372,10373],{"class":178},"(research);\n",[148,10375,10376,10378,10381,10383,10385,10388,10390],{"class":150,"line":182},[148,10377,270],{"class":167},[148,10379,10380],{"class":203}," review",[148,10382,276],{"class":167},[148,10384,499],{"class":167},[148,10386,10387],{"class":178}," reviewAgent.",[148,10389,10351],{"class":174},[148,10391,10392],{"class":178},"(draft);\n",[148,10394,10395,10397,10400,10402,10404,10407,10409],{"class":150,"line":195},[148,10396,270],{"class":167},[148,10398,10399],{"class":203}," final",[148,10401,276],{"class":167},[148,10403,499],{"class":167},[148,10405,10406],{"class":178}," publishAgent.",[148,10408,10351],{"class":174},[148,10410,10411],{"class":178},"(review);\n",[148,10413,10414],{"class":150,"line":207},[148,10415,916],{"emptyLinePlaceholder":704},[148,10417,10418],{"class":150,"line":217},[148,10419,6695],{"class":154},[148,10421,10422],{"class":150,"line":227},[148,10423,10424],{"class":154},"// - Sequential, no parallelism\n",[148,10426,10427],{"class":150,"line":513},[148,10428,10429],{"class":154},"// - No visibility into what's happening\n",[148,10431,10432],{"class":150,"line":993},[148,10433,10434],{"class":154},"// - No way to retry a single step\n",[148,10436,10437],{"class":150,"line":1004},[148,10438,10439],{"class":154},"// - No shared state between agents\n",[148,10441,10442],{"class":150,"line":1013},[148,10443,10444],{"class":154},"// - No human approval before publish\n",[148,10446,10447],{"class":150,"line":1019},[148,10448,10449],{"class":154},"// - If the orchestrator crashes, everything is lost\n",[12,10451,10452],{},"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 the orchestrator process crashes.",[12,10454,10455],{},"These aren't edge cases. They're table stakes for production multi-agent systems.",[35,10457,10459],{"id":10458},"why-pubsub-is-the-right-primitive","Why Pub/Sub Is the Right Primitive",[12,10461,10462],{},"Publish/subscribe messaging solves the coordination problem at the right level of abstraction. Here's why:",[12,10464,10465,10468],{},[30,10466,10467],{},"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,10470,10471,10474],{},[30,10472,10473],{},"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,10476,10477,10480],{},[30,10478,10479],{},"Persistence."," With QoS 2 (exactly-once delivery) and message retention, the system survives process crashes. Tasks aren't lost. State is recoverable. Workers can reconnect and pick up where they left off.",[12,10482,10483,10486],{},[30,10484,10485],{},"Presence."," Built-in presence tracking means you always know which agents are online, which are idle, and which are overloaded. No custom health check infrastructure needed.",[35,10488,10490],{"id":10489},"six-patterns-as-coordination-primitives","Six Patterns as Coordination Primitives",[12,10492,10493],{},"We identified six patterns that cover the coordination needs of multi-agent systems:",[629,10495,10496,10501,10507,10512,10517,10522],{},[600,10497,10498,10500],{},[30,10499,117],{}," - Task dispatch and result collection with capability-based routing",[600,10502,10503,10506],{},[30,10504,10505],{},"Inbox"," - Direct agent-to-agent messaging for point-to-point communication",[600,10508,10509,10511],{},[30,10510,129],{}," - Shared state with versioning so all agents see the same world",[600,10513,10514,10516],{},[30,10515,120],{}," - Real-time event stream for monitoring, logging, and debugging",[600,10518,10519,10521],{},[30,10520,126],{}," - Human-in-the-loop gates for high-stakes decisions",[600,10523,10524,10526],{},[30,10525,123],{}," - Remote tool invocation so agents can share capabilities",[12,10528,10529],{},"Each pattern maps to specific topics, QoS levels, and message flows. But you interact with them through a high-level SDK that hides the pub/sub mechanics.",[35,10531,10533],{"id":10532},"what-coordination-actually-looks-like","What Coordination Actually Looks Like",[12,10535,10536],{},"Here's the same content pipeline from earlier, but with proper coordination infrastructure:",[135,10538,10539],{},[138,10540,10542],{"className":140,"code":10541,"filename":142,"language":143,"meta":144,"style":144},"import { NoLagAgents } from \"@nolag/agents\";\n\nconst agents = new NoLagAgents(TOKEN);\nawait agents.connect();\nconst room = agents.joinRoom(\"content-pipeline\");\n\n// Shared state visible to all agents\nawait room.blackboard.set(\"plan\", {\n  steps: [\"research\", \"draft\", \"review\", \"publish\"],\n  current: 0,\n});\n\n// Dispatch to any capable worker (parallel-ready)\nawait room.handoff.dispatch({\n  type: \"research\",\n  payload: { query },\n  capabilities: [\"research\"],\n});\n\n// Human gate before publish\nroom.handoff.onResult(async (result) => {\n  if (result.taskType === \"review\") {\n    const approval = await room.approve.request({\n      action: \"publish\",\n      content: result.data,\n    });\n    if (approval.approved) {\n      await room.handoff.dispatch({\n        type: \"publish\", payload: result.data,\n        capabilities: [\"publishing\"],\n      });\n    }\n  }\n});\n\n// Full observability\nroom.observe.on(\"*\", (e) => log(e));\n",[84,10543,10544,10556,10560,10578,10588,10607,10611,10616,10632,10656,10665,10669,10673,10678,10689,10698,10703,10711,10715,10719,10724,10744,10758,10774,10782,10787,10791,10797,10807,10817,10827,10831,10835,10839,10843,10847,10852],{"__ignoreMap":144},[148,10545,10546,10548,10550,10552,10554],{"class":150,"line":151},[148,10547,902],{"class":167},[148,10549,8415],{"class":178},[148,10551,908],{"class":167},[148,10553,4935],{"class":299},[148,10555,4938],{"class":178},[148,10557,10558],{"class":150,"line":158},[148,10559,916],{"emptyLinePlaceholder":704},[148,10561,10562,10564,10566,10568,10570,10572,10574,10576],{"class":150,"line":164},[148,10563,270],{"class":167},[148,10565,8482],{"class":203},[148,10567,276],{"class":167},[148,10569,279],{"class":167},[148,10571,4997],{"class":174},[148,10573,296],{"class":178},[148,10575,5002],{"class":203},[148,10577,5126],{"class":178},[148,10579,10580,10582,10584,10586],{"class":150,"line":182},[148,10581,943],{"class":167},[148,10583,575],{"class":178},[148,10585,2204],{"class":174},[148,10587,5106],{"class":178},[148,10589,10590,10592,10594,10596,10598,10600,10602,10605],{"class":150,"line":195},[148,10591,270],{"class":167},[148,10593,2217],{"class":203},[148,10595,276],{"class":167},[148,10597,575],{"class":178},[148,10599,8542],{"class":174},[148,10601,296],{"class":178},[148,10603,10604],{"class":299},"\"content-pipeline\"",[148,10606,5126],{"class":178},[148,10608,10609],{"class":150,"line":207},[148,10610,916],{"emptyLinePlaceholder":704},[148,10612,10613],{"class":150,"line":217},[148,10614,10615],{"class":154},"// Shared state visible to all agents\n",[148,10617,10618,10620,10623,10625,10627,10630],{"class":150,"line":227},[148,10619,943],{"class":167},[148,10621,10622],{"class":178}," room.blackboard.",[148,10624,1994],{"class":174},[148,10626,296],{"class":178},[148,10628,10629],{"class":299},"\"plan\"",[148,10631,2971],{"class":178},[148,10633,10634,10637,10639,10641,10644,10646,10649,10651,10654],{"class":150,"line":513},[148,10635,10636],{"class":178},"  steps: [",[148,10638,9993],{"class":299},[148,10640,87],{"class":178},[148,10642,10643],{"class":299},"\"draft\"",[148,10645,87],{"class":178},[148,10647,10648],{"class":299},"\"review\"",[148,10650,87],{"class":178},[148,10652,10653],{"class":299},"\"publish\"",[148,10655,5064],{"class":178},[148,10657,10658,10661,10663],{"class":150,"line":993},[148,10659,10660],{"class":178},"  current: ",[148,10662,7693],{"class":203},[148,10664,2982],{"class":178},[148,10666,10667],{"class":150,"line":1004},[148,10668,5090],{"class":178},[148,10670,10671],{"class":150,"line":1013},[148,10672,916],{"emptyLinePlaceholder":704},[148,10674,10675],{"class":150,"line":1019},[148,10676,10677],{"class":154},"// Dispatch to any capable worker (parallel-ready)\n",[148,10679,10680,10682,10685,10687],{"class":150,"line":1028},[148,10681,943],{"class":167},[148,10683,10684],{"class":178}," room.handoff.",[148,10686,5549],{"class":174},[148,10688,972],{"class":178},[148,10690,10691,10694,10696],{"class":150,"line":1034},[148,10692,10693],{"class":178},"  type: ",[148,10695,9993],{"class":299},[148,10697,2982],{"class":178},[148,10699,10700],{"class":150,"line":1040},[148,10701,10702],{"class":178},"  payload: { query },\n",[148,10704,10705,10707,10709],{"class":150,"line":1046},[148,10706,8892],{"class":178},[148,10708,9993],{"class":299},[148,10710,5064],{"class":178},[148,10712,10713],{"class":150,"line":1052},[148,10714,5090],{"class":178},[148,10716,10717],{"class":150,"line":1699},[148,10718,916],{"emptyLinePlaceholder":704},[148,10720,10721],{"class":150,"line":1705},[148,10722,10723],{"class":154},"// Human gate before publish\n",[148,10725,10726,10728,10730,10732,10734,10736,10738,10740,10742],{"class":150,"line":1711},[148,10727,8882],{"class":178},[148,10729,455],{"class":174},[148,10731,296],{"class":178},[148,10733,1129],{"class":167},[148,10735,114],{"class":178},[148,10737,540],{"class":185},[148,10739,1142],{"class":178},[148,10741,1145],{"class":167},[148,10743,179],{"class":178},[148,10745,10746,10748,10751,10753,10756],{"class":150,"line":1717},[148,10747,8614],{"class":167},[148,10749,10750],{"class":178}," (result.taskType ",[148,10752,1386],{"class":167},[148,10754,10755],{"class":299}," \"review\"",[148,10757,4238],{"class":178},[148,10759,10760,10762,10764,10766,10768,10770,10772],{"class":150,"line":1723},[148,10761,1152],{"class":167},[148,10763,9223],{"class":203},[148,10765,276],{"class":167},[148,10767,499],{"class":167},[148,10769,9230],{"class":178},[148,10771,9233],{"class":174},[148,10773,972],{"class":178},[148,10775,10776,10778,10780],{"class":150,"line":1729},[148,10777,9240],{"class":178},[148,10779,10653],{"class":299},[148,10781,2982],{"class":178},[148,10783,10784],{"class":150,"line":1735},[148,10785,10786],{"class":178},"      content: result.data,\n",[148,10788,10789],{"class":150,"line":1741},[148,10790,9270],{"class":178},[148,10792,10793,10795],{"class":150,"line":1746},[148,10794,1380],{"class":167},[148,10796,9281],{"class":178},[148,10798,10799,10801,10803,10805],{"class":150,"line":1752},[148,10800,3349],{"class":167},[148,10802,10684],{"class":178},[148,10804,5549],{"class":174},[148,10806,972],{"class":178},[148,10808,10809,10812,10814],{"class":150,"line":1757},[148,10810,10811],{"class":178},"        type: ",[148,10813,10653],{"class":299},[148,10815,10816],{"class":178},", payload: result.data,\n",[148,10818,10819,10822,10825],{"class":150,"line":1763},[148,10820,10821],{"class":178},"        capabilities: [",[148,10823,10824],{"class":299},"\"publishing\"",[148,10826,5064],{"class":178},[148,10828,10829],{"class":150,"line":1769},[148,10830,9312],{"class":178},[148,10832,10833],{"class":150,"line":1774},[148,10834,3481],{"class":178},[148,10836,10837],{"class":150,"line":3517},[148,10838,1402],{"class":178},[148,10840,10841],{"class":150,"line":3522},[148,10842,5090],{"class":178},[148,10844,10845],{"class":150,"line":3530},[148,10846,916],{"emptyLinePlaceholder":704},[148,10848,10849],{"class":150,"line":3542},[148,10850,10851],{"class":154},"// Full observability\n",[148,10853,10854,10856,10858,10860,10863,10865,10868,10870,10872,10875],{"class":150,"line":3566},[148,10855,9446],{"class":178},[148,10857,2267],{"class":174},[148,10859,296],{"class":178},[148,10861,10862],{"class":299},"\"*\"",[148,10864,2274],{"class":178},[148,10866,10867],{"class":185},"e",[148,10869,1142],{"class":178},[148,10871,1145],{"class":167},[148,10873,10874],{"class":174}," log",[148,10876,10877],{"class":178},"(e));\n",[12,10879,10880],{},"The difference: workers can be scaled independently, every event is observable, shared state is accessible to all agents, human approval gates are built in, and the system survives crashes because messages are persisted.",[35,10882,10884],{"id":10883},"nolag-as-the-substrate","NoLag as the Substrate",[12,10886,10887],{},"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,10889,10890,10892],{},[84,10891,90],{}," is a high-level SDK that wraps these primitives into the six patterns above. Under the hood, it uses the same battle-tested WebSocket infrastructure that powers millions of real-time messages. You get persistent queues, exactly-once delivery, per-topic ACL, and sub-50ms latency without building any of it yourself.",[12,10894,10895],{},"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,10897,10898],{},[667,10899,10900],{"href":10284},"Learn more about @nolag/agents →",[684,10902,10903],{},"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":158,"depth":158,"links":10905},[10906,10907,10908,10909,10910],{"id":10318,"depth":158,"text":10319},{"id":10458,"depth":158,"text":10459},{"id":10489,"depth":158,"text":10490},{"id":10532,"depth":158,"text":10533},{"id":10883,"depth":158,"text":10884},"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":10307,"description":10911},"blog/multi-agent-coordination-layer","fIeG-k2clsE_sMQcaBQBJPR61kQ3jY7TakZkRixUbMY",{"id":10918,"title":10919,"author":7,"body":10920,"category":698,"date":11592,"description":11593,"excerpt":701,"extension":702,"meta":11594,"navigation":704,"path":11595,"readTime":1888,"seo":11596,"stem":11597,"__hash__":11598},"blog/blog/websocket-vs-polling.md","WebSockets vs Polling: Why Real-Time Apps Need a Persistent Connection",{"type":9,"value":10921,"toc":11583},[10922,10925,10928,10932,10935,11029,11032,11035,11039,11042,11142,11145,11149,11152,11235,11238,11241,11245,11248,11433,11436,11440,11443,11551,11554,11558,11561,11564,11568,11571,11574,11577,11580],[12,10923,10924],{},"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,10926,10927],{},"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,10929,10931],{"id":10930},"short-polling-the-simplest-thing-that-could-possibly-work","Short Polling: The Simplest Thing That Could Possibly Work",[12,10933,10934],{},"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,10936,10937],{},[138,10938,10940],{"className":140,"code":10939,"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,10941,10942,10947,10962,10985,11002,11019],{"__ignoreMap":144},[148,10943,10944],{"class":150,"line":151},[148,10945,10946],{"class":154},"// Short polling every 3 seconds\n",[148,10948,10949,10952,10954,10956,10958,10960],{"class":150,"line":158},[148,10950,10951],{"class":174},"setInterval",[148,10953,296],{"class":178},[148,10955,1129],{"class":167},[148,10957,3263],{"class":178},[148,10959,1145],{"class":167},[148,10961,179],{"class":178},[148,10963,10964,10966,10968,10970,10972,10974,10976,10979,10982],{"class":150,"line":164},[148,10965,491],{"class":167},[148,10967,2956],{"class":203},[148,10969,276],{"class":167},[148,10971,499],{"class":167},[148,10973,2963],{"class":174},[148,10975,296],{"class":178},[148,10977,10978],{"class":299},"'/api/messages?since='",[148,10980,10981],{"class":167}," +",[148,10983,10984],{"class":178}," lastTimestamp)\n",[148,10986,10987,10989,10992,10994,10996,10998,11000],{"class":150,"line":182},[148,10988,491],{"class":167},[148,10990,10991],{"class":203}," data",[148,10993,276],{"class":167},[148,10995,499],{"class":167},[148,10997,3080],{"class":178},[148,10999,3083],{"class":174},[148,11001,505],{"class":178},[148,11003,11004,11006,11009,11011,11013,11016],{"class":150,"line":195},[148,11005,8614],{"class":167},[148,11007,11008],{"class":178}," (data.messages.",[148,11010,5518],{"class":203},[148,11012,1142],{"class":178},[148,11014,11015],{"class":174},"renderMessages",[148,11017,11018],{"class":178},"(data.messages)\n",[148,11020,11021,11024,11027],{"class":150,"line":207},[148,11022,11023],{"class":178},"}, ",[148,11025,11026],{"class":203},"3000",[148,11028,303],{"class":178},[12,11030,11031],{},"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,11033,11034],{},"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,11036,11038],{"id":11037},"long-polling-holding-the-connection-open","Long Polling: Holding the Connection Open",[12,11040,11041],{},"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,11043,11044],{},[138,11045,11047],{"className":140,"code":11046,"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,11048,11049,11054,11065,11072,11091,11107,11115,11124,11134,11138],{"__ignoreMap":144},[148,11050,11051],{"class":150,"line":151},[148,11052,11053],{"class":154},"// Long polling - client re-requests immediately after each response\n",[148,11055,11056,11058,11060,11063],{"class":150,"line":158},[148,11057,1129],{"class":167},[148,11059,418],{"class":167},[148,11061,11062],{"class":174}," longPoll",[148,11064,2949],{"class":178},[148,11066,11067,11070],{"class":150,"line":164},[148,11068,11069],{"class":167},"  try",[148,11071,179],{"class":178},[148,11073,11074,11076,11078,11080,11082,11084,11086,11089],{"class":150,"line":182},[148,11075,1152],{"class":167},[148,11077,2956],{"class":203},[148,11079,276],{"class":167},[148,11081,499],{"class":167},[148,11083,2963],{"class":174},[148,11085,296],{"class":178},[148,11087,11088],{"class":299},"'/api/events?timeout=30'",[148,11090,303],{"class":178},[148,11092,11093,11095,11097,11099,11101,11103,11105],{"class":150,"line":195},[148,11094,1152],{"class":167},[148,11096,10991],{"class":203},[148,11098,276],{"class":167},[148,11100,499],{"class":167},[148,11102,3080],{"class":178},[148,11104,3083],{"class":174},[148,11106,505],{"class":178},[148,11108,11109,11112],{"class":150,"line":207},[148,11110,11111],{"class":174},"    handleEvent",[148,11113,11114],{"class":178},"(data)\n",[148,11116,11117,11119,11122],{"class":150,"line":217},[148,11118,9321],{"class":178},[148,11120,11121],{"class":167},"finally",[148,11123,179],{"class":178},[148,11125,11126,11129,11131],{"class":150,"line":227},[148,11127,11128],{"class":174},"    longPoll",[148,11130,5297],{"class":178},[148,11132,11133],{"class":154},"// reconnect immediately\n",[148,11135,11136],{"class":150,"line":513},[148,11137,1402],{"class":178},[148,11139,11140],{"class":150,"line":993},[148,11141,230],{"class":178},[12,11143,11144],{},"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,11146,11148],{"id":11147},"server-sent-events-one-direction-low-overhead","Server-Sent Events: One Direction, Low Overhead",[12,11150,11151],{},"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,11153,11154],{},[138,11155,11157],{"className":140,"code":11156,"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,11158,11159,11164,11185,11205,11224,11231],{"__ignoreMap":144},[148,11160,11161],{"class":150,"line":151},[148,11162,11163],{"class":154},"// SSE on the client - browser handles reconnection automatically\n",[148,11165,11166,11168,11171,11173,11175,11178,11180,11183],{"class":150,"line":158},[148,11167,270],{"class":167},[148,11169,11170],{"class":203}," source",[148,11172,276],{"class":167},[148,11174,279],{"class":167},[148,11176,11177],{"class":174}," EventSource",[148,11179,296],{"class":178},[148,11181,11182],{"class":299},"'/api/stream'",[148,11184,303],{"class":178},[148,11186,11187,11190,11193,11195,11197,11199,11201,11203],{"class":150,"line":164},[148,11188,11189],{"class":178},"source.",[148,11191,11192],{"class":174},"onmessage",[148,11194,276],{"class":167},[148,11196,114],{"class":178},[148,11198,1369],{"class":185},[148,11200,1142],{"class":178},[148,11202,1145],{"class":167},[148,11204,179],{"class":178},[148,11206,11207,11209,11211,11213,11216,11218,11221],{"class":150,"line":182},[148,11208,491],{"class":167},[148,11210,10991],{"class":203},[148,11212,276],{"class":167},[148,11214,11215],{"class":203}," JSON",[148,11217,1562],{"class":178},[148,11219,11220],{"class":174},"parse",[148,11222,11223],{"class":178},"(event.data)\n",[148,11225,11226,11229],{"class":150,"line":195},[148,11227,11228],{"class":174},"  renderUpdate",[148,11230,11114],{"class":178},[148,11232,11233],{"class":150,"line":207},[148,11234,230],{"class":178},[12,11236,11237],{},"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,11239,11240],{},"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,11242,11244],{"id":11243},"websockets-persistent-bidirectional-low-overhead","WebSockets: Persistent, Bidirectional, Low Overhead",[12,11246,11247],{},"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,11249,11250],{},[138,11251,11253],{"className":140,"code":11252,"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,11254,11255,11260,11281,11285,11301,11331,11335,11339,11357,11374,11382,11386,11390,11395,11408,11429],{"__ignoreMap":144},[148,11256,11257],{"class":150,"line":151},[148,11258,11259],{"class":154},"// WebSocket - bidirectional, persistent\n",[148,11261,11262,11264,11267,11269,11271,11274,11276,11279],{"class":150,"line":158},[148,11263,270],{"class":167},[148,11265,11266],{"class":203}," ws",[148,11268,276],{"class":167},[148,11270,279],{"class":167},[148,11272,11273],{"class":174}," WebSocket",[148,11275,296],{"class":178},[148,11277,11278],{"class":299},"'wss://api.example.com/ws'",[148,11280,303],{"class":178},[148,11282,11283],{"class":150,"line":164},[148,11284,916],{"emptyLinePlaceholder":704},[148,11286,11287,11290,11293,11295,11297,11299],{"class":150,"line":182},[148,11288,11289],{"class":178},"ws.",[148,11291,11292],{"class":174},"onopen",[148,11294,276],{"class":167},[148,11296,3263],{"class":178},[148,11298,1145],{"class":167},[148,11300,179],{"class":178},[148,11302,11303,11306,11309,11311,11313,11315,11317,11320,11323,11326,11328],{"class":150,"line":195},[148,11304,11305],{"class":178},"  ws.",[148,11307,11308],{"class":174},"send",[148,11310,296],{"class":178},[148,11312,3038],{"class":203},[148,11314,1562],{"class":178},[148,11316,3043],{"class":174},[148,11318,11319],{"class":178},"({ type: ",[148,11321,11322],{"class":299},"'subscribe'",[148,11324,11325],{"class":178},", room: ",[148,11327,2240],{"class":299},[148,11329,11330],{"class":178}," }))\n",[148,11332,11333],{"class":150,"line":207},[148,11334,230],{"class":178},[148,11336,11337],{"class":150,"line":217},[148,11338,916],{"emptyLinePlaceholder":704},[148,11340,11341,11343,11345,11347,11349,11351,11353,11355],{"class":150,"line":227},[148,11342,11289],{"class":178},[148,11344,11192],{"class":174},[148,11346,276],{"class":167},[148,11348,114],{"class":178},[148,11350,1369],{"class":185},[148,11352,1142],{"class":178},[148,11354,1145],{"class":167},[148,11356,179],{"class":178},[148,11358,11359,11361,11364,11366,11368,11370,11372],{"class":150,"line":513},[148,11360,491],{"class":167},[148,11362,11363],{"class":203}," msg",[148,11365,276],{"class":167},[148,11367,11215],{"class":203},[148,11369,1562],{"class":178},[148,11371,11220],{"class":174},[148,11373,11223],{"class":178},[148,11375,11376,11379],{"class":150,"line":993},[148,11377,11378],{"class":174},"  handleMessage",[148,11380,11381],{"class":178},"(msg)\n",[148,11383,11384],{"class":150,"line":1004},[148,11385,230],{"class":178},[148,11387,11388],{"class":150,"line":1013},[148,11389,916],{"emptyLinePlaceholder":704},[148,11391,11392],{"class":150,"line":1019},[148,11393,11394],{"class":154},"// Send from client at any time - no new request needed\n",[148,11396,11397,11399,11402,11404,11406],{"class":150,"line":1028},[148,11398,4589],{"class":167},[148,11400,11401],{"class":174}," sendMessage",[148,11403,296],{"class":178},[148,11405,830],{"class":185},[148,11407,4238],{"class":178},[148,11409,11410,11412,11414,11416,11418,11420,11422,11424,11426],{"class":150,"line":1034},[148,11411,11305],{"class":178},[148,11413,11308],{"class":174},[148,11415,296],{"class":178},[148,11417,3038],{"class":203},[148,11419,1562],{"class":178},[148,11421,3043],{"class":174},[148,11423,11319],{"class":178},[148,11425,2582],{"class":299},[148,11427,11428],{"class":178},", text }))\n",[148,11430,11431],{"class":150,"line":1040},[148,11432,230],{"class":178},[12,11434,11435],{},"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,11437,11439],{"id":11438},"the-overhead-math","The Overhead Math",[12,11441,11442],{},"To make the comparison concrete, here is a rough per-message cost comparison for a minimal payload (say, a 40-byte JSON message):",[135,11444,11445],{},[138,11446,11448],{"className":1578,"code":11447,"filename":8313,"language":1580,"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,11449,11450,11473,11493,11512,11534],{"__ignoreMap":144},[148,11451,11452,11455,11458,11461,11464,11467,11470],{"class":150,"line":151},[148,11453,11454],{"class":174},"Short",[148,11456,11457],{"class":299}," polling",[148,11459,11460],{"class":178}," (empty ",[148,11462,11463],{"class":299},"response",[148,11465,11466],{"class":178},"):  ",[148,11468,11469],{"class":167},"~",[148,11471,11472],{"class":178},"800 bytes headers + 0 bytes payload = 800 bytes wasted\n",[148,11474,11475,11477,11479,11482,11485,11488,11490],{"class":150,"line":158},[148,11476,11454],{"class":174},[148,11478,11457],{"class":299},[148,11480,11481],{"class":178}," (with ",[148,11483,11484],{"class":299},"message",[148,11486,11487],{"class":178},"):    ",[148,11489,11469],{"class":167},[148,11491,11492],{"class":178},"800 bytes headers + 40 bytes payload = 840 bytes total\n",[148,11494,11495,11498,11500,11502,11504,11507,11509],{"class":150,"line":164},[148,11496,11497],{"class":174},"Long",[148,11499,11457],{"class":299},[148,11501,11481],{"class":178},[148,11503,11484],{"class":299},[148,11505,11506],{"class":178},"):     ",[148,11508,11469],{"class":167},[148,11510,11511],{"class":178},"600 bytes headers + 40 bytes payload = 640 bytes total\n",[148,11513,11514,11517,11519,11521,11524,11526,11529,11531],{"class":150,"line":182},[148,11515,11516],{"class":174},"SSE",[148,11518,11481],{"class":178},[148,11520,11484],{"class":299},[148,11522,11523],{"class":178},"):              ",[148,11525,11469],{"class":167},[148,11527,11528],{"class":178},"400 bytes initial + ",[148,11530,11469],{"class":167},[148,11532,11533],{"class":178},"50 bytes per event frame\n",[148,11535,11536,11539,11541,11543,11546,11548],{"class":150,"line":195},[148,11537,11538],{"class":174},"WebSocket",[148,11540,11481],{"class":178},[148,11542,11484],{"class":299},[148,11544,11545],{"class":178},"):        ",[148,11547,11469],{"class":167},[148,11549,11550],{"class":178},"10 bytes framing  + 40 bytes payload = 50 bytes total\n",[12,11552,11553],{},"At low volume the difference is trivial. At high frequency or high concurrency it is the dominant cost in your system.",[35,11555,11557],{"id":11556},"when-polling-is-still-the-right-call","When Polling Is Still the Right Call",[12,11559,11560],{},"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,11562,11563],{},"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,11565,11567],{"id":11566},"how-nolag-approaches-this","How NoLag Approaches This",[12,11569,11570],{},"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,11572,11573],{},"On top of WebSockets, NoLag uses MessagePack as the wire format instead of JSON. MessagePack is a binary serialization format that produces payloads 30-50% smaller than equivalent JSON. 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,11575,11576],{},"The connection also handles reconnection and message replay automatically. If a client drops off the network and reconnects, it receives any messages it missed during the gap, so you do not have to build catch-up logic yourself.",[12,11578,11579],{},"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.",[684,11581,11582],{},"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":158,"depth":158,"links":11584},[11585,11586,11587,11588,11589,11590,11591],{"id":10930,"depth":158,"text":10931},{"id":11037,"depth":158,"text":11038},{"id":11147,"depth":158,"text":11148},{"id":11243,"depth":158,"text":11244},{"id":11438,"depth":158,"text":11439},{"id":11556,"depth":158,"text":11557},{"id":11566,"depth":158,"text":11567},"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":10919,"description":11593},"blog/websocket-vs-polling","Sy4VmYO3MJMv2i8nb3u72dVoF9N1OvfPVzNGXZ9gl9g",{"id":11600,"title":11601,"author":7,"body":11602,"category":6508,"date":12943,"description":12944,"excerpt":701,"extension":702,"meta":12945,"navigation":704,"path":12946,"readTime":2864,"seo":12947,"stem":12948,"__hash__":12949},"blog/blog/build-chat-app-in-minutes.md","How to Build a Real-Time Chat App in Under 10 Minutes",{"type":9,"value":11603,"toc":12930},[11604,11607,11613,11617,11620,11637,11640,11644,11647,11651,11660,11879,11882,11886,11897,12032,12047,12050,12077,12081,12088,12308,12315,12319,12322,12515,12522,12526,12532,12611,12615,12618,12703,12705,12708,12760,12879,12882,12885,12900,12903,12907,12915,12927],[12,11605,11606],{},"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,11608,11609,11610,11612],{},"This tutorial skips the plumbing. We will use ",[84,11611,8269],{},", a purpose-built chat SDK that wraps the NoLag core with a high-level API. Multi-room chat, typing indicators, presence, and message replay are all included out of the box.",[35,11614,11616],{"id":11615},"step-1-install-the-sdk","Step 1: Install the SDK",[12,11618,11619],{},"The chat SDK is a peer of the core NoLag JS SDK. Install both:",[135,11621,11622],{},[138,11623,11625],{"className":1578,"code":11624,"filename":8313,"language":1580,"meta":144,"style":144},"npm install @nolag/chat @nolag/js-sdk\n",[84,11626,11627],{"__ignoreMap":144},[148,11628,11629,11631,11633,11635],{"class":150,"line":151},[148,11630,2893],{"class":174},[148,11632,1590],{"class":299},[148,11634,8324],{"class":299},[148,11636,2898],{"class":299},[12,11638,11639],{},"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,11641,11643],{"id":11642},"step-2-get-your-token","Step 2: Get Your Token",[12,11645,11646],{},"You need a NoLag access token to authenticate the WebSocket connection. Generate one from the NoLag portal or via the API. The token scopes what the client can access.",[35,11648,11650],{"id":11649},"step-3-connect","Step 3: Connect",[12,11652,11653,11654,11657,11658,1562],{},"Create a ",[84,11655,11656],{},"NoLagChat"," instance with your token, a username, and optionally the rooms you want to pre-subscribe to. Then call ",[84,11659,236],{},[135,11661,11662],{},[138,11663,11665],{"className":140,"code":11664,"filename":142,"language":143,"meta":144,"style":144},"import { NoLagChat } from '@nolag/chat';\n\nconst chat = new NoLagChat(token, {\n  username: 'Alice',\n  appName: 'my-chat-app', // Found in the app settings page of your NoLag portal\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 chat.connect();\n",[84,11666,11667,11680,11684,11699,11709,11721,11735,11739,11743,11762,11775,11779,11783,11805,11819,11823,11827,11848,11861,11865,11869],{"__ignoreMap":144},[148,11668,11669,11671,11673,11675,11678],{"class":150,"line":151},[148,11670,902],{"class":167},[148,11672,8401],{"class":178},[148,11674,908],{"class":167},[148,11676,11677],{"class":299}," '@nolag/chat'",[148,11679,4938],{"class":178},[148,11681,11682],{"class":150,"line":158},[148,11683,916],{"emptyLinePlaceholder":704},[148,11685,11686,11688,11690,11692,11694,11696],{"class":150,"line":164},[148,11687,270],{"class":167},[148,11689,273],{"class":203},[148,11691,276],{"class":167},[148,11693,279],{"class":167},[148,11695,282],{"class":174},[148,11697,11698],{"class":178},"(token, {\n",[148,11700,11701,11704,11707],{"class":150,"line":182},[148,11702,11703],{"class":178},"  username: ",[148,11705,11706],{"class":299},"'Alice'",[148,11708,2982],{"class":178},[148,11710,11711,11713,11716,11718],{"class":150,"line":195},[148,11712,5009],{"class":178},[148,11714,11715],{"class":299},"'my-chat-app'",[148,11717,87],{"class":178},[148,11719,11720],{"class":154},"// Found in the app settings page of your NoLag portal\n",[148,11722,11723,11726,11728,11730,11733],{"class":150,"line":207},[148,11724,11725],{"class":178},"  rooms: [",[148,11727,2240],{"class":299},[148,11729,87],{"class":178},[148,11731,11732],{"class":299},"'random'",[148,11734,5064],{"class":178},[148,11736,11737],{"class":150,"line":217},[148,11738,5090],{"class":178},[148,11740,11741],{"class":150,"line":227},[148,11742,916],{"emptyLinePlaceholder":704},[148,11744,11745,11748,11750,11752,11755,11758,11760],{"class":150,"line":513},[148,11746,11747],{"class":178},"chat.",[148,11749,2267],{"class":174},[148,11751,296],{"class":178},[148,11753,11754],{"class":299},"'connected'",[148,11756,11757],{"class":178},", () ",[148,11759,1145],{"class":167},[148,11761,179],{"class":178},[148,11763,11764,11766,11768,11770,11773],{"class":150,"line":993},[148,11765,2107],{"class":178},[148,11767,2110],{"class":174},[148,11769,296],{"class":178},[148,11771,11772],{"class":299},"'Connected!'",[148,11774,5126],{"class":178},[148,11776,11777],{"class":150,"line":1004},[148,11778,5090],{"class":178},[148,11780,11781],{"class":150,"line":1013},[148,11782,916],{"emptyLinePlaceholder":704},[148,11784,11785,11787,11789,11791,11794,11796,11799,11801,11803],{"class":150,"line":1019},[148,11786,11747],{"class":178},[148,11788,2267],{"class":174},[148,11790,296],{"class":178},[148,11792,11793],{"class":299},"'userOnline'",[148,11795,2274],{"class":178},[148,11797,11798],{"class":185},"user",[148,11800,1142],{"class":178},[148,11802,1145],{"class":167},[148,11804,179],{"class":178},[148,11806,11807,11809,11811,11814,11817],{"class":150,"line":1028},[148,11808,2107],{"class":178},[148,11810,2110],{"class":174},[148,11812,11813],{"class":178},"(user.username, ",[148,11815,11816],{"class":299},"'is online'",[148,11818,5126],{"class":178},[148,11820,11821],{"class":150,"line":1034},[148,11822,5090],{"class":178},[148,11824,11825],{"class":150,"line":1040},[148,11826,916],{"emptyLinePlaceholder":704},[148,11828,11829,11831,11833,11835,11838,11840,11842,11844,11846],{"class":150,"line":1046},[148,11830,11747],{"class":178},[148,11832,2267],{"class":174},[148,11834,296],{"class":178},[148,11836,11837],{"class":299},"'userOffline'",[148,11839,2274],{"class":178},[148,11841,11798],{"class":185},[148,11843,1142],{"class":178},[148,11845,1145],{"class":167},[148,11847,179],{"class":178},[148,11849,11850,11852,11854,11856,11859],{"class":150,"line":1052},[148,11851,2107],{"class":178},[148,11853,2110],{"class":174},[148,11855,11813],{"class":178},[148,11857,11858],{"class":299},"'went offline'",[148,11860,5126],{"class":178},[148,11862,11863],{"class":150,"line":1699},[148,11864,5090],{"class":178},[148,11866,11867],{"class":150,"line":1705},[148,11868,916],{"emptyLinePlaceholder":704},[148,11870,11871,11873,11875,11877],{"class":150,"line":1711},[148,11872,943],{"class":167},[148,11874,8539],{"class":178},[148,11876,2204],{"class":174},[148,11878,5106],{"class":178},[12,11880,11881],{},"That is it for connection setup. The SDK opens a WebSocket to the NoLag edge network, sets up global presence tracking via a lobby, and pre-subscribes to the rooms you listed. If the connection drops, it reconnects automatically and restores your rooms.",[35,11883,11885],{"id":11884},"step-4-join-a-room-and-send-messages","Step 4: Join a Room and Send Messages",[12,11887,11888,11889,11892,11893,11896],{},"Rooms are the unit of conversation. Call ",[84,11890,11891],{},"joinRoom()"," to activate a room. This returns a ",[84,11894,11895],{},"ChatRoom"," object you use to send and receive messages.",[135,11898,11899],{},[138,11900,11902],{"className":140,"code":11901,"filename":142,"language":143,"meta":144,"style":144},"const room = chat.joinRoom('general');\n\n// Listen for incoming messages\nroom.on('message', (msg) => {\n  console.log(`${msg.username}: ${msg.text}`);\n\n  // msg.isReplay is true for messages replayed on reconnect\n  if (msg.isReplay) console.log('(replayed from history)');\n});\n\n// Send a message (returns an optimistic ChatMessage immediately)\nroom.sendMessage('Hello from the tutorial!');\n",[84,11903,11904,11922,11926,11931,11951,11981,11985,11990,12006,12010,12014,12019],{"__ignoreMap":144},[148,11905,11906,11908,11910,11912,11914,11916,11918,11920],{"class":150,"line":151},[148,11907,270],{"class":167},[148,11909,2217],{"class":203},[148,11911,276],{"class":167},[148,11913,8539],{"class":178},[148,11915,8542],{"class":174},[148,11917,296],{"class":178},[148,11919,2240],{"class":299},[148,11921,5126],{"class":178},[148,11923,11924],{"class":150,"line":158},[148,11925,916],{"emptyLinePlaceholder":704},[148,11927,11928],{"class":150,"line":164},[148,11929,11930],{"class":154},"// Listen for incoming messages\n",[148,11932,11933,11935,11937,11939,11941,11943,11945,11947,11949],{"class":150,"line":182},[148,11934,290],{"class":178},[148,11936,2267],{"class":174},[148,11938,296],{"class":178},[148,11940,2582],{"class":299},[148,11942,2274],{"class":178},[148,11944,2587],{"class":185},[148,11946,1142],{"class":178},[148,11948,1145],{"class":167},[148,11950,179],{"class":178},[148,11952,11953,11955,11957,11959,11961,11963,11965,11968,11971,11973,11975,11977,11979],{"class":150,"line":195},[148,11954,2107],{"class":178},[148,11956,2110],{"class":174},[148,11958,296],{"class":178},[148,11960,5511],{"class":299},[148,11962,2587],{"class":178},[148,11964,1562],{"class":299},[148,11966,11967],{"class":178},"username",[148,11969,11970],{"class":299},"}: ${",[148,11972,2587],{"class":178},[148,11974,1562],{"class":299},[148,11976,830],{"class":178},[148,11978,3010],{"class":299},[148,11980,5126],{"class":178},[148,11982,11983],{"class":150,"line":207},[148,11984,916],{"emptyLinePlaceholder":704},[148,11986,11987],{"class":150,"line":217},[148,11988,11989],{"class":154},"  // msg.isReplay is true for messages replayed on reconnect\n",[148,11991,11992,11994,11997,11999,12001,12004],{"class":150,"line":227},[148,11993,8614],{"class":167},[148,11995,11996],{"class":178}," (msg.isReplay) console.",[148,11998,2110],{"class":174},[148,12000,296],{"class":178},[148,12002,12003],{"class":299},"'(replayed from history)'",[148,12005,5126],{"class":178},[148,12007,12008],{"class":150,"line":513},[148,12009,5090],{"class":178},[148,12011,12012],{"class":150,"line":993},[148,12013,916],{"emptyLinePlaceholder":704},[148,12015,12016],{"class":150,"line":1004},[148,12017,12018],{"class":154},"// Send a message (returns an optimistic ChatMessage immediately)\n",[148,12020,12021,12023,12025,12027,12030],{"class":150,"line":1013},[148,12022,290],{"class":178},[148,12024,293],{"class":174},[148,12026,296],{"class":178},[148,12028,12029],{"class":299},"'Hello from the tutorial!'",[148,12031,5126],{"class":178},[12,12033,12034,12035,12038,12039,12042,12043,12046],{},"Messages are delivered in order. The ",[84,12036,12037],{},"sendMessage()"," call returns an optimistic message object instantly so your UI can render it before the server confirms. The message status starts as ",[84,12040,12041],{},"'sending'"," and flips to ",[84,12044,12045],{},"'sent'"," once acknowledged.",[12,12048,12049],{},"You can also fetch all messages in the room at any time:",[135,12051,12052],{},[138,12053,12055],{"className":140,"code":12054,"filename":142,"language":143,"meta":144,"style":144},"const messages = room.getMessages();\n// Returns all cached messages in timestamp order\n",[84,12056,12057,12072],{"__ignoreMap":144},[148,12058,12059,12061,12064,12066,12068,12070],{"class":150,"line":151},[148,12060,270],{"class":167},[148,12062,12063],{"class":203}," messages",[148,12065,276],{"class":167},[148,12067,5491],{"class":178},[148,12069,8697],{"class":174},[148,12071,5106],{"class":178},[148,12073,12074],{"class":150,"line":158},[148,12075,12076],{"class":154},"// Returns all cached messages in timestamp order\n",[35,12078,12080],{"id":12079},"step-5-add-typing-indicators","Step 5: Add Typing Indicators",[12,12082,12083,12084,12087],{},"Typing indicators make chat feel alive. The SDK handles the debounce and timeout for you. Call ",[84,12085,12086],{},"startTyping()"," when the user types, and it auto-stops after a configurable timeout (default 3 seconds).",[135,12089,12090],{},[138,12091,12093],{"className":140,"code":12092,"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,12094,12095,12100,12119,12128,12132,12136,12141,12165,12181,12208,12228,12264,12272,12283,12287,12291,12295,12300],{"__ignoreMap":144},[148,12096,12097],{"class":150,"line":151},[148,12098,12099],{"class":154},"// Trigger when the user types in the input\n",[148,12101,12102,12105,12108,12110,12113,12115,12117],{"class":150,"line":158},[148,12103,12104],{"class":178},"inputElement.",[148,12106,12107],{"class":174},"addEventListener",[148,12109,296],{"class":178},[148,12111,12112],{"class":299},"'input'",[148,12114,11757],{"class":178},[148,12116,1145],{"class":167},[148,12118,179],{"class":178},[148,12120,12121,12124,12126],{"class":150,"line":164},[148,12122,12123],{"class":178},"  room.",[148,12125,8646],{"class":174},[148,12127,5106],{"class":178},[148,12129,12130],{"class":150,"line":182},[148,12131,5090],{"class":178},[148,12133,12134],{"class":150,"line":195},[148,12135,916],{"emptyLinePlaceholder":704},[148,12137,12138],{"class":150,"line":207},[148,12139,12140],{"class":154},"// Listen for typing state changes\n",[148,12142,12143,12145,12147,12149,12152,12155,12158,12161,12163],{"class":150,"line":217},[148,12144,290],{"class":178},[148,12146,2267],{"class":174},[148,12148,296],{"class":178},[148,12150,12151],{"class":299},"'typing'",[148,12153,12154],{"class":178},", ({ ",[148,12156,12157],{"class":185},"users",[148,12159,12160],{"class":178}," }) ",[148,12162,1145],{"class":167},[148,12164,179],{"class":178},[148,12166,12167,12169,12172,12174,12177,12179],{"class":150,"line":227},[148,12168,8614],{"class":167},[148,12170,12171],{"class":178}," (users.",[148,12173,5518],{"class":203},[148,12175,12176],{"class":167}," ===",[148,12178,9494],{"class":203},[148,12180,4238],{"class":178},[148,12182,12183,12186,12188,12191,12193,12196,12198,12201,12203,12206],{"class":150,"line":513},[148,12184,12185],{"class":178},"    indicator.textContent ",[148,12187,1174],{"class":167},[148,12189,12190],{"class":299}," `${",[148,12192,12157],{"class":178},[148,12194,12195],{"class":299},"[",[148,12197,7693],{"class":203},[148,12199,12200],{"class":299},"].",[148,12202,11967],{"class":178},[148,12204,12205],{"class":299},"} is typing...`",[148,12207,4938],{"class":178},[148,12209,12210,12212,12214,12217,12219,12221,12224,12226],{"class":150,"line":993},[148,12211,9321],{"class":178},[148,12213,9324],{"class":167},[148,12215,12216],{"class":167}," if",[148,12218,12171],{"class":178},[148,12220,5518],{"class":203},[148,12222,12223],{"class":167}," >",[148,12225,9494],{"class":203},[148,12227,4238],{"class":178},[148,12229,12230,12232,12234,12236,12238,12240,12242,12244,12246,12249,12251,12253,12255,12257,12259,12262],{"class":150,"line":1004},[148,12231,12185],{"class":178},[148,12233,1174],{"class":167},[148,12235,12190],{"class":299},[148,12237,12157],{"class":178},[148,12239,12195],{"class":299},[148,12241,7693],{"class":203},[148,12243,12200],{"class":299},[148,12245,11967],{"class":178},[148,12247,12248],{"class":299},"} and ${",[148,12250,12157],{"class":178},[148,12252,1562],{"class":299},[148,12254,5518],{"class":203},[148,12256,9206],{"class":167},[148,12258,9494],{"class":203},[148,12260,12261],{"class":299},"} others are typing...`",[148,12263,4938],{"class":178},[148,12265,12266,12268,12270],{"class":150,"line":1013},[148,12267,9321],{"class":178},[148,12269,9324],{"class":167},[148,12271,179],{"class":178},[148,12273,12274,12276,12278,12281],{"class":150,"line":1019},[148,12275,12185],{"class":178},[148,12277,1174],{"class":167},[148,12279,12280],{"class":299}," ''",[148,12282,4938],{"class":178},[148,12284,12285],{"class":150,"line":1028},[148,12286,1402],{"class":178},[148,12288,12289],{"class":150,"line":1034},[148,12290,5090],{"class":178},[148,12292,12293],{"class":150,"line":1040},[148,12294,916],{"emptyLinePlaceholder":704},[148,12296,12297],{"class":150,"line":1046},[148,12298,12299],{"class":154},"// You can also stop typing explicitly\n",[148,12301,12302,12304,12306],{"class":150,"line":1052},[148,12303,290],{"class":178},[148,12305,8771],{"class":174},[148,12307,5106],{"class":178},[12,12309,12310,12311,12314],{},"Typing events are sent on a separate internal topic (",[84,12312,12313],{},"_typing",") so they do not pollute your message stream. They are ephemeral and not persisted.",[35,12316,12318],{"id":12317},"step-6-show-who-is-online","Step 6: Show Who Is Online",[12,12320,12321],{},"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,12323,12324],{},[138,12325,12327],{"className":140,"code":12326,"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,12328,12329,12334,12350,12354,12359,12375,12379,12384,12405,12418,12422,12426,12447,12460,12464,12468,12473,12491,12495,12500],{"__ignoreMap":144},[148,12330,12331],{"class":150,"line":151},[148,12332,12333],{"class":154},"// Global: all users online across all rooms\n",[148,12335,12336,12338,12341,12343,12345,12348],{"class":150,"line":158},[148,12337,270],{"class":167},[148,12339,12340],{"class":203}," onlineUsers",[148,12342,276],{"class":167},[148,12344,8539],{"class":178},[148,12346,12347],{"class":174},"getOnlineUsers",[148,12349,5106],{"class":178},[148,12351,12352],{"class":150,"line":164},[148,12353,916],{"emptyLinePlaceholder":704},[148,12355,12356],{"class":150,"line":182},[148,12357,12358],{"class":154},"// Per-room: users in this specific room\n",[148,12360,12361,12363,12366,12368,12370,12373],{"class":150,"line":195},[148,12362,270],{"class":167},[148,12364,12365],{"class":203}," roomMembers",[148,12367,276],{"class":167},[148,12369,5491],{"class":178},[148,12371,12372],{"class":174},"getUsers",[148,12374,5106],{"class":178},[148,12376,12377],{"class":150,"line":207},[148,12378,916],{"emptyLinePlaceholder":704},[148,12380,12381],{"class":150,"line":217},[148,12382,12383],{"class":154},"// Listen for users joining or leaving the room\n",[148,12385,12386,12388,12390,12392,12395,12397,12399,12401,12403],{"class":150,"line":227},[148,12387,290],{"class":178},[148,12389,2267],{"class":174},[148,12391,296],{"class":178},[148,12393,12394],{"class":299},"'userJoined'",[148,12396,2274],{"class":178},[148,12398,11798],{"class":185},[148,12400,1142],{"class":178},[148,12402,1145],{"class":167},[148,12404,179],{"class":178},[148,12406,12407,12409,12411,12413,12416],{"class":150,"line":513},[148,12408,2107],{"class":178},[148,12410,2110],{"class":174},[148,12412,11813],{"class":178},[148,12414,12415],{"class":299},"'joined the room'",[148,12417,5126],{"class":178},[148,12419,12420],{"class":150,"line":993},[148,12421,5090],{"class":178},[148,12423,12424],{"class":150,"line":1004},[148,12425,916],{"emptyLinePlaceholder":704},[148,12427,12428,12430,12432,12434,12437,12439,12441,12443,12445],{"class":150,"line":1013},[148,12429,290],{"class":178},[148,12431,2267],{"class":174},[148,12433,296],{"class":178},[148,12435,12436],{"class":299},"'userLeft'",[148,12438,2274],{"class":178},[148,12440,11798],{"class":185},[148,12442,1142],{"class":178},[148,12444,1145],{"class":167},[148,12446,179],{"class":178},[148,12448,12449,12451,12453,12455,12458],{"class":150,"line":1019},[148,12450,2107],{"class":178},[148,12452,2110],{"class":174},[148,12454,11813],{"class":178},[148,12456,12457],{"class":299},"'left the room'",[148,12459,5126],{"class":178},[148,12461,12462],{"class":150,"line":1028},[148,12463,5090],{"class":178},[148,12465,12466],{"class":150,"line":1034},[148,12467,916],{"emptyLinePlaceholder":704},[148,12469,12470],{"class":150,"line":1040},[148,12471,12472],{"class":154},"// Update your own status\n",[148,12474,12475,12477,12480,12482,12485,12488],{"class":150,"line":1046},[148,12476,11747],{"class":178},[148,12478,12479],{"class":174},"setStatus",[148,12481,296],{"class":178},[148,12483,12484],{"class":299},"'away'",[148,12486,12487],{"class":178},");   ",[148,12489,12490],{"class":154},"// 'online', 'away', 'busy', 'offline'\n",[148,12492,12493],{"class":150,"line":1052},[148,12494,916],{"emptyLinePlaceholder":704},[148,12496,12497],{"class":150,"line":1699},[148,12498,12499],{"class":154},"// Update your profile\n",[148,12501,12502,12504,12507,12510,12513],{"class":150,"line":1705},[148,12503,11747],{"class":178},[148,12505,12506],{"class":174},"updateProfile",[148,12508,12509],{"class":178},"({ username: ",[148,12511,12512],{"class":299},"'Alice B'",[148,12514,4979],{"class":178},[12,12516,12517,12518,12521],{},"Global presence uses a lobby under the hood. When a user connects, they are tracked across rooms. When the last connection closes, a ",[84,12519,12520],{},"userOffline"," event fires. You do not have to think about tab deduplication or heartbeats.",[35,12523,12525],{"id":12524},"switching-rooms","Switching Rooms",[12,12527,12528,12529,12531],{},"Calling ",[84,12530,11891],{}," activates that room and deactivates the previous one. Presence is tracked per active room. You can also leave rooms entirely:",[135,12533,12534],{},[138,12535,12537],{"className":140,"code":12536,"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,12538,12539,12544,12564,12568,12573,12586,12590,12595],{"__ignoreMap":144},[148,12540,12541],{"class":150,"line":151},[148,12542,12543],{"class":154},"// Switch to a different room\n",[148,12545,12546,12548,12551,12553,12555,12557,12559,12562],{"class":150,"line":158},[148,12547,270],{"class":167},[148,12549,12550],{"class":203}," devRoom",[148,12552,276],{"class":167},[148,12554,8539],{"class":178},[148,12556,8542],{"class":174},[148,12558,296],{"class":178},[148,12560,12561],{"class":299},"'dev'",[148,12563,5126],{"class":178},[148,12565,12566],{"class":150,"line":164},[148,12567,916],{"emptyLinePlaceholder":704},[148,12569,12570],{"class":150,"line":182},[148,12571,12572],{"class":154},"// Leave a room completely\n",[148,12574,12575,12577,12580,12582,12584],{"class":150,"line":195},[148,12576,11747],{"class":178},[148,12578,12579],{"class":174},"leaveRoom",[148,12581,296],{"class":178},[148,12583,11732],{"class":299},[148,12585,5126],{"class":178},[148,12587,12588],{"class":150,"line":207},[148,12589,916],{"emptyLinePlaceholder":704},[148,12591,12592],{"class":150,"line":217},[148,12593,12594],{"class":154},"// Get all joined rooms\n",[148,12596,12597,12599,12602,12604,12606,12609],{"class":150,"line":227},[148,12598,270],{"class":167},[148,12600,12601],{"class":203}," rooms",[148,12603,276],{"class":167},[148,12605,8539],{"class":178},[148,12607,12608],{"class":174},"getRooms",[148,12610,5106],{"class":178},[35,12612,12614],{"id":12613},"unread-counts","Unread Counts",[12,12616,12617],{},"The SDK tracks unread messages per room automatically. When a room is not active, incoming messages increment the unread count:",[135,12619,12620],{},[138,12621,12623],{"className":140,"code":12622,"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,12624,12625,12655,12663,12667,12671,12676,12685,12689,12694],{"__ignoreMap":144},[148,12626,12627,12629,12631,12633,12636,12638,12640,12642,12644,12646,12649,12651,12653],{"class":150,"line":151},[148,12628,290],{"class":178},[148,12630,2267],{"class":174},[148,12632,296],{"class":178},[148,12634,12635],{"class":299},"'unreadChanged'",[148,12637,12154],{"class":178},[148,12639,1101],{"class":185},[148,12641,3020],{"class":178},[148,12643,4222],{"class":185},[148,12645,87],{"class":178},[148,12647,12648],{"class":185},"count",[148,12650,12160],{"class":178},[148,12652,1145],{"class":167},[148,12654,179],{"class":178},[148,12656,12657,12660],{"class":150,"line":158},[148,12658,12659],{"class":174},"  updateBadge",[148,12661,12662],{"class":178},"(roomName, count);\n",[148,12664,12665],{"class":150,"line":164},[148,12666,5090],{"class":178},[148,12668,12669],{"class":150,"line":182},[148,12670,916],{"emptyLinePlaceholder":704},[148,12672,12673],{"class":150,"line":195},[148,12674,12675],{"class":154},"// Check the current count\n",[148,12677,12678,12680,12682],{"class":150,"line":207},[148,12679,5320],{"class":178},[148,12681,2110],{"class":174},[148,12683,12684],{"class":178},"(room.unreadCount);\n",[148,12686,12687],{"class":150,"line":217},[148,12688,916],{"emptyLinePlaceholder":704},[148,12690,12691],{"class":150,"line":227},[148,12692,12693],{"class":154},"// Mark as read (resets to 0)\n",[148,12695,12696,12698,12701],{"class":150,"line":513},[148,12697,290],{"class":178},[148,12699,12700],{"class":174},"markRead",[148,12702,5106],{"class":178},[35,12704,6182],{"id":6181},[12,12706,12707],{},"Building all of this yourself from raw WebSockets takes weeks. Here is what the SDK handles without any extra code:",[597,12709,12710,12720,12730,12742,12754],{},[600,12711,12712,12715,12716,12719],{},[30,12713,12714],{},"Message replay."," When a client reconnects after a disconnect, missed messages are automatically replayed. The ",[84,12717,12718],{},"isReplay"," flag lets you handle them differently in your UI (for example, don't trigger notification sounds for replayed messages).",[600,12721,12722,12725,12726,12729],{},[30,12723,12724],{},"Reconnection."," The SDK reconnects automatically on disconnect and restores all rooms and presence state. A ",[84,12727,12728],{},"reconnected"," event fires so you can update your UI.",[600,12731,12732,12735,12736,12738,12739,12741],{},[30,12733,12734],{},"Optimistic sends."," Messages appear in the UI instantly with a ",[84,12737,12041],{}," status that flips to ",[84,12740,12045],{}," on acknowledgement.",[600,12743,12744,12747,12748,1498,12751,1562],{},[30,12745,12746],{},"Unread counts."," Per-room unread tracking with ",[84,12749,12750],{},"unreadCount",[84,12752,12753],{},"markRead()",[600,12755,12756,12759],{},[30,12757,12758],{},"Framework agnostic."," Wire it to any framework's reactivity. The SDK is pure events.",[135,12761,12762],{},[138,12763,12765],{"className":140,"code":12764,"filename":142,"language":143,"meta":144,"style":144},"// Vue 3\nconst messages = ref([]);\nroom.on('message', () => {\n  messages.value = [...room.getMessages()];\n});\n\n// React\nconst [messages, setMessages] = useState([]);\nroom.on('message', () => setMessages([...room.getMessages()]));\n",[84,12766,12767,12772,12785,12801,12819,12823,12827,12832,12852],{"__ignoreMap":144},[148,12768,12769],{"class":150,"line":151},[148,12770,12771],{"class":154},"// Vue 3\n",[148,12773,12774,12776,12778,12780,12782],{"class":150,"line":158},[148,12775,270],{"class":167},[148,12777,12063],{"class":203},[148,12779,276],{"class":167},[148,12781,2074],{"class":174},[148,12783,12784],{"class":178},"([]);\n",[148,12786,12787,12789,12791,12793,12795,12797,12799],{"class":150,"line":164},[148,12788,290],{"class":178},[148,12790,2267],{"class":174},[148,12792,296],{"class":178},[148,12794,2582],{"class":299},[148,12796,11757],{"class":178},[148,12798,1145],{"class":167},[148,12800,179],{"class":178},[148,12802,12803,12806,12808,12810,12812,12814,12816],{"class":150,"line":182},[148,12804,12805],{"class":178},"  messages.value ",[148,12807,1174],{"class":167},[148,12809,3171],{"class":178},[148,12811,1285],{"class":167},[148,12813,290],{"class":178},[148,12815,8697],{"class":174},[148,12817,12818],{"class":178},"()];\n",[148,12820,12821],{"class":150,"line":195},[148,12822,5090],{"class":178},[148,12824,12825],{"class":150,"line":207},[148,12826,916],{"emptyLinePlaceholder":704},[148,12828,12829],{"class":150,"line":217},[148,12830,12831],{"class":154},"// React\n",[148,12833,12834,12836,12838,12840,12842,12844,12846,12848,12850],{"class":150,"line":227},[148,12835,270],{"class":167},[148,12837,3171],{"class":178},[148,12839,3174],{"class":203},[148,12841,87],{"class":178},[148,12843,3179],{"class":203},[148,12845,3182],{"class":178},[148,12847,1174],{"class":167},[148,12849,3187],{"class":174},[148,12851,12784],{"class":178},[148,12853,12854,12856,12858,12860,12862,12864,12866,12868,12870,12872,12874,12876],{"class":150,"line":513},[148,12855,290],{"class":178},[148,12857,2267],{"class":174},[148,12859,296],{"class":178},[148,12861,2582],{"class":299},[148,12863,11757],{"class":178},[148,12865,1145],{"class":167},[148,12867,3419],{"class":174},[148,12869,1120],{"class":178},[148,12871,1285],{"class":167},[148,12873,290],{"class":178},[148,12875,8697],{"class":174},[148,12877,12878],{"class":178},"()]));\n",[35,12880,12881],{"id":3473},"Disconnect",[12,12883,12884],{},"Clean up when the user leaves:",[135,12886,12887],{},[138,12888,12890],{"className":140,"code":12889,"filename":142,"language":143,"meta":144,"style":144},"chat.disconnect();\n",[84,12891,12892],{"__ignoreMap":144},[148,12893,12894,12896,12898],{"class":150,"line":151},[148,12895,11747],{"class":178},[148,12897,3473],{"class":174},[148,12899,5106],{"class":178},[12,12901,12902],{},"This leaves all rooms, unsubscribes from the lobby, and closes the WebSocket. Clean and immediate.",[35,12904,12906],{"id":12905},"where-to-go-from-here","Where to Go From Here",[12,12908,12909,12910,12914],{},"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 ",[667,12911,12913],{"href":12912},"/docs","NoLag docs",". But for a working chat prototype with rooms, presence, typing indicators, message replay, and unread counts, the steps above are all you need.",[12,12916,12917,12918,1498,12920,12922,12923,1562],{},"The full API reference for ",[84,12919,11656],{},[84,12921,11895],{}," is on the ",[667,12924,12926],{"href":12925},"/blueprints","Blueprints page",[684,12928,12929],{},"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":158,"depth":158,"links":12931},[12932,12933,12934,12935,12936,12937,12938,12939,12940,12941,12942],{"id":11615,"depth":158,"text":11616},{"id":11642,"depth":158,"text":11643},{"id":11649,"depth":158,"text":11650},{"id":11884,"depth":158,"text":11885},{"id":12079,"depth":158,"text":12080},{"id":12317,"depth":158,"text":12318},{"id":12524,"depth":158,"text":12525},{"id":12613,"depth":158,"text":12614},{"id":6181,"depth":158,"text":6182},{"id":3473,"depth":158,"text":12881},{"id":12905,"depth":158,"text":12906},"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":11601,"description":12944},"blog/build-chat-app-in-minutes","302uDiBVx7kj9CsqSquw7bUw0i_0-5g5a756yrUcrY4",{"id":12951,"title":12952,"author":7,"body":12953,"category":13645,"date":13646,"description":13647,"excerpt":701,"extension":702,"meta":13648,"navigation":704,"path":13649,"readTime":2388,"seo":13650,"stem":13651,"__hash__":13652},"blog/blog/messagepack-vs-json.md","MessagePack vs JSON: 47% Smaller Payloads for Real-Time Apps",{"type":9,"value":12954,"toc":13632},[12955,12958,12961,12965,12968,12971,12975,12978,13056,13421,13424,13428,13431,13457,13461,13464,13570,13573,13577,13580,13584,13587,13591,13594,13598,13601,13605,13608,13612,13615,13626,13629],[12,12956,12957],{},"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 latency numbers and your bandwidth bill.",[12,12959,12960],{},"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,12962,12964],{"id":12963},"what-messagepack-actually-is","What MessagePack Actually Is",[12,12966,12967],{},"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,12969,12970],{},"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,12972,12974],{"id":12973},"side-by-side-the-same-message-in-both-formats","Side-by-Side: The Same Message in Both Formats",[12,12976,12977],{},"Take a minimal chat message payload with a few fields:",[135,12979,12980],{},[138,12981,12984],{"className":12982,"code":12983,"filename":3038,"language":3083,"meta":144,"style":144},"language-json shiki shiki-themes github-light github-dark","// The logical data\n{\n  \"id\": \"01906b2e-4f3c-7a1d-8e2b-9f3c4d5e6a7b\",\n  \"room\": \"general\",\n  \"text\": \"Hello, world!\",\n  \"ts\": 1745827200000,\n  \"uid\": \"u_8x2k9\"\n}\n",[84,12985,12986,12991,12995,13007,13018,13030,13042,13052],{"__ignoreMap":144},[148,12987,12988],{"class":150,"line":151},[148,12989,12990],{"class":154},"// The logical data\n",[148,12992,12993],{"class":150,"line":158},[148,12994,9881],{"class":178},[148,12996,12997,13000,13002,13005],{"class":150,"line":164},[148,12998,12999],{"class":203},"  \"id\"",[148,13001,3020],{"class":178},[148,13003,13004],{"class":299},"\"01906b2e-4f3c-7a1d-8e2b-9f3c4d5e6a7b\"",[148,13006,2982],{"class":178},[148,13008,13009,13012,13014,13016],{"class":150,"line":182},[148,13010,13011],{"class":203},"  \"room\"",[148,13013,3020],{"class":178},[148,13015,9904],{"class":299},[148,13017,2982],{"class":178},[148,13019,13020,13023,13025,13028],{"class":150,"line":195},[148,13021,13022],{"class":203},"  \"text\"",[148,13024,3020],{"class":178},[148,13026,13027],{"class":299},"\"Hello, world!\"",[148,13029,2982],{"class":178},[148,13031,13032,13035,13037,13040],{"class":150,"line":207},[148,13033,13034],{"class":203},"  \"ts\"",[148,13036,3020],{"class":178},[148,13038,13039],{"class":203},"1745827200000",[148,13041,2982],{"class":178},[148,13043,13044,13047,13049],{"class":150,"line":217},[148,13045,13046],{"class":203},"  \"uid\"",[148,13048,3020],{"class":178},[148,13050,13051],{"class":299},"\"u_8x2k9\"\n",[148,13053,13054],{"class":150,"line":227},[148,13055,230],{"class":178},[135,13057,13058],{},[138,13059,13061],{"className":1578,"code":13060,"filename":8313,"language":1580,"meta":144,"style":144},"JSON encoding:\n{\n  \"id\":\"01906b2e-4f3c-7a1d-8e2b-9f3c4d5e6a7b\",\n  \"room\":\"general\",\"text\":\"Hello, world!\",\n  \"ts\":1745827200000,\"uid\":\"u_8x2k9\"\n}\nBytes: 113\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 e3 6e 9e 80 a3 75 69 64 a6 75 5f 38 78 32\n6b 39\nBytes: 67\n",[84,13062,13063,13070,13074,13084,13104,13118,13122,13130,13134,13145,13194,13235,13277,13319,13362,13406,13414],{"__ignoreMap":144},[148,13064,13065,13067],{"class":150,"line":151},[148,13066,3038],{"class":174},[148,13068,13069],{"class":299}," encoding:\n",[148,13071,13072],{"class":150,"line":158},[148,13073,9881],{"class":178},[148,13075,13076,13078,13080,13082],{"class":150,"line":164},[148,13077,12999],{"class":174},[148,13079,189],{"class":203},[148,13081,13004],{"class":174},[148,13083,2982],{"class":174},[148,13085,13086,13088,13090,13092,13095,13098,13100,13102],{"class":150,"line":182},[148,13087,13011],{"class":174},[148,13089,189],{"class":203},[148,13091,9904],{"class":174},[148,13093,13094],{"class":174},",",[148,13096,13097],{"class":174},"\"text\"",[148,13099,189],{"class":203},[148,13101,13027],{"class":174},[148,13103,2982],{"class":174},[148,13105,13106,13108,13111,13114,13116],{"class":150,"line":195},[148,13107,13034],{"class":174},[148,13109,13110],{"class":174},":1745827200000,",[148,13112,13113],{"class":174},"\"uid\"",[148,13115,189],{"class":203},[148,13117,13051],{"class":174},[148,13119,13120],{"class":150,"line":207},[148,13121,230],{"class":178},[148,13123,13124,13127],{"class":150,"line":217},[148,13125,13126],{"class":174},"Bytes:",[148,13128,13129],{"class":203}," 113\n",[148,13131,13132],{"class":150,"line":227},[148,13133,916],{"emptyLinePlaceholder":704},[148,13135,13136,13139,13142],{"class":150,"line":513},[148,13137,13138],{"class":174},"MessagePack",[148,13140,13141],{"class":299}," encoding",[148,13143,13144],{"class":178}," (hex):\n",[148,13146,13147,13150,13153,13156,13159,13162,13165,13168,13171,13174,13176,13179,13182,13185,13188,13191],{"class":150,"line":993},[148,13148,13149],{"class":174},"85",[148,13151,13152],{"class":299}," a2",[148,13154,13155],{"class":203}," 69",[148,13157,13158],{"class":203}," 64",[148,13160,13161],{"class":299}," d9",[148,13163,13164],{"class":203}," 24",[148,13166,13167],{"class":203}," 30",[148,13169,13170],{"class":203}," 31",[148,13172,13173],{"class":203}," 39",[148,13175,13167],{"class":203},[148,13177,13178],{"class":203}," 36",[148,13180,13181],{"class":203}," 62",[148,13183,13184],{"class":203}," 32",[148,13186,13187],{"class":203}," 65",[148,13189,13190],{"class":299}," 2d",[148,13192,13193],{"class":203}," 34\n",[148,13195,13196,13199,13202,13205,13207,13210,13213,13215,13217,13219,13222,13224,13226,13228,13230,13232],{"class":150,"line":1004},[148,13197,13198],{"class":174},"66",[148,13200,13201],{"class":203}," 33",[148,13203,13204],{"class":203}," 63",[148,13206,13190],{"class":299},[148,13208,13209],{"class":203}," 37",[148,13211,13212],{"class":203}," 61",[148,13214,13170],{"class":203},[148,13216,13158],{"class":203},[148,13218,13190],{"class":299},[148,13220,13221],{"class":203}," 38",[148,13223,13187],{"class":203},[148,13225,13184],{"class":203},[148,13227,13181],{"class":203},[148,13229,13190],{"class":299},[148,13231,13173],{"class":203},[148,13233,13234],{"class":203}," 66\n",[148,13236,13237,13240,13242,13245,13247,13250,13252,13254,13256,13258,13260,13263,13266,13269,13271,13274],{"class":150,"line":1013},[148,13238,13239],{"class":174},"33",[148,13241,13204],{"class":203},[148,13243,13244],{"class":203}," 34",[148,13246,13158],{"class":203},[148,13248,13249],{"class":203}," 35",[148,13251,13187],{"class":203},[148,13253,13178],{"class":203},[148,13255,13212],{"class":203},[148,13257,13209],{"class":203},[148,13259,13181],{"class":203},[148,13261,13262],{"class":299}," a4",[148,13264,13265],{"class":203}," 72",[148,13267,13268],{"class":299}," 6f",[148,13270,13268],{"class":299},[148,13272,13273],{"class":299}," 6d",[148,13275,13276],{"class":299}," a7\n",[148,13278,13279,13282,13284,13287,13289,13291,13293,13296,13298,13301,13303,13306,13308,13311,13314,13316],{"class":150,"line":1019},[148,13280,13281],{"class":174},"67",[148,13283,13187],{"class":203},[148,13285,13286],{"class":299}," 6e",[148,13288,13187],{"class":203},[148,13290,13265],{"class":203},[148,13292,13212],{"class":203},[148,13294,13295],{"class":299}," 6c",[148,13297,13262],{"class":299},[148,13299,13300],{"class":203}," 74",[148,13302,13187],{"class":203},[148,13304,13305],{"class":203}," 78",[148,13307,13300],{"class":203},[148,13309,13310],{"class":299}," ad",[148,13312,13313],{"class":203}," 48",[148,13315,13187],{"class":203},[148,13317,13318],{"class":299}," 6c\n",[148,13320,13321,13324,13326,13329,13332,13335,13337,13339,13341,13343,13346,13348,13350,13353,13356,13359],{"class":150,"line":1028},[148,13322,13323],{"class":174},"6c",[148,13325,13268],{"class":299},[148,13327,13328],{"class":299}," 2c",[148,13330,13331],{"class":203}," 20",[148,13333,13334],{"class":203}," 77",[148,13336,13268],{"class":299},[148,13338,13265],{"class":203},[148,13340,13295],{"class":299},[148,13342,13158],{"class":203},[148,13344,13345],{"class":203}," 21",[148,13347,13152],{"class":299},[148,13349,13300],{"class":203},[148,13351,13352],{"class":203}," 73",[148,13354,13355],{"class":299}," cf",[148,13357,13358],{"class":203}," 00",[148,13360,13361],{"class":203}," 00\n",[148,13363,13364,13367,13370,13373,13375,13378,13381,13384,13387,13389,13391,13394,13396,13399,13401,13403],{"class":150,"line":1034},[148,13365,13366],{"class":174},"01",[148,13368,13369],{"class":203}," 96",[148,13371,13372],{"class":299}," e3",[148,13374,13286],{"class":299},[148,13376,13377],{"class":299}," 9e",[148,13379,13380],{"class":203}," 80",[148,13382,13383],{"class":299}," a3",[148,13385,13386],{"class":203}," 75",[148,13388,13155],{"class":203},[148,13390,13158],{"class":203},[148,13392,13393],{"class":299}," a6",[148,13395,13386],{"class":203},[148,13397,13398],{"class":299}," 5f",[148,13400,13221],{"class":203},[148,13402,13305],{"class":203},[148,13404,13405],{"class":203}," 32\n",[148,13407,13408,13411],{"class":150,"line":1040},[148,13409,13410],{"class":174},"6b",[148,13412,13413],{"class":203}," 39\n",[148,13415,13416,13418],{"class":150,"line":1046},[148,13417,13126],{"class":174},[148,13419,13420],{"class":203}," 67\n",[12,13422,13423],{},"That is a 41% reduction for one message. Now scale 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 IoT devices. The savings are not theoretical.",[35,13425,13427],{"id":13426},"where-the-savings-come-from","Where the Savings Come From",[12,13429,13430],{},"The gains come from a few structural differences:",[597,13432,13433,13439,13445,13451],{},[600,13434,13435,13438],{},[30,13436,13437],{},"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.",[600,13440,13441,13444],{},[30,13442,13443],{},"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.",[600,13446,13447,13450],{},[30,13448,13449],{},"Compact integer encoding."," A 64-bit integer in JSON takes up to 20 ASCII characters. In MessagePack it takes exactly 9 bytes (1 type byte + 8 data bytes) for a 64-bit integer, or as few as 1 byte for small positive integers.",[600,13452,13453,13456],{},[30,13454,13455],{},"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,13458,13460],{"id":13459},"real-world-reduction-numbers","Real-World Reduction Numbers",[12,13462,13463],{},"We benchmarked a set of representative payloads across three workload types:",[135,13465,13466],{},[138,13467,13469],{"className":1578,"code":13468,"filename":8313,"language":1580,"meta":144,"style":144},"Workload               JSON (bytes)   MsgPack (bytes)   Reduction\n---------------------------------------------------------------\nChat message (small)       113             67              41%\nChat message (medium)      287            168              41%\nIoT telemetry (10 fields)  312            181              42%\nIoT telemetry (binary)     894            421              53%\nDashboard update           1,840        1,024              44%\nPresence event              98             58              41%\n",[84,13470,13471,13488,13493,13503,13512,13529,13538,13554],{"__ignoreMap":144},[148,13472,13473,13476,13479,13482,13485],{"class":150,"line":151},[148,13474,13475],{"class":174},"Workload",[148,13477,13478],{"class":299},"               JSON",[148,13480,13481],{"class":178}," (bytes)   MsgPack (",[148,13483,13484],{"class":174},"bytes",[148,13486,13487],{"class":178},")   Reduction\n",[148,13489,13490],{"class":150,"line":158},[148,13491,13492],{"class":174},"---------------------------------------------------------------\n",[148,13494,13495,13497,13500],{"class":150,"line":164},[148,13496,8343],{"class":174},[148,13498,13499],{"class":299}," message",[148,13501,13502],{"class":178}," (small)       113             67              41%\n",[148,13504,13505,13507,13509],{"class":150,"line":182},[148,13506,8343],{"class":174},[148,13508,13499],{"class":299},[148,13510,13511],{"class":178}," (medium)      287            168              41%\n",[148,13513,13514,13517,13520,13523,13526],{"class":150,"line":195},[148,13515,13516],{"class":174},"IoT",[148,13518,13519],{"class":299}," telemetry",[148,13521,13522],{"class":178}," (10 ",[148,13524,13525],{"class":299},"fields",[148,13527,13528],{"class":178},")  312            181              42%\n",[148,13530,13531,13533,13535],{"class":150,"line":207},[148,13532,13516],{"class":174},[148,13534,13519],{"class":299},[148,13536,13537],{"class":178}," (binary)     894            421              53%\n",[148,13539,13540,13542,13545,13548,13551],{"class":150,"line":217},[148,13541,66],{"class":174},[148,13543,13544],{"class":299}," update",[148,13546,13547],{"class":299},"           1,840",[148,13549,13550],{"class":299},"        1,024",[148,13552,13553],{"class":299},"              44%\n",[148,13555,13556,13558,13561,13564,13567],{"class":150,"line":227},[148,13557,4083],{"class":174},[148,13559,13560],{"class":299}," event",[148,13562,13563],{"class":203},"              98",[148,13565,13566],{"class":203},"             58",[148,13568,13569],{"class":299},"              41%\n",[12,13571,13572],{},"The 30-50% range cited in benchmarks across the industry holds up consistently. Payloads with binary blobs see even larger reductions because they avoid Base64. Payloads that are mostly long string values see smaller reductions because string content itself does not compress further.",[35,13574,13576],{"id":13575},"when-this-actually-matters","When This Actually Matters",[12,13578,13579],{},"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.",[6184,13581,13583],{"id":13582},"high-frequency-iot","High-Frequency IoT",[12,13585,13586],{},"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 40% payload reduction translates directly into server costs, CDN egress fees, and mobile data usage for devices on cellular connections.",[6184,13588,13590],{"id":13589},"chat-at-scale","Chat at Scale",[12,13592,13593],{},"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.",[6184,13595,13597],{"id":13596},"mobile-clients-on-limited-data","Mobile Clients on Limited Data",[12,13599,13600],{},"Mobile apps on 3G or metered data plans benefit directly from smaller payloads. Faster serialization (MessagePack is typically 2-4x faster to encode and decode than JSON in most runtimes) also reduces CPU time on constrained devices, which means better battery life.",[35,13602,13604],{"id":13603},"the-debuggability-tradeoff","The Debuggability Tradeoff",[12,13606,13607],{},"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 use JSON in development and switch to MessagePack in production, or to build a small decoder step into your debugging tooling. Most serious real-time systems end up doing this anyway because their debugging tooling ends up outliving any specific wire format decision.",[35,13609,13611],{"id":13610},"how-nolag-uses-messagepack","How NoLag Uses MessagePack",[12,13613,13614],{},"MessagePack is the wire format for all NoLag WebSocket traffic. Every message published to a topic, every presence event, every typing indicator, every acknowledgment travels as a MessagePack-encoded binary frame over the WebSocket connection.",[12,13616,13617,13618,13621,13622,13625],{},"The SDK handles encoding and decoding transparently. You pass a plain JavaScript object to ",[84,13619,13620],{},"room.sendMessage()"," and receive a plain JavaScript object in your ",[84,13623,13624],{},"onMessage()"," handler. The binary serialization is invisible to application code.",[12,13627,13628],{},"The combination of WebSocket framing (which has a 2-14 byte overhead per message vs hundreds of bytes for HTTP headers) and MessagePack encoding gives NoLag a per-message overhead that is close to the theoretical minimum for a framed binary transport. For customers running IoT workloads or high-frequency trading dashboards, this is measurable in their cloud bills within the first month of migration from JSON-over-HTTP polling.",[684,13630,13631],{},"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 .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 .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}",{"title":144,"searchDepth":158,"depth":158,"links":13633},[13634,13635,13636,13637,13638,13643,13644],{"id":12963,"depth":158,"text":12964},{"id":12973,"depth":158,"text":12974},{"id":13426,"depth":158,"text":13427},{"id":13459,"depth":158,"text":13460},{"id":13575,"depth":158,"text":13576,"children":13639},[13640,13641,13642],{"id":13582,"depth":164,"text":13583},{"id":13589,"depth":164,"text":13590},{"id":13596,"depth":164,"text":13597},{"id":13603,"depth":158,"text":13604},{"id":13610,"depth":158,"text":13611},"Performance","2026-04-14","MessagePack produces payloads 30-50% smaller than JSON with no Base64 overhead. Learn when binary serialization matters for real-time apps and how NoLag uses it.",{},"/blog/messagepack-vs-json",{"title":12952,"description":13647},"blog/messagepack-vs-json","jM_SN7bPj4gKorFDOjpNgixdeMNBeruIvlI-bjLQacM",{"id":13654,"title":13655,"author":7,"body":13656,"category":698,"date":14391,"description":14392,"excerpt":701,"extension":702,"meta":14393,"navigation":704,"path":14394,"readTime":14395,"seo":14396,"stem":14397,"__hash__":14398},"blog/blog/pubsub-pattern-explained.md","The Pub/Sub Pattern Explained: Topics, Rooms, and Message Routing",{"type":9,"value":13657,"toc":14379},[13658,13661,13664,13668,13671,13674,13677,13681,13684,13761,13764,13768,13771,13774,13853,13856,13860,13863,13987,13990,13994,13997,14050,14053,14060,14064,14067,14155,14158,14162,14169,14172,14176,14179,14345,14348,14352,14355,14360,14365,14373,14376],[12,13659,13660],{},"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,13662,13663],{},"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,13665,13667],{"id":13666},"the-problem-with-direct-connections","The Problem with Direct Connections",[12,13669,13670],{},"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,13672,13673],{},"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,13675,13676],{},"Direct connections couple the sender to the receiver. Pub/sub breaks that coupling.",[35,13678,13680],{"id":13679},"the-core-idea-decouple-publishers-from-subscribers","The Core Idea: Decouple Publishers from Subscribers",[12,13682,13683],{},"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,13685,13686],{},[138,13687,13689],{"className":140,"code":13688,"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,13690,13691,13696,13720,13724,13729,13749,13757],{"__ignoreMap":144},[148,13692,13693],{"class":150,"line":151},[148,13694,13695],{"class":154},"// Publisher: doesn't know who is listening\n",[148,13697,13698,13701,13703,13705,13708,13710,13712,13715,13718],{"class":150,"line":158},[148,13699,13700],{"class":178},"broker.",[148,13702,2622],{"class":174},[148,13704,296],{"class":178},[148,13706,13707],{"class":299},"'chat:general'",[148,13709,2319],{"class":178},[148,13711,300],{"class":299},[148,13713,13714],{"class":178},", from: ",[148,13716,13717],{"class":299},"'alice'",[148,13719,2150],{"class":178},[148,13721,13722],{"class":150,"line":164},[148,13723,916],{"emptyLinePlaceholder":704},[148,13725,13726],{"class":150,"line":182},[148,13727,13728],{"class":154},"// Subscriber: doesn't know who is publishing\n",[148,13730,13731,13733,13735,13737,13739,13741,13743,13745,13747],{"class":150,"line":195},[148,13732,13700],{"class":178},[148,13734,2249],{"class":174},[148,13736,296],{"class":178},[148,13738,13707],{"class":299},[148,13740,2274],{"class":178},[148,13742,11484],{"class":185},[148,13744,1142],{"class":178},[148,13746,1145],{"class":167},[148,13748,179],{"class":178},[148,13750,13751,13754],{"class":150,"line":207},[148,13752,13753],{"class":174},"  renderMessage",[148,13755,13756],{"class":178},"(message)\n",[148,13758,13759],{"class":150,"line":217},[148,13760,1055],{"class":178},[12,13762,13763],{},"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. And the broker can buffer messages for subscribers who are temporarily offline.",[35,13765,13767],{"id":13766},"topics-named-channels-for-message-routing","Topics: Named Channels for Message Routing",[12,13769,13770],{},"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,13772,13773],{},"Topics are typically hierarchical strings. Common conventions:",[135,13775,13776],{},[138,13777,13779],{"className":1578,"code":13778,"filename":8313,"language":1580,"meta":144,"style":144},"// MQTT-style hierarchy with /\nsensors/floor-2/temp-sensor-4\nusers/alice/notifications\norders/eu-west/status\n\n// Colon-delimited (common in web systems)\nchat:general:messages\npresence:room-42\ngame:session-7f3a:events\n",[84,13780,13781,13798,13803,13808,13813,13817,13838,13843,13848],{"__ignoreMap":144},[148,13782,13783,13786,13789,13792,13795],{"class":150,"line":151},[148,13784,13785],{"class":174},"//",[148,13787,13788],{"class":299}," MQTT-style",[148,13790,13791],{"class":299}," hierarchy",[148,13793,13794],{"class":299}," with",[148,13796,13797],{"class":299}," /\n",[148,13799,13800],{"class":150,"line":158},[148,13801,13802],{"class":174},"sensors/floor-2/temp-sensor-4\n",[148,13804,13805],{"class":150,"line":164},[148,13806,13807],{"class":174},"users/alice/notifications\n",[148,13809,13810],{"class":150,"line":182},[148,13811,13812],{"class":174},"orders/eu-west/status\n",[148,13814,13815],{"class":150,"line":195},[148,13816,916],{"emptyLinePlaceholder":704},[148,13818,13819,13821,13824,13827,13830,13833,13836],{"class":150,"line":207},[148,13820,13785],{"class":174},[148,13822,13823],{"class":299}," Colon-delimited",[148,13825,13826],{"class":178}," (common ",[148,13828,13829],{"class":299},"in",[148,13831,13832],{"class":299}," web",[148,13834,13835],{"class":299}," systems",[148,13837,303],{"class":178},[148,13839,13840],{"class":150,"line":217},[148,13841,13842],{"class":174},"chat:general:messages\n",[148,13844,13845],{"class":150,"line":227},[148,13846,13847],{"class":174},"presence:room-42\n",[148,13849,13850],{"class":150,"line":513},[148,13851,13852],{"class":174},"game:session-7f3a:events\n",[12,13854,13855],{},"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.",[6184,13857,13859],{"id":13858},"wildcards-and-patterns","Wildcards and Patterns",[12,13861,13862],{},"Most pub/sub systems support wildcard subscriptions. Instead of subscribing to one specific topic, you subscribe to a pattern that matches many topics.",[135,13864,13865],{},[138,13866,13868],{"className":1578,"code":13867,"filename":8313,"language":1580,"meta":144,"style":144},"// Subscribe to all sensor readings on floor 2\nbroker.subscribe('sensors/floor-2/+', handler)   // MQTT single-level wildcard\n\n// Subscribe to all events for any session\nbroker.subscribe('game/+/events', handler)\n\n// Subscribe to everything under orders\nbroker.subscribe('orders/#', handler)            // MQTT multi-level wildcard\n",[84,13869,13870,13898,13914,13918,13939,13952,13956,13973],{"__ignoreMap":144},[148,13871,13872,13874,13877,13880,13883,13886,13889,13892,13895],{"class":150,"line":151},[148,13873,13785],{"class":174},[148,13875,13876],{"class":299}," Subscribe",[148,13878,13879],{"class":299}," to",[148,13881,13882],{"class":299}," all",[148,13884,13885],{"class":299}," sensor",[148,13887,13888],{"class":299}," readings",[148,13890,13891],{"class":299}," on",[148,13893,13894],{"class":299}," floor",[148,13896,13897],{"class":203}," 2\n",[148,13899,13900,13903,13906,13908,13911],{"class":150,"line":158},[148,13901,13902],{"class":174},"broker.subscribe(",[148,13904,13905],{"class":174},"'sensors/floor-2/+'",[148,13907,13094],{"class":174},[148,13909,13910],{"class":299}," handler",[148,13912,13913],{"class":178},")   // MQTT single-level wildcard\n",[148,13915,13916],{"class":150,"line":164},[148,13917,916],{"emptyLinePlaceholder":704},[148,13919,13920,13922,13924,13926,13928,13931,13934,13936],{"class":150,"line":182},[148,13921,13785],{"class":174},[148,13923,13876],{"class":299},[148,13925,13879],{"class":299},[148,13927,13882],{"class":299},[148,13929,13930],{"class":299}," events",[148,13932,13933],{"class":299}," for",[148,13935,4427],{"class":299},[148,13937,13938],{"class":299}," session\n",[148,13940,13941,13943,13946,13948,13950],{"class":150,"line":195},[148,13942,13902],{"class":174},[148,13944,13945],{"class":174},"'game/+/events'",[148,13947,13094],{"class":174},[148,13949,13910],{"class":299},[148,13951,303],{"class":178},[148,13953,13954],{"class":150,"line":207},[148,13955,916],{"emptyLinePlaceholder":704},[148,13957,13958,13960,13962,13964,13967,13970],{"class":150,"line":217},[148,13959,13785],{"class":174},[148,13961,13876],{"class":299},[148,13963,13879],{"class":299},[148,13965,13966],{"class":299}," everything",[148,13968,13969],{"class":299}," under",[148,13971,13972],{"class":299}," orders\n",[148,13974,13975,13977,13980,13982,13984],{"class":150,"line":227},[148,13976,13902],{"class":174},[148,13978,13979],{"class":174},"'orders/#'",[148,13981,13094],{"class":174},[148,13983,13910],{"class":299},[148,13985,13986],{"class":178},")            // MQTT multi-level wildcard\n",[12,13988,13989],{},"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.",[35,13991,13993],{"id":13992},"rooms-grouping-topics-for-application-logic","Rooms: Grouping Topics for Application Logic",[12,13995,13996],{},"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 presence events, a topic for typing indicators, and possibly a topic for reactions.",[135,13998,13999],{},[138,14000,14002],{"className":1578,"code":14001,"filename":8313,"language":1580,"meta":144,"style":144},"// All topics that belong to the \"general\" room\nroom:general:messages\nroom:general:presence\nroom:general:typing\nroom:general:reactions\n",[84,14003,14004,14030,14035,14040,14045],{"__ignoreMap":144},[148,14005,14006,14008,14011,14014,14017,14020,14022,14025,14028],{"class":150,"line":151},[148,14007,13785],{"class":174},[148,14009,14010],{"class":299}," All",[148,14012,14013],{"class":299}," topics",[148,14015,14016],{"class":299}," that",[148,14018,14019],{"class":299}," belong",[148,14021,13879],{"class":299},[148,14023,14024],{"class":299}," the",[148,14026,14027],{"class":299}," \"general\"",[148,14029,4457],{"class":299},[148,14031,14032],{"class":150,"line":158},[148,14033,14034],{"class":174},"room:general:messages\n",[148,14036,14037],{"class":150,"line":164},[148,14038,14039],{"class":174},"room:general:presence\n",[148,14041,14042],{"class":150,"line":182},[148,14043,14044],{"class":174},"room:general:typing\n",[148,14046,14047],{"class":150,"line":195},[148,14048,14049],{"class":174},"room:general:reactions\n",[12,14051,14052],{},"When a user joins the \"general\" room, they subscribe to all four of these topics. When they leave, they unsubscribe from all four. The room is an abstraction that bundles a set of subscriptions and gives them a shared lifecycle.",[12,14054,14055,14056,14059],{},"Without the room abstraction, the application would have to manage these topic subscriptions individually. With it, you call ",[84,14057,14058],{},"joinRoom('general')"," and the SDK handles the rest.",[35,14061,14063],{"id":14062},"filters-narrowing-message-delivery","Filters: Narrowing Message Delivery",[12,14065,14066],{},"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 above a certain severity level. Filters let subscribers express finer-grained interest without creating a separate topic for every possible combination of criteria.",[135,14068,14069],{},[138,14070,14072],{"className":140,"code":14071,"filename":142,"language":143,"meta":144,"style":144},"// Subscribe to orders topic but only receive messages for customer_id 42\nbroker.subscribe('orders', handler, {\n  filter: { customerId: '42' }\n})\n\n// Subscribe to notifications but only severity >= 'warning'\nbroker.subscribe('notifications', handler, {\n  filter: { severity: ['warning', 'error', 'critical'] }\n})\n",[84,14073,14074,14079,14093,14104,14108,14112,14117,14130,14151],{"__ignoreMap":144},[148,14075,14076],{"class":150,"line":151},[148,14077,14078],{"class":154},"// Subscribe to orders topic but only receive messages for customer_id 42\n",[148,14080,14081,14083,14085,14087,14090],{"class":150,"line":158},[148,14082,13700],{"class":178},[148,14084,2249],{"class":174},[148,14086,296],{"class":178},[148,14088,14089],{"class":299},"'orders'",[148,14091,14092],{"class":178},", handler, {\n",[148,14094,14095,14098,14101],{"class":150,"line":164},[148,14096,14097],{"class":178},"  filter: { customerId: ",[148,14099,14100],{"class":299},"'42'",[148,14102,14103],{"class":178}," }\n",[148,14105,14106],{"class":150,"line":182},[148,14107,1055],{"class":178},[148,14109,14110],{"class":150,"line":195},[148,14111,916],{"emptyLinePlaceholder":704},[148,14113,14114],{"class":150,"line":207},[148,14115,14116],{"class":154},"// Subscribe to notifications but only severity >= 'warning'\n",[148,14118,14119,14121,14123,14125,14128],{"class":150,"line":217},[148,14120,13700],{"class":178},[148,14122,2249],{"class":174},[148,14124,296],{"class":178},[148,14126,14127],{"class":299},"'notifications'",[148,14129,14092],{"class":178},[148,14131,14132,14135,14138,14140,14143,14145,14148],{"class":150,"line":227},[148,14133,14134],{"class":178},"  filter: { severity: [",[148,14136,14137],{"class":299},"'warning'",[148,14139,87],{"class":178},[148,14141,14142],{"class":299},"'error'",[148,14144,87],{"class":178},[148,14146,14147],{"class":299},"'critical'",[148,14149,14150],{"class":178},"] }\n",[148,14152,14153],{"class":150,"line":513},[148,14154,1055],{"class":178},[12,14156,14157],{},"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 but requires the broker to understand your message schema. Client-side filtering is simpler to implement but wastes bandwidth on messages the client will ignore.",[35,14159,14161],{"id":14160},"how-pubsub-scales-vs-direct-connections","How Pub/Sub Scales vs Direct Connections",[12,14163,14164,14165,14168],{},"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 ",[84,14166,14167],{},"room:general:messages",", the broker fans the message out to all 999 other subscribers. 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,14170,14171],{},"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,14173,14175],{"id":14174},"using-pubsub-in-practice-the-chat-app","Using Pub/Sub in Practice: The Chat App",[12,14177,14178],{},"Here is how all three concepts (topics, rooms, filters) work together in a real chat app with multiple channels:",[135,14180,14181],{},[138,14182,14184],{"className":140,"code":14183,"filename":142,"language":143,"meta":144,"style":144},"// User joins two rooms\nconst general = await chat.joinRoom('general')\nconst engineering = await chat.joinRoom('engineering')\n\n// Under the hood, each joinRoom subscribes to multiple topics:\n// room:general:messages, room:general:presence, room:general:typing\n// room:engineering:messages, room:engineering:presence, room:engineering:typing\n\n// A message published to room:general:messages only reaches\n// subscribers of that specific topic. Users in #engineering\n// do not receive messages from #general.\n\n// User sends a message - published to room:general:messages\nawait general.sendMessage({ text: 'Morning everyone' })\n\n// User receives only messages from rooms they have joined\ngeneral.onMessage(msg => appendToGeneralFeed(msg))\nengineering.onMessage(msg => appendToEngineeringFeed(msg))\n",[84,14185,14186,14191,14212,14234,14238,14243,14248,14253,14257,14262,14267,14272,14276,14281,14297,14301,14306,14327],{"__ignoreMap":144},[148,14187,14188],{"class":150,"line":151},[148,14189,14190],{"class":154},"// User joins two rooms\n",[148,14192,14193,14195,14198,14200,14202,14204,14206,14208,14210],{"class":150,"line":158},[148,14194,270],{"class":167},[148,14196,14197],{"class":203}," general",[148,14199,276],{"class":167},[148,14201,499],{"class":167},[148,14203,8539],{"class":178},[148,14205,8542],{"class":174},[148,14207,296],{"class":178},[148,14209,2240],{"class":299},[148,14211,303],{"class":178},[148,14213,14214,14216,14219,14221,14223,14225,14227,14229,14232],{"class":150,"line":164},[148,14215,270],{"class":167},[148,14217,14218],{"class":203}," engineering",[148,14220,276],{"class":167},[148,14222,499],{"class":167},[148,14224,8539],{"class":178},[148,14226,8542],{"class":174},[148,14228,296],{"class":178},[148,14230,14231],{"class":299},"'engineering'",[148,14233,303],{"class":178},[148,14235,14236],{"class":150,"line":182},[148,14237,916],{"emptyLinePlaceholder":704},[148,14239,14240],{"class":150,"line":195},[148,14241,14242],{"class":154},"// Under the hood, each joinRoom subscribes to multiple topics:\n",[148,14244,14245],{"class":150,"line":207},[148,14246,14247],{"class":154},"// room:general:messages, room:general:presence, room:general:typing\n",[148,14249,14250],{"class":150,"line":217},[148,14251,14252],{"class":154},"// room:engineering:messages, room:engineering:presence, room:engineering:typing\n",[148,14254,14255],{"class":150,"line":227},[148,14256,916],{"emptyLinePlaceholder":704},[148,14258,14259],{"class":150,"line":513},[148,14260,14261],{"class":154},"// A message published to room:general:messages only reaches\n",[148,14263,14264],{"class":150,"line":993},[148,14265,14266],{"class":154},"// subscribers of that specific topic. Users in #engineering\n",[148,14268,14269],{"class":150,"line":1004},[148,14270,14271],{"class":154},"// do not receive messages from #general.\n",[148,14273,14274],{"class":150,"line":1013},[148,14275,916],{"emptyLinePlaceholder":704},[148,14277,14278],{"class":150,"line":1019},[148,14279,14280],{"class":154},"// User sends a message - published to room:general:messages\n",[148,14282,14283,14285,14288,14290,14292,14295],{"class":150,"line":1028},[148,14284,943],{"class":167},[148,14286,14287],{"class":178}," general.",[148,14289,293],{"class":174},[148,14291,4746],{"class":178},[148,14293,14294],{"class":299},"'Morning everyone'",[148,14296,2150],{"class":178},[148,14298,14299],{"class":150,"line":1034},[148,14300,916],{"emptyLinePlaceholder":704},[148,14302,14303],{"class":150,"line":1040},[148,14304,14305],{"class":154},"// User receives only messages from rooms they have joined\n",[148,14307,14308,14311,14314,14316,14318,14321,14324],{"class":150,"line":1046},[148,14309,14310],{"class":178},"general.",[148,14312,14313],{"class":174},"onMessage",[148,14315,296],{"class":178},[148,14317,2587],{"class":185},[148,14319,14320],{"class":167}," =>",[148,14322,14323],{"class":174}," appendToGeneralFeed",[148,14325,14326],{"class":178},"(msg))\n",[148,14328,14329,14332,14334,14336,14338,14340,14343],{"class":150,"line":1052},[148,14330,14331],{"class":178},"engineering.",[148,14333,14313],{"class":174},[148,14335,296],{"class":178},[148,14337,2587],{"class":185},[148,14339,14320],{"class":167},[148,14341,14342],{"class":174}," appendToEngineeringFeed",[148,14344,14326],{"class":178},[12,14346,14347],{},"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,14349,14351],{"id":14350},"nolags-three-noun-model-app-room-topic","NoLag's Three-Noun Model: App, Room, Topic",[12,14353,14354],{},"NoLag organises all of this around three nouns.",[12,14356,8350,14357,14359],{},[30,14358,4213],{}," is the top-level namespace. It maps to your application and has its own API keys, connection limits, and subscriber counts. All rooms and topics belong to an app. Isolation between apps is complete: no topic in one app is visible to another.",[12,14361,1540,14362,14364],{},[30,14363,1101],{}," 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 lists, message history, and permission policies. Users connect to a room, not to individual topics.",[12,14366,1540,14367,14369,14370,14372],{},[30,14368,4231],{}," is the actual message channel within a room. Each room contains multiple topics for different event types. When you call ",[84,14371,11891],{}," on the SDK, it subscribes to all standard topics for that room. You can also create custom topics inside a room for application-specific event streams.",[12,14374,14375],{},"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.",[684,14377,14378],{},"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":158,"depth":158,"links":14380},[14381,14382,14383,14386,14387,14388,14389,14390],{"id":13666,"depth":158,"text":13667},{"id":13679,"depth":158,"text":13680},{"id":13766,"depth":158,"text":13767,"children":14384},[14385],{"id":13858,"depth":164,"text":13859},{"id":13992,"depth":158,"text":13993},{"id":14062,"depth":158,"text":14063},{"id":14160,"depth":158,"text":14161},{"id":14174,"depth":158,"text":14175},{"id":14350,"depth":158,"text":14351},"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":13655,"description":14392},"blog/pubsub-pattern-explained","80ItsCb2BJ5huOHECYai8bGdGbFTGFZ_Y-vb7GenFos",{"id":14400,"title":14401,"author":7,"body":14402,"category":698,"date":15065,"description":15066,"excerpt":701,"extension":702,"meta":15067,"navigation":704,"path":15068,"readTime":2388,"seo":15069,"stem":15070,"__hash__":15071},"blog/blog/qos-levels-real-time-messaging.md","QoS 0, 1, and 2: Choosing the Right Delivery Guarantee for Every Message",{"type":9,"value":14403,"toc":15050},[14404,14407,14410,14414,14417,14495,14498,14502,14528,14532,14535,14634,14637,14641,14667,14671,14674,14778,14781,14785,14811,14815,14818,14919,14922,14926,14929,14932,14936,14939,15014,15017,15021,15024,15044,15047],[12,14405,14406],{},"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,14408,14409],{},"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 to guarantee it. The three levels originated in MQTT but the concepts apply broadly to any real-time messaging system.",[35,14411,14413],{"id":14412},"qos-0-at-most-once-fire-and-forget","QoS 0: At Most Once (Fire and Forget)",[12,14415,14416],{},"QoS 0 is the simplest delivery mode. The publisher sends the message once. The broker delivers it if the subscriber is currently connected. No acknowledgment, no retry, no persistence. If the connection drops between publish and delivery, the message is gone.",[135,14418,14419],{},[138,14420,14422],{"className":140,"code":14421,"filename":142,"language":143,"meta":144,"style":144},"// QoS 0: no ack, no retry\nawait topic.publish(\n  { lat: 51.5074, lng: -0.1278, speed: 42 },\n  { qos: 0 }\n)\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,14423,14424,14429,14439,14463,14472,14476,14480,14485,14490],{"__ignoreMap":144},[148,14425,14426],{"class":150,"line":151},[148,14427,14428],{"class":154},"// QoS 0: no ack, no retry\n",[148,14430,14431,14433,14435,14437],{"class":150,"line":158},[148,14432,943],{"class":167},[148,14434,1548],{"class":178},[148,14436,2622],{"class":174},[148,14438,424],{"class":178},[148,14440,14441,14444,14447,14450,14452,14455,14458,14461],{"class":150,"line":164},[148,14442,14443],{"class":178},"  { lat: ",[148,14445,14446],{"class":203},"51.5074",[148,14448,14449],{"class":178},", lng: ",[148,14451,5300],{"class":167},[148,14453,14454],{"class":203},"0.1278",[148,14456,14457],{"class":178},", speed: ",[148,14459,14460],{"class":203},"42",[148,14462,5081],{"class":178},[148,14464,14465,14468,14470],{"class":150,"line":182},[148,14466,14467],{"class":178},"  { qos: ",[148,14469,7693],{"class":203},[148,14471,14103],{"class":178},[148,14473,14474],{"class":150,"line":195},[148,14475,303],{"class":178},[148,14477,14478],{"class":150,"line":207},[148,14479,916],{"emptyLinePlaceholder":704},[148,14481,14482],{"class":150,"line":217},[148,14483,14484],{"class":154},"// The broker sends this to subscribers and forgets it.\n",[148,14486,14487],{"class":150,"line":227},[148,14488,14489],{"class":154},"// If a subscriber is offline, they miss it. That's acceptable\n",[148,14491,14492],{"class":150,"line":513},[148,14493,14494],{"class":154},"// for position updates because the next one arrives in 100ms.\n",[12,14496,14497],{},"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.",[6184,14499,14501],{"id":14500},"when-to-use-qos-0","When to use QoS 0",[597,14503,14504,14510,14516,14522],{},[600,14505,14506,14509],{},[30,14507,14508],{},"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.",[600,14511,14512,14515],{},[30,14513,14514],{},"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.",[600,14517,14518,14521],{},[30,14519,14520],{},"Live cursor positions."," In a collaborative document editor, cursor positions are sent many times per second. Missing one frame is invisible to users.",[600,14523,14524,14527],{},[30,14525,14526],{},"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,14529,14531],{"id":14530},"qos-1-at-least-once-acknowledged-delivery","QoS 1: At Least Once (Acknowledged Delivery)",[12,14533,14534],{},"QoS 1 adds an acknowledgment handshake. The publisher sends a message and waits for an ack from the broker. If no ack arrives within the timeout window, the publisher resends the message. This continues until the broker acknowledges receipt.",[135,14536,14537],{},[138,14538,14540],{"className":140,"code":14539,"filename":142,"language":143,"meta":144,"style":144},"// QoS 1: publisher retries until ack received\nawait topic.publish(\n  { type: 'message', text: 'Hey, are you free?', msgId: 'msg_01' },\n  { qos: 1 }\n)\n\n// Flow:\n// 1. Publisher sends MESSAGE (msgId: msg_01)\n// 2. Broker stores message, delivers to subscribers\n// 3. Broker sends PUBACK to publisher\n// 4. Publisher discards the message from retry queue\n//\n// If step 3 fails: publisher resends MESSAGE\n// Subscriber may receive it twice - application must handle this\n",[84,14541,14542,14547,14557,14578,14587,14591,14595,14600,14605,14610,14615,14620,14624,14629],{"__ignoreMap":144},[148,14543,14544],{"class":150,"line":151},[148,14545,14546],{"class":154},"// QoS 1: publisher retries until ack received\n",[148,14548,14549,14551,14553,14555],{"class":150,"line":158},[148,14550,943],{"class":167},[148,14552,1548],{"class":178},[148,14554,2622],{"class":174},[148,14556,424],{"class":178},[148,14558,14559,14562,14564,14567,14570,14573,14576],{"class":150,"line":164},[148,14560,14561],{"class":178},"  { type: ",[148,14563,2582],{"class":299},[148,14565,14566],{"class":178},", text: ",[148,14568,14569],{"class":299},"'Hey, are you free?'",[148,14571,14572],{"class":178},", msgId: ",[148,14574,14575],{"class":299},"'msg_01'",[148,14577,5081],{"class":178},[148,14579,14580,14582,14585],{"class":150,"line":182},[148,14581,14467],{"class":178},[148,14583,14584],{"class":203},"1",[148,14586,14103],{"class":178},[148,14588,14589],{"class":150,"line":195},[148,14590,303],{"class":178},[148,14592,14593],{"class":150,"line":207},[148,14594,916],{"emptyLinePlaceholder":704},[148,14596,14597],{"class":150,"line":217},[148,14598,14599],{"class":154},"// Flow:\n",[148,14601,14602],{"class":150,"line":227},[148,14603,14604],{"class":154},"// 1. Publisher sends MESSAGE (msgId: msg_01)\n",[148,14606,14607],{"class":150,"line":513},[148,14608,14609],{"class":154},"// 2. Broker stores message, delivers to subscribers\n",[148,14611,14612],{"class":150,"line":993},[148,14613,14614],{"class":154},"// 3. Broker sends PUBACK to publisher\n",[148,14616,14617],{"class":150,"line":1004},[148,14618,14619],{"class":154},"// 4. Publisher discards the message from retry queue\n",[148,14621,14622],{"class":150,"line":1013},[148,14623,8090],{"class":154},[148,14625,14626],{"class":150,"line":1019},[148,14627,14628],{"class":154},"// If step 3 fails: publisher resends MESSAGE\n",[148,14630,14631],{"class":150,"line":1028},[148,14632,14633],{"class":154},"// Subscriber may receive it twice - application must handle this\n",[12,14635,14636],{},"The \"at least once\" label is precise: the message will definitely be delivered, but it might be delivered more than once if an ack gets lost and the publisher retries. The subscriber must handle duplicate delivery, either by ignoring duplicates (idempotent processing) or by tracking message IDs and deduplicating.",[6184,14638,14640],{"id":14639},"when-to-use-qos-1","When to use QoS 1",[597,14642,14643,14649,14655,14661],{},[600,14644,14645,14648],{},[30,14646,14647],{},"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.",[600,14650,14651,14654],{},[30,14652,14653],{},"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.",[600,14656,14657,14660],{},[30,14658,14659],{},"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.",[600,14662,14663,14666],{},[30,14664,14665],{},"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,14668,14670],{"id":14669},"qos-2-exactly-once-four-way-handshake","QoS 2: Exactly Once (Four-Way Handshake)",[12,14672,14673],{},"QoS 2 guarantees that a message is delivered exactly once. No drops, no duplicates. It achieves this with a four-step handshake between publisher, broker, and subscriber.",[135,14675,14676],{},[138,14677,14679],{"className":140,"code":14678,"filename":142,"language":143,"meta":144,"style":144},"// QoS 2: exactly-once delivery via four-way handshake\nawait topic.publish(\n  { type: 'payment', amount: 299.00, orderId: 'ord_7x2k' },\n  { qos: 2 }\n)\n\n// Full flow:\n// 1. Publisher sends PUBLISH (stored by broker)\n// 2. Broker sends PUBREC (publish received)\n// 3. Publisher sends PUBREL (publish release) - broker can now deliver\n// 4. Broker delivers to subscriber, sends PUBCOMP (complete)\n// 5. Publisher removes from retry queue\n//\n// At no point in this flow can a retry produce a duplicate delivery.\n// The broker tracks message state across all four steps.\n",[84,14680,14681,14686,14696,14717,14726,14730,14734,14739,14744,14749,14754,14759,14764,14768,14773],{"__ignoreMap":144},[148,14682,14683],{"class":150,"line":151},[148,14684,14685],{"class":154},"// QoS 2: exactly-once delivery via four-way handshake\n",[148,14687,14688,14690,14692,14694],{"class":150,"line":158},[148,14689,943],{"class":167},[148,14691,1548],{"class":178},[148,14693,2622],{"class":174},[148,14695,424],{"class":178},[148,14697,14698,14700,14703,14706,14709,14712,14715],{"class":150,"line":164},[148,14699,14561],{"class":178},[148,14701,14702],{"class":299},"'payment'",[148,14704,14705],{"class":178},", amount: ",[148,14707,14708],{"class":203},"299.00",[148,14710,14711],{"class":178},", orderId: ",[148,14713,14714],{"class":299},"'ord_7x2k'",[148,14716,5081],{"class":178},[148,14718,14719,14721,14724],{"class":150,"line":182},[148,14720,14467],{"class":178},[148,14722,14723],{"class":203},"2",[148,14725,14103],{"class":178},[148,14727,14728],{"class":150,"line":195},[148,14729,303],{"class":178},[148,14731,14732],{"class":150,"line":207},[148,14733,916],{"emptyLinePlaceholder":704},[148,14735,14736],{"class":150,"line":217},[148,14737,14738],{"class":154},"// Full flow:\n",[148,14740,14741],{"class":150,"line":227},[148,14742,14743],{"class":154},"// 1. Publisher sends PUBLISH (stored by broker)\n",[148,14745,14746],{"class":150,"line":513},[148,14747,14748],{"class":154},"// 2. Broker sends PUBREC (publish received)\n",[148,14750,14751],{"class":150,"line":993},[148,14752,14753],{"class":154},"// 3. Publisher sends PUBREL (publish release) - broker can now deliver\n",[148,14755,14756],{"class":150,"line":1004},[148,14757,14758],{"class":154},"// 4. Broker delivers to subscriber, sends PUBCOMP (complete)\n",[148,14760,14761],{"class":150,"line":1013},[148,14762,14763],{"class":154},"// 5. Publisher removes from retry queue\n",[148,14765,14766],{"class":150,"line":1019},[148,14767,8090],{"class":154},[148,14769,14770],{"class":150,"line":1028},[148,14771,14772],{"class":154},"// At no point in this flow can a retry produce a duplicate delivery.\n",[148,14774,14775],{"class":150,"line":1034},[148,14776,14777],{"class":154},"// The broker tracks message state across all four steps.\n",[12,14779,14780],{},"This four-step protocol is significantly more expensive than QoS 1. Each message requires two round-trips instead of one. The broker must persist the message state across the full handshake, which means more writes, more memory, and more latency. For a high-volume stream this overhead is prohibitive.",[6184,14782,14784],{"id":14783},"when-to-use-qos-2","When to use QoS 2",[597,14786,14787,14793,14799,14805],{},[600,14788,14789,14792],{},[30,14790,14791],{},"Financial transactions."," A payment that processes twice charges the customer twice. A payment that is silently dropped causes a lost sale or a failed service. Exactly-once delivery is a hard requirement here.",[600,14794,14795,14798],{},[30,14796,14797],{},"Inventory mutations."," Decrementing stock by one must happen exactly once per sale. A retry that gets delivered twice would decrement stock by two.",[600,14800,14801,14804],{},[30,14802,14803],{},"Audit log entries."," If you are building a compliance system where every event must appear exactly once in the audit trail, QoS 2 ensures the record is accurate.",[600,14806,14807,14810],{},[30,14808,14809],{},"Smart contracts or on-chain triggers."," Any message that triggers an irreversible action downstream needs exactly-once delivery.",[35,14812,14814],{"id":14813},"the-latency-vs-reliability-tradeoff","The Latency vs Reliability Tradeoff",[12,14816,14817],{},"The cost of each QoS level is concrete:",[135,14819,14820],{},[138,14821,14823],{"className":1578,"code":14822,"filename":8313,"language":1580,"meta":144,"style":144},"QoS Level   Round-Trips   Broker Storage   Relative Latency   Duplicate Risk\n--------------------------------------------------------------------------\nQoS 0           0            None               1x                None\nQoS 1           1            Until ack          1.5-2x            Possible\nQoS 2           2            Until complete     2.5-4x            None\n",[84,14824,14825,14854,14859,14877,14898],{"__ignoreMap":144},[148,14826,14827,14830,14833,14836,14839,14842,14845,14848,14851],{"class":150,"line":151},[148,14828,14829],{"class":174},"QoS",[148,14831,14832],{"class":299}," Level",[148,14834,14835],{"class":299},"   Round-Trips",[148,14837,14838],{"class":299},"   Broker",[148,14840,14841],{"class":299}," Storage",[148,14843,14844],{"class":299},"   Relative",[148,14846,14847],{"class":299}," Latency",[148,14849,14850],{"class":299},"   Duplicate",[148,14852,14853],{"class":299}," Risk\n",[148,14855,14856],{"class":150,"line":158},[148,14857,14858],{"class":174},"--------------------------------------------------------------------------\n",[148,14860,14861,14863,14865,14868,14871,14874],{"class":150,"line":164},[148,14862,14829],{"class":174},[148,14864,9422],{"class":203},[148,14866,14867],{"class":203},"           0",[148,14869,14870],{"class":299},"            None",[148,14872,14873],{"class":299},"               1x",[148,14875,14876],{"class":299},"                None\n",[148,14878,14879,14881,14883,14886,14889,14892,14895],{"class":150,"line":182},[148,14880,14829],{"class":174},[148,14882,9494],{"class":203},[148,14884,14885],{"class":203},"           1",[148,14887,14888],{"class":299},"            Until",[148,14890,14891],{"class":299}," ack",[148,14893,14894],{"class":299},"          1.5-2x",[148,14896,14897],{"class":299},"            Possible\n",[148,14899,14900,14902,14905,14908,14910,14913,14916],{"class":150,"line":195},[148,14901,14829],{"class":174},[148,14903,14904],{"class":203}," 2",[148,14906,14907],{"class":203},"           2",[148,14909,14888],{"class":299},[148,14911,14912],{"class":299}," complete",[148,14914,14915],{"class":299},"     2.5-4x",[148,14917,14918],{"class":299},"            None\n",[12,14920,14921],{},"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.",[35,14923,14925],{"id":14924},"per-message-qos-not-per-connection","Per-Message QoS, Not Per-Connection",[12,14927,14928],{},"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,14930,14931],{},"Per-message QoS is the right model. A single connection can carry QoS 0 telemetry, QoS 1 chat messages, and QoS 2 payment confirmations simultaneously. The broker applies the appropriate delivery logic per message based on what the publisher requested.",[35,14933,14935],{"id":14934},"how-nolag-handles-qos","How NoLag Handles QoS",[12,14937,14938],{},"NoLag attaches QoS per message, not per connection. When you publish, you specify the delivery guarantee for that specific message. The SDK and broker handle the ack handshake, retry logic, and deduplication transparently based on the level you specify.",[135,14940,14941],{},[138,14942,14944],{"className":140,"code":14943,"filename":142,"language":143,"meta":144,"style":144},"// Ephemeral presence update - no storage needed\nawait topic.publish(presenceUpdate, { qos: 0 })\n\n// Chat message - must arrive, duplicates are manageable\nawait topic.publish(chatMessage, { qos: 1 })\n\n// Payment event - must arrive exactly once\nawait topic.publish(paymentEvent, { qos: 2 })\n",[84,14945,14946,14951,14966,14970,14975,14990,14994,14999],{"__ignoreMap":144},[148,14947,14948],{"class":150,"line":151},[148,14949,14950],{"class":154},"// Ephemeral presence update - no storage needed\n",[148,14952,14953,14955,14957,14959,14962,14964],{"class":150,"line":158},[148,14954,943],{"class":167},[148,14956,1548],{"class":178},[148,14958,2622],{"class":174},[148,14960,14961],{"class":178},"(presenceUpdate, { qos: ",[148,14963,7693],{"class":203},[148,14965,2150],{"class":178},[148,14967,14968],{"class":150,"line":164},[148,14969,916],{"emptyLinePlaceholder":704},[148,14971,14972],{"class":150,"line":182},[148,14973,14974],{"class":154},"// Chat message - must arrive, duplicates are manageable\n",[148,14976,14977,14979,14981,14983,14986,14988],{"class":150,"line":195},[148,14978,943],{"class":167},[148,14980,1548],{"class":178},[148,14982,2622],{"class":174},[148,14984,14985],{"class":178},"(chatMessage, { qos: ",[148,14987,14584],{"class":203},[148,14989,2150],{"class":178},[148,14991,14992],{"class":150,"line":207},[148,14993,916],{"emptyLinePlaceholder":704},[148,14995,14996],{"class":150,"line":217},[148,14997,14998],{"class":154},"// Payment event - must arrive exactly once\n",[148,15000,15001,15003,15005,15007,15010,15012],{"class":150,"line":227},[148,15002,943],{"class":167},[148,15004,1548],{"class":178},[148,15006,2622],{"class":174},[148,15008,15009],{"class":178},"(paymentEvent, { qos: ",[148,15011,14723],{"class":203},[148,15013,2150],{"class":178},[12,15015,15016],{},"All three can flow over the same WebSocket connection. The connection overhead is paid once. The per-message overhead reflects only what that specific message requires.",[35,15018,15020],{"id":15019},"practical-guidance-what-to-use-when","Practical Guidance: What to Use When",[12,15022,15023],{},"If you are not sure where to start, here is a simple decision tree:",[597,15025,15026,15032,15038],{},[600,15027,15028,15031],{},[30,15029,15030],{},"The message is transient state (typing, cursor, presence)."," Use QoS 0. A missed delivery has no lasting effect.",[600,15033,15034,15037],{},[30,15035,15036],{},"The message represents a durable event (chat, notification, status change)."," Use QoS 1. Ensure your subscriber deduplicates on message ID for resilience.",[600,15039,15040,15043],{},[30,15041,15042],{},"The message triggers an irreversible action (payment, inventory decrement, audit record)."," Use QoS 2. Pay the latency cost. It is worth it.",[12,15045,15046],{},"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.",[684,15048,15049],{},"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 .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 .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":144,"searchDepth":158,"depth":158,"links":15051},[15052,15055,15058,15061,15062,15063,15064],{"id":14412,"depth":158,"text":14413,"children":15053},[15054],{"id":14500,"depth":164,"text":14501},{"id":14530,"depth":158,"text":14531,"children":15056},[15057],{"id":14639,"depth":164,"text":14640},{"id":14669,"depth":158,"text":14670,"children":15059},[15060],{"id":14783,"depth":164,"text":14784},{"id":14813,"depth":158,"text":14814},{"id":14924,"depth":158,"text":14925},{"id":14934,"depth":158,"text":14935},{"id":15019,"depth":158,"text":15020},"2026-03-31","Learn what QoS 0, 1, and 2 mean in real-time messaging. At most once, at least once, and exactly once delivery, with practical guidance on which to use for telemetry, chat, and payments.",{},"/blog/qos-levels-real-time-messaging",{"title":14401,"description":15066},"blog/qos-levels-real-time-messaging","vXOx5OwUVXmChBTC_a5RPZpDXOlmM1Qfs1BPdKnKUtU",{"id":15073,"title":15074,"author":7,"body":15075,"category":15503,"date":15504,"description":15505,"excerpt":701,"extension":702,"meta":15506,"navigation":704,"path":15507,"readTime":1888,"seo":15508,"stem":15509,"__hash__":15510},"blog/blog/presence-tracking-at-scale.md","Building Presence at Scale: Who Is Online and How to Track It",{"type":9,"value":15076,"toc":15490},[15077,15080,15083,15087,15090,15093,15119,15122,15126,15129,15248,15251,15254,15257,15260,15264,15267,15270,15403,15406,15410,15413,15419,15425,15428,15432,15436,15443,15447,15450,15454,15461,15464,15468,15471,15475,15478,15481,15484,15487],[12,15078,15079],{},"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,15081,15082],{},"This post covers what presence actually means, why the naive approaches fall apart, and how to build something that holds up at scale.",[35,15084,15086],{"id":15085},"what-presence-actually-means","What Presence Actually Means",[12,15088,15089],{},"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,15091,15092],{},"A richer presence model typically includes:",[597,15094,15095,15101,15107,15113],{},[600,15096,15097,15100],{},[30,15098,15099],{},"Status."," Online, offline, away, busy, do not disturb.",[600,15102,15103,15106],{},[30,15104,15105],{},"Last seen."," A timestamp of the most recent activity or heartbeat.",[600,15108,15109,15112],{},[30,15110,15111],{},"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.",[600,15114,15115,15118],{},[30,15116,15117],{},"Device context."," Whether the user is on mobile or desktop, which can affect what status transitions mean.",[12,15120,15121],{},"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,15123,15125],{"id":15124},"the-naive-approach-polling","The Naive Approach: Polling",[12,15127,15128],{},"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,15130,15131],{},[138,15132,15134],{"className":2157,"code":15133,"filename":142,"language":2159,"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,15135,15136,15141,15155,15173,15182,15186,15191,15205,15224,15239],{"__ignoreMap":144},[148,15137,15138],{"class":150,"line":151},[148,15139,15140],{"class":154},"// Client heartbeat poll every 30 seconds\n",[148,15142,15143,15145,15147,15149,15151,15153],{"class":150,"line":158},[148,15144,10951],{"class":174},[148,15146,296],{"class":178},[148,15148,1129],{"class":167},[148,15150,3263],{"class":178},[148,15152,1145],{"class":167},[148,15154,179],{"class":178},[148,15156,15157,15159,15161,15163,15166,15169,15171],{"class":150,"line":164},[148,15158,8657],{"class":167},[148,15160,2963],{"class":174},[148,15162,296],{"class":178},[148,15164,15165],{"class":299},"'/api/presence/heartbeat'",[148,15167,15168],{"class":178},", { method: ",[148,15170,2979],{"class":299},[148,15172,2150],{"class":178},[148,15174,15175,15177,15180],{"class":150,"line":182},[148,15176,11023],{"class":178},[148,15178,15179],{"class":203},"30_000",[148,15181,303],{"class":178},[148,15183,15184],{"class":150,"line":195},[148,15185,916],{"emptyLinePlaceholder":704},[148,15187,15188],{"class":150,"line":207},[148,15189,15190],{"class":154},"// Other clients poll for status\n",[148,15192,15193,15195,15197,15199,15201,15203],{"class":150,"line":217},[148,15194,10951],{"class":174},[148,15196,296],{"class":178},[148,15198,1129],{"class":167},[148,15200,3263],{"class":178},[148,15202,1145],{"class":167},[148,15204,179],{"class":178},[148,15206,15207,15209,15211,15213,15215,15217,15219,15222],{"class":150,"line":227},[148,15208,491],{"class":167},[148,15210,2956],{"class":203},[148,15212,276],{"class":167},[148,15214,499],{"class":167},[148,15216,2963],{"class":174},[148,15218,296],{"class":178},[148,15220,15221],{"class":299},"'/api/presence?userIds=u1,u2,u3'",[148,15223,303],{"class":178},[148,15225,15226,15229,15231,15233,15235,15237],{"class":150,"line":513},[148,15227,15228],{"class":174},"  updatePresenceUI",[148,15230,296],{"class":178},[148,15232,943],{"class":167},[148,15234,3080],{"class":178},[148,15236,3083],{"class":174},[148,15238,2124],{"class":178},[148,15240,15241,15243,15246],{"class":150,"line":993},[148,15242,11023],{"class":178},[148,15244,15245],{"class":203},"5_000",[148,15247,303],{"class":178},[12,15249,15250],{},"This works for a demo. It falls apart in production for several reasons.",[12,15252,15253],{},"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,15255,15256],{},"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,15258,15259],{},"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,15261,15263],{"id":15262},"the-event-driven-approach","The Event-Driven Approach",[12,15265,15266],{},"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,15268,15269],{},"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,15271,15272],{},[138,15273,15275],{"className":2157,"code":15274,"filename":142,"language":2159,"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,15276,15277,15282,15296,15306,15316,15330,15340,15349,15353,15357,15362,15375,15383,15391,15399],{"__ignoreMap":144},[148,15278,15279],{"class":150,"line":151},[148,15280,15281],{"class":154},"// Server emits on connection\n",[148,15283,15284,15287,15289,15291,15294],{"class":150,"line":158},[148,15285,15286],{"class":178},"presence.",[148,15288,2312],{"class":174},[148,15290,296],{"class":178},[148,15292,15293],{"class":299},"'join'",[148,15295,2971],{"class":178},[148,15297,15298,15301,15304],{"class":150,"line":164},[148,15299,15300],{"class":178},"  userId: ",[148,15302,15303],{"class":299},"'u_abc123'",[148,15305,2982],{"class":178},[148,15307,15308,15311,15314],{"class":150,"line":182},[148,15309,15310],{"class":178},"  status: ",[148,15312,15313],{"class":299},"'online'",[148,15315,2982],{"class":178},[148,15317,15318,15321,15323,15325,15328],{"class":150,"line":195},[148,15319,15320],{"class":178},"  metadata: { displayName: ",[148,15322,11706],{"class":299},[148,15324,8466],{"class":178},[148,15326,15327],{"class":299},"'https://...'",[148,15329,5081],{"class":178},[148,15331,15332,15335,15338],{"class":150,"line":207},[148,15333,15334],{"class":178},"  roomId: ",[148,15336,15337],{"class":299},"'room_xyz'",[148,15339,2982],{"class":178},[148,15341,15342,15345,15347],{"class":150,"line":217},[148,15343,15344],{"class":178},"  timestamp: Date.",[148,15346,5294],{"class":174},[148,15348,8007],{"class":178},[148,15350,15351],{"class":150,"line":227},[148,15352,1055],{"class":178},[148,15354,15355],{"class":150,"line":513},[148,15356,916],{"emptyLinePlaceholder":704},[148,15358,15359],{"class":150,"line":993},[148,15360,15361],{"class":154},"// Server emits on disconnect\n",[148,15363,15364,15366,15368,15370,15373],{"class":150,"line":1004},[148,15365,15286],{"class":178},[148,15367,2312],{"class":174},[148,15369,296],{"class":178},[148,15371,15372],{"class":299},"'leave'",[148,15374,2971],{"class":178},[148,15376,15377,15379,15381],{"class":150,"line":1013},[148,15378,15300],{"class":178},[148,15380,15303],{"class":299},[148,15382,2982],{"class":178},[148,15384,15385,15387,15389],{"class":150,"line":1019},[148,15386,15334],{"class":178},[148,15388,15337],{"class":299},[148,15390,2982],{"class":178},[148,15392,15393,15395,15397],{"class":150,"line":1028},[148,15394,15344],{"class":178},[148,15396,5294],{"class":174},[148,15398,8007],{"class":178},[148,15400,15401],{"class":150,"line":1034},[148,15402,1055],{"class":178},[12,15404,15405],{},"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,15407,15409],{"id":15408},"per-room-vs-global-presence","Per-Room vs Global Presence",[12,15411,15412],{},"There are two presence scopes you will need to think about separately.",[12,15414,15415,15418],{},[30,15416,15417],{},"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,15420,15421,15424],{},[30,15422,15423],{},"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,15426,15427],{},"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,15429,15431],{"id":15430},"edge-cases-that-will-bite-you","Edge Cases That Will Bite You",[6184,15433,15435],{"id":15434},"tab-switching","Tab Switching",[12,15437,15438,15439,15442],{},"When a user switches to another browser tab, the Page Visibility API fires a ",[84,15440,15441],{},"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.",[6184,15444,15446],{"id":15445},"mobile-backgrounding","Mobile Backgrounding",[12,15448,15449],{},"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.",[6184,15451,15453],{"id":15452},"ungraceful-disconnects","Ungraceful Disconnects",[12,15455,15456,15457,15460],{},"A graceful disconnect happens when the client calls ",[84,15458,15459],{},"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,15462,15463],{},"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.",[6184,15465,15467],{"id":15466},"multiple-connections","Multiple Connections",[12,15469,15470],{},"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,15472,15474],{"id":15473},"how-nolag-handles-presence","How NoLag Handles Presence",[12,15476,15477],{},"NoLag builds presence into the room and lobby primitives so you do not have to wire it yourself.",[12,15479,15480],{},"When a client joins a room, NoLag automatically tracks membership and emits join/leave events to other room members. Heartbeat timeouts, graceful disconnects, and ungraceful drops are all handled at the infrastructure level. You subscribe to presence events on a room and receive a consistent stream of join, leave, and metadata update events without writing any server-side heartbeat logic.",[12,15482,15483],{},"For global presence, the lobby channel serves as the application-wide presence tracker. Every connected client is visible in the lobby, and you can query the current snapshot on join to bootstrap your contacts list with the current online state before live updates start flowing.",[12,15485,15486],{},"Custom metadata is first-class. When a client connects, it can include a metadata payload that gets broadcast to other presence subscribers. Update the metadata at any time, for example when the user navigates to a different page, and the update propagates immediately. No custom presence server, no polling, no heartbeat management code in your application.",[684,15488,15489],{},"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);}",{"title":144,"searchDepth":158,"depth":158,"links":15491},[15492,15493,15494,15495,15496,15502],{"id":15085,"depth":158,"text":15086},{"id":15124,"depth":158,"text":15125},{"id":15262,"depth":158,"text":15263},{"id":15408,"depth":158,"text":15409},{"id":15430,"depth":158,"text":15431,"children":15497},[15498,15499,15500,15501],{"id":15434,"depth":164,"text":15435},{"id":15445,"depth":164,"text":15446},{"id":15452,"depth":164,"text":15453},{"id":15466,"depth":164,"text":15467},{"id":15473,"depth":158,"text":15474},"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":15074,"description":15505},"blog/presence-tracking-at-scale","94L7SVn-JRhG5Iu98qEb6-viXdPU35Kd7YdwH4sdFas",{"id":15512,"title":15513,"author":7,"body":15514,"category":6508,"date":16056,"description":16057,"excerpt":701,"extension":702,"meta":16058,"navigation":704,"path":16059,"readTime":2864,"seo":16060,"stem":16061,"__hash__":16062},"blog/blog/real-time-dashboard-filters.md","Real-Time Dashboards Without the Firehose: Filtered Subscriptions",{"type":9,"value":15515,"toc":16047},[15516,15519,15522,15526,15533,15536,15539,15543,15546,15723,15726,15729,15842,15849,15853,15856,15859,15862,15866,15869,15872,15996,15999,16003,16006,16009,16013,16016,16019,16022,16026,16041,16044],[12,15517,15518],{},"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,15520,15521],{},"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,15523,15525],{"id":15524},"the-unfiltered-problem-in-detail","The Unfiltered Problem in Detail",[12,15527,15528,15529,15532],{},"Imagine an e-commerce operations dashboard. The platform processes 500 order updates per second across 50,000 live orders. An operator loads the dashboard and subscribes to the ",[84,15530,15531],{},"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,15534,15535],{},"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,15537,15538],{},"The right fix is infrastructure-level filtering: only send the client the messages it actually needs.",[35,15540,15542],{"id":15541},"how-topic-filters-work","How Topic Filters Work",[12,15544,15545],{},"A topic filter is a set of 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 match one of those keys.",[135,15547,15548],{},[138,15549,15551],{"className":140,"code":15550,"filename":142,"language":143,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\n\nconst client = NoLag('YOUR_TOKEN')\n\n// Connect to your app and room\nconst room = client.setApp('my-dashboard').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 however many are on screen\n  ],\n})\n\nroom.on('orders', (message) => {\n  updateOrderRow(message.payload)\n})\n",[84,15552,15553,15563,15567,15584,15588,15593,15621,15625,15630,15635,15647,15652,15659,15666,15673,15678,15683,15687,15691,15711,15719],{"__ignoreMap":144},[148,15554,15555,15557,15559,15561],{"class":150,"line":151},[148,15556,902],{"class":167},[148,15558,2168],{"class":178},[148,15560,908],{"class":167},[148,15562,2173],{"class":299},[148,15564,15565],{"class":150,"line":158},[148,15566,916],{"emptyLinePlaceholder":704},[148,15568,15569,15571,15573,15575,15577,15579,15582],{"class":150,"line":164},[148,15570,270],{"class":167},[148,15572,1274],{"class":203},[148,15574,276],{"class":167},[148,15576,1279],{"class":174},[148,15578,296],{"class":178},[148,15580,15581],{"class":299},"'YOUR_TOKEN'",[148,15583,303],{"class":178},[148,15585,15586],{"class":150,"line":182},[148,15587,916],{"emptyLinePlaceholder":704},[148,15589,15590],{"class":150,"line":195},[148,15591,15592],{"class":154},"// Connect to your app and room\n",[148,15594,15595,15597,15599,15601,15603,15605,15607,15610,15612,15614,15616,15619],{"class":150,"line":207},[148,15596,270],{"class":167},[148,15598,2217],{"class":203},[148,15600,276],{"class":167},[148,15602,2201],{"class":178},[148,15604,2224],{"class":174},[148,15606,296],{"class":178},[148,15608,15609],{"class":299},"'my-dashboard'",[148,15611,2232],{"class":178},[148,15613,2235],{"class":174},[148,15615,296],{"class":178},[148,15617,15618],{"class":299},"'orders-room'",[148,15620,303],{"class":178},[148,15622,15623],{"class":150,"line":217},[148,15624,916],{"emptyLinePlaceholder":704},[148,15626,15627],{"class":150,"line":227},[148,15628,15629],{"class":154},"// Subscribe to the orders topic with filters,\n",[148,15631,15632],{"class":150,"line":513},[148,15633,15634],{"class":154},"// only receive updates for the 10 orders currently on screen\n",[148,15636,15637,15639,15641,15643,15645],{"class":150,"line":993},[148,15638,290],{"class":178},[148,15640,2249],{"class":174},[148,15642,296],{"class":178},[148,15644,14089],{"class":299},[148,15646,2971],{"class":178},[148,15648,15649],{"class":150,"line":1004},[148,15650,15651],{"class":178},"  filters: [\n",[148,15653,15654,15657],{"class":150,"line":1013},[148,15655,15656],{"class":299},"    'order_abc123'",[148,15658,2982],{"class":178},[148,15660,15661,15664],{"class":150,"line":1019},[148,15662,15663],{"class":299},"    'order_def456'",[148,15665,2982],{"class":178},[148,15667,15668,15671],{"class":150,"line":1028},[148,15669,15670],{"class":299},"    'order_ghi789'",[148,15672,2982],{"class":178},[148,15674,15675],{"class":150,"line":1034},[148,15676,15677],{"class":154},"    // ...up to however many are on screen\n",[148,15679,15680],{"class":150,"line":1040},[148,15681,15682],{"class":178},"  ],\n",[148,15684,15685],{"class":150,"line":1046},[148,15686,1055],{"class":178},[148,15688,15689],{"class":150,"line":1052},[148,15690,916],{"emptyLinePlaceholder":704},[148,15692,15693,15695,15697,15699,15701,15703,15705,15707,15709],{"class":150,"line":1699},[148,15694,290],{"class":178},[148,15696,2267],{"class":174},[148,15698,296],{"class":178},[148,15700,14089],{"class":299},[148,15702,2274],{"class":178},[148,15704,11484],{"class":185},[148,15706,1142],{"class":178},[148,15708,1145],{"class":167},[148,15710,179],{"class":178},[148,15712,15713,15716],{"class":150,"line":1705},[148,15714,15715],{"class":174},"  updateOrderRow",[148,15717,15718],{"class":178},"(message.payload)\n",[148,15720,15721],{"class":150,"line":1711},[148,15722,1055],{"class":178},[12,15724,15725],{},"The infrastructure evaluates the filter on the server side before the message is transmitted. If the message's entity key is not in your filter list, the message is never sent to your connection. Your client receives only what it asked for.",[12,15727,15728],{},"Publishers do not change anything. They publish to the topic as normal, including an entity key in the message:",[135,15730,15731],{},[138,15732,15734],{"className":140,"code":15733,"filename":142,"language":143,"meta":144,"style":144},"// Server publishes an order update\nconst room = client.setApp('my-dashboard').setRoom('orders-room')\n\nroom.publish('orders', {\n  key: 'order_abc123',\n  payload: {\n    orderId: 'order_abc123',\n    status: 'shipped',\n    updatedAt: new Date().toISOString(),\n  }\n})\n",[84,15735,15736,15741,15767,15771,15783,15793,15798,15807,15817,15834,15838],{"__ignoreMap":144},[148,15737,15738],{"class":150,"line":151},[148,15739,15740],{"class":154},"// Server publishes an order update\n",[148,15742,15743,15745,15747,15749,15751,15753,15755,15757,15759,15761,15763,15765],{"class":150,"line":158},[148,15744,270],{"class":167},[148,15746,2217],{"class":203},[148,15748,276],{"class":167},[148,15750,2201],{"class":178},[148,15752,2224],{"class":174},[148,15754,296],{"class":178},[148,15756,15609],{"class":299},[148,15758,2232],{"class":178},[148,15760,2235],{"class":174},[148,15762,296],{"class":178},[148,15764,15618],{"class":299},[148,15766,303],{"class":178},[148,15768,15769],{"class":150,"line":164},[148,15770,916],{"emptyLinePlaceholder":704},[148,15772,15773,15775,15777,15779,15781],{"class":150,"line":182},[148,15774,290],{"class":178},[148,15776,2622],{"class":174},[148,15778,296],{"class":178},[148,15780,14089],{"class":299},[148,15782,2971],{"class":178},[148,15784,15785,15788,15791],{"class":150,"line":195},[148,15786,15787],{"class":178},"  key: ",[148,15789,15790],{"class":299},"'order_abc123'",[148,15792,2982],{"class":178},[148,15794,15795],{"class":150,"line":207},[148,15796,15797],{"class":178},"  payload: {\n",[148,15799,15800,15803,15805],{"class":150,"line":217},[148,15801,15802],{"class":178},"    orderId: ",[148,15804,15790],{"class":299},[148,15806,2982],{"class":178},[148,15808,15809,15812,15815],{"class":150,"line":227},[148,15810,15811],{"class":178},"    status: ",[148,15813,15814],{"class":299},"'shipped'",[148,15816,2982],{"class":178},[148,15818,15819,15822,15824,15827,15829,15832],{"class":150,"line":513},[148,15820,15821],{"class":178},"    updatedAt: ",[148,15823,349],{"class":167},[148,15825,15826],{"class":174}," Date",[148,15828,8700],{"class":178},[148,15830,15831],{"class":174},"toISOString",[148,15833,8007],{"class":178},[148,15835,15836],{"class":150,"line":993},[148,15837,1402],{"class":178},[148,15839,15840],{"class":150,"line":1004},[148,15841,1055],{"class":178},[12,15843,15844,15845,15848],{},"Subscribers with ",[84,15846,15847],{},"order_abc123"," in their filter list receive this. Subscribers without it do not. The logic lives entirely in the infrastructure, not in application code.",[35,15850,15852],{"id":15851},"why-infrastructure-level-filtering-matters","Why Infrastructure-Level Filtering Matters",[12,15854,15855],{},"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,15857,15858],{},"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,15860,15861],{},"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,15863,15865],{"id":15864},"dynamic-filter-swapping","Dynamic Filter Swapping",[12,15867,15868],{},"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,15870,15871],{},"You can update filters on an active subscription without tearing it down and reconnecting:",[135,15873,15874],{},[138,15875,15877],{"className":140,"code":15876,"filename":142,"language":143,"meta":144,"style":144},"// User navigates to page 2 of orders\nconst room = client.setApp('my-dashboard').setRoom('orders-room')\n\nroom.subscribe('orders', {\n  filters: currentPageOrderIds,\n})\n\nroom.on('orders', handleOrderUpdate)\n\n// Later, when the user changes page or applies a filter\nroom.subscribe('orders', {\n  filters: newPageOrderIds,\n})\n\n// The old filters are replaced atomically.\n// You start receiving updates for the new set immediately.\n",[84,15878,15879,15884,15910,15914,15926,15931,15935,15939,15952,15956,15961,15973,15978,15982,15986,15991],{"__ignoreMap":144},[148,15880,15881],{"class":150,"line":151},[148,15882,15883],{"class":154},"// User navigates to page 2 of orders\n",[148,15885,15886,15888,15890,15892,15894,15896,15898,15900,15902,15904,15906,15908],{"class":150,"line":158},[148,15887,270],{"class":167},[148,15889,2217],{"class":203},[148,15891,276],{"class":167},[148,15893,2201],{"class":178},[148,15895,2224],{"class":174},[148,15897,296],{"class":178},[148,15899,15609],{"class":299},[148,15901,2232],{"class":178},[148,15903,2235],{"class":174},[148,15905,296],{"class":178},[148,15907,15618],{"class":299},[148,15909,303],{"class":178},[148,15911,15912],{"class":150,"line":164},[148,15913,916],{"emptyLinePlaceholder":704},[148,15915,15916,15918,15920,15922,15924],{"class":150,"line":182},[148,15917,290],{"class":178},[148,15919,2249],{"class":174},[148,15921,296],{"class":178},[148,15923,14089],{"class":299},[148,15925,2971],{"class":178},[148,15927,15928],{"class":150,"line":195},[148,15929,15930],{"class":178},"  filters: currentPageOrderIds,\n",[148,15932,15933],{"class":150,"line":207},[148,15934,1055],{"class":178},[148,15936,15937],{"class":150,"line":217},[148,15938,916],{"emptyLinePlaceholder":704},[148,15940,15941,15943,15945,15947,15949],{"class":150,"line":227},[148,15942,290],{"class":178},[148,15944,2267],{"class":174},[148,15946,296],{"class":178},[148,15948,14089],{"class":299},[148,15950,15951],{"class":178},", handleOrderUpdate)\n",[148,15953,15954],{"class":150,"line":513},[148,15955,916],{"emptyLinePlaceholder":704},[148,15957,15958],{"class":150,"line":993},[148,15959,15960],{"class":154},"// Later, when the user changes page or applies a filter\n",[148,15962,15963,15965,15967,15969,15971],{"class":150,"line":1004},[148,15964,290],{"class":178},[148,15966,2249],{"class":174},[148,15968,296],{"class":178},[148,15970,14089],{"class":299},[148,15972,2971],{"class":178},[148,15974,15975],{"class":150,"line":1013},[148,15976,15977],{"class":178},"  filters: newPageOrderIds,\n",[148,15979,15980],{"class":150,"line":1019},[148,15981,1055],{"class":178},[148,15983,15984],{"class":150,"line":1028},[148,15985,916],{"emptyLinePlaceholder":704},[148,15987,15988],{"class":150,"line":1034},[148,15989,15990],{"class":154},"// The old filters are replaced atomically.\n",[148,15992,15993],{"class":150,"line":1040},[148,15994,15995],{"class":154},"// You start receiving updates for the new set immediately.\n",[12,15997,15998],{},"This keeps the subscription lifecycle clean. You open one subscription when the dashboard mounts, swap the filters whenever the visible set changes, and close the subscription when the user navigates away. No reconnections, no subscription bookkeeping beyond a single handle.",[35,16000,16002],{"id":16001},"a-concrete-bandwidth-example","A Concrete Bandwidth Example",[12,16004,16005],{},"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,16007,16008],{},"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,16010,16012],{"id":16011},"why-mobile-makes-this-non-negotiable","Why Mobile Makes This Non-Negotiable",[12,16014,16015],{},"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,16017,16018],{},"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,16020,16021],{},"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,16023,16025],{"id":16024},"getting-started-with-nolag-filters","Getting Started with NoLag Filters",[12,16027,16028,16029,16032,16033,16036,16037,16040],{},"Filtered subscriptions are built into the NoLag client SDK. You do not need any special configuration or server-side code changes. Publish messages with a ",[84,16030,16031],{},"key"," field, subscribe with a ",[84,16034,16035],{},"filters"," array, and the infrastructure handles the rest. Update filters dynamically with ",[84,16038,16039],{},"subscription.updateFilters()"," as your UI state changes.",[12,16042,16043],{},"The dashboard example above, with 50,000 orders and 10 on screen at a time, is a workload NoLag handles without any special tuning. The filtering logic runs at the connection layer, before messages touch your application tier.",[684,16045,16046],{},"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":158,"depth":158,"links":16048},[16049,16050,16051,16052,16053,16054,16055],{"id":15524,"depth":158,"text":15525},{"id":15541,"depth":158,"text":15542},{"id":15851,"depth":158,"text":15852},{"id":15864,"depth":158,"text":15865},{"id":16001,"depth":158,"text":16002},{"id":16011,"depth":158,"text":16012},{"id":16024,"depth":158,"text":16025},"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":15513,"description":16057},"blog/real-time-dashboard-filters","LwZcBl5dUR5Pw6zfnnGb8mfUHJ1vDr6SmsNucDSKpQ8",{"id":16064,"title":16065,"author":7,"body":16066,"category":698,"date":16816,"description":16817,"excerpt":701,"extension":702,"meta":16818,"navigation":704,"path":16819,"readTime":14395,"seo":16820,"stem":16821,"__hash__":16822},"blog/blog/iot-fleet-tracking-websockets.md","IoT Fleet Tracking Over WebSockets: Architecture for 100K Devices",{"type":9,"value":16067,"toc":16808},[16068,16071,16074,16078,16081,16087,16093,16099,16105,16109,16112,16122,16132,16135,16374,16377,16381,16384,16390,16396,16497,16500,16504,16507,16510,16513,16754,16757,16761,16764,16767,16793,16797,16800,16803,16806],[12,16069,16070],{},"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,16072,16073],{},"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,16075,16077],{"id":16076},"the-core-challenge","The Core Challenge",[12,16079,16080],{},"Fleet tracking has a few properties that make it harder than most real-time workloads.",[12,16082,16083,16086],{},[30,16084,16085],{},"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,16088,16089,16092],{},[30,16090,16091],{},"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,16094,16095,16098],{},[30,16096,16097],{},"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,16100,16101,16104],{},[30,16102,16103],{},"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,16106,16108],{"id":16107},"topic-design-fleet-vs-vehicle","Topic Design: Fleet vs Vehicle",[12,16110,16111],{},"The first architecture decision is how to organize topics. There are two main patterns.",[12,16113,16114,16117,16118,16121],{},[30,16115,16116],{},"One topic per vehicle:"," Each device publishes to its own topic, for example ",[84,16119,16120],{},"vehicle.v_abc123",". Dashboards subscribe to individual vehicle topics for the vehicles they are tracking.",[12,16123,16124,16127,16128,16131],{},[30,16125,16126],{},"One topic per fleet with filtered subscriptions:"," All vehicles in a fleet publish to a shared topic, for example ",[84,16129,16130],{},"fleet.f_xyz",". Dashboards subscribe to the fleet topic with a filter list specifying which vehicle IDs they want updates for.",[12,16133,16134],{},"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.",[135,16136,16137],{},[138,16138,16140],{"className":140,"code":16139,"filename":142,"language":143,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\n\nconst client = NoLag('YOUR_TOKEN')\nconst room = client.setApp('fleet-tracker').setRoom('fleet-f_xyz')\n\n// Device publishes its position\nroom.publish('positions', {\n  key: 'vehicle.v_abc123',\n  payload: {\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, // fire and forget for position updates\n})\n\n// Dashboard subscribes to positions for visible vehicles only\nroom.subscribe('positions', {\n  filters: visibleVehicleIds,\n})\n\nroom.on('positions', (msg) => updateVehicleMarker(msg.payload))\n",[84,16141,16142,16152,16156,16172,16200,16204,16209,16222,16231,16235,16245,16257,16267,16277,16287,16296,16300,16312,16316,16320,16325,16337,16342,16346,16350],{"__ignoreMap":144},[148,16143,16144,16146,16148,16150],{"class":150,"line":151},[148,16145,902],{"class":167},[148,16147,2168],{"class":178},[148,16149,908],{"class":167},[148,16151,2173],{"class":299},[148,16153,16154],{"class":150,"line":158},[148,16155,916],{"emptyLinePlaceholder":704},[148,16157,16158,16160,16162,16164,16166,16168,16170],{"class":150,"line":164},[148,16159,270],{"class":167},[148,16161,1274],{"class":203},[148,16163,276],{"class":167},[148,16165,1279],{"class":174},[148,16167,296],{"class":178},[148,16169,15581],{"class":299},[148,16171,303],{"class":178},[148,16173,16174,16176,16178,16180,16182,16184,16186,16189,16191,16193,16195,16198],{"class":150,"line":182},[148,16175,270],{"class":167},[148,16177,2217],{"class":203},[148,16179,276],{"class":167},[148,16181,2201],{"class":178},[148,16183,2224],{"class":174},[148,16185,296],{"class":178},[148,16187,16188],{"class":299},"'fleet-tracker'",[148,16190,2232],{"class":178},[148,16192,2235],{"class":174},[148,16194,296],{"class":178},[148,16196,16197],{"class":299},"'fleet-f_xyz'",[148,16199,303],{"class":178},[148,16201,16202],{"class":150,"line":195},[148,16203,916],{"emptyLinePlaceholder":704},[148,16205,16206],{"class":150,"line":207},[148,16207,16208],{"class":154},"// Device publishes its position\n",[148,16210,16211,16213,16215,16217,16220],{"class":150,"line":217},[148,16212,290],{"class":178},[148,16214,2622],{"class":174},[148,16216,296],{"class":178},[148,16218,16219],{"class":299},"'positions'",[148,16221,2971],{"class":178},[148,16223,16224,16226,16229],{"class":150,"line":227},[148,16225,15787],{"class":178},[148,16227,16228],{"class":299},"'vehicle.v_abc123'",[148,16230,2982],{"class":178},[148,16232,16233],{"class":150,"line":513},[148,16234,15797],{"class":178},[148,16236,16237,16240,16243],{"class":150,"line":993},[148,16238,16239],{"class":178},"    vehicleId: ",[148,16241,16242],{"class":299},"'v_abc123'",[148,16244,2982],{"class":178},[148,16246,16247,16250,16252,16255],{"class":150,"line":1004},[148,16248,16249],{"class":178},"    lat: ",[148,16251,5300],{"class":167},[148,16253,16254],{"class":203},"33.8688",[148,16256,2982],{"class":178},[148,16258,16259,16262,16265],{"class":150,"line":1013},[148,16260,16261],{"class":178},"    lng: ",[148,16263,16264],{"class":203},"151.2093",[148,16266,2982],{"class":178},[148,16268,16269,16272,16275],{"class":150,"line":1019},[148,16270,16271],{"class":178},"    speed: ",[148,16273,16274],{"class":203},"62",[148,16276,2982],{"class":178},[148,16278,16279,16282,16285],{"class":150,"line":1028},[148,16280,16281],{"class":178},"    heading: ",[148,16283,16284],{"class":203},"287",[148,16286,2982],{"class":178},[148,16288,16289,16292,16294],{"class":150,"line":1034},[148,16290,16291],{"class":178},"    timestamp: Date.",[148,16293,5294],{"class":174},[148,16295,8007],{"class":178},[148,16297,16298],{"class":150,"line":1040},[148,16299,1043],{"class":178},[148,16301,16302,16305,16307,16309],{"class":150,"line":1046},[148,16303,16304],{"class":178},"  qos: ",[148,16306,7693],{"class":203},[148,16308,87],{"class":178},[148,16310,16311],{"class":154},"// fire and forget for position updates\n",[148,16313,16314],{"class":150,"line":1052},[148,16315,1055],{"class":178},[148,16317,16318],{"class":150,"line":1699},[148,16319,916],{"emptyLinePlaceholder":704},[148,16321,16322],{"class":150,"line":1705},[148,16323,16324],{"class":154},"// Dashboard subscribes to positions for visible vehicles only\n",[148,16326,16327,16329,16331,16333,16335],{"class":150,"line":1711},[148,16328,290],{"class":178},[148,16330,2249],{"class":174},[148,16332,296],{"class":178},[148,16334,16219],{"class":299},[148,16336,2971],{"class":178},[148,16338,16339],{"class":150,"line":1717},[148,16340,16341],{"class":178},"  filters: visibleVehicleIds,\n",[148,16343,16344],{"class":150,"line":1723},[148,16345,1055],{"class":178},[148,16347,16348],{"class":150,"line":1729},[148,16349,916],{"emptyLinePlaceholder":704},[148,16351,16352,16354,16356,16358,16360,16362,16364,16366,16368,16371],{"class":150,"line":1735},[148,16353,290],{"class":178},[148,16355,2267],{"class":174},[148,16357,296],{"class":178},[148,16359,16219],{"class":299},[148,16361,2274],{"class":178},[148,16363,2587],{"class":185},[148,16365,1142],{"class":178},[148,16367,1145],{"class":167},[148,16369,16370],{"class":174}," updateVehicleMarker",[148,16372,16373],{"class":178},"(msg.payload))\n",[12,16375,16376],{},"The exception is very large fleets where different parts of the fleet are managed by different teams. In that case, per-sub-fleet topics can make access control simpler, with each team only having permission to subscribe to their own fleet's topic.",[35,16378,16380],{"id":16379},"qos-choices-for-fleet-data","QoS Choices for Fleet Data",[12,16382,16383],{},"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.",[12,16385,16386,16389],{},[30,16387,16388],{},"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 storing and retrying thousands of messages per second, most of which would be stale by the time they are retried. QoS 0 is the right choice.",[12,16391,16392,16395],{},[30,16393,16394],{},"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 ensures the message is acknowledged and retried if the initial delivery fails.",[135,16397,16398],{},[138,16399,16401],{"className":140,"code":16400,"filename":142,"language":143,"meta":144,"style":144},"// Geofence breach: use QoS 1\nroom.publish('alerts', {\n  key: 'vehicle.v_abc123',\n  payload: {\n    type: 'geofence_breach',\n    vehicleId: 'v_abc123',\n    geofenceId: 'zone_restricted_01',\n    breachType: 'exit',\n    timestamp: Date.now(),\n  },\n  qos: 1, // at least once delivery\n})\n",[84,16402,16403,16408,16421,16429,16433,16442,16450,16460,16470,16478,16482,16493],{"__ignoreMap":144},[148,16404,16405],{"class":150,"line":151},[148,16406,16407],{"class":154},"// Geofence breach: use QoS 1\n",[148,16409,16410,16412,16414,16416,16419],{"class":150,"line":158},[148,16411,290],{"class":178},[148,16413,2622],{"class":174},[148,16415,296],{"class":178},[148,16417,16418],{"class":299},"'alerts'",[148,16420,2971],{"class":178},[148,16422,16423,16425,16427],{"class":150,"line":164},[148,16424,15787],{"class":178},[148,16426,16228],{"class":299},[148,16428,2982],{"class":178},[148,16430,16431],{"class":150,"line":182},[148,16432,15797],{"class":178},[148,16434,16435,16437,16440],{"class":150,"line":195},[148,16436,8669],{"class":178},[148,16438,16439],{"class":299},"'geofence_breach'",[148,16441,2982],{"class":178},[148,16443,16444,16446,16448],{"class":150,"line":207},[148,16445,16239],{"class":178},[148,16447,16242],{"class":299},[148,16449,2982],{"class":178},[148,16451,16452,16455,16458],{"class":150,"line":217},[148,16453,16454],{"class":178},"    geofenceId: ",[148,16456,16457],{"class":299},"'zone_restricted_01'",[148,16459,2982],{"class":178},[148,16461,16462,16465,16468],{"class":150,"line":227},[148,16463,16464],{"class":178},"    breachType: ",[148,16466,16467],{"class":299},"'exit'",[148,16469,2982],{"class":178},[148,16471,16472,16474,16476],{"class":150,"line":513},[148,16473,16291],{"class":178},[148,16475,5294],{"class":174},[148,16477,8007],{"class":178},[148,16479,16480],{"class":150,"line":993},[148,16481,1043],{"class":178},[148,16483,16484,16486,16488,16490],{"class":150,"line":1004},[148,16485,16304],{"class":178},[148,16487,14584],{"class":203},[148,16489,87],{"class":178},[148,16491,16492],{"class":154},"// at least once delivery\n",[148,16494,16495],{"class":150,"line":1013},[148,16496,1055],{"class":178},[12,16498,16499],{},"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,16501,16503],{"id":16502},"reconnection-and-replay-for-spotty-devices","Reconnection and Replay for Spotty Devices",[12,16505,16506],{},"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,16508,16509],{},"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,16511,16512],{},"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,16514,16515],{},[138,16516,16518],{"className":140,"code":16517,"filename":142,"language":143,"meta":144,"style":144},"// 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  async flush(room: ReturnType\u003Ctypeof client.setApp(...).setRoom>) {\n    while (this.queue.length > 0) {\n      const event = this.queue[0]\n      room.publish('alerts', {\n        key: `vehicle.${event.vehicleId}`,\n        payload: event,\n        qos: 1,\n      })\n      this.queue.shift()\n    }\n  }\n}\n",[84,16519,16520,16525,16535,16556,16560,16575,16588,16602,16606,16610,16641,16661,16680,16692,16711,16716,16725,16730,16742,16746,16750],{"__ignoreMap":144},[148,16521,16522],{"class":150,"line":151},[148,16523,16524],{"class":154},"// Device-side: buffer critical events during outage\n",[148,16526,16527,16530,16533],{"class":150,"line":158},[148,16528,16529],{"class":167},"class",[148,16531,16532],{"class":174}," EventBuffer",[148,16534,179],{"class":178},[148,16536,16537,16540,16543,16545,16548,16551,16553],{"class":150,"line":164},[148,16538,16539],{"class":167},"  private",[148,16541,16542],{"class":185}," queue",[148,16544,189],{"class":167},[148,16546,16547],{"class":174}," AlertEvent",[148,16549,16550],{"class":178},"[] ",[148,16552,1174],{"class":167},[148,16554,16555],{"class":178}," []\n",[148,16557,16558],{"class":150,"line":182},[148,16559,916],{"emptyLinePlaceholder":704},[148,16561,16562,16565,16567,16569,16571,16573],{"class":150,"line":195},[148,16563,16564],{"class":174},"  enqueue",[148,16566,296],{"class":178},[148,16568,1369],{"class":185},[148,16570,189],{"class":167},[148,16572,16547],{"class":174},[148,16574,4238],{"class":178},[148,16576,16577,16580,16583,16585],{"class":150,"line":207},[148,16578,16579],{"class":203},"    this",[148,16581,16582],{"class":178},".queue.",[148,16584,2000],{"class":174},[148,16586,16587],{"class":178},"(event)\n",[148,16589,16590,16592,16594,16597,16599],{"class":150,"line":217},[148,16591,16579],{"class":203},[148,16593,1562],{"class":178},[148,16595,16596],{"class":174},"persistToStorage",[148,16598,5297],{"class":178},[148,16600,16601],{"class":154},"// survive process restarts\n",[148,16603,16604],{"class":150,"line":227},[148,16605,1402],{"class":178},[148,16607,16608],{"class":150,"line":513},[148,16609,916],{"emptyLinePlaceholder":704},[148,16611,16612,16615,16618,16620,16622,16624,16626,16628,16630,16632,16634,16636,16638],{"class":150,"line":993},[148,16613,16614],{"class":167},"  async",[148,16616,16617],{"class":174}," flush",[148,16619,296],{"class":178},[148,16621,1101],{"class":185},[148,16623,189],{"class":167},[148,16625,3223],{"class":174},[148,16627,480],{"class":178},[148,16629,3228],{"class":167},[148,16631,2201],{"class":178},[148,16633,2224],{"class":174},[148,16635,296],{"class":178},[148,16637,1285],{"class":167},[148,16639,16640],{"class":178},").setRoom>) {\n",[148,16642,16643,16646,16648,16651,16653,16655,16657,16659],{"class":150,"line":1004},[148,16644,16645],{"class":167},"    while",[148,16647,114],{"class":178},[148,16649,16650],{"class":203},"this",[148,16652,16582],{"class":178},[148,16654,5518],{"class":203},[148,16656,12223],{"class":167},[148,16658,9422],{"class":203},[148,16660,4238],{"class":178},[148,16662,16663,16665,16667,16669,16672,16675,16677],{"class":150,"line":1013},[148,16664,3272],{"class":167},[148,16666,13560],{"class":203},[148,16668,276],{"class":167},[148,16670,16671],{"class":203}," this",[148,16673,16674],{"class":178},".queue[",[148,16676,7693],{"class":203},[148,16678,16679],{"class":178},"]\n",[148,16681,16682,16684,16686,16688,16690],{"class":150,"line":1019},[148,16683,3390],{"class":178},[148,16685,2622],{"class":174},[148,16687,296],{"class":178},[148,16689,16418],{"class":299},[148,16691,2971],{"class":178},[148,16693,16694,16697,16700,16702,16704,16707,16709],{"class":150,"line":1028},[148,16695,16696],{"class":178},"        key: ",[148,16698,16699],{"class":299},"`vehicle.${",[148,16701,1369],{"class":178},[148,16703,1562],{"class":299},[148,16705,16706],{"class":178},"vehicleId",[148,16708,3010],{"class":299},[148,16710,2982],{"class":178},[148,16712,16713],{"class":150,"line":1034},[148,16714,16715],{"class":178},"        payload: event,\n",[148,16717,16718,16721,16723],{"class":150,"line":1040},[148,16719,16720],{"class":178},"        qos: ",[148,16722,14584],{"class":203},[148,16724,2982],{"class":178},[148,16726,16727],{"class":150,"line":1046},[148,16728,16729],{"class":178},"      })\n",[148,16731,16732,16735,16737,16740],{"class":150,"line":1052},[148,16733,16734],{"class":203},"      this",[148,16736,16582],{"class":178},[148,16738,16739],{"class":174},"shift",[148,16741,505],{"class":178},[148,16743,16744],{"class":150,"line":1699},[148,16745,3481],{"class":178},[148,16747,16748],{"class":150,"line":1705},[148,16749,1402],{"class":178},[148,16751,16752],{"class":150,"line":1711},[148,16753,230],{"class":178},[12,16755,16756],{},"On the dashboard side, replay of missed alert messages can be handled by the message broker's retention window. If alerts are published with QoS 1 and a retention window configured, a dashboard that reconnects can request messages from the last known sequence number and receive anything it missed.",[35,16758,16760],{"id":16759},"scaling-considerations","Scaling Considerations",[12,16762,16763],{},"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%. Most messages are evaluated at the filter layer and dropped before reaching any subscriber. The infrastructure needs to do that evaluation fast and cheaply.",[12,16765,16766],{},"A few design choices help at this scale:",[597,16768,16769,16775,16781,16787],{},[600,16770,16771,16774],{},[30,16772,16773],{},"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.",[600,16776,16777,16780],{},[30,16778,16779],{},"Use binary encoding for telemetry."," MessagePack or a custom binary format cuts payload size by 30 to 50% compared to JSON. At 20,000 messages per second, that is meaningful bandwidth savings on your ingest pipeline.",[600,16782,16783,16786],{},[30,16784,16785],{},"Separate telemetry from metadata."," Position updates and geofence alerts should be on different topics with different retention and QoS settings. Mixing them complicates scaling and retention policy management.",[600,16788,16789,16792],{},[30,16790,16791],{},"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.",[35,16794,16796],{"id":16795},"how-nolag-approaches-iot-workloads","How NoLag Approaches IoT Workloads",[12,16798,16799],{},"NoLag's infrastructure is designed for asymmetric fanout workloads like fleet tracking. The filter evaluation happens at the connection layer, before messages are transmitted, so subscribers only receive what they asked for. QoS levels are configurable per message, so you can mix fire-and-forget telemetry and at-least-once alerts on the same topic without any special configuration.",[12,16801,16802],{},"The reconnection and replay system handles dashboard reconnects transparently. A fleet manager who closes their laptop and comes back in the morning can pick up any alerts they missed during the retention window, without any custom replay logic in the application.",[12,16804,16805],{},"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.",[684,16807,16046],{},{"title":144,"searchDepth":158,"depth":158,"links":16809},[16810,16811,16812,16813,16814,16815],{"id":16076,"depth":158,"text":16077},{"id":16107,"depth":158,"text":16108},{"id":16379,"depth":158,"text":16380},{"id":16502,"depth":158,"text":16503},{"id":16759,"depth":158,"text":16760},{"id":16795,"depth":158,"text":16796},"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":16065,"description":16817},"blog/iot-fleet-tracking-websockets","bfmYDCIik7xHnffJUOTv0fV5YQMCUbg-511A2ggr_II",{"id":16824,"title":16825,"author":7,"body":16826,"category":15503,"date":17287,"description":17288,"excerpt":701,"extension":702,"meta":17289,"navigation":704,"path":17290,"readTime":2388,"seo":17291,"stem":17292,"__hash__":17293},"blog/blog/reconnection-message-replay.md","Handling Disconnects Gracefully: Reconnection and Message Replay",{"type":9,"value":16827,"toc":17274},[16828,16831,16834,16837,16841,16844,16850,16856,16862,16868,16874,16878,16881,16884,16887,16891,16895,16898,16902,16905,16909,16912,16916,16919,16922,17104,17107,17111,17116,17119,17124,17243,17247,17250,17253,17256,17260,17269,17272],[12,16829,16830],{},"Every real-time application eventually has to answer a simple but uncomfortable question: what happens when the connection drops?",[12,16832,16833],{},"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,16835,16836],{},"Reconnection and message replay solve this properly. This post explains why disconnects happen, what the naive recovery approaches get wrong, and how a well-designed replay system makes disconnects invisible to users.",[35,16838,16840],{"id":16839},"why-disconnects-happen","Why Disconnects Happen",[12,16842,16843],{},"Disconnects are not edge cases. They are a normal part of operating a real-time application in the real world.",[12,16845,16846,16849],{},[30,16847,16848],{},"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,16851,16852,16855],{},[30,16853,16854],{},"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,16857,16858,16861],{},[30,16859,16860],{},"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,16863,16864,16867],{},[30,16865,16866],{},"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,16869,16870,16873],{},[30,16871,16872],{},"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,16875,16877],{"id":16876},"the-reconnection-problem","The Reconnection Problem",[12,16879,16880],{},"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,16882,16883],{},"The client was connected, received messages up to sequence number 1,042, then lost the connection for 90 seconds. During those 90 seconds, messages 1,043 through 1,081 were published to the topic. The client reconnects and resumes receiving from message 1,082 onward. Messages 1,043 through 1,081 are gone forever. The client's state is now inconsistent with reality.",[12,16885,16886],{},"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,16888,16890],{"id":16889},"naive-approaches-and-why-they-fall-short","Naive Approaches and Why They Fall Short",[6184,16892,16894],{"id":16893},"re-fetching-everything","Re-fetching Everything",[12,16896,16897],{},"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.",[6184,16899,16901],{"id":16900},"ignoring-the-gap","Ignoring the Gap",[12,16903,16904],{},"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.",[6184,16906,16908],{"id":16907},"client-side-timestamps","Client-Side Timestamps",[12,16910,16911],{},"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,16913,16915],{"id":16914},"proper-replay-sequence-based-delivery","Proper Replay: Sequence-Based Delivery",[12,16917,16918],{},"The right solution is sequence-based message delivery with server-side retention.",[12,16920,16921],{},"Every message published to a topic gets a monotonically increasing sequence number, assigned by the broker, not the client. The broker retains messages for a configurable window, say 24 hours or 7 days. When a client reconnects, it sends its last acknowledged sequence number. The broker replays everything from that point forward.",[135,16923,16924],{},[138,16925,16927],{"className":140,"code":16926,"filename":142,"language":143,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\n\nconst client = NoLag('YOUR_TOKEN')\n\n// Connect to your app and room\nconst room = client.setApp('my-app').setRoom('room_abc')\n\n// The SDK tracks the last received sequence number automatically.\n// On reconnect, it sends this number and the broker replays missed messages.\nroom.subscribe('chat')\n\nroom.on('chat', (message) => {\n  if (message.isReplay) {\n    // This message was missed during a disconnect.\n    // Add it to the UI silently, do not trigger a notification.\n    appendMessageSilently(message.payload)\n  } else {\n    // This is a live message, trigger normal notification behavior.\n    appendMessage(message.payload)\n    showNotificationIfNeeded(message.payload)\n  }\n})\n",[84,16928,16929,16939,16943,16959,16963,16967,16995,16999,17004,17009,17021,17025,17045,17052,17057,17062,17069,17077,17082,17089,17096,17100],{"__ignoreMap":144},[148,16930,16931,16933,16935,16937],{"class":150,"line":151},[148,16932,902],{"class":167},[148,16934,2168],{"class":178},[148,16936,908],{"class":167},[148,16938,2173],{"class":299},[148,16940,16941],{"class":150,"line":158},[148,16942,916],{"emptyLinePlaceholder":704},[148,16944,16945,16947,16949,16951,16953,16955,16957],{"class":150,"line":164},[148,16946,270],{"class":167},[148,16948,1274],{"class":203},[148,16950,276],{"class":167},[148,16952,1279],{"class":174},[148,16954,296],{"class":178},[148,16956,15581],{"class":299},[148,16958,303],{"class":178},[148,16960,16961],{"class":150,"line":182},[148,16962,916],{"emptyLinePlaceholder":704},[148,16964,16965],{"class":150,"line":195},[148,16966,15592],{"class":154},[148,16968,16969,16971,16973,16975,16977,16979,16981,16984,16986,16988,16990,16993],{"class":150,"line":207},[148,16970,270],{"class":167},[148,16972,2217],{"class":203},[148,16974,276],{"class":167},[148,16976,2201],{"class":178},[148,16978,2224],{"class":174},[148,16980,296],{"class":178},[148,16982,16983],{"class":299},"'my-app'",[148,16985,2232],{"class":178},[148,16987,2235],{"class":174},[148,16989,296],{"class":178},[148,16991,16992],{"class":299},"'room_abc'",[148,16994,303],{"class":178},[148,16996,16997],{"class":150,"line":217},[148,16998,916],{"emptyLinePlaceholder":704},[148,17000,17001],{"class":150,"line":227},[148,17002,17003],{"class":154},"// The SDK tracks the last received sequence number automatically.\n",[148,17005,17006],{"class":150,"line":513},[148,17007,17008],{"class":154},"// On reconnect, it sends this number and the broker replays missed messages.\n",[148,17010,17011,17013,17015,17017,17019],{"class":150,"line":993},[148,17012,290],{"class":178},[148,17014,2249],{"class":174},[148,17016,296],{"class":178},[148,17018,2229],{"class":299},[148,17020,303],{"class":178},[148,17022,17023],{"class":150,"line":1004},[148,17024,916],{"emptyLinePlaceholder":704},[148,17026,17027,17029,17031,17033,17035,17037,17039,17041,17043],{"class":150,"line":1013},[148,17028,290],{"class":178},[148,17030,2267],{"class":174},[148,17032,296],{"class":178},[148,17034,2229],{"class":299},[148,17036,2274],{"class":178},[148,17038,11484],{"class":185},[148,17040,1142],{"class":178},[148,17042,1145],{"class":167},[148,17044,179],{"class":178},[148,17046,17047,17049],{"class":150,"line":1019},[148,17048,8614],{"class":167},[148,17050,17051],{"class":178}," (message.isReplay) {\n",[148,17053,17054],{"class":150,"line":1028},[148,17055,17056],{"class":154},"    // This message was missed during a disconnect.\n",[148,17058,17059],{"class":150,"line":1034},[148,17060,17061],{"class":154},"    // Add it to the UI silently, do not trigger a notification.\n",[148,17063,17064,17067],{"class":150,"line":1040},[148,17065,17066],{"class":174},"    appendMessageSilently",[148,17068,15718],{"class":178},[148,17070,17071,17073,17075],{"class":150,"line":1046},[148,17072,9321],{"class":178},[148,17074,9324],{"class":167},[148,17076,179],{"class":178},[148,17078,17079],{"class":150,"line":1052},[148,17080,17081],{"class":154},"    // This is a live message, trigger normal notification behavior.\n",[148,17083,17084,17087],{"class":150,"line":1699},[148,17085,17086],{"class":174},"    appendMessage",[148,17088,15718],{"class":178},[148,17090,17091,17094],{"class":150,"line":1705},[148,17092,17093],{"class":174},"    showNotificationIfNeeded",[148,17095,15718],{"class":178},[148,17097,17098],{"class":150,"line":1711},[148,17099,1402],{"class":178},[148,17101,17102],{"class":150,"line":1717},[148,17103,1055],{"class":178},[12,17105,17106],{},"The sequence number is managed by the SDK, not your application code. You do not need to store it, persist it, or send it manually. The SDK tracks the last acknowledged sequence number per subscription and handles the replay request on reconnect transparently.",[35,17108,17110],{"id":17109},"the-isreplay-flag","The isReplay Flag",[12,17112,1219,17113,17115],{},[84,17114,12718],{}," flag is a small but important detail that most teams get wrong the first time.",[12,17117,17118],{},"When a user reconnects and the broker replays 50 missed messages, those messages need to be added to the UI to restore consistency. But they should not trigger notifications. If a chat app shows a desktop notification for every replayed message, a user who lost connectivity for an hour might come back to 200 notification popups for messages they already saw on another device, or messages from a conversation they have muted. That is a horrible experience.",[12,17120,1219,17121,17123],{},[84,17122,12718],{}," flag tells your message handler to distinguish between live messages (trigger notifications, animate in) and replayed messages (restore state silently). Live messages deserve the full treatment: badge counts increment, notifications fire, scroll behavior updates. Replayed messages should just appear in their correct position in the timeline, quietly.",[135,17125,17126],{},[138,17127,17129],{"className":140,"code":17128,"filename":142,"language":143,"meta":144,"style":144},"function handleChatMessage(message: NoLagMessage) {\n  // Always add to the message list\n  chatStore.addMessage(message.payload)\n\n  // Only do notification side effects for live messages\n  if (!message.isReplay) {\n    badgeCount.increment()\n    if (document.hidden) {\n      showDesktopNotification(message.payload)\n    }\n    if (isScrolledToBottom()) {\n      scrollToLatestMessage()\n    }\n  }\n}\n",[84,17130,17131,17149,17154,17164,17168,17173,17184,17194,17201,17208,17212,17224,17231,17235,17239],{"__ignoreMap":144},[148,17132,17133,17135,17138,17140,17142,17144,17147],{"class":150,"line":151},[148,17134,4589],{"class":167},[148,17136,17137],{"class":174}," handleChatMessage",[148,17139,296],{"class":178},[148,17141,11484],{"class":185},[148,17143,189],{"class":167},[148,17145,17146],{"class":174}," NoLagMessage",[148,17148,4238],{"class":178},[148,17150,17151],{"class":150,"line":158},[148,17152,17153],{"class":154},"  // Always add to the message list\n",[148,17155,17156,17159,17162],{"class":150,"line":164},[148,17157,17158],{"class":178},"  chatStore.",[148,17160,17161],{"class":174},"addMessage",[148,17163,15718],{"class":178},[148,17165,17166],{"class":150,"line":182},[148,17167,916],{"emptyLinePlaceholder":704},[148,17169,17170],{"class":150,"line":195},[148,17171,17172],{"class":154},"  // Only do notification side effects for live messages\n",[148,17174,17175,17177,17179,17181],{"class":150,"line":207},[148,17176,8614],{"class":167},[148,17178,114],{"class":178},[148,17180,3322],{"class":167},[148,17182,17183],{"class":178},"message.isReplay) {\n",[148,17185,17186,17189,17192],{"class":150,"line":217},[148,17187,17188],{"class":178},"    badgeCount.",[148,17190,17191],{"class":174},"increment",[148,17193,505],{"class":178},[148,17195,17196,17198],{"class":150,"line":227},[148,17197,1380],{"class":167},[148,17199,17200],{"class":178}," (document.hidden) {\n",[148,17202,17203,17206],{"class":150,"line":513},[148,17204,17205],{"class":174},"      showDesktopNotification",[148,17207,15718],{"class":178},[148,17209,17210],{"class":150,"line":993},[148,17211,3481],{"class":178},[148,17213,17214,17216,17218,17221],{"class":150,"line":1004},[148,17215,1380],{"class":167},[148,17217,114],{"class":178},[148,17219,17220],{"class":174},"isScrolledToBottom",[148,17222,17223],{"class":178},"()) {\n",[148,17225,17226,17229],{"class":150,"line":1013},[148,17227,17228],{"class":174},"      scrollToLatestMessage",[148,17230,505],{"class":178},[148,17232,17233],{"class":150,"line":1019},[148,17234,3481],{"class":178},[148,17236,17237],{"class":150,"line":1028},[148,17238,1402],{"class":178},[148,17240,17241],{"class":150,"line":1034},[148,17242,230],{"class":178},[35,17244,17246],{"id":17245},"configurable-retention-windows","Configurable Retention Windows",[12,17248,17249],{},"Retention windows are a tradeoff between coverage and storage cost. A longer window means clients that were offline for longer can catch up. A shorter window is cheaper to operate.",[12,17251,17252],{},"The right window depends on your use case. A chat application where users check in daily probably wants at least 24 hours of retention. A live trading dashboard where data older than a few minutes is worthless might want 5 minutes. An IoT alert stream might want 7 days to handle devices that lose connectivity for extended periods.",[12,17254,17255],{},"If a client reconnects after the retention window has expired, full replay is not possible. The SDK detects this and you can handle it in your application, typically by triggering a fresh data load from your REST API for the affected subscriptions. This is the same as the re-fetch approach, but it only fires when the gap is too large to replay, not on every reconnect.",[35,17257,17259],{"id":17258},"how-this-works-in-nolag-sdks","How This Works in NoLag SDKs",[12,17261,17262,17263,17265,17266,17268],{},"In the NoLag client SDK, all of this is automatic. The SDK maintains the last sequence number per subscription in memory. On reconnect, it sends the sequence number to the broker and receives replayed messages before live messages resume. Messages are delivered to your ",[84,17264,14313],{}," handler in order, with the ",[84,17267,12718],{}," flag set correctly.",[12,17270,17271],{},"You do not write reconnection logic. You do not manage sequence numbers. You do not implement retry backoff. The SDK handles the transport layer and gives you a clean, ordered stream of messages with enough context to handle replayed vs live messages differently. Your application code only sees the message stream.",[684,17273,16046],{},{"title":144,"searchDepth":158,"depth":158,"links":17275},[17276,17277,17278,17283,17284,17285,17286],{"id":16839,"depth":158,"text":16840},{"id":16876,"depth":158,"text":16877},{"id":16889,"depth":158,"text":16890,"children":17279},[17280,17281,17282],{"id":16893,"depth":164,"text":16894},{"id":16900,"depth":164,"text":16901},{"id":16907,"depth":164,"text":16908},{"id":16914,"depth":158,"text":16915},{"id":17109,"depth":158,"text":17110},{"id":17245,"depth":158,"text":17246},{"id":17258,"depth":158,"text":17259},"2026-03-03","Mobile networks drop, Wi-Fi switches, laptops close. Learn how reconnection and message replay keep real-time apps consistent when connections fail, without flooding users with stale notifications.",{},"/blog/reconnection-message-replay",{"title":16825,"description":17288},"blog/reconnection-message-replay","O8OKXJXAIe4JgJoFbIFSxH-3pw9Gsb1CNIzJtg7bz8Q",{"id":17295,"title":17296,"author":7,"body":17297,"category":18207,"date":18208,"description":18209,"excerpt":701,"extension":702,"meta":18210,"navigation":704,"path":675,"readTime":2864,"seo":18211,"stem":18212,"__hash__":18213},"blog/blog/blueprint-sdks-vs-raw-pubsub.md","Blueprint SDKs vs Raw Pub/Sub: Ship in Minutes Instead of Weeks",{"type":9,"value":17298,"toc":18200},[17299,17302,17305,17309,17312,17353,17356,17360,17363,17912,17915,17919,17925,18117,18126,18130,18133,18167,18173,18179,18183,18186,18189,18195,18198],[12,17300,17301],{},"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 replay missed messages, and how to manage reconnection state. Then they spend two to four weeks wiring it together before they can show a product demo.",[12,17303,17304],{},"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,17306,17308],{"id":17307},"what-every-real-time-app-rebuilds","What Every Real-Time App Rebuilds",[12,17310,17311],{},"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:",[597,17313,17314,17320,17326,17331,17336,17342,17347],{},[600,17315,17316,17319],{},[30,17317,17318],{},"Room management."," Creating rooms, joining and leaving them, listing members, enforcing membership rules.",[600,17321,17322,17325],{},[30,17323,17324],{},"Presence tracking."," Who is online, detecting disconnects, heartbeat timeouts, multi-tab handling, custom status metadata.",[600,17327,17328,17330],{},[30,17329,14514],{}," Starting and stopping a typing state, broadcasting it to other room members, timing out stale typing states.",[600,17332,17333,17335],{},[30,17334,12714],{}," Tracking sequence numbers, requesting missed messages on reconnect, rendering replayed vs live messages differently.",[600,17337,17338,17341],{},[30,17339,17340],{},"Reconnection state."," Detecting drops, exponential backoff, re-subscribing to channels, re-joining rooms, flushing any locally queued messages.",[600,17343,17344,17346],{},[30,17345,12746],{}," Tracking which messages each user has seen, computing unread badges, marking conversations as read.",[600,17348,17349,17352],{},[30,17350,17351],{},"Message history."," Paginating through past messages, loading older messages on scroll, merging history with the live stream.",[12,17354,17355],{},"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,17357,17359],{"id":17358},"the-raw-pubsub-approach","The Raw Pub/Sub Approach",[12,17361,17362],{},"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,17364,17365],{},[138,17366,17368],{"className":140,"code":17367,"filename":142,"language":143,"meta":144,"style":144},"import { NoLag } from '@nolag/js-sdk'\n\nconst client = NoLag(token)\nlet typingTimeout: ReturnType\u003Ctypeof setTimeout> | null = null\n\n// Subscribe to the room's message channel\nawait client.subscribe(`chat.room.${roomId}`, {\n  onMessage: (msg) => {\n    if (msg.payload.type === 'message') renderMessage(msg.payload)\n    if (msg.payload.type === 'typing_start') showTypingIndicator(msg.payload.userId)\n    if (msg.payload.type === 'typing_stop') hideTypingIndicator(msg.payload.userId)\n  }\n})\n\n// Subscribe to presence\nawait client.subscribe(`chat.presence.${roomId}`, {\n  onMessage: (msg) => updatePresence(msg.payload)\n})\n\n// Send a message\nasync function sendMessage(text: string) {\n  await client.publish(`chat.room.${roomId}`, {\n    payload: { type: 'message', userId, text, timestamp: Date.now() }\n  })\n}\n\n// Track typing with debounce\nfunction onInputChange() {\n  client.publish(`chat.room.${roomId}`, {\n    payload: { type: 'typing_start', userId }\n  })\n  if (typingTimeout) clearTimeout(typingTimeout)\n  typingTimeout = setTimeout(() => {\n    client.publish(`chat.room.${roomId}`, {\n      payload: { type: 'typing_stop', userId }\n    })\n  }, 3000)\n}\n\n// On connect, publish presence join\nasync function joinRoom() {\n  await client.publish(`chat.presence.${roomId}`, {\n    payload: { type: 'join', userId, displayName, timestamp: Date.now() }\n  })\n}\n\n// On disconnect, need to publish presence leave\n// But ungraceful disconnects won't fire this...\n// You need server-side heartbeat logic to handle that case.\nasync function leaveRoom() {\n  await client.publish(`chat.presence.${roomId}`, {\n    payload: { type: 'leave', userId, timestamp: Date.now() }\n  })\n}\n",[84,17369,17370,17380,17384,17396,17423,17427,17432,17452,17467,17487,17506,17524,17528,17532,17536,17541,17560,17577,17581,17585,17590,17608,17626,17641,17645,17649,17653,17658,17667,17684,17694,17698,17711,17727,17744,17754,17758,17767,17771,17775,17780,17791,17810,17824,17829,17834,17839,17845,17851,17857,17869,17888,17902,17907],{"__ignoreMap":144},[148,17371,17372,17374,17376,17378],{"class":150,"line":151},[148,17373,902],{"class":167},[148,17375,2168],{"class":178},[148,17377,908],{"class":167},[148,17379,2173],{"class":299},[148,17381,17382],{"class":150,"line":158},[148,17383,916],{"emptyLinePlaceholder":704},[148,17385,17386,17388,17390,17392,17394],{"class":150,"line":164},[148,17387,270],{"class":167},[148,17389,1274],{"class":203},[148,17391,276],{"class":167},[148,17393,1279],{"class":174},[148,17395,3344],{"class":178},[148,17397,17398,17400,17403,17405,17407,17409,17411,17414,17416,17419,17421],{"class":150,"line":182},[148,17399,9414],{"class":167},[148,17401,17402],{"class":178}," typingTimeout",[148,17404,189],{"class":167},[148,17406,3223],{"class":174},[148,17408,480],{"class":178},[148,17410,3228],{"class":167},[148,17412,17413],{"class":178}," setTimeout> ",[148,17415,3234],{"class":167},[148,17417,17418],{"class":203}," null",[148,17420,276],{"class":167},[148,17422,4492],{"class":203},[148,17424,17425],{"class":150,"line":195},[148,17426,916],{"emptyLinePlaceholder":704},[148,17428,17429],{"class":150,"line":207},[148,17430,17431],{"class":154},"// Subscribe to the room's message channel\n",[148,17433,17434,17436,17438,17440,17442,17445,17448,17450],{"class":150,"line":217},[148,17435,943],{"class":167},[148,17437,2201],{"class":178},[148,17439,2249],{"class":174},[148,17441,296],{"class":178},[148,17443,17444],{"class":299},"`chat.room.${",[148,17446,17447],{"class":178},"roomId",[148,17449,3010],{"class":299},[148,17451,2971],{"class":178},[148,17453,17454,17457,17459,17461,17463,17465],{"class":150,"line":227},[148,17455,17456],{"class":174},"  onMessage",[148,17458,1347],{"class":178},[148,17460,2587],{"class":185},[148,17462,1142],{"class":178},[148,17464,1145],{"class":167},[148,17466,179],{"class":178},[148,17468,17469,17471,17474,17476,17479,17481,17484],{"class":150,"line":513},[148,17470,1380],{"class":167},[148,17472,17473],{"class":178}," (msg.payload.type ",[148,17475,1386],{"class":167},[148,17477,17478],{"class":299}," 'message'",[148,17480,1142],{"class":178},[148,17482,17483],{"class":174},"renderMessage",[148,17485,17486],{"class":178},"(msg.payload)\n",[148,17488,17489,17491,17493,17495,17498,17500,17503],{"class":150,"line":993},[148,17490,1380],{"class":167},[148,17492,17473],{"class":178},[148,17494,1386],{"class":167},[148,17496,17497],{"class":299}," 'typing_start'",[148,17499,1142],{"class":178},[148,17501,17502],{"class":174},"showTypingIndicator",[148,17504,17505],{"class":178},"(msg.payload.userId)\n",[148,17507,17508,17510,17512,17514,17517,17519,17522],{"class":150,"line":1004},[148,17509,1380],{"class":167},[148,17511,17473],{"class":178},[148,17513,1386],{"class":167},[148,17515,17516],{"class":299}," 'typing_stop'",[148,17518,1142],{"class":178},[148,17520,17521],{"class":174},"hideTypingIndicator",[148,17523,17505],{"class":178},[148,17525,17526],{"class":150,"line":1013},[148,17527,1402],{"class":178},[148,17529,17530],{"class":150,"line":1019},[148,17531,1055],{"class":178},[148,17533,17534],{"class":150,"line":1028},[148,17535,916],{"emptyLinePlaceholder":704},[148,17537,17538],{"class":150,"line":1034},[148,17539,17540],{"class":154},"// Subscribe to presence\n",[148,17542,17543,17545,17547,17549,17551,17554,17556,17558],{"class":150,"line":1040},[148,17544,943],{"class":167},[148,17546,2201],{"class":178},[148,17548,2249],{"class":174},[148,17550,296],{"class":178},[148,17552,17553],{"class":299},"`chat.presence.${",[148,17555,17447],{"class":178},[148,17557,3010],{"class":299},[148,17559,2971],{"class":178},[148,17561,17562,17564,17566,17568,17570,17572,17575],{"class":150,"line":1046},[148,17563,17456],{"class":174},[148,17565,1347],{"class":178},[148,17567,2587],{"class":185},[148,17569,1142],{"class":178},[148,17571,1145],{"class":167},[148,17573,17574],{"class":174}," updatePresence",[148,17576,17486],{"class":178},[148,17578,17579],{"class":150,"line":1052},[148,17580,1055],{"class":178},[148,17582,17583],{"class":150,"line":1699},[148,17584,916],{"emptyLinePlaceholder":704},[148,17586,17587],{"class":150,"line":1705},[148,17588,17589],{"class":154},"// Send a message\n",[148,17591,17592,17594,17596,17598,17600,17602,17604,17606],{"class":150,"line":1711},[148,17593,1129],{"class":167},[148,17595,418],{"class":167},[148,17597,11401],{"class":174},[148,17599,296],{"class":178},[148,17601,830],{"class":185},[148,17603,189],{"class":167},[148,17605,4208],{"class":203},[148,17607,4238],{"class":178},[148,17609,17610,17612,17614,17616,17618,17620,17622,17624],{"class":150,"line":1717},[148,17611,8657],{"class":167},[148,17613,2201],{"class":178},[148,17615,2622],{"class":174},[148,17617,296],{"class":178},[148,17619,17444],{"class":299},[148,17621,17447],{"class":178},[148,17623,3010],{"class":299},[148,17625,2971],{"class":178},[148,17627,17628,17631,17633,17636,17638],{"class":150,"line":1723},[148,17629,17630],{"class":178},"    payload: { type: ",[148,17632,2582],{"class":299},[148,17634,17635],{"class":178},", userId, text, timestamp: Date.",[148,17637,5294],{"class":174},[148,17639,17640],{"class":178},"() }\n",[148,17642,17643],{"class":150,"line":1729},[148,17644,1216],{"class":178},[148,17646,17647],{"class":150,"line":1735},[148,17648,230],{"class":178},[148,17650,17651],{"class":150,"line":1741},[148,17652,916],{"emptyLinePlaceholder":704},[148,17654,17655],{"class":150,"line":1746},[148,17656,17657],{"class":154},"// Track typing with debounce\n",[148,17659,17660,17662,17665],{"class":150,"line":1752},[148,17661,4589],{"class":167},[148,17663,17664],{"class":174}," onInputChange",[148,17666,2949],{"class":178},[148,17668,17669,17672,17674,17676,17678,17680,17682],{"class":150,"line":1757},[148,17670,17671],{"class":178},"  client.",[148,17673,2622],{"class":174},[148,17675,296],{"class":178},[148,17677,17444],{"class":299},[148,17679,17447],{"class":178},[148,17681,3010],{"class":299},[148,17683,2971],{"class":178},[148,17685,17686,17688,17691],{"class":150,"line":1763},[148,17687,17630],{"class":178},[148,17689,17690],{"class":299},"'typing_start'",[148,17692,17693],{"class":178},", userId }\n",[148,17695,17696],{"class":150,"line":1769},[148,17697,1216],{"class":178},[148,17699,17700,17702,17705,17708],{"class":150,"line":1774},[148,17701,8614],{"class":167},[148,17703,17704],{"class":178}," (typingTimeout) ",[148,17706,17707],{"class":174},"clearTimeout",[148,17709,17710],{"class":178},"(typingTimeout)\n",[148,17712,17713,17716,17718,17721,17723,17725],{"class":150,"line":3517},[148,17714,17715],{"class":178},"  typingTimeout ",[148,17717,1174],{"class":167},[148,17719,17720],{"class":174}," setTimeout",[148,17722,3207],{"class":178},[148,17724,1145],{"class":167},[148,17726,179],{"class":178},[148,17728,17729,17732,17734,17736,17738,17740,17742],{"class":150,"line":3522},[148,17730,17731],{"class":178},"    client.",[148,17733,2622],{"class":174},[148,17735,296],{"class":178},[148,17737,17444],{"class":299},[148,17739,17447],{"class":178},[148,17741,3010],{"class":299},[148,17743,2971],{"class":178},[148,17745,17746,17749,17752],{"class":150,"line":3530},[148,17747,17748],{"class":178},"      payload: { type: ",[148,17750,17751],{"class":299},"'typing_stop'",[148,17753,17693],{"class":178},[148,17755,17756],{"class":150,"line":3542},[148,17757,1037],{"class":178},[148,17759,17760,17763,17765],{"class":150,"line":3566},[148,17761,17762],{"class":178},"  }, ",[148,17764,11026],{"class":203},[148,17766,303],{"class":178},[148,17768,17769],{"class":150,"line":3586},[148,17770,230],{"class":178},[148,17772,17773],{"class":150,"line":3592},[148,17774,916],{"emptyLinePlaceholder":704},[148,17776,17777],{"class":150,"line":3602},[148,17778,17779],{"class":154},"// On connect, publish presence join\n",[148,17781,17782,17784,17786,17789],{"class":150,"line":3608},[148,17783,1129],{"class":167},[148,17785,418],{"class":167},[148,17787,17788],{"class":174}," joinRoom",[148,17790,2949],{"class":178},[148,17792,17794,17796,17798,17800,17802,17804,17806,17808],{"class":150,"line":17793},42,[148,17795,8657],{"class":167},[148,17797,2201],{"class":178},[148,17799,2622],{"class":174},[148,17801,296],{"class":178},[148,17803,17553],{"class":299},[148,17805,17447],{"class":178},[148,17807,3010],{"class":299},[148,17809,2971],{"class":178},[148,17811,17813,17815,17817,17820,17822],{"class":150,"line":17812},43,[148,17814,17630],{"class":178},[148,17816,15293],{"class":299},[148,17818,17819],{"class":178},", userId, displayName, timestamp: Date.",[148,17821,5294],{"class":174},[148,17823,17640],{"class":178},[148,17825,17827],{"class":150,"line":17826},44,[148,17828,1216],{"class":178},[148,17830,17832],{"class":150,"line":17831},45,[148,17833,230],{"class":178},[148,17835,17837],{"class":150,"line":17836},46,[148,17838,916],{"emptyLinePlaceholder":704},[148,17840,17842],{"class":150,"line":17841},47,[148,17843,17844],{"class":154},"// On disconnect, need to publish presence leave\n",[148,17846,17848],{"class":150,"line":17847},48,[148,17849,17850],{"class":154},"// But ungraceful disconnects won't fire this...\n",[148,17852,17854],{"class":150,"line":17853},49,[148,17855,17856],{"class":154},"// You need server-side heartbeat logic to handle that case.\n",[148,17858,17860,17862,17864,17867],{"class":150,"line":17859},50,[148,17861,1129],{"class":167},[148,17863,418],{"class":167},[148,17865,17866],{"class":174}," leaveRoom",[148,17868,2949],{"class":178},[148,17870,17872,17874,17876,17878,17880,17882,17884,17886],{"class":150,"line":17871},51,[148,17873,8657],{"class":167},[148,17875,2201],{"class":178},[148,17877,2622],{"class":174},[148,17879,296],{"class":178},[148,17881,17553],{"class":299},[148,17883,17447],{"class":178},[148,17885,3010],{"class":299},[148,17887,2971],{"class":178},[148,17889,17891,17893,17895,17898,17900],{"class":150,"line":17890},52,[148,17892,17630],{"class":178},[148,17894,15372],{"class":299},[148,17896,17897],{"class":178},", userId, timestamp: Date.",[148,17899,5294],{"class":174},[148,17901,17640],{"class":178},[148,17903,17905],{"class":150,"line":17904},53,[148,17906,1216],{"class":178},[148,17908,17910],{"class":150,"line":17909},54,[148,17911,230],{"class":178},[12,17913,17914],{},"That is already over 50 lines and it is missing: reconnection re-join logic, multi-tab presence deduplication, heartbeat-based disconnect detection, message history loading, unread count tracking, and sequence-based replay. Each of those adds another chunk of code and another set of edge cases to manage.",[35,17916,17918],{"id":17917},"the-blueprint-sdk-approach","The Blueprint SDK Approach",[12,17920,17921,17922,17924],{},"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,17923,8269],{}," Blueprint:",[135,17926,17927],{},[138,17928,17930],{"className":140,"code":17929,"filename":142,"language":143,"meta":144,"style":144},"import { NoLagChat } from '@nolag/chat'\n\nconst chat = new NoLagChat(token, { appName: 'my-chat-app' })\n\n// Join a room. Presence, history, and replay are automatic.\nconst room = await chat.joinRoom(roomId, {\n  user: { userId, displayName },\n  onMessage: (msg) => renderMessage(msg),\n  onPresenceChange: (presence) => updateMemberList(presence),\n  onTypingChange: (typing) => updateTypingIndicator(typing),\n})\n\n// Send a message\nawait room.sendMessage({ text: 'Hello!' })\n\n// Start typing indicator (auto-stops after inactivity)\nroom.startTyping()\n\n// Leave the room (presence updates automatically)\nawait room.leave()\n",[84,17931,17932,17943,17947,17966,17970,17975,17992,17997,18015,18034,18054,18058,18062,18066,18080,18084,18089,18097,18101,18106],{"__ignoreMap":144},[148,17933,17934,17936,17938,17940],{"class":150,"line":151},[148,17935,902],{"class":167},[148,17937,8401],{"class":178},[148,17939,908],{"class":167},[148,17941,17942],{"class":299}," '@nolag/chat'\n",[148,17944,17945],{"class":150,"line":158},[148,17946,916],{"emptyLinePlaceholder":704},[148,17948,17949,17951,17953,17955,17957,17959,17962,17964],{"class":150,"line":164},[148,17950,270],{"class":167},[148,17952,273],{"class":203},[148,17954,276],{"class":167},[148,17956,279],{"class":167},[148,17958,282],{"class":174},[148,17960,17961],{"class":178},"(token, { appName: ",[148,17963,11715],{"class":299},[148,17965,2150],{"class":178},[148,17967,17968],{"class":150,"line":182},[148,17969,916],{"emptyLinePlaceholder":704},[148,17971,17972],{"class":150,"line":195},[148,17973,17974],{"class":154},"// Join a room. Presence, history, and replay are automatic.\n",[148,17976,17977,17979,17981,17983,17985,17987,17989],{"class":150,"line":207},[148,17978,270],{"class":167},[148,17980,2217],{"class":203},[148,17982,276],{"class":167},[148,17984,499],{"class":167},[148,17986,8539],{"class":178},[148,17988,8542],{"class":174},[148,17990,17991],{"class":178},"(roomId, {\n",[148,17993,17994],{"class":150,"line":217},[148,17995,17996],{"class":178},"  user: { userId, displayName },\n",[148,17998,17999,18001,18003,18005,18007,18009,18012],{"class":150,"line":227},[148,18000,17456],{"class":174},[148,18002,1347],{"class":178},[148,18004,2587],{"class":185},[148,18006,1142],{"class":178},[148,18008,1145],{"class":167},[148,18010,18011],{"class":174}," renderMessage",[148,18013,18014],{"class":178},"(msg),\n",[148,18016,18017,18020,18022,18024,18026,18028,18031],{"class":150,"line":513},[148,18018,18019],{"class":174},"  onPresenceChange",[148,18021,1347],{"class":178},[148,18023,2792],{"class":185},[148,18025,1142],{"class":178},[148,18027,1145],{"class":167},[148,18029,18030],{"class":174}," updateMemberList",[148,18032,18033],{"class":178},"(presence),\n",[148,18035,18036,18039,18041,18044,18046,18048,18051],{"class":150,"line":993},[148,18037,18038],{"class":174},"  onTypingChange",[148,18040,1347],{"class":178},[148,18042,18043],{"class":185},"typing",[148,18045,1142],{"class":178},[148,18047,1145],{"class":167},[148,18049,18050],{"class":174}," updateTypingIndicator",[148,18052,18053],{"class":178},"(typing),\n",[148,18055,18056],{"class":150,"line":1004},[148,18057,1055],{"class":178},[148,18059,18060],{"class":150,"line":1013},[148,18061,916],{"emptyLinePlaceholder":704},[148,18063,18064],{"class":150,"line":1019},[148,18065,17589],{"class":154},[148,18067,18068,18070,18072,18074,18076,18078],{"class":150,"line":1028},[148,18069,943],{"class":167},[148,18071,5491],{"class":178},[148,18073,293],{"class":174},[148,18075,4746],{"class":178},[148,18077,300],{"class":299},[148,18079,2150],{"class":178},[148,18081,18082],{"class":150,"line":1034},[148,18083,916],{"emptyLinePlaceholder":704},[148,18085,18086],{"class":150,"line":1040},[148,18087,18088],{"class":154},"// Start typing indicator (auto-stops after inactivity)\n",[148,18090,18091,18093,18095],{"class":150,"line":1046},[148,18092,290],{"class":178},[148,18094,8646],{"class":174},[148,18096,505],{"class":178},[148,18098,18099],{"class":150,"line":1052},[148,18100,916],{"emptyLinePlaceholder":704},[148,18102,18103],{"class":150,"line":1699},[148,18104,18105],{"class":154},"// Leave the room (presence updates automatically)\n",[148,18107,18108,18110,18112,18115],{"class":150,"line":1705},[148,18109,943],{"class":167},[148,18111,5491],{"class":178},[148,18113,18114],{"class":174},"leave",[148,18116,505],{"class":178},[12,18118,18119,18120,18122,18123,18125],{},"Ten lines of application code. Presence, typing indicators, message replay on reconnect, and history loading are all handled by the Blueprint. The ",[84,18121,14313],{}," handler receives messages in order, with the ",[84,18124,12718],{}," flag already handled correctly. Reconnection re-joins the room automatically. Typing states time out on their own.",[35,18127,18129],{"id":18128},"what-a-blueprint-includes","What a Blueprint Includes",[12,18131,18132],{},"A Blueprint is not just a client SDK. It is a complete package with three parts:",[12,18134,18135,18138,18139,87,18141,87,18143,87,18145,18148,18149,87,18151,87,18154,18157,18158,87,18161,87,18164,1562],{},[30,18136,18137],{},"A purpose-built SDK."," The client library with the domain-specific API, covering the common patterns for that use case. For chat: ",[84,18140,8542],{},[84,18142,293],{},[84,18144,8646],{},[84,18146,18147],{},"getHistory",". For notifications: ",[84,18150,2249],{},[84,18152,18153],{},"markAsRead",[84,18155,18156],{},"getUnreadCount",". For dashboards: ",[84,18159,18160],{},"watch",[84,18162,18163],{},"unwatch",[84,18165,18166],{},"setFilters",[12,18168,18169,18172],{},[30,18170,18171],{},"Infrastructure configuration."," The topic structure, room settings, QoS levels, retention windows, and lobby configuration that the SDK expects. This is pre-configured and deployed to your NoLag project when you install the Blueprint.",[12,18174,18175,18178],{},[30,18176,18177],{},"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,18180,18182],{"id":18181},"when-to-use-raw-pubsub-vs-a-blueprint","When to Use Raw Pub/Sub vs a Blueprint",[12,18184,18185],{},"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,18187,18188],{},"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,18190,18191,18192,18194],{},"The two are not mutually exclusive. You can use the ",[84,18193,8269],{}," Blueprint for your chat feature while using raw pub/sub for a custom activity feed with unusual filtering requirements. Blueprints sit on top of the same infrastructure, so they compose with raw pub/sub subscriptions in the same application without any conflicts.",[12,18196,18197],{},"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.",[684,18199,16046],{},{"title":144,"searchDepth":158,"depth":158,"links":18201},[18202,18203,18204,18205,18206],{"id":17307,"depth":158,"text":17308},{"id":17358,"depth":158,"text":17359},{"id":17917,"depth":158,"text":17918},{"id":18128,"depth":158,"text":18129},{"id":18181,"depth":158,"text":18182},"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":17296,"description":18209},"blog/blueprint-sdks-vs-raw-pubsub","S17c5l1A5RG4P5HK6ge7NoOd3eMAswMTGOTQFnPmwz8",1788160345088]