Web Development

Building an Embeddable AI Chat Widget: Streaming SSE, Angular Signals, and LLM Backends

DD
Ankur Ishwar
9 min read Updated Sep 7, 2026
Building an Embeddable Streaming AI Chat Widget with Angular Signals

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:

  1. The user clicks send and submits a text prompt (a standard HTTP POST request).
  2. 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/catch block 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.

Found this useful?
View all articles
Free Technical Interview Prep

Practicing for Engineering Interviews?

Skip the expensive coaching bootcamps and dry LeetCode memorization. Practice real production scenarios with instant turn-by-turn AI feedback on Frontend, Backend, System Design, and DSA.

Free Utilities

Recommended Developer Tools for this Topic

Explore all 25+ tools

Keep Reading

Related Articles

Learn with Dropout Developer

Build real software with AI

Step-by-step learning paths, vibe coding tutorials, and certified developer programs designed for the modern engineer.