The 400KB Widget Problem
During my first freelance web development contract, the client insisted on adding an AI assistant widget to their homepage. I checked the market leaders. You already know the names: commercial support tools asking for $50 to $100 every single month per seat. If you are an Indian startup or an independent business owner, paying ₹8,000 every month just for a chat bubble hurts.
Even worse was what happened when I tested their snippet. Dropping their official script tag into index.html pulled down 450KB of minified JavaScript, 14 different tracking pixels, and three third-party fonts. Our mobile PageSpeed score dropped from 94 down to 58 on a standard 4G connection.
The solution was simple: build our own embeddable widget. With modern Angular Signals and a lightweight Node.js streaming proxy, you can build a production assistant that weighs under 18KB, streams tokens instantly, and costs you only fractions of a cent per conversation.
Why Server-Sent Events Beats WebSockets for AI Chat
When developers decide to build a chat interface, their first instinct is often to install Socket.io or configure raw WebSockets. That is usually a mistake for LLM applications.
WebSockets are bidirectional, which is necessary if both sides are constantly pushing events back and forth (like in a multiplayer chess game or live crypto ticker). But with an AI chatbot, the interaction pattern is strictly request-and-stream:
- The user clicks send and submits a text prompt (a standard HTTP POST request).
- The server streams back tokens one by one as the language model generates them.
For this workflow, Server-Sent Events (SSE) over HTTP/2 is vastly simpler and more reliable:
| Feature | Server-Sent Events (SSE) | WebSockets |
|---|---|---|
| Transport Protocol | Standard HTTP/1.1 or HTTP/2 | TCP upgrade handshake (ws:// or wss://) |
| Infrastructure Cost | Runs behind free Cloudflare CDN proxies | Requires persistent socket servers and Redis pub/sub |
| Corporate Firewalls | Traverses standard HTTPS ports cleanly | Frequently blocked on office and university WiFi |
| Browser Reconnection | Native browser retry logic | Manual reconnection timers required |
Frontend Implementation: Angular Standalone Signals Component
Here is the complete frontend component. It uses Angular standalone architecture, Angular Signals for fine-grained DOM updates without zone pollution, and the native fetch ReadableStream API to render tokens as they arrive:
// src/app/chat-widget/chat-widget.component.ts
import { Component, signal, ViewChild, ElementRef, afterNextRender } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: number;
}
@Component({
selector: 'app-chat-widget',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="chat-box fixed bottom-6 right-6 w-96 bg-white dark:bg-slate-900 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-800 flex flex-col h-[520px] overflow-hidden z-50">
<!-- Header -->
<div class="px-4 py-3 bg-slate-900 text-white flex justify-between items-center">
<div class="flex items-center space-x-2">
<span class="w-2.5 h-2.5 rounded-full bg-emerald-400 animate-pulse"></span>
<h3 class="font-semibold text-sm">Dropout Assistant</h3>
</div>
</div>
<!-- Message History -->
<div #scrollContainer class="flex-1 p-4 overflow-y-auto space-y-4">
<div *ngFor="let msg of messages()" [ngClass]="msg.role === 'user' ? 'justify-end' : 'justify-start'" class="flex">
<div [ngClass]="msg.role === 'user' ? 'bg-blue-600 text-white' : 'bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-slate-100'" class="max-w-[85%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap">
{{ msg.content }}
</div>
</div>
</div>
<!-- Input Bar -->
<form (submit)="sendMessage()" class="p-3 border-t border-slate-200 dark:border-slate-800 flex items-center gap-2">
<input [(ngModel)]="currentInput" name="prompt" placeholder="Ask about coding or architecture..." [disabled]="isStreaming()" class="flex-1 bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
<button type="submit" [disabled]="isStreaming() || !currentInput.trim()" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-xl text-sm font-medium transition-colors disabled:opacity-50">Send</button>
</form>
</div>
`
})
export class ChatWidgetComponent {
@ViewChild('scrollContainer') scrollContainer!: ElementRef<HTMLDivElement>;
messages = signal<ChatMessage[]>([
{ id: '1', role: 'assistant', content: 'Hey there! How can I help with your code today?', timestamp: Date.now() }
]);
currentInput = '';
isStreaming = signal(false);
constructor() {
afterNextRender(() => {
this.scrollToBottom();
});
}
async sendMessage(): Promise<void> {
const text = this.currentInput.trim();
if (!text || this.isStreaming()) return;
const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', content: text, timestamp: Date.now() };
this.messages.update((prev) => [...prev, userMsg]);
this.currentInput = '';
this.isStreaming.set(true);
const assistantMsgId = crypto.randomUUID();
this.messages.update((prev) => [...prev, { id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now() }]);
try {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: text }),
});
if (!response.ok || !response.body) throw new Error(`HTTP error: ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let partialChunk = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
partialChunk += decoder.decode(value, { stream: true });
this.messages.update((list) =>
list.map((m) => m.id === assistantMsgId ? { ...m, content: partialChunk } : m)
);
this.scrollToBottom();
}
} catch (err) {
this.messages.update((list) =>
list.map((m) => m.id === assistantMsgId ? { ...m, content: 'Network glitch. Please try asking again in a moment.' } : m)
);
} finally {
this.isStreaming.set(false);
}
}
private scrollToBottom(): void {
if (this.scrollContainer?.nativeElement) {
this.scrollContainer.nativeElement.scrollTop = this.scrollContainer.nativeElement.scrollHeight;
}
}
}
If you are new to modern Angular components, you can review our Angular developer roadmap to see how signals replaced older Zone.js boilerplate.
The Backend Streaming Proxy: Guarding Your API Keys
Never call OpenAI, Anthropic, or Groq directly from the browser. If you put an API key in client-side JavaScript, anyone can open DevTools Network tab, copy your key, and run up thousands of dollars on your credit card.
Always route chat requests through a small backend proxy server. Here is a clean Node.js implementation using Fastify:
// server/routes/chat.ts
import { FastifyPluginAsync } from 'fastify';
import OpenAI from 'openai';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export const chatRoutes: FastifyPluginAsync = async (fastify) => {
fastify.post('/api/chat/stream', async (request, reply) => {
const { prompt } = request.body as { prompt: string };
if (!prompt || prompt.length > 1000) {
return reply.status(400).send({ error: 'Prompt must be between 1 and 1000 characters.' });
}
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
const stream = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: 'You are an authentic, friendly developer assistant. Give direct, practical code answers.' },
{ role: 'user', content: prompt }
],
stream: true,
});
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content ?? '';
if (delta) {
reply.raw.write(delta);
}
}
reply.raw.end();
});
};
Three Production Lessons I Learned the Hard Way
When you take a chat widget from your local laptop to production, you will encounter edge cases that tutorials never mention:
- Mobile Safari Background Sleep: When an iPhone user switches to WhatsApp while waiting for an AI response, Safari immediately freezes the TCP connection. When they switch back, the stream throws an abort error. Handle this in your frontend
try/catchblock by preserving whatever tokens were already received instead of clearing the message bubble. - Token Counting and Cost Caps: If an automated bot finds your endpoint, it can submit huge prompts every second. Use a token counter like our free token counter tool to estimate prompt costs and enforce strict IP rate limiting with Redis.
- Auto-Scrolling Annoyance: If a user scrolls up to read the top of an answer while the model is still typing, forced auto-scrolling will yank their screen down repeatedly. Only call
scrollToBottom()if the user was already within 50 pixels of the bottom.
The Bottom Line
You do not need to spend monthly venture-backed SaaS dollars on third-party chat widgets. By writing forty lines of Angular frontend code and twenty lines of Node.js backend proxy code, you own your data, protect your keys, and keep your website blazing fast.
Clone the component into your project, connect your API key, and test it on your local server today.
