Loading...
Loading...
Streaming tokens, context, cost, safety, and fallbacks for AI-powered UI.
Module 24 · AI Features in the Frontend
Streaming LLM tokens, context window management, cost controls, content safety, fallbacks, and observability for AI-powered UI.
Streaming LLM tokens, context window management, cost controls, content safety moderation, fallbacks, and AI feature observability.
AI features introduce new frontend challenges: token streaming, context limits, cost visibility, output moderation, and graceful degradation when models fail.
This module covers ReadableStream token rendering, server-side context assembly, credit/rate-limit UX, input/output moderation, fallback models, and TTFT observability.
Staff interviews ask how you ship AI chat without XSS, runaway costs, or infinite spinners.
Staff track module — pairs with Frontend Reliability (Module 19) for circuit breakers and fallbacks.
AI features are now staff-level system design topics — interviewers test streaming UX, cost boundaries, safety guardrails, and fallback when models fail.
Streaming UX, safety, cost, and human-in-the-loop at scale.
AI-powered UI is not a chat box bolted on — staff engineers design streaming experiences, context budgets, safety guardrails, fallbacks, and human review loops that keep latency, cost, and trust under control.
The frontend owns three budgets the model provider will never manage for you: time-to-first-token (TTFT), tokens per session, and the blast radius when the provider degrades. Treat the model as an unreliable third-party dependency with a per-call price tag, not as a database.
| Budget | Target | Owner |
|---|---|---|
| TTFT | p75 < 800ms; p95 < 2s | Frontend + gateway |
| Tokens / session | < 8k in, < 2k out for chat | Feature team |
| Cost / MAU | < $0.15 for a free tier assistant | Feature + finance |
| Stream completion rate | > 98% of started streams finish | Frontend + SRE |
| Unsafe output rate | < 0.01% of responses shipped to user | Trust & safety |
Ownership boundary
SSE, WebSockets, and progressive rendering.
// Streaming handler: rAF-batched append + abort + resume offset export async function streamChat( prompt: string, onFlush: (text: string) => void, signal: AbortSignal, ): Promise<{ text: string; finished: boolean }> { const res = await fetch("/api/chat", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt }), signal, }); if (!res.ok || !res.body) throw new Error(`chat_${res.status}`); const reader = res.body.pipeThrough(new TextDecoderStream()).getReader(); let text = ""; let pending = ""; let frame = 0; const flush = () => { frame = 0; if (!pending) return; text += pending; pending = ""; onFlush(text); // one React state write per animation frame }; try { while (true) { const { done, value } = await reader.read(); if (done) break; pending += value; frame ||= requestAnimationFrame(flush); } flush(); return { text, finished: true }; } catch (err) { cancelAnimationFrame(frame); flush(); // Partial text is still useful — render it with a Resume affordance. if ((err as Error).name === "AbortError") return { text, finished: false }; throw err; } finally { reader.releaseLock(); } }
| Transport | Use when | Cost of choosing it |
|---|---|---|
| SSE over fetch | One-way tokens, HTTP/2, simple retry | No binary frames; proxies may buffer |
| WebSocket | Duplex — voice, live cursors, interrupts | Sticky sessions, own heartbeat and reconnect |
| Long poll | Legacy proxies strip streaming | TTFT becomes total latency |
| RSC / streamed HTML | Server-rendered answer, no client state | Hard to cancel mid-stream |
Failure mode
What to send when tokens are expensive and limited.
A context window is a budget, and the frontend spends it. A 128k window sounds infinite until you notice that 60k input tokens cost roughly 20x a 3k request and add ~400ms of prefill latency. Decide explicitly what earns a slot.
| Strategy | Use | Trade-off |
|---|---|---|
| Sliding window | Recent N messages (typically 8–12 turns) | Loses early constraints the user stated once |
| Rolling summary | Compress turns older than N into ~300 tokens | Summary drift; one bad summary poisons the thread |
| RAG retrieval | Top-k chunks (k = 4–8, 500 tokens each) | Adds 80–250ms retrieval; recall misses look like amnesia |
| Prompt caching | Static system prompt + tool schemas | Provider-specific; cache breaks on any prefix edit |
| Structured state | Send a JSON entity snapshot, not chat scrollback | Requires product modelling; best fidelity per token |
// Deterministic budgeter: never let the request exceed the window const BUDGET = { system: 1_200, retrieved: 3_000, history: 3_500, reply: 1_024 }; export function buildMessages(thread: Turn[], docs: Chunk[]): Message[] { const retrieved = takeWhileUnder(docs, BUDGET.retrieved, (d) => d.tokens); const recent = takeFromEndUnder(thread, BUDGET.history, (t) => t.tokens); const dropped = thread.length - recent.length; return [ { role: "system", content: SYSTEM_PROMPT }, // stable prefix => cacheable ...(dropped > 0 ? [{ role: "system" as const, content: summaryFor(thread.slice(0, dropped)) }] : []), { role: "system", content: renderCitations(retrieved) }, ...recent.map(toMessage), ]; }
Failure mode
Model routing, caching, and perceived performance.
| Tier | Routed work | TTFT | Relative cost |
|---|---|---|---|
| Rules / regex | Greetings, unit conversion, canned help | < 20ms | 0x |
| Small model | Classification, rewrite, autocomplete | 150–350ms | 1x |
| Mid model | Default chat, summarize, extract | 400–900ms | 8–12x |
| Frontier model | Multi-step reasoning, code generation | 1.2–3s | 40–60x |
| Human | Refunds, legal, anything irreversible | minutes | ~$3 / contact |
// Three-layer cache in front of the gateway const exact = await kv.get(`ai:v3:${hash(model + promptPrefix + input)}`); if (exact) return { ...exact, cache: "exact" }; // ~0ms, ~35% hit const near = await vectorCache.query(embedding, { minScore: 0.94 }); if (near) return { ...near.value, cache: "semantic" }; // ~25ms, +10% hit const answer = await gateway.complete({ model: route(intent), messages }); await Promise.all([kv.set(key, answer, { ttl: 3600 }), vectorCache.put(embedding, answer)]); return { ...answer, cache: "miss" };
Reversibility
Input validation, output filtering, and policy enforcement.
| Threat | What the user sees | Control |
|---|---|---|
| Prompt injection via retrieved doc | Assistant leaks another tenant's data | Never trust retrieved text as instructions; tag it as data |
| PII in prompt | Support ticket with a card number in logs | Redact client-side, hash before logging, no raw prompt storage |
| Unsafe token mid-stream | Harmful sentence already painted | Chunk-level moderation + client rollback of the last buffer |
| Markdown/HTML injection | Rendered link exfiltrates the session | Sanitize, allowlist tags, block javascript: and data: URLs |
| Tool call abuse | Model deletes a record unprompted | Allowlist tools, require confirmation for write scopes |
// Never dangerouslySetInnerHTML raw model output. import DOMPurify from "dompurify"; const SAFE = { ALLOWED_TAGS: ["p", "ul", "ol", "li", "code", "pre", "strong", "em", "a", "h3"], ALLOWED_ATTR: ["href", "class"], ALLOWED_URI_REGEXP: /^(https?:|mailto:|#|\/)/i, }; export function renderAnswer(markdown: string): string { return DOMPurify.sanitize(markdownToHtml(markdown), SAFE); } // Stream-side: moderate each flushed buffer, not just the final text. if (verdict.blocked) { controller.abort(); ui.replaceWith(refusal(verdict.category)); // roll back painted tokens audit.write({ promptHash, category: verdict.category, action: "blocked" }); }
When the model fails, users still need an answer.
Model providers have worse availability than your own API tier. Design the assistant so a full provider outage degrades the surface rather than breaking the product — the blast radius should stop at one panel.
| Failure | Fallback UX | Blast radius |
|---|---|---|
| API timeout (> 20s) | Retry with backoff; offer cached FAQ answer | One message |
| 429 rate limited | Queue with position and ETA; disable send | One tenant |
| Model refusal | Explain category; suggest rephrase; link docs | One message |
| Stream interrupted | Keep partial text + Resume from offset | One message |
| Provider region down | Failover to secondary provider at lower quality | All AI surfaces |
| Gateway down | Kill switch hides entry points; classic search remains | AI features only |
// Breaker + provider failover, evaluated per surface const breaker = getBreaker("ai:assistant"); // opens after 5 failures / 30s if (breaker.isOpen()) return { mode: "keyword-search", reason: "breaker_open" }; for (const provider of ["primary", "secondary"] as const) { try { const out = await withTimeout(complete(provider, req), 20_000); breaker.recordSuccess(); return { mode: "ai", out, provider }; } catch (err) { breaker.recordFailure(); if (!isRetryable(err)) break; } } return { mode: "keyword-search", reason: "all_providers_failed" };
Migration path
Review, approve, and correct high-stakes AI output.
| Action class | Autonomy | UI contract |
|---|---|---|
| Read / summarize | Full auto | Citations with jump-to-source |
| Draft content | Auto with review | Editable draft, never auto-send |
| Reversible write | Auto + undo window | Toast with Undo for 10s, audit entry |
| Irreversible write (refund, delete) | Explicit confirm | Diff preview + typed confirmation |
| Regulated decision (credit, hiring) | Human decides | AI shown as advisory only, reason codes logged |
Compliance
Trace prompts, latency, cost, and quality.
// One low-cardinality event per turn — joins cost, latency, and quality rum.track("ai_turn", { feature: "support_assistant", model: "mid-v4", prompt_version: "sys-2026-07-11", cache: "semantic", // exact | semantic | miss ttft_ms: 640, total_ms: 4_180, tokens_in: 3_412, tokens_out: 517, cost_usd: 0.0091, outcome: "completed", // completed | aborted | blocked | error edited_before_send: true, });
| Signal | Alert threshold | Likely cause |
|---|---|---|
| TTFT p95 | > 3s for 10 min | Provider degradation or oversized context |
| Stream completion rate | < 95% | Proxy buffering, idle timeouts, mobile networks |
| Cost per 1k sessions | 2x week-over-week | Prompt bloat, cache key churn, retry storm |
| Cache hit rate | Drops below 30% | Prompt version bumped and never re-warmed |
| Thumbs-down rate | > 8% of rated turns | Model or retrieval regression |
The staff question is rarely whether the feature works in a demo. It is what happens in week three when usage is 20x the estimate and a prompt change doubled input tokens overnight.
| Stage | Exposure | Gate to advance |
|---|---|---|
| Internal dogfood | Employees only | Golden-set eval pass, zero unsafe outputs |
| Design partners | 5–10 accounts | Thumbs-down < 10%, TTFT p75 < 1s |
| 1% public | Sticky bucket by user id | Cost per session within 1.5x model, no SLO burn |
| 25% | Region by region | Support ticket rate flat, cache hit > 30% |
| 100% + holdout | 5% permanently without AI | Measures real lift instead of novelty |
Interview framing
AI frontend design at staff depth.
AI features in frontend interview questions.
Test your understanding — 5 questions
Users describe your assistant as slow even though median end-to-end response time is unchanged after an optimization pass. Which metric should you have optimized?