# Pitfalls Research **Domain:** Blazor WebAssembly AI Chat Application (OpenAI GPT, JSON storage, streaming) **Researched:** 2026-03-27 **Confidence:** HIGH (multiple authoritative sources: official GitHub issues, Microsoft docs, verified community findings) --- ## Critical Pitfalls ### Pitfall 1: Streaming Silently Broken in Blazor WASM Without Custom Transport **What goes wrong:** OpenAI's .NET SDK streaming (`CompleteChatStreamingAsync`, returning `IAsyncEnumerable`) does not stream token-by-token in Blazor WASM. The entire response arrives at once after generation completes, making it appear like a non-streaming call. There is no error thrown — it just does not stream. This is confirmed in the official `openai-dotnet` GitHub issue tracker (#65). **Why it happens:** Blazor WASM uses a browser-based `HttpClient` backed by the Fetch API via JS interop. Response streaming requires explicitly calling `SetBrowserResponseStreamingEnabled(true)` on the underlying `HttpRequestMessage`. The OpenAI .NET SDK does not set this flag by default. Without it, the browser buffers the entire response body before exposing it to the .NET layer. **How to avoid:** Create a custom `HttpClientPipelineTransport` that overrides `OnSendingRequest` to enable browser streaming: ```csharp public class BlazorHttpClientTransport : HttpClientPipelineTransport { protected override void OnSendingRequest( PipelineMessage message, HttpRequestMessage httpRequest) { httpRequest.SetBrowserResponseStreamingEnabled(true); } } // Wire up at client construction: var options = new OpenAIClientOptions(); options.Transport = new BlazorHttpClientTransport(); var chatClient = new ChatClient(model: "gpt-4o", apiKey, options); ``` This must be done server-side (in the backend API that proxies to OpenAI), not in the WASM client directly. **Warning signs:** - Tokens appear all at once after a delay instead of progressively - No console errors — it looks like it is working but is not streaming - Local dev works "fine" because the full response still arrives, just not incrementally **Phase to address:** Phase that introduces streaming (SSE/token-by-token rendering). Must be addressed before any streaming demo is built. --- ### Pitfall 2: OpenAI API Key Exposed in Blazor WASM Client **What goes wrong:** A developer puts the OpenAI API key in `wwwroot/appsettings.json` or reads it from `IConfiguration` in a WASM component. The key is then downloadable by any browser visitor by requesting `/_framework/blazor.boot.json` or simply navigating to `wwwroot/appsettings.json` directly. The key is burned. **Why it happens:** Experienced C# developers coming from ASP.NET Core or console apps expect `appsettings.json` and `IConfiguration` to be server-side. In Blazor WASM, `wwwroot/appsettings.json` is a static file served to the browser — it is not protected in any way. User Secrets also do not help: they are embedded into the published bundle in plaintext. **How to avoid:** The OpenAI API key must live exclusively in the backend API (ASP.NET Core Minimal API or Web API project). The WASM client calls a backend endpoint (e.g., `/api/chat`) that holds the key and proxies requests to OpenAI. The key never appears in any client-side file or JS bundle. Use `dotnet user-secrets` on the server project only. **Warning signs:** - Any `IConfiguration["OpenAI:ApiKey"]` usage in a `.razor` file or service registered in the WASM `Program.cs` - `appsettings.json` in `wwwroot/` containing any token, key, or secret - The word `sk-` visible in browser DevTools → Network → response body for any `.json` file **Phase to address:** Phase 1 (project setup / architecture foundation). This must be locked in before any OpenAI code is written. --- ### Pitfall 3: Scoped DI Services Act as Singletons in WASM — State Leaks Across Conversations **What goes wrong:** A developer registers a `ConversationService` or `ChatStateService` as `Scoped`, expecting it to reset between logical "sessions" like it would in ASP.NET Core (per-request). In Blazor WASM there is exactly one DI scope for the lifetime of the browser tab. The service never resets. All conversations accumulate state in a single object, producing corrupted cross-conversation history. **Why it happens:** In ASP.NET Core, Scoped = per HTTP request. In Blazor WASM, Scoped = per application lifetime (equivalent to Singleton). There is no shorter-lived scope unless you use `OwningComponentBase`. Developers familiar with server-side DI expect different behavior. **How to avoid:** - Understand that in WASM, `Scoped` and `Singleton` are functionally identical - For services that manage per-conversation state, design them to hold a collection keyed by conversation ID rather than holding mutable "current conversation" state - If a service must be component-scoped, inherit from `OwningComponentBase` which creates a DI scope tied to the component's lifetime - Never store mutable "active session" state in a scoped/singleton service; store a dictionary of `ConversationId → ConversationState` **Warning signs:** - Switching conversations causes the wrong history to appear - Deleting a conversation does not fully clear its state from memory - Services have fields like `CurrentConversation` or `ActiveMessages` rather than `Dictionary` **Phase to address:** Phase introducing conversation state management and multi-conversation switching. --- ### Pitfall 4: `StateHasChanged` Not Called During Token Streaming — UI Freezes Until Completion **What goes wrong:** The developer wires up streaming correctly (transport fixed, backend proxying works) but the UI does not update token-by-token. The message bubble stays empty until all tokens arrive, then the entire response appears at once. This is indistinguishable from the streaming transport bug (Pitfall 1) if not diagnosed carefully. **Why it happens:** Blazor does not automatically re-render after every `await` inside an `async` event handler or lifecycle method. When consuming an `IAsyncEnumerable` (streaming tokens), the component must explicitly call `StateHasChanged()` after appending each token. Without this call, Blazor batches rendering and only repaints when the entire method completes. **How to avoid:** ```csharp await foreach (var token in streamingResponse) { currentMessage += token; StateHasChanged(); // required — Blazor will not re-render otherwise await Task.Yield(); // prevents UI thread starvation on rapid token delivery } ``` Additionally, consider throttling `StateHasChanged` calls (e.g., every 50ms or every N tokens) to avoid excessive rendering if token delivery is very fast. **Warning signs:** - Streaming transport is confirmed working (via backend logs) but UI still shows nothing until complete - Token-by-token updates visible in server logs but not in the browser - Removing `await Task.Yield()` causes the browser tab to become unresponsive during streaming **Phase to address:** Streaming UI rendering phase. Document this explicitly inline in the component code. --- ### Pitfall 5: JSON File Storage Architecture Assumes Server Filesystem — Not Viable Pure Client-Side **What goes wrong:** A developer writes file I/O code (`File.ReadAllText`, `File.WriteAllText`) directly in the WASM project. The code compiles without error but throws at runtime because Blazor WASM runs in a browser sandbox with no access to the host filesystem. The virtual WASM filesystem resets on every page refresh. **Why it happens:** C# file APIs exist in the WASM .NET runtime but map to an in-memory virtual filesystem, not the OS disk. Developers coming from console or desktop C# assume `File.WriteAllText("conversations.json", json)` writes to disk. It does not — the data vanishes on refresh. **How to avoid:** JSON file storage must live in the backend API (server-side), not in the WASM client. The correct architecture: - WASM client calls `POST /api/conversations` → backend writes JSON to disk - WASM client calls `GET /api/conversations` → backend reads JSON from disk and returns it - Backend stores files in a configurable local path (e.g., `~/chat-data/`) This reinforces the same architectural boundary required for API key protection (Pitfall 2). **Warning signs:** - Any `System.IO.File` or `System.IO.Directory` usage inside the WASM project (`Client/`) - Conversations persist during a session but disappear on browser refresh - Data is present in the WASM virtual FS (`MemoryFileSystem`) but absent from the OS **Phase to address:** Phase 1 (architecture setup). The WASM/backend split must be established before any persistence code is written. --- ### Pitfall 6: IL Trimming Silently Breaks Code in Release Builds **What goes wrong:** The app works perfectly in `dotnet run` (Debug) but breaks in `dotnet publish` (Release). JSON serialization loses properties, services cannot be resolved, or features silently stop working. No exceptions in development, cryptic failures in production. **Why it happens:** Blazor WASM uses aggressive IL trimming (ILLink) during publish to reduce bundle size. The trimmer performs static analysis and removes types/methods that appear unreachable — including types used only via reflection (JSON serialization, DI, JSInterop callbacks). Debug builds do not trim. **How to avoid:** - Use `[JsonSerializable]` with `System.Text.Json` source generation for all DTO types - Apply `[DynamicDependency]` to methods called via reflection - Apply `[JSInvokable]` to all methods callable from JavaScript - Run `dotnet publish` early in the project (Phase 1 or 2) to detect trim warnings while the surface is small - Treat `` as a last resort, not a first step **Warning signs:** - App works in `dotnet run` but throws `NullReferenceException` or loses data after `dotnet publish` - JSON responses missing properties that were present in debug - IL trimmer warnings during publish that were ignored **Phase to address:** Phase 1 (publish pipeline verification). Also Phase covering JSON data models for conversations. --- ## Technical Debt Patterns | Shortcut | Immediate Benefit | Long-term Cost | When Acceptable | |----------|-------------------|----------------|-----------------| | Put API key in WASM `appsettings.json` | Faster to get OpenAI call working | Key is permanently burned; must rotate | Never | | Call OpenAI directly from WASM HttpClient | Eliminates backend project | API key exposed; no server-side rate limiting; blocks v2 RAG/LangChain which needs server | Never | | Write file I/O in WASM project | Familiar C# patterns | Silent data loss on refresh; hard to migrate later | Never | | All logic in `.razor` files | Faster iteration in early phases | Untestable; components become unmaintainable; hard to add v2 agent layer | Phase 1 only, refactor before Phase 3 | | Single `ChatService` singleton holding all state | Simple to start | State leaks across conversations; breaks multi-conversation feature | Never in this project — multi-conversation is a core requirement | | Skip `StateHasChanged` calls during streaming | Code is simpler | UI appears broken; streaming appears non-functional | Never | | Skip IL trim testing until "done" | Saves time during early phases | Trim bugs compound as codebase grows | Acceptable in Phase 1-2 if `dotnet publish` test is added to Phase 3 | --- ## Integration Gotchas | Integration | Common Mistake | Correct Approach | |-------------|----------------|-----------------| | OpenAI .NET SDK + Blazor WASM | Using SDK directly in WASM project without custom transport — streaming silently broken | Custom `BlazorHttpClientTransport` with `SetBrowserResponseStreamingEnabled(true)` on backend; or server-side only call | | OpenAI streaming via backend proxy | Backend returns full `StreamingChatCompletionUpdate` objects — client gets batched response | Backend uses `IAsyncEnumerable` with `[EnumeratorCancellation]`; streams SSE or NDJSON to client | | CORS between WASM client and backend API | Forgetting `AddCors` + `UseCors` on backend; 403/CORS errors during local dev | Configure CORS policy explicitly in backend `Program.cs`; scope to localhost dev origins | | JSON serialization of conversation models | Properties stripped by trimmer in Release; conversation history loses fields | Use `System.Text.Json` source generators with `[JsonSerializable]` for all model types | | Markdown rendering (AI responses) | Using `MarkupString` with raw AI output — XSS risk if AI returns script tags | Use a library like `Markdig` server-side, or a client-side library with HTML sanitization enabled | --- ## Performance Traps | Trap | Symptoms | Prevention | When It Breaks | |------|----------|------------|----------------| | Calling `StateHasChanged` on every token without throttling | Browser tab becomes unresponsive during fast streaming; CPU spikes | Throttle to every 50ms or every N tokens using a timer or counter | At ~20+ tokens/second (typical GPT-4o speed) | | Re-rendering entire conversation list on every message append | Visible flicker; full list DOM re-created on each token | Use `@key` directive on conversation list items; isolate streaming component from sidebar | At 5+ conversations in the list | | Loading all conversation history on app start | Slow initial load for users with many old conversations | Lazy-load conversation content; sidebar shows metadata only; load full messages on selection | At 50+ conversations stored in JSON | | Excessive component nesting for chat messages | Sluggish scroll performance with many messages | Keep message list in a single component with virtualization (`Virtualize`) for long histories | At 200+ messages in a single conversation | --- ## Security Mistakes | Mistake | Risk | Prevention | |---------|------|------------| | OpenAI API key in `wwwroot/appsettings.json` | Key visible to any browser user; unlimited API charges | Key lives only in backend server project; accessed via `dotnet user-secrets` or environment variable | | Calling OpenAI API directly from WASM (no backend) | Same as above; also bypasses rate limiting and logging | Mandatory backend proxy — this is enforced by the architecture from Phase 1 | | Rendering AI response HTML without sanitization | AI model could produce `