Summary
Frontend coding round at OpenAI for a frontend engineer role. The whole thing was one meaty component design question about building a streaming chat UI, with a bunch of follow-ups that got progressively more painful the further in you went.
Questions Asked(8)
The state model tripped me up at first.
Suggested Approach
Start by outlining the component hierarchy and data flow before diving into implementation details, demonstrating systems thinking. Then walk through the streaming mechanism using the Fetch API with ReadableStream or EventSource, explaining how incremental state updates drive re-renders. Conclude by addressing edge cases like error handling, abort control, and UX considerations such as auto-scrolling and loading indicators.
Define Component Structure
Sketch a clear hierarchy: a top-level ChatContainer managing state, a MessageList rendering conversation history, a MessageBubble for individual messages, a StreamingMessage component for the in-progress assistant response, and a PromptInput for user submission. Keeping streaming logic isolated in StreamingMessage prevents unnecessary re-renders of the full list.
Design State Management
Use a messages array in state (each with role, content, and optional isStreaming flag) plus a separate streamingContent string for the active response being built. Separating committed messages from the live stream avoids mutating history mid-flight and makes the data model predictable.
Implement the Streaming Fetch
Use fetch() with the OpenAI Chat Completions API in streaming mode, reading response.body as a ReadableStream and decoding chunks with TextDecoder. Parse SSE-formatted data lines, extract delta content, and append each token to a ref-backed accumulator before syncing to state.
Handle Incremental Rendering & UX
Flush the accumulated ref value to state on each chunk (or batched via requestAnimationFrame for performance), triggering a re-render of only the StreamingMessage component. Add auto-scroll-to-bottom behavior using a useEffect on message updates, a visible cursor/blinking indicator during streaming, and disable the input while a response is in flight.
Address Error Handling & Cleanup
Attach an AbortController to the fetch so the user can cancel mid-stream, and clean up in a useEffect return. Handle network errors, malformed chunks, and the [DONE] SSE sentinel gracefully, finalizing the streamed content into the messages array and resetting streaming state.
Key Points to Mention
Disabling the button is the obvious part.
Suggested Approach
Frame your answer around two distinct problems: preventing new duplicate submissions from being triggered, and gracefully handling the already-in-flight streaming request. Walk through a concrete implementation strategy that covers UI state management, request lifecycle control, and cleanup, demonstrating you've thought about both the user experience and the technical mechanics.
Disable the UI to prevent new submissions
As soon as a stream begins, set a loading/streaming state flag (e.g., isStreaming: true) and use it to disable or hide the submit button. This is the first line of defense against duplicate submissions at the UI layer.
Track the in-flight request with a ref or controller
Store an AbortController instance in a ref (e.g., useRef in React) tied to the current streaming request. This gives you a handle to cancel the ongoing stream if needed without triggering unnecessary re-renders.
Decide on a cancellation policy
Define what happens if a new request must be initiated — either block it entirely until the stream completes, or implement 'cancel-and-replace' by calling abort() on the existing controller before starting a new one. Discuss the trade-offs of each approach with respect to UX and data consistency.
Abort and clean up the running stream
If cancellation is chosen, call abortController.abort() to terminate the fetch/SSE connection, then clear partial streamed state and reset the UI. Handle the resulting AbortError gracefully so it doesn't surface as an unhandled error to the user.
Handle edge cases and cleanup on unmount
Ensure the AbortController is also called in a cleanup function (e.g., useEffect cleanup) to prevent memory leaks or state updates on unmounted components. Consider race conditions where a response arrives just after an abort signal is sent.
Key Points to Mention
Talked about batching updates with requestAnimationFrame or a small timeout, and appending to a ref instead of state for the in-progress text.
Suggested Approach
Frame your answer around the core tension between latency (showing updates quickly) and throughput (batching updates efficiently), then walk through concrete techniques like debouncing, buffering, and virtual DOM optimizations. Demonstrate that you understand both the browser rendering pipeline and React's reconciliation model, and explain how you'd measure and validate your approach.
Identify the Root Problem
Explain that re-rendering on every chunk causes layout thrashing and excessive reconciliation work, especially when hundreds of chunks arrive per second over a streaming connection. Clarify that the goal is to decouple the data ingestion rate from the render rate.
Buffer and Batch Incoming Chunks
Describe accumulating chunks in a mutable buffer (e.g., a ref or module-level variable) outside of React state, then flushing the buffer to state on a controlled schedule. This prevents each chunk from independently triggering a re-render.
Throttle or Schedule Renders
Explain using requestAnimationFrame, setTimeout with a small interval (e.g., 16ms for ~60fps), or React 18's startTransition/useDeferredValue to schedule state updates at a human-perceptible cadence rather than at the network chunk rate.
Minimize Reconciliation Cost
Discuss keeping the rendered component tree shallow and stable — for example, rendering the streaming text in a single leaf node rather than mapping each chunk to a separate element, and using React.memo or avoiding unnecessary parent re-renders.
Measure and Validate
Emphasize profiling with Chrome DevTools Performance tab and React DevTools Profiler to confirm frame times stay under 16ms, and mention setting up synthetic benchmarks that simulate high-frequency chunk arrival to catch regressions.
Key Points to Mention
Classic stale closure / race condition question.
Suggested Approach
Frame your answer around the real-world problem of race conditions in streaming APIs, then walk through concrete mitigation strategies you would implement on the frontend. Demonstrate that you understand both the symptom (stale/interleaved content) and the root cause (async, out-of-order delivery), and tie your solution to patterns like request IDs or AbortController.
Define the Problem
Clearly articulate the race condition: a slow streaming response from request N can deliver chunks after request N+1 has already begun rendering, causing corrupted or interleaved UI output. Establish why this is non-trivial in streaming (SSE/fetch ReadableStream) contexts.
Explain the Impact
Describe the user-facing consequences — garbled text, incorrect state, or flickering — and any downstream side effects like incorrect token counts or broken markdown rendering. This shows you think beyond just 'wrong data' to actual UX degradation.
Present the Core Solution
Introduce a request-ID or generation-counter pattern: tag each request with a unique ID, store the current active ID in a ref or closure, and discard any incoming chunk whose ID doesn't match the latest. Combine this with AbortController to cancel the previous fetch/stream at the network level.
Address Edge Cases
Discuss scenarios like rapid successive requests (debouncing vs. immediate cancel), partial renders that must be cleared when a new request starts, and error handling when an aborted stream throws a DOMException. Show awareness of cleanup in useEffect or equivalent lifecycle hooks.
Mention Trade-offs & Alternatives
Acknowledge trade-offs: aborting immediately wastes any useful partial output already received, while ignoring stale chunks risks subtle bugs. Briefly mention alternatives like queuing requests sequentially or using a state machine (e.g., XState) to manage streaming lifecycle for more complex UIs.
Key Points to Mention
Didn't get deep into this one.
Suggested Approach
Frame your answer around the core tension between maintaining a smooth streaming UX and managing the growing complexity of a multi-turn conversation list. Start by identifying the key architectural challenges (virtualization, state management, scroll anchoring), then walk through concrete solutions for each. Tie your decisions back to measurable performance outcomes like frame rate and time-to-interactive.
Define the Problem Space
Articulate the two competing concerns: the DOM growing unboundedly as conversation turns accumulate, and the active streaming bubble needing high-frequency, low-jitter re-renders. Establish that naive approaches (re-rendering the full list on each token) will degrade quickly.
Virtualize the Message List
Introduce a windowed/virtualized list (e.g., react-window, TanStack Virtual) to keep only visible message nodes in the DOM, capping render cost regardless of conversation length. Explain how dynamic row heights for variable-length messages require careful measurement caching.
Isolate the Streaming Bubble
Decouple the actively streaming message from the virtualized list — render it as a separate, fixed component outside the scroll container so token updates never trigger list re-renders. Merge it into the virtualized list only once streaming is complete.
Handle Scroll Anchoring & Auto-Scroll
Implement 'stick to bottom' behavior that auto-scrolls during streaming but gracefully pauses if the user scrolls up to read history. Use an IntersectionObserver on a sentinel element at the bottom to detect user intent and toggle auto-scroll without polling.
State Management & Memory Hygiene
Store conversation turns in a normalized, append-only structure (e.g., a Map keyed by message ID) to avoid expensive array mutations. Discuss strategies like summarizing or paginating older turns server-side to cap client-side memory for very long sessions.
Key Points to Mention
Call abort on the controller, transition status from streaming to something like stopped or idle, and make sure the partial text is preserved rather than wiped.
Suggested Approach
Frame your answer around the full lifecycle of a streaming request — from initiation to cancellation — and map each phase to concrete UI state and network-level actions. Walk through the problem systematically: what the user sees, what the client holds in memory, and what actually needs to be aborted on the wire. Conclude by addressing edge cases like partial responses and race conditions to show production-level thinking.
Define the UI States
Identify the distinct states the component needs: idle, streaming, and cancelled/stopped. Explain how a boolean or enum flag (e.g., `isStreaming`) drives button visibility and disables the input field during generation.
Set Up the AbortController
When the user submits a prompt, instantiate an `AbortController` and pass its `signal` to the `fetch` call (or EventSource / WebSocket equivalent). Store the controller reference in a ref or state so the Stop button can access it.
Wire Up the Stop Button
On click, call `abortController.abort()` to cancel the in-flight network request and catch the resulting `AbortError` in your stream-reading loop. Immediately transition UI state back to idle and preserve whatever partial response text has already been rendered.
Handle Partial Response & Cleanup
Decide on the product behavior for partial text — typically you keep it visible but mark it as incomplete (e.g., a truncation indicator). Clear the AbortController reference, re-enable the input, and ensure no further state updates are applied from the now-dead stream.
Address Race Conditions & Edge Cases
Guard against stale closures or async chunks arriving after abort by checking a cancellation flag before each state update. Also consider what happens if the user clicks Stop and immediately resubmits — ensure the old controller is fully cleaned up before creating a new one.
Key Points to Mention
Honestly a nice practical question.
Suggested Approach
Frame your answer around a 'scroll lock' pattern: auto-scroll only when the user is already at (or near) the bottom, and pause it the moment they scroll up. Walk through the detection logic, the UX affordances you'd add, and edge cases like new messages arriving while the user is reading history.
Define the Core Invariant
Establish the rule: auto-scroll should fire only when the user is already pinned to the bottom. Any upward scroll by the user breaks this pin and suspends auto-scroll until they explicitly or implicitly re-engage.
Implement Scroll Position Detection
Track whether the user is 'at the bottom' by comparing scrollTop + clientHeight against scrollHeight with a small tolerance threshold (e.g., 50px). Listen to the scroll event to toggle an isUserScrolledUp boolean flag.
Conditionally Trigger Auto-Scroll
On each new message or content update, check the flag: if isUserScrolledUp is false, call scrollToBottom(); otherwise, suppress the scroll and optionally queue a 'new messages' indicator. Use requestAnimationFrame or a MutationObserver to scroll after the DOM has updated.
Provide a Re-Engagement UX Affordance
Show a sticky 'Jump to latest' button or toast when new messages arrive while the user is scrolled up. Clicking it scrolls to the bottom and resets the pin flag, giving users a clear, non-disruptive path back.
Handle Edge Cases & Performance
Address cases like streaming tokens (rapid DOM mutations), virtualized lists (where scrollHeight changes unpredictably), and mobile rubber-band scrolling. Debounce the scroll listener and consider using IntersectionObserver on a sentinel element at the bottom for a more performant detection approach.
Key Points to Mention
Weak answer from me here.
Suggested Approach
Frame your answer around two distinct but related challenges: the accessibility problem (making dynamic, streaming content perceivable and operable for screen reader users) and the testing problem (making non-deterministic async behavior deterministic and reliable). Demonstrate awareness of ARIA live regions, chunked rendering strategies, and how to mock streaming APIs to write stable tests.
Identify the Core Accessibility Challenge
Explain that screen readers don't automatically detect DOM mutations, so streaming text appended to the page is invisible to AT users without explicit ARIA instrumentation. Establish that the goal is to surface content progressively without overwhelming the user with per-token announcements.
Design the ARIA Live Region Strategy
Propose using an `aria-live='polite'` region (or `assertive` for critical updates) and discuss chunking strategies — e.g., announcing every N tokens, every sentence boundary, or on punctuation — to balance responsiveness with cognitive load. Mention `aria-atomic` and `aria-relevant` attributes for fine-grained control.
Address Visual and Focus Management
Discuss ensuring the streaming container is reachable via keyboard, that focus is not hijacked during streaming, and that a 'stop generating' control is keyboard-accessible and announced. Consider a visually-hidden status region that announces stream completion.
Design a Deterministic Testing Strategy
Explain how to replace the real streaming API with a controlled mock that emits chunks synchronously or on a fake timer (e.g., using Jest fake timers or a ReadableStream stub), allowing tests to assert on intermediate and final DOM states without race conditions. Mention tools like Testing Library's `waitFor` or manual tick-stepping.
Validate Accessibility in Tests
Describe running axe-core or jest-axe assertions after each simulated chunk to catch live-region misconfiguration, and using tools like NVDA/VoiceOver in manual QA to verify the announcement cadence feels natural. Emphasize combining automated checks with real AT smoke tests.
Key Points to Mention
Discussion(8)
Sign in to join the discussion.
The aria-live polite approach is correct but there's a subtlety worth knowing: some screen readers throttle polite regions if updates come too fast, so you might want to batch your live region updates the same way you batch visual updates, rather than updating it on every single token. Some implementations use a separate visually-hidden div as the live region and only push text to it every few hundred milliseconds. For testing, a fake ReadableStream backed by an async generator is the right shape. You yield chunks with small awaits between them, wrap it in a Response object so it looks like a real fetch response, and then your component code doesn't need to know it's fake. The deterministic part means controlling exactly when each chunk arrives, which lets you assert intermediate states like that the streaming bubble shows partial text before the final chunk lands.
Nice practical question. I'd track a boolean in a ref (not state, no need to re-render) that flips to false when the user scrolls up past a threshold, say 100px from the bottom. On each new chunk, if that ref is true, call scrollIntoView or set scrollTop on the container. When the user scrolls back down to the bottom, flip the ref back to true. The scroll listener needs to be passive and probably debounced slightly so it's not firing on every pixel of scroll.
Pretty much exactly what you described. The one thing I'd add is that the button label itself can do some work here: during streaming it says Stop Generating, after stopped it could say Resume or just go back to the send icon. That label state is derivable from your status enum so you're not managing extra booleans. The AbortError catch is the part candidates most often miss, and it's the part that makes the feature feel polished versus janky.
Scoping the streaming updates to a ref inside the last bubble is the key insight. If your messages array lives in state and you append a character to the in-progress message on every chunk, every message bubble re-renders. Instead, keep the in-progress text in a ref and have only the active bubble subscribe to it, maybe via a context or just by passing a ref down. Older bubbles are static and never touch that ref, so they're completely inert during streaming. Virtualization is worth mentioning for very long conversations but the ref scoping gets you most of the way there without the complexity of a virtual list.
The boolean trap is so real. I did the exact same thing at a different company, ended up with isLoading, isStreaming, isError, isDone, and then the conditional logic for what the button should look like became this gnarly nested mess. A status enum collapses all of that: one field, exhaustive switch, done. For the TextDecoder thing, don't be too hard on yourself, that's genuinely obscure. The reason you need to reuse the same instance with stream: true is that a multi-byte UTF-8 character like an emoji can be split across two separate Uint8Array chunks, and a fresh decoder on each chunk would misinterpret the partial byte sequence. The stateful decoder holds the incomplete bytes in an internal buffer and flushes them when the next chunk arrives. I only knew this because I hit the bug in production first and then went spelunking through the spec. The component structure I'd reach for is pretty flat: one parent that owns the status enum and the messages array, a controlled input, and a message bubble component that for the streaming case reads from a ref rather than state so you're not triggering a full React reconcile on every token. The ref gets flushed into state at natural pause points or on completion.
Increment a requestId counter on each submission, capture the value in the closure, and after every await check if the captured id still matches the current ref. If not, return early. Two lines of code, prevents a whole class of bugs. AbortController handles the network side but this check handles the React side.
The await-yields-to-event-loop point is the crux of it. Each iteration of your read loop does await reader.read(), and that await is a microtask checkpoint. The browser gets to process paint frames and input events between chunks. So the loop isn't blocking even though it looks synchronous. Where you can still get into trouble is if you call setState on every single chunk and React schedules a synchronous re-render each time. Batching helps: accumulate chunks into a ref, then flush to state either on requestAnimationFrame or after a small debounce. The visual difference is imperceptible to users but the frame budget difference is real.
The abort-on-unmount piece is easy to forget and it matters a lot in a Next.js app where route transitions can leave a fetch dangling. But the user-abort-vs-network-error distinction is the actually interesting bit. When you call controller.abort(), fetch rejects with a DOMException whose name is AbortError. If you catch all errors the same way and set status to error, you get a red error banner when the user just clicked Stop, which looks broken. The fix is a simple name check in your catch block: if err.name === 'AbortError' and the abort was user-initiated (you can track this with a ref flag), transition to stopped or idle and keep the partial text. If it's a real network failure, then show the error state. One thing I'd add: store the AbortController in a ref, not state, so creating a new one for each submission doesn't trigger a render.