You open the draft. The AI just handed you a 4,200-word screenplay—scene headings, dialogue blocks, parentheticals, the works. Your React UI locks up for 1.8 seconds while the text lands. The cursor won’t blink. The scrollbar won’t budge. The user already bounced.
This isn’t a thought experiment. If you’re building a script writing app that ingests AI-generated drafts, you’re shoving large, dynamic text payloads into a rendering pipeline that was never tuned for them. React’s reconciliation, the browser’s layout engine, and your own formatting logic gang up into a main-thread blockade that turns a 60fps interface into a sluggish word processor from two decades ago.
The culprit isn’t React. It’s the assumption that rendering text is cheap. For a paragraph, sure. For a screenplay, it’s anything but. Here’s why—and exactly what to do about it.
What Actually Happens When You Dump 4,000 Words Into a React Component
Let’s walk the pipeline. You get a string from your AI endpoint. You drop it in state. React schedules a re-render. Your component spits out a tree of <div>, <p>, <span>—maybe hundreds of them if you’re breaking lines for scene headings, character names, and dialogue. React diffs the virtual DOM. The reconciler figures out which nodes to insert, update, or delete. Then it commits to the DOM. Then the browser calculates layout, paint, and composite.
Each stage carries a cost that scales with text volume, but not in a straight line. The hidden multiplier is structural complexity. A flat wall of text in a single <pre> tag is one thing. A formatted screenplay—where every line is a semantic unit wrapped in its own element, often with syntax highlighting spans inside—is another beast. You’re not rendering 4,000 words. You’re rendering 4,000 words plus 2,000 DOM nodes plus CSS rules that trigger layout on each one.
I profiled a real scenario: a React component that receives a 3,800-word screenplay string, parses it into scene blocks, and renders each block with formatted dialogue, character cues, and action lines. The component tree depth averaged 6 levels. Total DOM nodes after commit: 4,200. Here’s what React Profiler and the User Timing API caught.
- Render phase (React): 340ms. The reconciler walked 4,200 nodes, diffed against the previous empty state, and built the effect list.
- Commit phase (React): 120ms. DOM mutations applied. The browser queued style recalculation and layout for every inserted node.
- Layout and paint (browser): 1,100ms. The browser recalculated styles for 4,200 new elements, built the layout tree, painted, and composited. This is where the freeze lives.
- Total blocking time: 1,560ms. The main thread was hogged for over a second and a half. Interaction to Next Paint (INP) for the first keystroke after render: 1,820ms.
That’s a failing Core Web Vital on a single state update. And it gets worse when you layer on features.
The O(n²) Trap: Syntax Highlighting and Collaborative Cursors
Most script editors tack on syntax highlighting—coloring character names, italicizing parentheticals, bolding scene headings. The naive approach: parse the text, split it into tokens, wrap each token in a <span> with a class, and render. For a 4,000-word script, you might generate 8,000–12,000 spans.
Now add collaborative cursors. Each remote user’s cursor position is a floating <div> absolutely positioned over the text. To place it, you measure the text node’s bounding rect on every render. That triggers a forced synchronous layout (FSL) for each cursor. If you have three collaborators, you just forced three layouts inside a render that already created 12,000 DOM nodes.
The profiler shows the damage. With syntax highlighting enabled and two simulated remote cursors, the same 3,800-word screenplay produced:
- Render phase: 520ms (up from 340ms—more nodes to diff)
- Commit phase: 190ms (more DOM mutations)
- Layout thrashing: 1,800ms (forced reflows from cursor positioning interleaved with style recalc)
- Total blocking time: 2,510ms
You didn’t add features. You added a performance regression that compounds with every line of text.
Pattern 1: Chunked Rendering with startTransition
The first fix isn’t virtualization. It’s breaking the synchronous render into chunks the browser can interleave with user input. React’s startTransition marks a state update as non-urgent, letting the scheduler yield the main thread between render units.
But startTransition alone won’t save you if you’re still rendering 4,200 nodes in one shot. You need to pair it with chunked ingestion: split the incoming text into segments, render each segment in its own transition, and append incrementally.
Here’s the pattern:
function ScriptRenderer({ rawText }) {
const [chunks, setChunks] = useState([]);
const chunkSize = 500; // words per chunk
useEffect(() => {
const words = rawText.split(' ');
let offset = 0;
const timer = setInterval(() => {
if (offset >= words.length) {
clearInterval(timer);
return;
}
const slice = words.slice(offset, offset + chunkSize).join(' ');
offset += chunkSize;
startTransition(() => {
setChunks(prev => [...prev, slice]);
});
}, 16); // ~60fps cadence
return () => clearInterval(timer);
}, [rawText]);
return (
<div>
{chunks.map((chunk, i) => (
<ScriptChunk key={i} text={chunk} />
))}
</div>
);
}
Each chunk is a separate commit. The browser gets 16ms gaps to handle input events, update the scrollbar, and paint incrementally. The user sees text appearing progressively—not a frozen screen.
Benchmark result: Same 3,800-word screenplay, chunked into 8 segments of ~475 words each. Total render time spread across 8 commits: 480ms total React time, but the longest single commit was 62ms. Layout and paint per chunk: 80–120ms. INP after first chunk visible: 94ms. The interface stayed interactive the whole time.
Trade-off: The user sees partial content for ~500ms. If your UI needs the full text to compute something (like a word count or a structural outline), you have to process the raw string separately from the display chunks. Also, chunked rendering complicates undo/redo if the user edits during ingestion—you need to reconcile the incoming stream with local mutations.
Pattern 2: Virtualized Text Windows for Editing UIs
Chunked rendering solves the initial paint. But if your script editor lets users scroll through and edit a 10,000-word document, you still have a problem: keeping 10,000 words’ worth of DOM nodes alive in the document. Scroll performance degrades. Memory grows. GC pauses creep in.
The answer is a virtualized text window—but not the kind you use for data tables. Text virtualization is harder because line heights vary. A dialogue block might be one line. An action paragraph might be six. You can’t assume fixed row heights.
The pattern that works: measure line heights dynamically with a ResizeObserver on a hidden measurement container, build a line-height map, and only render the lines currently in the viewport plus a 300px overscan buffer.
function VirtualizedScript({ lines, lineHeights }) {
const containerRef = useRef(null);
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
useEffect(() => {
const container = containerRef.current;
const observer = new IntersectionObserver(
() => {
const scrollTop = container.scrollTop;
const viewportHeight = container.clientHeight;
let accumulatedHeight = 0;
let start = 0;
let end = 0;
for (let i = 0; i < lines.length; i++) {
const lineHeight = lineHeights[i] || 20;
if (accumulatedHeight + lineHeight > scrollTop - 300 && start === 0) {
start = i;
}
if (accumulatedHeight > scrollTop + viewportHeight + 300) {
end = i;
break;
}
accumulatedHeight += lineHeight;
}
if (end === 0) end = lines.length;
startTransition(() => setVisibleRange({ start, end }));
},
{ threshold: [0, 0.25, 0.5, 0.75, 1] }
);
if (container) observer.observe(container);
return () => observer.disconnect();
}, [lines, lineHeights]);
const totalHeight = lineHeights.reduce((sum, h) => sum + h, 0);
const offsetY = lineHeights.slice(0, visibleRange.start).reduce((sum, h) => sum + h, 0);
return (
<div ref={containerRef} style={{ height: '100vh', overflow: 'auto' }}>
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ transform: `translateY(${offsetY}px)` }}>
{lines.slice(visibleRange.start, visibleRange.end).map((line, i) => (
<ScriptLine key={visibleRange.start + i} text={line} height={lineHeights[visibleRange.start + i]} />
))}
</div>
</div>
</div>
);
}
Benchmark result: A 12,000-word script with variable line heights. Without virtualization: 8,200 DOM nodes, scroll jank at 18fps, 340MB JS heap. With virtualization (viewport showing ~40 lines): 280 DOM nodes, scroll at 58fps, 42MB heap. INP during scroll: 22ms vs. 340ms.
Trade-off: Dynamic line-height measurement requires an initial layout pass on the full text (or a representative sample) to build the height map. You can do this offscreen in a hidden container with visibility: hidden and position: absolute to avoid jank. Also, native browser find-in-page breaks because not all text is in the DOM. You need to implement your own search that temporarily renders matching lines.
Pattern 3: Avoiding Accidental O(n²) in Syntax Highlighting
Syntax highlighting is the most common performance killer in text-heavy React UIs. The typical pattern:
function HighlightedLine({ text }) {
const tokens = useMemo(() => parseTokens(text), [text]);
return (
<div>
{tokens.map((token, i) => (
<span key={i} className={token.type}>{token.value}</span>
))}
</div>
);
}
This looks fine. But parseTokens runs on every line, on every render. If you have 2,000 lines and a parent re-render triggers all 2,000 HighlightedLine components to re-render, you just ran parseTokens 2,000 times synchronously. Even if each call takes 0.5ms, that’s 1,000ms of JavaScript execution blocking the main thread.
The fix: move tokenization to a Web Worker, or at minimum, memoize at the document level, not the line level. Tokenize the entire text once, store the token array, and have each line component slice its portion by index range.
// Tokenize once for the whole document
const allTokens = useMemo(() => tokenizeDocument(rawText), [rawText]);
// Each line gets a start/end index into the token array
function HighlightedLine({ tokenStart, tokenEnd, allTokens }) {
const lineTokens = allTokens.slice(tokenStart, tokenEnd);
return (
<div>
{lineTokens.map((token, i) => (
<span key={tokenStart + i} className={token.type}>{token.value}</span>
))}
</div>
);
}
Now tokenizeDocument runs once per text change, not once per line per render. The line components do a cheap array slice. No per-line parsing.
Benchmark result: 3,800-word screenplay with syntax highlighting. Before: 1,200ms spent in parseTokens across 1,900 line components during initial render. After: 45ms spent in tokenizeDocument once, plus negligible slice time. Total render phase dropped from 520ms to 180ms.
Trade-off: Token index ranges must stay in sync with the text. If the user edits a line, you need to re-tokenize the whole document or implement incremental tokenization—which is non-trivial. For read-only AI-generated drafts displayed before user editing begins, this is a pure win. For live collaborative editing, you’ll need a CRDT-aware tokenizer or accept re-tokenization on each remote change.
When Not to Do Any of This
These patterns add complexity. You don’t need them if:
- Your text payloads are consistently under 1,500 words and your DOM node count stays below 800. React’s default reconciliation handles that fine.
- You’re rendering plain text with no formatting spans. A single
<pre>or<div>withwhite-space: pre-wrapcreates one DOM node, not thousands. The browser’s text layout is highly optimized for continuous text runs. - Your users never edit the text in-browser. If it’s a static display, chunked rendering on mount is enough. You don’t need virtualization or incremental tokenization.
- You’re already streaming the response from the server using React Server Components and streaming SSR. The server sends HTML chunks progressively; the client hydrates incrementally. This is the ideal architecture for AI-generated text display, but it requires a Next.js or Remix backend that supports streaming—not every project has that.
The threshold where these patterns become necessary is roughly 2,500 words with semantic markup, or 1,500 words with syntax highlighting spans. Below that, measure first. Above that, you’ll see the jank in your profiler before your users report it.
Measuring the Real Impact: User Timing API Marks
React Profiler tells you what React did. It doesn’t tell you what the browser did after React finished. For that, you need the User Timing API.
function ScriptView({ text }) {
useEffect(() => {
performance.mark('render-start');
// After React commits, the browser still needs to paint
requestAnimationFrame(() => {
requestAnimationFrame(() => {
performance.mark('paint-complete');
performance.measure('total-blocking', 'render-start', 'paint-complete');
const measure = performance.getEntriesByName('total-blocking')[0];
if (measure.duration > 100) {
console.warn(`Script render blocked main thread for ${measure.duration.toFixed(0)}ms`);
}
});
});
}, [text]);
// ... render logic
}
The double requestAnimationFrame is critical. The first rAF fires before the browser paints the frame. The second fires after paint completes. The gap between your render-start mark and the second rAF is the true user-perceived blocking time—what INP measures.
In the chunked rendering pattern, you can wrap each chunk’s commit with these marks to verify that no single chunk exceeds your 50ms budget. If one does, reduce chunkSize.
What Screenplay Structure Means for Your Component Tree
Screenplays aren’t arbitrary text. They follow strict formatting rules—scene headings, character names, dialogue, parentheticals, transitions, action lines. Industry-standard screenplay format dictates specific margins, capitalization, and element ordering. When you parse an AI-generated script, you’re not just splitting on newlines. You’re identifying semantic units that each deserve their own component.
This is good for editing UX—users expect to tab between character names and dialogue fields. But it’s bad for performance because it multiplies DOM nodes. A single line of dialogue might become:
<DialogueBlock>
<CharacterName>JACK</CharacterName>
<DialogueLine>I can't go back there.</DialogueLine>
<Parenthetical>(quietly)</Parenthetical>
</DialogueBlock>
Three DOM nodes for one line of text. Multiply by 1,200 lines of dialogue in a feature-length script, and you’ve added 3,600 nodes just for dialogue structure—before any syntax highlighting spans.
The architectural decision: do you need semantic components at render time, or only at edit time? If the user is reading an AI-generated draft before editing, render it as flat formatted text with CSS classes simulating the structure. When they click to edit a line, swap that line into its semantic component tree. This is “progressive enhancement” for text editing—and it keeps your initial render node count low.
The AI Context: Why This Matters Now
AI script generators are producing longer, more structured output than ever. A single prompt can return a complete short film script with 15 scenes, 8 characters, and 4,000+ words. Writers are using these tools for drafting, and professional organizations are establishing best practices for AI-assisted writing. The tools that display these drafts—the script writing apps, the collaborative editors, the feedback platforms—are the ones that will face this rendering problem first.
If your app freezes for two seconds every time a draft loads, no amount of AI quality will retain users. The performance is the product.
Ship It Today: The Minimum Viable Fix
You don’t need to implement all three patterns at once. Here’s the priority order based on impact-to-effort ratio:
- Chunked rendering with startTransition. 30 lines of code. Eliminates the initial freeze. Works for any text payload. Do this first.
- Document-level tokenization. Refactor your syntax highlighting to tokenize once, not per-line. 20 lines of change. Cuts render phase time by 60–70% if you have highlighting.
- Virtualized text windows. Only necessary if users edit 8,000+ word documents in a single session. 150+ lines of code with dynamic height measurement. Implement when scroll jank becomes measurable.
Measure before and after each change. Use React Profiler for render/commit times. Use User Timing API with double rAF for real blocking time. If your INP drops below 200ms on script load, you’re done. If not, go to the next pattern.
The goal isn’t to render 4,000 words faster. It’s to make the user forget there were 4,000 words at all.