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
| Question | Answer this design assumes | Why 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 day | The 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 accounts | We 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 human | The 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 zone | Outside 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 apps | On a multi-page site the widget is destroyed and rebuilt on every page. |
| Mobile share? | More than half of visits on many shops | The 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.
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
| Topic | Where it's taught | What this guide adds |
|---|---|---|
Loader snippet, command queue, iframe vs shadow DOM, CSS isolation, postMessage, theming, versioning | Embeddable widget guide | Why a support launcher can't be a pure facade, and its KB budget |
| Loading on intent, CSP, focus and announcements across the frame | Embeddable widget guide | The chat's own early-load signals, and a silent restore after navigation |
| Third-party storage partitioning, CHIPS, the Storage Access API | Embeddable widget guide, client-storage lesson | Where one visitor id lives across a shop's subdomains and domains |
| Message list, optimistic sends, ordering, receipts, typing, presence | Chat app guide | Restoring the chat after each host page load without a reconnect storm |
| Queues, assignment and SLAs for agents | Support dashboard guide | What the visitor sees: queue position, expected wait, offline form |
Leader tab, BroadcastChannel, SharedWorker | Chat app guide, multi-tab sync guide | Tabs that have only the launcher, and events scoped to one visitor |
| Notification permission and push | Notifications center guide | Why the request must come from the host page, and when to stay quiet |
| Consent banner | Cookie consent guide | Loading nothing until consent when the widget sets identifiers |
| Streaming AI answers | LLM chat UI guide | The 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?
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
| Piece | When it loads | Budget (compressed) | Main-thread budget |
|---|---|---|---|
| Loader snippet (inline) | In the host's HTML | under 1 KB | under 1 ms |
| Loader script | async, after the host's own scripts | about 5 KB | under 10 ms |
| Launcher (button, badge, rules) | Right after the loader, at idle | about 10 KB | under 20 ms, split into small tasks |
| Boot API call | With the launcher | about 1 KB of JSON | none |
| Messenger (iframe app) | On intent | 150 to 250 KB | runs 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.
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:openflag 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?
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.
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);
}
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.
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.
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 | What the visitor sees | Composer | Real-time connection |
|---|---|---|---|
bot | Bot answers, suggested articles, a "Talk to a person" button | Enabled | Only while the panel is open |
waiting | "You're number 3 in line, usually under 5 minutes" | Enabled: messages go to the agent when assigned | Yes, even with the panel closed |
with_agent | Agent's name and avatar, typing indicator | Enabled | Yes |
offline | "We're away until 9:00 (your time). Leave a message and we'll email you." | Replaced by a short form: email and message | No |
awaiting_reply | "Got it. We'll reply here and at ana@example.com." | Enabled, adds to the same conversation | No; catch-up on boot |
resolved | "This conversation was closed" and a rating prompt | Enabled: a new message reopens it | No |
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?
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.
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";
}
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.
| Signal | Default | When it fires | Notes |
|---|---|---|---|
| Unread badge on the launcher | On | Any unread agent or bot message while the panel is closed | Also part of the launcher's accessible name |
| Reply preview bubble above the launcher | On | An agent replies while the panel is closed and the tab is visible | One bubble, replaced by newer replies; never stacks |
Tab title prefix, such as (1) Running shoes | On while waiting or with_agent | Tab hidden and an agent replied | A static prefix, not a flashing title; removed on visibilitychange |
| System notification | Off; customer can enable | Tab hidden or panel closed, agent replied, permission granted | Asked only after the visitor taps "Notify me" while waiting |
| Sound | Off | Agent replied and the visitor turned sound on | Browsers 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.
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 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.
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
visualViewportreports the visible area (inside an iframe it just mirrors the frame's own size), so the loader listens to itsresizeevent and sets the iframe's height tovisualViewport.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
popstatecloses the sheet instead of leaving the shop. Closing with the sheet's own button callshistory.back()to remove that entry, and the entry carries a marker inhistory.stateso 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)andenv(safe-area-inset-bottom)padding. This works only if the host's viewport meta tag hasviewport-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.
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 callsChat('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
buttonwitharia-expandedand an accessible name such as "Open support chat, 2 unread messages". The badge itself isaria-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.
| Part | Lives in | Responsible for |
|---|---|---|
| Loader | Host page, about 5 KB | Command queue, visitor id cookie, page rules, chat:open flag, mounting the launcher and iframe |
| Launcher | Host page, shadow root, about 10 KB | Button, badge, preview bubble, proactive rules, intent detection, title prefix |
| Messenger app | Our iframe | Conversation UI, state machine rendering, drafts, offline form, accessibility |
| Session | Our iframe, memory | Exchanges visitor id or identity JWT for a session token; wipes on logout |
| Sync layer | Our iframe | Boot catch-up, socket when needed, leader election, BroadcastChannel events |
| Local cache | Our iframe, IndexedDB and sessionStorage | Last messages, sequence numbers, drafts, scroll |
| Boot API | Our edge, CDN-cached config plus a per-visitor part | Config, 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.
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 };
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" }] }
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-up | Strong 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?
The cost the widget puts on host pages, from real users: launcher KB, long tasks we cause, and the INP of pages with and without the widget. That is what decides whether customers keep us installed.
Then the restore time after navigation and the number of sockets per active visitor, which show whether the reconnect storm is really gone.
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.
References
- Intercom JavaScript API: methods (boot, update, shutdown) Shutdown on logout, first-party cookies, update throttling.
- Intercom: authenticating users in the Messenger with JWTs
- Intercom: migrating from Identity Verification to Messenger security with JWTs HMAC user_hash versus expiring JWTs.
- Zendesk messaging Web Widget: authentication loginUser with a JWT callback, exp handling, logoutUser.
- Lighthouse: lazy load third-party resources with facades Named Intercom and Drift as facade candidates. Lighthouse 13 removed the audit; the pattern still applies.
- MDN: state partitioning Storage, BroadcastChannel and SharedWorker keyed by top-level site.
- Chrome: storage partitioning Partitioned storage, BroadcastChannel, SharedWorker and Web Locks since Chrome 115.
- WebKit: tracking prevention Ephemeral third-party storage, the 7-day cap on script-writable storage, CNAME cloaking defense.
- MDN: Notification.requestPermission() Ask from a user action; no requests from cross-origin iframes in Chrome and Firefox.
- MDN: VisualViewport Only the top-level window has a distinct visual viewport.
- web.dev: back/forward cache Never use unload; close open connections in pagehide.
- WAI-ARIA APG: dialog (modal) pattern
Concepts behind it
- Third-party scripts and supply chainEvery script you load from a vendor and every package you install runs with your page's full power, so you pick them carefully, pin and verify them, isolate what you can, and pay attention to what they cost in speed and privacy.
- Loading strategiesDeciding what the browser loads first, what it loads later, and what it loads only when needed, so the pixels that matter show up fast.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Syncing state across tabsEvery open tab of your app is a separate program with its own memory, so a change in one tab is invisible to the others until you send it across with the storage event, BroadcastChannel, a SharedWorker, or a leader tab chosen with Web Locks.
- Authentication and token storageHow a web app proves who the user is on every request after login: sessions or tokens, where the proof lives in the browser, how it is refreshed and revoked, and how OAuth with PKCE and OIDC fit in.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.