cf.completefrontend
My progress
SYSTEM DESIGN · #51

Design a customer support chat widget

The chat bubble in the corner of other companies' websites. The hard parts are loading it without hurting the host page, knowing who the visitor is without letting anyone impersonate a customer, keeping the conversation alive while the visitor clicks through pages and tabs, handing over from a bot to a human, and getting attention without becoming the most annoying thing on the page.

The question

We sell a customer support product. Our customers (shops, SaaS apps, banks) paste one script tag into their site, and a chat bubble appears in the corner. Their visitors click it, talk to a bot, and if the bot can't help, talk to a support agent.

Design the visitor side of that chat. Tell me how it loads without slowing down our customers' pages, how it knows who the visitor is, what happens when the visitor moves between pages and tabs, how a conversation goes from the bot to a human, and how it gets the visitor's attention when an agent replies.

Real-life examples: Intercom Messenger, Zendesk Messaging (Web Widget), Drift chat, Crisp chatbox, HubSpot chat, Freshchat, Tawk.to

Requirements: a chat that lives on someone else's site

A support chat widget is a small chat window that a support company ships to its customers. A customer, say a shoe shop, pastes a script tag into its site. A round launcher button appears in the corner; clicking it opens a messenger panel with a greeting, a bot for common questions, and a way to reach a human agent. Intercom Messenger, Zendesk Messaging, Drift, Crisp, HubSpot chat, Freshchat and Tawk.to all look like this.

The host site is the customer's website. The visitor is a person browsing it. The agent answers from a dashboard on our own site. We design the part the visitor sees.

Questions to ask first

QuestionAnswer this design assumesWhy it changes the design
How many host sites, and how big?About 50,000 customer sites, from blogs to shops with 10 million page views a dayThe launcher runs on billions of page views a month, so every KB and every millisecond of it is multiplied.
What share of visitors open the chat?Assume 1 to 3 percent of page views (an estimate)97 percent of visitors never open it, so they should pay almost nothing for it.
Do host sites have signed-in users?Many do: SaaS apps and shops with accountsWe need a safe way for the host to tell us who the visitor is.
Is there a bot?Yes: an automated assistant answers first, then hands off to a humanThe conversation has more states than a plain chat, and the UI must show each one.
Are agents online all the time?No: office hours per team, often 9 to 6 in one time zoneOutside hours the widget must switch to a leave-a-message form with email follow-up.
Single-page or multi-page host sites?Both: classic shops reload on every click, SaaS apps are single-page appsOn a multi-page site the widget is destroyed and rebuilt on every page.
Mobile share?More than half of visits on many shopsThe messenger becomes a full-screen sheet with the on-screen keyboard in play.

Functional requirements

  • A launcher button on every page the customer chooses, with an unread badge.
  • A messenger panel: start a conversation, see past conversations, send text and files.
  • A bot answers first (FAQ search or an AI assistant), and the visitor can ask for a human.
  • Hand-off to a human with queue position or expected wait; outside office hours, an offline form that collects an email.
  • Anonymous visitors, and signed-in users of the host site identified in a way that can't be faked.
  • The open conversation survives page navigation and is the same in every tab.
  • Proactive messages ("Need help choosing a size?") based on rules the customer sets, with frequency caps.
  • Notifications when an agent replies and the messenger is closed or the tab is hidden.

Non-functional requirements

  • Do no harm to the host page: the launcher adds under about 15 KB of compressed JavaScript (our budget, not an industry number), causes no layout shift, and never blocks the main thread long enough to hurt INP (Interaction to Next Paint: how quickly the page responds to a tap; good is at most 200 ms).
  • Open fast: the messenger is usable within about 1 second of clicking the launcher on a typical 4G phone, and within about 300 ms when restored after a page navigation.
  • Safe identity: one visitor can never read another visitor's conversation by guessing or changing an id.
  • Accessible: keyboard and screen reader users can open, use and close it; new messages are announced without flooding.
  • Private: respects the host's consent banner, masks card numbers, and deletes transcripts after the customer's retention period.

Out of scope

  • The agent dashboard, routing and SLAs (see the support dashboard guide).
  • How the bot's answers are generated. We treat the bot as a server that replies with messages; streaming AI answers are covered in the LLM chat UI guide.
  • Voice and video calls, co-browsing, and in-app chat in native mobile SDKs.

What makes a support widget hard

A support widget looks like a small chat app in a box. The box is the problem: four things make it different from a chat app on our own site.

  • It's a guest. It runs on pages we don't own. The host's Core Web Vitals, consent banner, sticky footer and routing are rules we must live with, and if the widget makes the shop slower, the shop removes it.
  • Almost nobody uses it, but everybody loads it. With 1 to 3 percent of page views opening the chat, the code that runs on every page must be tiny.
  • It doesn't control its own lifetime. On a multi-page shop, every click destroys the widget and builds a new one, and three tabs hold three copies of it. The conversation must still feel like one continuous thing.
  • It speaks for strangers. The host site knows who the visitor is; we don't. We must accept identity from the host without letting anyone pretend to be another customer, and forget it when they sign out.

On top of that, a support conversation has more states than a chat between friends: bot, queue, human, closed office, resolved, reopened. The widget must show the right one after any reload.

Wireframe of a support chat on a shop. Left, the shop page shoeshop.com with a product photo, the product name and price, a proactive message bubble saying need help with size, and the round launcher button in the bottom corner with a badge showing 2 unread. Right, the same page with the messenger open: a header with the team name and reply time, a bot answer, a talk to a person button, a line saying Sam joined, and the composer at the bottom.Closed: shoeshop.comproduct photoRunning shoe, 89Need help with size?chat2Open: messengerSupport: 5 minBot: size guideTalk to a personSam joinedType a messagelauncher, badge, proactive bubbleheader, conversation, composer
Everything the visitor sees comes from two layers: the launcher and its bubbles, which sit on every page, and the messenger, which loads only when someone wants it. On a phone, the right side becomes a full-screen sheet.

The wireframe shows the parts we will design. The launcher is a round button, about 60 pixels across, fixed to a corner with an unread badge; above it, a proactive message bubble appears when a rule fires. Clicking opens the messenger: a header with the team's status ("Usually replies in 5 minutes" or "We're away"), the conversation, and a composer. On a phone it fills the screen.

Which guide covers what

TopicWhere it's taughtWhat this guide adds
Loader snippet, command queue, iframe vs shadow DOM, CSS isolation, postMessage, theming, versioningEmbeddable widget guideWhy a support launcher can't be a pure facade, and its KB budget
Loading on intent, CSP, focus and announcements across the frameEmbeddable widget guideThe chat's own early-load signals, and a silent restore after navigation
Third-party storage partitioning, CHIPS, the Storage Access APIEmbeddable widget guide, client-storage lessonWhere one visitor id lives across a shop's subdomains and domains
Message list, optimistic sends, ordering, receipts, typing, presenceChat app guideRestoring the chat after each host page load without a reconnect storm
Queues, assignment and SLAs for agentsSupport dashboard guideWhat the visitor sees: queue position, expected wait, offline form
Leader tab, BroadcastChannel, SharedWorkerChat app guide, multi-tab sync guideTabs that have only the launcher, and events scoped to one visitor
Notification permission and pushNotifications center guideWhy the request must come from the host page, and when to stay quiet
Consent bannerCookie consent guideLoading nothing until consent when the widget sets identifiers
Streaming AI answersLLM chat UI guideThe bot as one state in a support conversation

Check your understanding · Exercise 1

Which of these answers would change the widget's design the most? Select all that apply.

The launcher: load almost nothing until someone asks

The naive install is one script tag that loads the whole messenger on every page: the UI framework, the conversation list, the composer, emoji, file upload, the socket client and translations. A real messenger bundle is easily 150 to 250 KB of compressed JavaScript (an estimate for a full-featured widget). On a mid-range phone, parsing and running that much code takes hundreds of milliseconds of main-thread time, right when the shop's own page is trying to become interactive.

Multiply it out. A shop with 2 million page views a day and a 2 percent open rate loads the full messenger 2 million times so that 40,000 visitors can use it, and the host's Core Web Vitals pay. For years Lighthouse had a "Lazy load third-party resources with facades" audit that named live chat products such as Intercom and Drift. Lighthouse 13 removed it in 2025, but a facade (a light placeholder that looks like the real widget and loads it on interaction) is still the standard answer.

Decision When should the full messenger load?

Eagerly, on every page load

The snippet loads the full bundle with async.

The launcher and messenger boot together.

Pros

  • Opening the chat is instant.
  • Proactive messages and unread badges work with no extra logic.

Cons

  • Every visitor pays 150 KB or more and hundreds of ms of CPU.
  • Long tasks (over 50 ms) during page load can hurt the host's INP.
  • Shows up in the host's performance audits; customers complain or remove the widget.

A facade, full messenger on click

A tiny loader draws a launcher that only looks real.

On click it loads the messenger and swaps it in.

Pros

  • Near-zero cost for the 97 percent who never click.
  • Simple to reason about.

Cons

  • The first open waits for a download: roughly 500 ms to 2 s on mobile (an estimate).
  • Unread badges and proactive messages need data the facade doesn't have.

Two stages: a small real launcher, messenger on intent

Stage 1 (every page): a loader of about 5 KB plus a launcher of about 10 KB that knows the unread count and rules.

Stage 2: preconnect on hover or focus, load the messenger on click, or on idle when we know the visitor has an open conversation.

Pros

  • The launcher is real: badge, proactive messages, restore after navigation.
  • The heavy code loads only for people likely to use it.

Cons

  • Two bundles to build, version and test.
  • The launcher needs a small, cheap API call to learn the unread count.

What we pick: Two stages. A pure facade is right for a video player, but a support launcher must show an unread badge and fire proactive messages, so it needs a little real code and a little data. We keep that part under about 15 KB and load the messenger only on intent: on click, or early when a returning visitor has an open conversation, because they are the ones most likely to open it.

The budget, in KB and milliseconds

Our own budgets, not industry numbers. They are what a reviewer would hold the build to.
PieceWhen it loadsBudget (compressed)Main-thread budget
Loader snippet (inline)In the host's HTMLunder 1 KBunder 1 ms
Loader scriptasync, after the host's own scriptsabout 5 KBunder 10 ms
Launcher (button, badge, rules)Right after the loader, at idleabout 10 KBunder 20 ms, split into small tasks
Boot API callWith the launcherabout 1 KB of JSONnone
Messenger (iframe app)On intent150 to 250 KBruns in the iframe, which may still share the host's main thread on phones

One fact makes these budgets matter more. Browsers now partition the HTTP cache by top-level site, so our bundle cached on shop A is not reused on shop B. Every customer's site pays for its own first download.

Timeline of a page with the widget. The host page row: HTML, then host JavaScript, then its largest paint at about 1.5 seconds. The widget row: the loader of about 5 KB starts after that, then the launcher of about 10 KB at idle, and the messenger of about 200 KB only on intent. The visitor row: hovering over the launcher adds preconnect hints, and clicking shows a skeleton in the next frame while the messenger loads.hostwidgetvisitorHTMLhost JSLCP5 KB10 KB200 KBloaderlaunchermessengerhoverclickpreconnectskeleton0 sabout 1.5 son intent
The host page finishes its important work before we start, and the expensive part waits for a signal from the visitor. All sizes are our own budgets and estimates.

Loading on intent

Intent means a signal that comes before the click. The embeddable widget guide covers the general recipe: preconnect to our origins on hover or focus, start the download on press, and paint feedback in the click handler before doing any work. A support widget has three extra signals that justify loading the messenger before any click.

  • The panel was open on the last page. The loader's chat:open flag says so, and the visitor expects it back at once (the navigation chapter).
  • A human is involved. If the boot call says the conversation is waiting for or talking to an agent, load at idle: a reply is coming.
  • A proactive message fired. A visitor who sees "Need help with sizing?" is far more likely to open the chat than one who sees only the button.

Not hurting the host's Core Web Vitals

Customers judge us by their Core Web Vitals. A fixed launcher causes no layout shift (CLS), and an async loader that starts after the host's critical resources leaves the largest paint (LCP) alone; the embeddable widget guide shows both. INP is where a support widget is most at risk, because its boot code runs on every page view of every visitor.

So the launcher boots in small tasks at idle, never one 200 ms block, and a tap on "Add to cart" during boot waits for one small task at most. The messenger is not exempt just because it lives in an iframe. On many phones a cross-origin iframe runs on the host page's main thread, so its long tasks still delay the host's taps.

Check your understanding · Exercise 2

A customer reports that their INP got worse after installing the widget. Which changes would help? Select all that apply.

Who is the visitor?

Every conversation belongs to someone. For a first-time visitor we create an anonymous visitor: a random id stored in the browser and sent with every request, so tomorrow's visit brings back today's conversation. When a signed-in user of the host site opens the chat, we attach the conversation to their account, so the agent sees their name, plan and past tickets.

Both halves have a trap. The anonymous id has to live somewhere the browser will keep, and browsers now partition third-party storage. The signed-in identity comes from the host page, and anything the host page says can be changed by the person sitting at the keyboard.

Where the anonymous id lives

Chrome, Firefox and Safari all partition a third-party iframe's storage: it is keyed by our origin plus the top-level site of the page (scheme plus registrable domain, such as https://shoeshop.com). So www.shoeshop.com and help.shoeshop.com share one partition, but shoeshop.co.uk gets a different one. A visitor who crosses domains becomes a second anonymous contact until they sign in. Two customers' sites never share storage, which is fine: a visitor of two shops is two different contacts anyway.

Decision Where should the anonymous visitor id be stored?

localStorage inside our iframe

The messenger iframe generates crypto.randomUUID() and stores it in its own storage.

Partitioned by the host's top-level site.

Pros

  • Fully under our control; the host's scripts can't read it.
  • Shared across the host's subdomains on the same site.

Cons

  • Only exists once the iframe loads, so the stage-1 launcher can't use it without loading the iframe.
  • Safari keeps third-party storage only until the browser quits, so returning visitors look new there.
  • Partitioned cookies (CHIPS) live in the same partition, and the Storage Access API needs a click and often a prompt, so neither fixes this.

A first-party cookie written by the loader

The loader runs in the host page and sets a cookie on the host's registrable domain, such as Domain=shoeshop.com.

It passes the id to the launcher and the iframe at boot.

Pros

  • Available instantly to the stage-1 launcher.
  • Shared by every subdomain of the shop.
  • The host can delete it on logout even when our script isn't loaded.

Cons

  • Safari deletes cookies set from JavaScript after 7 days without a visit to the site, so a visitor who returns after two weeks gets a new id.
  • The host's own scripts can read it, so it must not be enough on its own to read a conversation.

A cookie set by our server on a host subdomain

The customer points chat.shoeshop.com at our servers with a DNS record.

Our server sets an HttpOnly first-party cookie.

Pros

  • HttpOnly, so no page script can read it.
  • Set by a server, so Safari's limit on script-set cookies doesn't apply.

Cons

  • Every customer must change DNS and we must issue certificates for their domains.
  • Safari treats a subdomain that resolves to another company's servers as CNAME cloaking and caps its cookies at 7 days anyway.

What we pick: A first-party cookie written by the loader, as Intercom does (its docs say its cookies are prefixed intercom- and created on the customer's domain). The launcher needs the id before the iframe exists, and it must cover every subdomain of the shop. Because page scripts can read the cookie, the id alone never unlocks a conversation: at boot our server exchanges it for a short-lived session token that lives only in the iframe's memory. Anonymous identity is best effort in every option; the identity that lasts is the signed-in one.

Identifying a signed-in user without impersonation

The naive API is Chat('boot', { userId: '42' }). It's a security hole: anyone can open DevTools on the shop, boot with userId: '43', and read user 43's support history, with addresses and order numbers. The page is not a trusted source. Only the host's server knows who is really signed in.

So the host's server signs the identity with a secret it shares with us, and we verify the signature. Intercom's original identity verification sent a user_hash: an HMAC-SHA256 (a keyed hash) of the user id with the shared secret. Intercom now marks that method deprecated and recommends a signed JWT instead (a JSON Web Token: a small JSON payload plus a signature), passed in the Messenger settings as intercom_user_jwt.

Zendesk's messaging Web Widget, which replaces the older Web Widget Classic, works the same way through zE('messenger', 'loginUser', jwtCallback). It honours the JWT's exp (expiry) claim and calls the callback again for a fresh token whenever a request is rejected with a 401.

ts
import { SignJWT } from "jose";

const secret = new TextEncoder().encode(process.env.CHAT_IDENTITY_SECRET);

export async function chatIdentityToken(user: { id: string; email: string; name: string }) {
  return new SignJWT({ email: user.email, name: user.name })
    .setProtectedHeader({ alg: "HS256" })
    .setSubject(user.id)
    .setIssuedAt()
    .setExpirationTime("10m")
    .sign(secret);
}
On the host's server. The secret never reaches the browser; the token expires in 10 minutes.

An HMAC of the user id never expires: if it leaks once, in a log or a shared HAR file, it works forever. A JWT with a short exp limits the damage, and its extra claims (email, plan) are signed too, so a visitor can't edit them. The cost: the host must hand us fresh tokens, so our API takes a callback rather than a fixed string.

Sequence for identifying a signed-in visitor. The host page asks the host server for a chat token. The host server replies with a JWT that expires in 10 minutes. The host page calls boot with the identity. The widget posts the JWT to our session API, which verifies it and returns a session token and the visitor's messages. Later, the failure path: our API answers 401 identity expired. The widget asks the host page's callback for a fresh JWT, the host page returns a new one, and the widget retries. If that also fails, the widget continues as an anonymous visitor.Host pageHost serverWidgetOur APIget chat tokenJWT, exp 10 minboot(identity)POST /sessiontoken, messages401: expiredcallback()fresh JWT, retry
The browser carries the token but can never mint one: only the host's server holds the secret. An expired token costs one callback, not an error screen, and a second failure falls back to anonymous instead of showing someone else's data.

Merging the anonymous conversation

A common story: a visitor asks a pre-sales question anonymously, then signs up and signs in. The agent should see one person with one history, so when an identified boot arrives with an anonymous id attached, the server moves that anonymous contact's conversations onto the user.

Three rules keep this safe. Merge only an anonymous contact that has never been merged into anyone. Never merge one identified user into another, and never merge the other way, from a user into an anonymous contact.

Logout on the host must clear the widget

On a shared computer, the next person must not see the last person's conversations. The host calls Chat('logout') in its sign-out flow, and the widget drops the session token, wipes cached messages and drafts, closes the socket, deletes its cookies and creates a fresh anonymous id. Intercom's docs warn that without shutdown, the last user's conversations stay in the Messenger for a week, and that a leftover anonymous cookie can merge the last user's history into the next person who signs in.

Hosts forget to call logout, so we add a second line of defence: identity is declared on every boot. If the stored session belongs to user 42 but this page boots with no user, or with user 57, the widget treats it as a logout before showing anything. And the merge rule above means a stale anonymous id that once belonged to user 42 can't be merged into user 57.

Check your understanding · Exercise 3

Which designs stop a visitor from reading another customer's conversations? Select all that apply.

One visitor, many tabs of the shop

Shoppers open products in new tabs to compare them. With three tabs, each with its own messenger iframe and socket, an agent's reply rings three times, reading it in one tab leaves a red badge in the other two, and logging out in one tab leaves the old conversation visible in the others.

Partitioning helps here. Our three iframes share an origin and a top-level site, so they share one partition, and BroadcastChannel, SharedWorker and Web Locks are partitioned the same way. The iframes can talk to each other, while the shop's scripts, and our own iframes on other customers' sites, can't listen in.

The chat app guide designs the rest: one leader tab holds a Web Lock, owns the socket and relays events to the others on a BroadcastChannel, and only the leader shows notifications. The multi-tab sync guide covers election and failover in depth. A SharedWorker that owns the socket is the cleaner option, but Chrome on Android only shipped it in version 148 (2026) and Samsung Internet still lacks it, so a fallback is needed either way.

Two things are different for a support widget. Sockets are already rare, because we connect only while someone waits for a human, so one socket per tab is an acceptable fallback. And the channel and lock names include the app and the visitor (chat:app_123:v_9f2c), so a logout event reaches every tab of this visitor and never leaks into the next visitor's session.

Unread counts that agree

The unread count belongs to the server: an agent's message is unread until the visitor sees it, in any tab or on any device. When the visitor reads a message in tab A, tab A tells the server (POST /read with the last seen sequence number) and broadcasts read on the channel, so tabs B and C clear their badges at once.

A tab that has only the stage-1 launcher, and so no iframe and no channel, refetches the count on visibilitychange instead. That sounds lazy, but it's exactly right: nobody can see the badge in a hidden tab, so it only has to be correct when the tab becomes visible.

The lab below puts this chapter and the last together. Pick a strategy and how many tabs the visitor has open, then click through pages. Watch the number of sockets opened, how long the panel takes to come back, and whether the badges agree after the visitor reads a reply in one tab.

Every page loads the full messenger and opens a socket: 2 so far.

Tab A, visible: page 1, chat closed, draft none, badge 0. Tab B: page 1, chat closed, draft none, badge 0. Reload naively, 2 tabs. Sockets open now 2, sockets opened in total 2, alerts played 0. Last restore none. Badges that disagree with the server: 0.Tab A: visiblepage 1chat closedno draftbadge 0Tab Bpage 1chat closedno draftbadge 0sockets now 2, opened 2, alerts 0server unread 0, last restore none
A red badge disagrees with the server's unread count. Click through pages with the chat open and a draft typed, then let the agent reply and switch tabs. Compare the sockets opened and the restore time across the three strategies. Times are estimates.

Check your understanding · Exercise 5

A visitor reads an agent's reply in tab A. Tab B is hidden and has only the stage-1 launcher (no iframe). What keeps tab B's badge correct? Select all that apply.

Bot first, then a human

Most support questions repeat: where is my order, how do I reset my password. So the visitor first talks to an automated assistant, a help-centre search or an AI agent. When it can't help, a human takes over, unless it's 11 pm in the team's time zone. The widget must make each situation obvious and survive a reload in any of them.

The naive model is a boolean, isBot, plus a list of messages. It can't say whether the visitor is waiting for a human or still with the bot, whether a closed conversation should keep its composer, or whether a reply tomorrow reopens it. Each of these needs an explicit state, so we model the conversation as a state machine: a fixed set of states and the events that move between them.

State machine of a support conversation. It starts in bot. Asking for a human moves it to waiting. An agent accepting moves it to with agent. The agent closing moves it to resolved, and a new visitor message reopens it into waiting. From bot when the office is closed, or from waiting when nobody accepts before closing time, it moves to offline. Sending the offline form moves it to awaiting reply, and the emailed reply resolves it.botwaitingwith agentofflineawaiting replyresolvedask humanacceptedoffice closednobody acceptsformreplyclosesreopenedthe server owns the state; the widget renders it
The server owns these states and pushes every change; the widget only renders them. A reload or a second tab asks the server and lands on the same screen.
StateWhat the visitor seesComposerReal-time connection
botBot answers, suggested articles, a "Talk to a person" buttonEnabledOnly while the panel is open
waiting"You're number 3 in line, usually under 5 minutes"Enabled: messages go to the agent when assignedYes, even with the panel closed
with_agentAgent's name and avatar, typing indicatorEnabledYes
offline"We're away until 9:00 (your time). Leave a message and we'll email you."Replaced by a short form: email and messageNo
awaiting_reply"Got it. We'll reply here and at ana@example.com."Enabled, adds to the same conversationNo; catch-up on boot
resolved"This conversation was closed" and a rating promptEnabled: a new message reopens itNo

The server owns the state. The widget never decides that a conversation is with_agent; it receives a conversation.state event and renders it. This matters for reloads (the boot call returns the current state, so any page restores the right screen) and for tabs (all tabs get the same event). The widget only adds local states on top, such as sending for the moment between pressing "Talk to a person" and the server's answer.

Decision How easy should it be to skip the bot?

Human first, no bot

Every new conversation goes straight to the queue.

Pros

  • Visitors never feel stuck with a machine.

Cons

  • Agents answer the same 20 questions all day; queues grow; costs grow with traffic.

Bot with a hidden escape

The visitor must try the bot several times, or type "agent", before a human is offered.

Pros

  • Highest deflection numbers on a dashboard.

Cons

  • Visitors with real problems get angry; satisfaction drops; some leave the site.

Bot first, human always one tap away

The bot answers first; after its first answer a "Talk to a person" button is always visible.

The bot also hands off on its own when it isn't confident.

Pros

  • Easy questions get instant answers; hard ones reach a human quickly.
  • The visitor stays in control.

Cons

  • Some visitors skip the bot even for easy questions.

What we pick: Bot first, human one tap away. The widget's job is to help the visitor, not to hide the agents. The customer can tune when the button appears, but the default shows it after the first bot answer, and the bot hands off with the conversation so far, so the visitor never repeats themselves.

Queue position and expected wait

Once the visitor asks for a person, waiting is the whole experience. The server sends queue.update events with a position and an estimate, and two details make this calm instead of stressful. Show the estimate as a rounded range ("usually under 5 minutes") rather than a ticking countdown that will be wrong. And update the visible text at most every 30 seconds, because a number that jumps from 3 to 4 when a VIP is routed ahead feels worse than no number.

While waiting, the connection stays open even if the visitor closes the panel, so the badge and a notification can fire when the agent answers.

Office hours and the offline form

Office hours are a server decision. The boot call returns availability: { online: false, nextOpenAt: '2026-10-23T09:00:00+01:00' }, and the widget formats it in the visitor's own time zone with Intl.DateTimeFormat. Never compute "are we open?" from the visitor's clock: it can be wrong, and the team's holidays live on the server.

When offline, the composer turns into a short form. If the visitor is identified we already have their email; if anonymous, we ask for one, because the reply will arrive after they've left the site. The reply then shows up in two places: by email, and in the widget on their next visit.

ts
type ServerState =
  | { kind: "bot" }
  | { kind: "waiting"; position: number; etaBucket: "under_2m" | "under_5m" | "under_15m" | "long" }
  | { kind: "with_agent"; agent: { name: string; avatarUrl: string } }
  | { kind: "offline"; nextOpenAt: string }
  | { kind: "awaiting_reply"; email: string }
  | { kind: "resolved"; rated: boolean };

type LocalOverlay = { requestingHuman: boolean; formError?: string };

function composerMode(s: ServerState): "chat" | "offline_form" {
  return s.kind === "offline" ? "offline_form" : "chat";
}
The widget's view of the conversation: server states plus one local overlay.

Check your understanding · Exercise 6

A visitor asks for a human at 18:59. Agents go home at 19:00 and nobody accepts the conversation. What should happen? Select all that apply.

Getting attention without being annoying

An agent replies after four minutes. By then the visitor has closed the panel or switched tabs, and if they never see the reply, the team's work is wasted. But a widget that beeps, flashes the title and pops up "Can I help you?" on every page gets uninstalled. So we need rules: which signal, when, and how often.

The rule of thumb: the stronger the signal, the more the visitor must have asked for it. A badge is always fine, and a change to the tab title is fine while a human is actually answering. A system notification needs explicit permission and a reason, and sound needs the visitor to turn it on.

SignalDefaultWhen it firesNotes
Unread badge on the launcherOnAny unread agent or bot message while the panel is closedAlso part of the launcher's accessible name
Reply preview bubble above the launcherOnAn agent replies while the panel is closed and the tab is visibleOne bubble, replaced by newer replies; never stacks
Tab title prefix, such as (1) Running shoesOn while waiting or with_agentTab hidden and an agent repliedA static prefix, not a flashing title; removed on visibilitychange
System notificationOff; customer can enableTab hidden or panel closed, agent replied, permission grantedAsked only after the visitor taps "Notify me" while waiting
SoundOffAgent replied and the visitor turned sound onBrowsers block audio before a user gesture anyway

Browser notifications from an embedded widget

Two platform rules shape this. Browsers expect a permission request to follow a user gesture, such as a click, and Chrome and Firefox no longer let a cross-origin iframe request notification permission at all (MDN documents both). Our messenger lives in a cross-origin iframe, so it can't ask.

If a customer turns system notifications on, the loader asks on the host's behalf, so the permission belongs to the shop's origin. It asks only after the visitor taps a visible "Notify me when they reply" button, and only while the conversation is waiting or with_agent. Because the notification is shown from the host page, it says only "New reply from support": the text stays in our iframe. It fires only when the tab is hidden or the panel is closed.

With several tabs, only the leader tab's loader shows it, with the conversation id as its tag so a newer one replaces the older. The notifications center guide covers permission UX and service-worker push; a support widget rarely needs push, because email follows up once the visitor has left.

Proactive messages

Proactive messages are the customer's tool for starting a conversation: "Questions about sizing? We reply in minutes" after 30 seconds on a product page, or "Need help with your plan?" on /pricing. The rules come in the boot response and are evaluated by the stage-1 launcher, so we don't load the messenger just to check a timer. When a rule fires, the launcher shows a small bubble and starts loading the messenger, one of the early signals from the launcher chapter.

  • Frequency caps (our defaults): at most one proactive message per session and three per week per visitor, stored with the visitor id. Customers can lower them, not raise them past a hard ceiling.
  • Dismissal is a decision: if the visitor closes a proactive bubble, no proactive message for 7 days on that site. Ignoring it for 10 seconds hides it without counting as a dismissal.
  • Never interrupt work: don't fire while a form field on the host page has focus, while the visitor is in a conversation, or on pages the customer marks as sensitive, such as the payment step.
  • Mobile restraint: on small screens a bubble covers content, so show only a badge-sized nudge and never auto-open the sheet.
ts
function mayShowProactive(rule: Rule, ctx: VisitorContext, now: number): boolean {
  if (ctx.conversationState !== "none" && ctx.conversationState !== "resolved") return false;
  if (ctx.messengerOpen || ctx.hostFieldFocused) return false;
  if (ctx.sensitivePage) return false;
  if (now - ctx.lastDismissedAt < 7 * DAY) return false;
  if (ctx.shownThisSession >= 1) return false;
  if (ctx.shownTimestamps.filter((t) => now - t < 7 * DAY).length >= 3) return false;
  return matchesUrl(rule.urlPattern, location.href) && ctx.secondsOnPage >= rule.afterSeconds;
}
The stage-1 launcher's rule check, run when a rule's condition becomes true.

The lab below follows one conversation from the bot through the queue to an agent, and the offline path when the office is closed. Open and minimise the panel, hide the tab, and watch which signals fire: badge, preview, title prefix, notification. Notice how the same agent reply produces different signals depending on where the visitor's attention is.

The visitor opens the messenger. Ask a question to start.

Office open. State no chat. Panel open, tab visible, notify me not asked. Unread 0. Signals on the last message: badge no, preview no, title prefix no, notification no, sound off.botqueueagentofflineform sentresolvedoffice openbadge 0previewtitle (1)notificationpanel open, tab visible, sound offnotify me: only offered while a human is involved
The top row is the server's state machine, with the current state marked. The bottom row shows which signals the last message fired. Minimise the panel or hide the tab before the agent replies, and compare.

Check your understanding · Exercise 7

The visitor is with_agent, has granted notification permission, and is reading the open conversation in the visible tab when the agent replies. Which signals fire? Pick one.

Phones, settings, privacy and accessibility

On a phone, a 380 by 600 pixel panel floating over the page doesn't fit. The messenger becomes a full-screen sheet: the iframe grows to cover the viewport, and the host page behind it stops scrolling. Four mobile problems follow, each with a specific fix.

  • The on-screen keyboard. When the composer gets focus, the keyboard covers the bottom of the screen. Only the top-level page's visualViewport reports the visible area (inside an iframe it just mirrors the frame's own size), so the loader listens to its resize event and sets the iframe's height to visualViewport.height. Without this, iOS often hides the composer under the keyboard or scrolls the whole host page.
  • The back button. On Android, people press back to close things. When the sheet opens, the loader pushes one history entry, and popstate closes the sheet instead of leaving the shop. Closing with the sheet's own button calls history.back() to remove that entry, and the entry carries a marker in history.state so a single-page host's router ignores it.
  • Safe areas. On phones with a notch or a home indicator, the loader's wrapper around the iframe adds env(safe-area-inset-top) and env(safe-area-inset-bottom) padding. This works only if the host's viewport meta tag has viewport-fit=cover; otherwise the insets are zero and nothing breaks.
  • The host's own sticky UI. Shops have sticky "Add to cart" bars at the bottom. The customer sets a vertical offset per page pattern in the dashboard (for example 72 px on /products/*), and the JavaScript API lets the host move or hide the launcher while its own bottom sheet is open.
Two phone screens. Left, the shop page with the chat closed: a product photo, the product name, the launcher button raised 72 pixels so it sits above the shop's sticky add to cart bar. Right, the chat open as a full-screen sheet: a header with a close button below the safe area, a bot reply, the visitor's message, and the composer pinned just above the on-screen keyboard, which takes the bottom part of the screen. The back button closes the sheet.ClosedRunning shoechatAdd to cartOpen: sheetSupport, closeBot replyMy ordercomposerkeyboardoffset 72 pxback closeslauncher above the sticky barcomposer above the keyboard
The offset comes from the customer's settings, per page pattern. The loader sizes the sheet to the host page's visualViewport.height, so the keyboard never hides the composer, and the history entry pushed on open makes the back button close the sheet.

Configuration from the dashboard

Customers change the widget without redeploying their site: colours, launcher position and offset, greeting, office hours, which pages show the launcher, and proactive rules. All of it arrives with the launcher's boot call, cached at our edge for about a minute per app id, so a change shows up within about a minute. Page rules run in the loader before anything is drawn, so a page without the launcher costs one small request and no launcher code.

Customers with a strict Content Security Policy must allow our script, frame, API and wss: socket origins, or the widget fails silently. The embeddable widget guide lists the directives and shows how the loader reports a blocked request, so support can tell the customer which line to add.

Check your understanding · Exercise 8

On Android, a visitor opens the chat sheet and presses the system back button. What should happen, and what makes it work? Select all that apply.

Privacy: consent, card numbers and retention

A support chat collects what frustrated people type: order numbers, addresses, sometimes a full card number. It also stores an identifier in the browser. Both make privacy part of the design.

  • Consent first. Because the loader sets a visitor id cookie, many customers in the EU must not run it before consent. In consent: 'wait' mode the loader draws nothing, sets nothing and loads nothing until the host's consent banner calls Chat('consent', { functional: true }); the cookie consent guide covers the banner side.
  • Mask card numbers before they leave the browser. The composer looks for runs of 13 to 19 digits (with spaces or dashes) that pass the Luhn checksum, replaces all but the last four, and tells the visitor why. The server runs the same redaction again, because a modified client can skip ours.
  • Retention. Each customer sets how long transcripts are kept. The browser keeps only the last 50 messages of the open conversation, deletes the cache when a conversation has been resolved for 30 days, and wipes everything on logout.

Accessibility specific to a support chat

The embeddable widget guide explains why a floating chat panel is a non-modal dialog on desktop: the visitor may copy an order number from the page while chatting, so there is no focus trap, and Escape returns focus to the launcher. The phone sheet covers everything, so it is modal in practice. There the loader opens the sheet's wrapper as a dialog with showModal(), which makes the rest of the host page inert without touching the host's DOM.

  • The launcher's name carries the count. It is a button with aria-expanded and an accessible name such as "Open support chat, 2 unread messages". The badge itself is aria-hidden, so the number isn't read twice.
  • Restoring is silent. The message list is not a live region, because restoring 50 cached messages after every navigation would read them all aloud. A separate polite live region announces only messages that arrive after boot, batched as the chat app guide describes.
  • Quiet status changes. Queue updates change the visible text but are announced only when the state changes ("An agent has joined: Sam"), not every time the position moves.

Check your understanding · Exercise 9

A screen reader user navigates to a new page of the shop with the chat open, and the messenger restores 50 cached messages. What should they hear? Select all that apply.

Architecture, data and API

Putting the chapters together gives three layers that live in different places for different reasons. The loader and launcher run in the host page, because they must be tiny, must exist on every page, and need the host's first-party cookie and URL. The messenger runs in our cross-origin iframe, because it handles private conversation data that the host's scripts must not read. Our servers own identity, conversation state, availability and rules, because nothing in the browser can be trusted with them.

Architecture of the support widget. At the top, our servers: the store, the launcher API, the realtime service and the session API. Bottom left, the host page: the host app calls Chat boot, the loader of 5 KB keeps the visitor id cookie, the host server signs the identity JWT, and the launcher shows the badge and runs the rules. The launcher API sends config and unread count to the loader, and the loader mounts the launcher. Bottom right, our iframe: the sync layer receives events from the realtime service, the session part exchanges the JWT for a token with the session API, the messenger renders the state, and the cache keeps messages in IndexedDB and drafts in sessionStorage. The launcher and messenger talk over a postMessage bridge.Our serversStoreLauncher APIRealtimeSession APIHost pageHost appChat('boot')Loader5 KB, cookieHost serversigns JWTLauncherbadge, rulesOur iframeSyncsocket, channelSessiontoken in memoryMessengerstates, a11yCachemessages, draftsconfig, unreadeventsJWTJWTmountsstatebridge
Three trust zones. The host page gets only config and a number; conversation text stays inside our iframe; identity and state live on our servers. The bridge in the middle is the only door between the host page and the iframe.
PartLives inResponsible for
LoaderHost page, about 5 KBCommand queue, visitor id cookie, page rules, chat:open flag, mounting the launcher and iframe
LauncherHost page, shadow root, about 10 KBButton, badge, preview bubble, proactive rules, intent detection, title prefix
Messenger appOur iframeConversation UI, state machine rendering, drafts, offline form, accessibility
SessionOur iframe, memoryExchanges visitor id or identity JWT for a session token; wipes on logout
Sync layerOur iframeBoot catch-up, socket when needed, leader election, BroadcastChannel events
Local cacheOur iframe, IndexedDB and sessionStorageLast messages, sequence numbers, drafts, scroll
Boot APIOur edge, CDN-cached config plus a per-visitor partConfig, availability, rules, unread count, conversation state

The boundary between launcher and messenger is the postMessage bridge described in the embeddable widget guide. Only a handful of messages cross it: open, close, unread (a number), resize (for the sheet), notify (show the generic notification) and logout. Message text never crosses into the host page, which is why the reply preview bubble is drawn by a tiny second iframe of ours rather than by the launcher. That's a deliberate privacy line: the host page can know that there is an unread message, never what it says.

Data flow for an agent's reply. The agent's reply goes to our realtime service. It sends an event with sequence 120 to the sync layer in the leader tab. The sync layer persists it to the IndexedDB cache and applies it to the store. The store broadcasts it on the channel to other tabs, and the messenger UI subscribes and re-renders. The messenger tells the launcher, through the bridge, that the unread count is 0 because the panel is open. When the visitor sends a message, the store posts it to the messages API.AgentRealtimeMessages APISyncleader tabStorestate, unreadChannelother tabsCacheIndexedDBMessengerre-renderLauncherbadgereplyevent, seq 120applyfan outpersistsubscribeunread 0POST send
One event enters through one tab and fans out: to disk for the next page load, to the screen, and to the other tabs. Only a number crosses the bridge to the host page. Writes leave from the tab where the visitor typed, with the optimistic send from the chat app guide.
ts
type Visitor = { visitorId: string; userId?: string; email?: string; identified: boolean };

type Conversation = {
  id: string;
  state: ServerState;          // from the hand-off chapter
  lastSeq: number;             // highest message sequence we have
  lastReadSeq: number;         // highest sequence the visitor has seen
  unread: number;              // derived on the server, cached here
};

type CachedMessage = { seq: number; author: "visitor" | "bot" | "agent" | "system"; text: string; at: string };

type TabUiState = { open: boolean; screen: "home" | "conversation" | "offline_form"; scrollTop?: number };
type Draft = { conversationId: string; text: string; updatedAt: number };
The messenger's client-side data model.
http
GET /v1/apps/app_123/launcher?v=v_9f2c...&url=/p/42      (launcher, every page)
200 OK  Cache-Control: private, max-age=10
{ "config": { "color": "#0B6", "position": "right", "offsetY": 72 },
  "availability": { "online": false, "nextOpenAt": "2026-10-23T09:00:00+01:00" },
  "conversation": { "state": "awaiting_reply", "unread": 1 },
  "rules": [{ "id": "r_size", "urlPattern": "/p/*", "afterSeconds": 30 }] }

POST /v1/apps/app_123/session                              (messenger iframe)
{ "visitorId": "v_9f2c...", "identity": "eyJhbGciOiJIUzI1NiJ9...", "sinceSeq": 118 }
200 OK
{ "token": "st_...", "expiresIn": 900,
  "conversation": { "id": "c_77", "state": { "kind": "awaiting_reply", "email": "ana@example.com" } },
  "messages": [{ "seq": 119, "author": "agent", "text": "Hi Ana, ...", "at": "2026-10-22T18:40:00Z" }] }
Two boot calls. The launcher's is small, cacheable and holds nothing private; the messenger's carries the identity and the messages.

Errors follow the same shape. A 401 with reason: "identity_expired" makes the widget call the host's token callback once and retry; a second failure boots anonymously rather than showing an error to the visitor. A 403 with reason: "origin_not_allowed" means the snippet was copied onto a site the customer didn't list, and the widget stays hidden. Socket events reuse the chat app guide's envelope, with three support-specific types: conversation.state, queue.update and availability.changed.

Check your understanding · Exercise 10

Why is the agent's reply preview not drawn by the launcher in the host page? Select all that apply.

Running it in the interview

This question is a trap for candidates who start drawing a chat app. Spend the first 5 minutes on what makes it a widget: whose page it runs on, how few visitors open it, single-page or multi-page hosts, signed-in users, office hours. Say early that message delivery works like any chat and that you'll focus on the embedding.

Then draw the three layers (host page, our iframe, our servers) and the two-stage load with a KB budget, about 10 minutes. Spend the next 15 on the two topics that separate strong answers: signed identity with logout, and restoring across navigations and tabs without a reconnect storm. Use the last 10 for the bot to human state machine and the attention rules, and leave 5 for questions.

Follow-upStrong answer
The customer says the widget hurts their Lighthouse score.Two stages: a launcher of about 15 KB, messenger on intent; skeleton first on click; boot in small idle tasks; transform-only animations.
Can a visitor read someone else's conversation?Not with a server-signed, short-lived JWT for identified users and a server-issued session token for anonymous ones. The page's word is never trusted.
What happens on a shared computer?The host calls logout; identity is also declared on every boot, so a mismatch wipes the session and rotates the anonymous id.
Why not keep one socket open on every page?Most page views never need it. Catch up over HTTP on boot, connect only while someone waits for a human, and give presence a grace period.
How do you handle 3 tabs?One leader socket via Web Locks, events on a scoped BroadcastChannel; launcher-only tabs refetch the count when they become visible.
What if the bot can't answer at 2 am?The server switches the state to offline; the widget shows a form, collects an email if anonymous, and the reply arrives by email and in the widget.

Think first

The interviewer asks: if you had one more week, what would you measure first?

Check your understanding · Exercise 11

Which openings show the interviewer you understood this problem? Select all that apply.

Summary

  • Almost nobody opens the chat, but everybody loads the launcher: keep stage 1 around 15 KB and load the messenger on intent.
  • Never trust the page about who the visitor is: the host's server signs a short-lived token, and our server verifies it.
  • Logout must wipe identity, cache and anonymous id; declaring identity on every boot catches hosts that forget.
  • On multi-page hosts, restore open state, draft and scroll from storage before any network call, and connect only while someone waits for a human.
  • Share events between tabs on a channel scoped to app and visitor; badges in hidden tabs only need to be right when the tab becomes visible.
  • The server owns the conversation state machine: bot, waiting, with agent, offline, awaiting reply, resolved.
  • Signals scale with consent: badge always, title prefix while a human answers, notifications and sound only when asked for.
  • Conversation text never enters the host page; the host only learns that something is unread.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next