Web Development

Building a live chat widget with Socket.io

DEV

A retail client wanted a live chat widget so shoppers could reach a support agent directly from the product page, without the delay and generic branding of an off-the-shelf chat widget. Since we already had a Node and Express backend for the storefront, adding Socket.io on top made more sense than bolting on a third-party service that would have meant another vendor relationship, another script tag slowing the page down, and a support experience that looked nothing like the rest of the site.

Why we didn't reach for a third-party widget first

The usual pitch for an off-the-shelf chat widget is that it is faster to ship, and for a client with no existing backend that is often true. This client already had a Node and Express API serving the storefront, session handling already in place, and a support team who wanted the chat transcript to live alongside the order data they already had, not in a separate vendor's dashboard. Building it ourselves meant one more system to maintain, but it also meant chat history that could be queried against orders, support availability that could be driven by the same staff scheduling data as everything else, and no monthly per-agent licensing fee that scales awkwardly as the support team grows.

The setup

  • A Socket.io server running alongside the existing Express app, sharing the same session middleware so we know who is chatting without a second login
  • Rooms per conversation, so a support agent can be in several chats at once without message bleed between customers
  • A simple "agent available" presence indicator, broadcast to all connected shoppers so the widget can show a live or offline state rather than always inviting a chat nobody will answer
  • Message history persisted to MongoDB so a refreshed page does not lose the conversation, and so a returning shopper's prior conversation is available to the next agent who picks it up

Getting the transport layer right

Socket.io's automatic fallback to long-polling meant we did not have to worry about the handful of shoppers behind corporate proxies that block raw WebSocket connections — it just downgrades transparently, and we confirmed this behavior deliberately by testing against a locked-down network configuration before launch rather than trusting it in theory alone. That single test caught a subtle issue: our load balancer's sticky-session configuration was not actually sticky for the long-polling fallback path, which meant a shopper's polling requests could bounce between two different Node instances mid-conversation, each with no idea the other existed. We fixed it by making the load balancer route on a cookie set at the first handshake rather than relying on its default round-robin behavior, and we now treat that as a standard checklist item on any future Socket.io deployment.

Scaling beyond one process

Scaling beyond a single Node process needs the Redis adapter to keep sockets in sync across instances, which we set up from day one even though the client's current traffic does not require it, because retrofitting that later would have meant a much bigger migration once real conversation data and connection state already existed in a single-instance-assuming shape. The Redis adapter works by publishing every emitted event to a shared Redis channel that every Node instance subscribes to, so a message sent by a shopper connected to instance A still reaches an agent connected to instance B without either side needing to know which instance the other is on. It adds a small amount of latency per message — low single-digit milliseconds in our testing — which is completely invisible in a human conversation but worth knowing about if you were ever tempted to use the same pattern for something latency-sensitive.

Reconnection handling needed more care than expected

A shopper's phone locking mid-conversation, or a brief network blip, triggers a disconnect that Socket.io handles gracefully on the transport level, but we still had to build our own logic for re-syncing any messages sent while disconnected, since the client-side message list otherwise silently missed anything that arrived during the gap. Our fix was simple in hindsight: every message carries a monotonically increasing sequence number per conversation, and on reconnect the client tells the server the last sequence number it saw, so the server can replay anything missed rather than assuming the client's local state is complete. Before we added this, a shopper's own message could occasionally appear to vanish if their phone locked in the moment right after they hit send, which is about the worst possible failure mode for a support tool meant to build trust.

Building the agent side

The support-side dashboard needed its own set of considerations that had nothing to do with the shopper-facing widget. An agent handling four or five simultaneous conversations needs a clear visual signal for which conversation just received a new message, sound notifications that do not become obnoxious during a busy shift, and a way to mark a conversation resolved without losing its history. We also added a basic canned-response feature after watching agents retype the same shipping-policy answer dozens of times a day, which turned out to be a bigger time saver for the support team than anything on the real-time messaging side itself.

Security and abuse considerations

Because the widget accepts input from anonymous shoppers, we rate-limited message sends per socket connection to prevent a single bad actor from flooding an agent's queue, and we sanitize message content before storing or rendering it to prevent a shopper from pasting markup that could execute in an agent's browser. None of this is exotic, but it is easy to skip when a real-time feature feels like an internal tool rather than a public-facing surface that anyone can connect to.

Results

The whole widget, including the support-side dashboard for agents to manage multiple simultaneous conversations, took about a week to build and has held up well through a full holiday shopping season, including a couple of days with meaningfully higher concurrent chat volume than the client's typical traffic, without any stability issues surfacing. Average first-response time from an agent has stayed well under the client's target, and the support team has told us the ability to see order history alongside a live chat has cut the average conversation length noticeably, since an agent no longer has to ask a shopper to repeat their order number before helping them.

Testing under real network conditions

Before launch we deliberately tested the widget on a throttled connection profile simulating a slow 3G handoff, since a meaningful share of the client's mobile shoppers are on older devices with patchy connectivity in-store while comparing prices. The widget degraded acceptably — messages queued locally and sent once the connection recovered rather than silently failing — but it took two rounds of adjustment to get there. Our first attempt showed a shopper's message as "sending" indefinitely if the connection dropped mid-send, with no visible retry, which is a worse experience than an honest failure message would have been. We now show a clear "message will send when you're back online" state instead of a spinner that never resolves.

Cost considerations that mattered to the client

The client cared about ongoing cost as much as the initial build, since the alternative they were comparing against charged per agent seat every month regardless of how much the agents actually used it. Running our own Socket.io server on infrastructure the client already paid for meant the marginal cost of the chat feature itself was close to zero beyond the one-time build cost, and it will not creep upward as the support team grows the way a per-seat vendor pricing model would have.

What we would build differently next time

If we were starting this again, we would add typing indicators from day one rather than as a later addition — support agents specifically asked for this within the first week of using the tool, since not knowing whether a shopper is still composing a reply or has abandoned the conversation made it harder to judge when to send a gentle follow-up message. We also underestimated how much agents would want basic conversation tagging (billing question, sizing question, shipping question) for their own reporting purposes, which we added a few weeks after launch and which now feeds a simple report the client's support manager checks weekly.

Handoff between agents

One scenario we had not fully planned for during the build: a conversation started with one agent needing to be handed off to another, either because the first agent's shift ended or because the question needed a specialist's answer. We added an explicit "transfer conversation" action that moves a room's ownership to a different agent and posts a small system message in the transcript noting the handoff, so the shopper is not confused when a different name suddenly starts responding, and so a transferred conversation still keeps its full history intact for the new agent to review before jumping in.

← Back to the journal

Have a project in mind?
Let’s talk.

Tell us where you are and where you want to go. We'll map the fastest route between the two.

Currently accepting new clients