cf.completefrontend
My progress
SYSTEM DESIGN · #115

Design a log viewer for millions of lines

A log pane that opens a 2 million line build log or a live service tail without freezing the tab. Virtualizing the rows is the easy part; the hard parts are where the lines live in memory, following a fast stream without fighting the reader, searching text that was never rendered, regexes that can hang a thread, and copying, linking, and reading lines that don't exist in the page.

The question

Our CI product shows build logs, and our observability product shows live service logs. Both use the same log pane today, and it falls over: a 400 MB build log crashes the tab, a busy service tail at 2,000 lines per second makes scrolling stutter, and Ctrl+F finds nothing that is off screen.

Design the log viewer component. How does it hold millions of lines, follow a live stream, search and filter, jump to a time or a line, copy and download, and stay usable with a keyboard and a screen reader?

Real-life examples: GitHub Actions and Vercel build logs, Datadog Log Explorer and Kibana Discover, Grafana Explore with Loki, Chrome DevTools console, kubectl logs dashboards such as Lens

Requirements: what to ask before drawing anything

A log viewer shows lines of text that a program printed, in the order it printed them, usually with a timestamp and a level such as INFO or ERROR. It looks like the simplest UI there is: a scrolling box of monospace text.

It becomes a hard problem because of size and speed. One CI build can print 2 million lines, and one busy service can print thousands of lines per second. People open these panes when something is broken and they are in a hurry, so a frozen tab is the worst possible moment to fail.

This is a component question. You are designing the pane that two products embed: a CI product (one finished or running build log, a single file) and an observability product (logs from many services, queried by time range). The design has to serve both, so the first job is to find out how they differ.

Two related guides cover the general parts, and this one builds on them instead of repeating them. The virtualized-list guide covers windowing math, rows of unknown height, and browser height limits. The live-comments guide covers turning a fast stream into calm renders and a live region that doesn't flood a screen reader. This guide spends its time on what is specific to logs: the data volume, text search over lines that were never drawn, and the tools around the lines.

Questions that change the design

QuestionAnswer we assumeWhat it changes
How big is the biggest log?A build log: up to 2 million lines, about 250 MB of text. A service query: unbounded, the server pages it.Whether the whole log can sit in the tab at all, and in what form.
Is it live?Both modes. A running build streams new lines; the service tail streams up to 2,000 lines per second at peak.Streaming transport, batching, auto-scroll, and an eviction cap.
Where does search run?Build log: in the browser, over what is loaded. Service logs: on the server, as a query.Whether we need a worker and a compact buffer, or just a good query API.
Do lines have structure?Build logs are plain text with ANSI color codes. Service logs are often JSON with level, service, ts, msg.Parsing, the filter index, and the expand-to-JSON view.
How long are lines?Most under 200 characters, but some are 50,000 character JSON blobs or minified stack traces.Wrap versus horizontal scroll, and whether line height can stay fixed.
Must links point at a line?Yes. People paste #L1234 into chat and code review.A stable line number for every line, even after eviction.
Which devices?Desktop first (engineers at work), but the pane must not crash a 4 GB laptop or a phone opened from an alert.The memory budget and the eviction cap.

Functional requirements

  • Open a log of up to 2 million lines and scroll anywhere in it with a normal scrollbar.
  • Follow a live stream: new lines appear at the bottom and the view stays pinned there until the reader scrolls up.
  • Search by substring or regular expression, see the match count, and step to the next and previous match.
  • Filter by level, service, and time range, and combine filters with search.
  • Jump to a timestamp or a line number, link to a line or a range (#L120-L180), and select lines to share.
  • Show ANSI colors from build tools, pretty-print a JSON line on expand, and toggle timestamps between local time and UTC.
  • Copy selected lines as plain text and download the full log.

Non-functional requirements, with numbers

  • First lines on screen within 1 second of opening a build log on a fast connection, before the whole file has arrived.
  • Scrolling at 60 frames per second on a 4-core laptop: under 8 ms of script per frame, so style, layout, and paint fit in the rest of the 16.7 ms frame.
  • Live tail of 2,000 lines per second with no frame over 50 ms, the threshold Chrome uses for long tasks and long animation frames.
  • Memory: stay under about 300 MB of JavaScript heap for the pane in the worst case, an estimate that keeps a typical laptop tab alive. Past that, evict old lines and say so.
  • Search over 2 million lines in under 1 second for a substring, with the first counts visible in under 100 ms.
  • Security: log text is untrusted. A line that contains HTML or escape codes must never run script or inject markup.
  • Accessibility: every action by keyboard, a screen reader hears match counts and new errors, and the live region never floods it.

Out of scope

  • The log pipeline itself: collection, indexing, and retention on the server. We design the API we need from it, not the storage engine.
  • Charts of log volume over time (a histogram above the pane is common; the analytics-dashboard guide covers charts).
  • Alerting and saved queries. They live in the product around the pane.
Wireframe of the log viewer pane for build 123. A toolbar holds the search box showing timeout, match 4 of 312, then buttons for levels, wrap, UTC, and download. Below it a notice says 240,000 older lines were dropped. The main area has a line number gutter and six rows from line 48,309 to 48,314, each with a time, a level, and a message; line 48,311, an error about a timeout, is the current match and is selected. A button near the bottom says 312 new lines. A status bar says 2,000,000 lines, paused, and L48311.Build #123 logstimeout 4/312levelswrapUTCdownload240,000 older lines dropped. Download full log4830914:32:05.101 INFO GET /cart 200 12ms4831014:32:05.117 WARN retry 1/3 payments4831114:32:05.120 ERROR timeout after 3000ms4831214:32:05.164 INFO GET /cart 200 9ms4831314:32:05.201 ERROR card declined4831414:32:05.230 INFO POST /order 201312 new lines2,000,000 lines, paused, L48311 selected
Every part names a decision in this guide: the search box with its count, the notice that old lines were dropped, the gutter that makes #L48311 links, the new lines button while paused, and a status bar that says what the pane holds.

Check your understanding · Exercise 1

Which answers from the interviewer would change the design the most? Select all that apply.

What makes a log viewer hard

A list of 100,000 products and a log of 2 million lines both need virtualization, but they are hard in different ways. A product list is short on text and long on images. A log is nothing but text, a lot of it, arriving fast, and the reader treats it as a document: they search it, select it, link to it, and copy it. Five traits drive every later decision.

  1. Huge and flat. A 2 million line log is about 250 MB of text if lines average 125 bytes. That is far too many lines for the DOM, and even too many to keep as 2 million separate JavaScript objects. Where the bytes live is the first design decision, not a detail.
  2. Live and fast. A running build or a service tail appends lines while the reader looks at them. At 2,000 lines per second, anything you do per line (a state update, a parse, a DOM node) is done 2,000 times a second. You must batch.
  3. Read as a document. People expect Ctrl+F to find text, a drag to select lines, Ctrl+C to copy them, and a link to point at line 1,234. Virtualization removes the lines from the page, so the browser's own find, select, and copy only see the 40 or so rows that exist. Each of those features has to be rebuilt in our code.
  4. Untrusted text. A log line is whatever a program printed. It can contain <script>, ANSI escape codes, 50,000 character JSON, or a regex-hostile string. Rendering it, parsing it, and searching it must all be safe against hostile input.
  5. Read under pressure. People open logs during an incident. A pane that freezes for 3 seconds when they type a regex, or jumps away from the line they were reading when new lines arrive, costs real time when it matters most.

The good news: logs have one trait that makes them easier than a general list. Every line is one row of monospace text. If we choose not to wrap long lines, every row has the same height, and the whole windowing problem from the virtualized-list guide (measuring, estimating, correcting offsets) shrinks to one division: the first visible line is Math.floor(scrollTop / lineHeight). The rendering chapter shows how to keep that simplicity when the reader turns wrapping on.

The numbers to keep in your head

QuantityValueWhere it comes from
Lines in the biggest build log2,000,000Requirement. Large monorepo builds with verbose test output reach this.
Average line lengthabout 125 bytesEstimate. Timestamps and prefixes make build lines longer than you'd guess.
Raw text sizeabout 250 MB2,000,000 times 125 bytes.
Line height20 pxA 13 px monospace font with 1.5 line spacing, rounded.
Visible linesabout 40An 800 px tall pane divided by 20 px.
Rows in the DOMabout 8040 visible plus an overscan of 20 above and 20 below.
Full scroll height40,000,000 px2,000,000 times 20 px. Over the browser's maximum element height, which matters later.
Peak tail rate2,000 lines per secondRequirement. About 33 lines per frame at 60 Hz.

Two of those rows deserve a second look. 33 lines per frame means the tail must never do work per line on the main thread: one update per frame, carrying all 33. And 40 million pixels is past what browsers allow for one element's height: depending on engine and version, the limit sits somewhere between about 16 and 34 million pixels, so a naive spacer div silently stops growing. The rendering chapter handles that with scaled scrolling.

Think first

Why not just render all lines and let the browser cope? Browsers are fast, and content-visibility: auto skips rendering off-screen elements.

Check your understanding · Exercise 2

A teammate says: "We virtualize the rows, so we're done with performance." Which problems does virtualization alone NOT solve? Select all that apply.

Where the lines live: a compact buffer, not an array of objects

Before rendering, decide what a line is in memory. The naive answer comes straight from a React tutorial: fetch the log, split it on newlines, parse each line into an object like { id, ts, level, text }, and keep an array of them in state. For a 2,000 line log this is perfect. For 2 million lines it fails in three ways at once.

  • Memory. Every object carries a header and a pointer to its hidden class (the engine's internal description of the object's shape), every string carries its own header, and a millisecond timestamp is too big for V8's small-integer format, so it usually lives in a separate boxed heap number. A rough estimate is 50 to 100 bytes per line on top of the text, more for every extra parsed field, so 2 million lines cost about 400 to 500 MB of heap. On a 4 GB laptop, a tab that size is at real risk of being killed.
  • Garbage collection. 2 million live objects give every major garbage collection 2 million objects to mark. V8 does much of that marking on background threads, but the main thread still pays for some of it, and pauses long enough to drop frames can show up while the reader scrolls.
  • Parse time. text.split("\n") on 250 MB, then a JSON.parse or regex per line, is several seconds of main thread work. Nothing paints until it ends.

The fix is to stop thinking of a line as an object. A log is a stream of bytes with newlines in it. Keep the bytes as bytes, and keep a separate index of where each line starts. A line becomes a number (its index), and we only turn it into a string when it is about to be drawn or copied.

Decision How should the client hold the lines of a large log?

Array of parsed line objects

Split the text into lines and parse each into { ts, level, text }.

Keep the array in a store; rows read fields directly.

Pros

  • Simplest code; every feature can read any field.
  • Fine up to tens of thousands of lines.

Cons

  • Estimated 400 to 500 MB for 2 million lines.
  • Millions of objects slow every garbage collection.
  • Can't be moved to a worker without a full copy.

One big string plus an offsets index

Concatenate the text into one string.

Record each line's start in a Uint32Array; text.slice(a, b) gives a line.

Pros

  • Compact while the text is plain ASCII: V8 stores a string whose characters are all Latin-1 at 1 byte each.
  • Slicing is cheap and the code stays small.

Cons

  • JavaScript strings are UTF-16: one emoji or box-drawing character anywhere in the log makes V8 store the whole string at 2 bytes per character, 500 MB instead of 250.
  • Appending to a huge string makes the engine build a rope and later flatten it, a multi-megabyte copy at a bad moment.
  • Engines cap string length (V8 at about 536 million characters on 64-bit), and sending it to a worker copies it.

Byte chunks plus an offsets index, owned by a worker

Store the raw UTF-8 bytes in 1 MB Uint8Array chunks that end on a newline.

Keep each line's start offset in a typed array; decode only the lines the screen needs with TextDecoder.

The worker owns the bytes; the main thread keeps a small cache of decoded lines near the viewport.

Pros

  • About 1 byte per character for ASCII text, plus 4 bytes per line for the index. UTF-8 spends 2 to 4 bytes only on the characters that need them.
  • No per-line objects, so garbage collection stays cheap.
  • Chunks can be transferred between threads without copying, and evicted one at a time.

Cons

  • More code: a line store, a message protocol, and a cache.
  • The main thread must ask the worker for lines, so the cache must be warm before the reader scrolls there.

Server paging only

Keep a window of, say, 50,000 lines; fetch more by line range or time cursor as the reader scrolls.

Pros

  • Client memory is bounded no matter how big the log is.
  • The only option for unbounded service logs.

Cons

  • Every far scroll and every search is a round trip.
  • Needs a server that can serve line ranges and run searches.

What we pick: Use byte chunks plus an offsets index, owned by a worker, for everything we hold. Add server paging on top when the source is bigger than the budget: service logs always page by time, and a build log past the cap pages by byte range. One store serves both products: the build log fills it from one file, the service log fills it page by page.

How the line store works

Bytes arrive from the network in pieces that end anywhere, often in the middle of a line or even in the middle of a multi-byte UTF-8 character. The store keeps the unfinished tail of each piece as a carry-over and prepends it to the next piece, so every stored chunk ends exactly on a newline. Each line then lives inside one chunk, and decoding a line never has to stitch two chunks together.

ts
const NEWLINE = 10;
class LineStore {
  chunks: Uint8Array[] = [];
  chunkFirstLine: number[] = []; // line number that starts each chunk
  starts = new Uint32Array(1 << 16); // byte offset of each line inside its chunk
  count = 0; // lines stored
  firstLine = 0; // absolute number of the oldest kept line (grows on eviction)
  private decoder = new TextDecoder();

  appendChunk(bytes: Uint8Array) {   // bytes end on a newline
    this.chunkFirstLine.push(this.firstLine + this.count);
    this.chunks.push(bytes);
    for (let i = 0, start = 0; i < bytes.length; i++) {
      if (bytes[i] !== NEWLINE) continue;
      if (this.count === this.starts.length) this.grow();
      this.starts[this.count++] = start;
      start = i + 1;
    }
  }

  line(n: number): string {          // n is an absolute line number
    const c = upperBound(this.chunkFirstLine, n) - 1; // binary search: the chunk holding n
    const chunk = this.chunks[c];
    const i = n - this.firstLine;      // index into the columns
    const nextChunk = this.chunkFirstLine[c + 1] ?? this.firstLine + this.count;
    const end = n + 1 < nextChunk ? this.starts[i + 1] - 1 : chunk.length - 1; // drop the newline
    return this.decoder.decode(chunk.subarray(this.starts[i], end));
  }
  // grow() doubles the typed arrays; eviction is covered in the tail chapter.
}
The line store's core: append bytes, find a line, decode it. Runs in the worker.

Line numbers are absolute: line 1,234 is always line 1,234, even after the oldest lines are evicted. firstLine tracks how many have been dropped. That keeps deep links, selections, and search results valid across eviction, which the tail chapter relies on.

The line store's layout. Three chunks of raw UTF-8 bytes hold lines 1 to 3, 4 to 6, and 7 to 9; each chunk ends on a newline, and chunk 0 is the next to be evicted. Below, the starts column holds each line's byte offset inside its chunk: 0, 61, 143, then 0, 48, 97, then 0, 72, 130. The level column holds one byte per line. Line 5 starts at byte 48 of chunk 1. At the bottom, the LineStore in the worker, with chunkFirstLine 1, 4, 7, decodes lines 4 to 6 into strings for the window on the main thread.chunks of UTF-8 bytes, each ends on a newlineL1L2L3chunk 0, evicted next061143IWEL4L5L6chunk 104897IEIL7L8L9chunk 2072130WIIstarts: byte offset inside its chunk (Uint32Array)level: one byte per line (ts and service the same way)LineStore (worker)chunkFirstLine: 1, 4, 7window (main)lines 4 to 6 as stringsdecode
A line is a number. Its bytes are found with two lookups (which chunk, then starts[n]) and turned into a string only when the screen needs it. Evicting chunk 0 drops whole lines and their column entries together.

The memory budget, as estimates

Estimates for 2 million lines of about 125 bytes, assuming mostly ASCII text. Real numbers vary by engine; measure with a heap snapshot. As a sanity check, Node 22 (64-bit, without Chrome's pointer compression) held 2 million 134-character lines as four-field objects in about 440 MB of heap.
PartArray of objectsByte chunks plus index
Textabout 290 MB (string headers plus 1 byte per character; 2 bytes for any line with a non-Latin-1 character)250 MB of UTF-8 bytes
Per-line structureabout 100 to 200 MB (objects, boxed timestamps, extra fields)8 MB (Uint32Array of starts)
Filter columns (level, service id)inside the objects6 MB (Uint8Array levels, Uint16Array service ids)
Timestampsinside the objects16 MB (Float64Array, milliseconds)
Decoded windows on the main threadnot neededabout 1 MB (about 6,000 cached lines)
Totalabout 400 to 500 MBabout 280 MB

Even the compact layout spends about 280 MB, so the store needs a cap. We cap at 256 MB of bytes on desktop and 64 MB when navigator.deviceMemory reports 4 GB or less (that property only exists in Chromium browsers, so default to the lower cap when it is missing on mobile). Past the cap, a live tail evicts its oldest chunk, and a finished build log switches to paging: it keeps the chunks around the reader and fetches others again with HTTP Range requests when needed.

Check your understanding · Exercise 3

Why does the store make every chunk end on a newline? Select all that apply.

The live tail: streaming, batching, and following

A running build or a kubectl logs -f style tail adds lines while the reader watches. The naive version opens a socket and, for every message, calls setLines([...lines, line]). At 2,000 lines per second that is 2,000 renders per second, each copying an array that keeps growing, and the tab locks up within a minute. The fixes are about where the stream lands, how often the UI hears about it, and how the view decides whether to follow.

Getting the bytes: which transport

Decision How should new log lines reach the browser?

Polling with an offset

Every 2 seconds, GET /logs/123?from=byteOffset and append what comes back.

Pros

  • Works through every proxy; trivial to cache and retry.
  • The offset makes resume exact.

Cons

  • Up to 2 seconds of lag and lots of empty responses.
  • Bursty: 4,000 lines arrive at once, then nothing.

Server-sent events

An EventSource receives data: messages, each carrying a batch of lines and an id: with the last line number.

On reconnect the browser sends Last-Event-ID, and the server resumes after that line.

Pros

  • One direction is all a tail needs.
  • Automatic reconnect with resume built into the protocol.

Cons

  • Text only, so binary batches must be encoded.
  • Over HTTP/1.1, browsers allow only about 6 connections per domain, shared across tabs; HTTP/2 removes that limit in practice.

WebSocket

Subscribe to a log stream over the product's existing socket; frames carry line batches.

Pros

  • Binary frames, two-way control (pause, change filter) on one connection.
  • Reuses a socket the product may already have.

Cons

  • Reconnect and resume are ours to build.
  • More infrastructure than a plain HTTP response.

Streaming fetch

One fetch whose response body stays open; read it with response.body.getReader() as bytes arrive.

Pros

  • Raw bytes straight into the line store, no encoding.
  • Works inside a worker, so the main thread never sees the bytes.
  • The same code loads a finished log and follows a running one.

Cons

  • No built-in resume: on a drop, start a new request with Range or ?from= the last byte we kept.

What we pick: For a build log, use a streaming fetch inside the worker: the finished part and the live part arrive on one response, as bytes, and resume is a new request from the last byte offset. For the service tail, use server-sent events with the last line's id as the event id, so the browser's reconnect resumes without our code. If the product already multiplexes a WebSocket, subscribe over it instead. The server-sent-events and websockets lessons compare these in general; here, raw bytes and exact resume decide it.

Batching: one update per frame, not one per line

The worker appends bytes to the store as they arrive; that is cheap and off the main thread. What must be rationed is how often the main thread hears about it. The worker posts a small message after each append: the new total line count and, if the view is following, the decoded text of the last 100 lines so the bottom can render without a round trip.

The main thread does not render on each message. It records the latest one and schedules a single requestAnimationFrame callback, which commits whatever arrived since the last frame in one state update.

ts
let pending: TailUpdate | null = null;
let frame = 0;

worker.onmessage = (event: MessageEvent<TailUpdate>) => {
  // Keep only the newest update; it already includes everything before it.
  pending = event.data;
  if (!frame) frame = requestAnimationFrame(flush);
};

function flush() {
  frame = 0;
  if (!pending) return;
  const update = pending;
  pending = null;
  viewStore.setTotal(update.total, update.tail); // one state change, one render
  // While following, the viewport's layout effect sets scrollTop to total * lineHeight.
}
Main thread: coalesce worker messages into at most one render per frame.

At 2,000 lines per second this turns about 2,000 events into 60 renders, each handling about 33 lines. When the tab is in the background, requestAnimationFrame stops firing, so no renders happen at all. The worker keeps appending, and the first frame after the reader returns jumps to the new total. The live-comments guide uses the same idea with a slower 150 ms tick, because a chat row costs more to render than a line of text.

Lines per second
A live tail at 200 lines per second, batched to one render per frame. Following the bottom. The store keeps lines 1 to 40, at most 6000.3514:32:06.295 ERROR timeout after 3000ms3614:32:06.332 INFO cache miss sku=88123714:32:06.369 ERROR timeout after 3000ms3814:32:06.406 INFO GET /health 200 1ms3914:32:06.443 INFO user_id=482913 signed in4014:32:06.480 INFO GET /cart 200 12msstate: followingkept: 40 of 40per frame: 3 linesrenders per second: 60main thread: 120 ms/skeeps up
Press Start. Then scroll up while lines stream in, and watch what follows and what doesn't.

Following, pausing, and the new lines button

Auto-scroll is the feature people notice when it is wrong. If the view keeps jumping to the bottom while someone is reading an error 300 lines up, the pane is unusable. The rule, the same one the live-comments and llm-chat-ui guides use: follow only while the reader is at the bottom. The moment they scroll up, stop following and count what they are missing.

  • Following: after each flush, set scrollTop to the bottom. The view is "at the bottom" if it is within 1 line height (20 px) of it, which absorbs rounding on zoomed or high-DPI screens.
  • Paused: entered when a scroll event finds the view more than 20 px from the bottom and the scroll was not ours. We tell them apart by remembering the scrollTop we set; any other value came from the reader (wheel, keyboard, scrollbar drag, or find-and-jump).
  • The button: while paused, show "312 new lines" pinned above the bottom edge. Clicking it, pressing End, or scrolling back to the bottom resumes following.
  • Never pause on our own changes: eviction and a growing scroll height also move things. Only a reader-initiated scroll may change the follow state.
The follow state machine. Following stays pinned to the bottom: new lines scroll it down and its own scrolling changes nothing. When the reader scrolls up, it moves to Paused, which counts unseen lines and compensates scrollTop on eviction. End, the new lines button, or scrolling back to the bottom returns to Following. Opening a link with #L1234 starts in Going to line, which waits for that line and moves to Paused when it arrives.new lines: scroll downown scroll: no changenew lines: unseen += nevict: scrollTop -= dropFollowingpinned to bottomPausedcounting unseenreader scrolls upEnd, button, or bottomGoing to linewaits for #L lineopen with #L1234line arrives
Only the reader's own actions move between Following and Paused. New lines and eviction are our changes, so they update counts and offsets but never the state.

The cap: evicting the oldest lines

A service tail never ends. Once the store reaches its cap, the worker drops the oldest chunk (about 8,000 lines per 1 MB chunk) and raises firstLine. The columns must not be copied on every eviction, so each typed array works as a ring buffer: a fixed-size array whose start index moves forward, so dropping the oldest 8,000 entries is one addition, not a copy of 2 million.

Two things must then happen in the UI. First, a notice at the top of the pane says what was lost and how to get it back: "Showing the latest 2,000,000 lines. 240,000 older lines were dropped. Download the full log." Second, if the reader is paused in the middle, the lines above them just vanished, so the content would jump up by the dropped height. Subtract dropped * lineHeight from scrollTop in the same frame that applies the new total, and the lines under their eyes stay put.

One case needs a policy: the reader is paused on the very chunk that is next to go. Evicting it would delete what they are reading. We allow a soft overflow of 25 percent past the cap while a paused reader sits in the oldest chunk. If the hard limit is reached anyway, we evict, keep their line number, and show "The lines you were reading were dropped. Reload them", which fetches that range from the server.

Sequence of the live tail. The server streams lines 4001 to 4066 to the store worker, which appends them in two network chunks and posts total 4033 and then total 4066 to the main thread within one frame. The main thread renders once for the frame, showing the reader 66 new lines. Then the connection drops. The browser reconnects with Last-Event-ID 4066, and the server resumes at line 4067. The store worker posts total 4120 and the main thread renders again.ReaderMainStore workerServerlines 4001 to 4066total: 4033total: 40661 render/frameconnection lostLast-Event-ID 4066resume at 4067total: 4120render
Two worker messages in one frame become one render. When the connection drops, the event id makes the resume exact: the server starts at the line after Last-Event-ID, so nothing is lost or repeated.

Check your understanding · Exercise 4

The reader is paused at line 50,000 of a live tail. The worker evicts the oldest chunk, 8,000 lines. What should the main thread do in the next frame? Select all that apply.

Rendering: fixed heights, long lines, and what is inside a line

The fixed line height from the hard-parts chapter does most of the work here, so the general windowing machinery in the virtualized-list guide (measuring, estimating, correcting) isn't needed. With 20 px rows, the rows to draw run from the first visible line minus 20 rows of overscan to that line plus 40 visible rows plus 20 more. The visible block sits inside one tall spacer and moves with a single transform: translateY(...), as the performance chapter explains.

A row has four parts, in fixed-width columns so they line up: a line number gutter (a link, for deep links), the timestamp, a level badge, and the message. The message uses white-space: pre so spaces and alignment from the program survive. Tabs are expanded to spaces when the line is decoded, so a tab is always the same width and character counts stay meaningful.

When the log is taller than the browser allows

2 million lines at 20 px is 40 million pixels, past every browser's maximum element height, so a spacer that tall is silently clipped and the last lines become unreachable. The virtualized-list guide measures those limits and compares three fixes; a log viewer takes its scaled scroll space. Cap the spacer at 10 million pixels, safely under the smallest limit, and map scroll positions proportionally, so one pixel of scrollbar is about 0.2 lines.

The cost is that the browser's own wheel and arrow-key scrolling now moves four times as far, so the pane applies those deltas 1:1 in script, exactly as that guide recommends. Dragging the scrollbar still reaches any line, and jumps land exactly. Below the cap, the scale is exactly 1 and nothing changes.

Long lines: wrap, scroll, or truncate

Some lines are huge: a minified stack trace, a 50,000 character JSON payload, a progress bar printed with carriage returns. The choice here decides whether the fixed-height model survives.

Decision How should the pane show a line longer than its width?

Horizontal scroll, no wrap

white-space: pre on every row; the whole pane scrolls sideways together, like an editor.

Pros

  • Every row stays exactly 20 px, so the simple math holds.
  • Columns and indentation stay aligned.

Cons

  • The end of a long line is off screen; readers must scroll sideways to read it.
  • One 50,000 character line makes the scroll width enormous for everyone.

Wrap with measured heights

white-space: pre-wrap; measure each rendered row and use the variable-height machinery from the virtualized-list guide.

Pros

  • Everything is readable without sideways scrolling.

Cons

  • Rows we have never rendered have unknown heights, so the scrollbar and jumps are estimates.
  • Every width change invalidates every height.

Wrap with computed heights

With white-space: break-spaces and line-break: anywhere, monospace text wraps at a fixed column count: cols = Math.floor(width / charWidth).

A line of n characters takes Math.ceil(n / cols) rows, computed in the worker for every line, with prefix sums for offsets.

Pros

  • Exact heights without rendering, for all 2 million lines.
  • Jumps and the scrollbar stay exact.

Cons

  • Wide characters (CJK, many emoji) take two columns; a per-line ASCII flag routes those rare lines to measurement.
  • Recomputing on resize is an O(n) pass in the worker, about 10 to 20 ms for 2 million lines.

Truncate with expand

Draw the first 2,000 characters and a "Show 48,000 more" button that opens the full line in a detail panel.

Pros

  • Caps the cost of any one row; a giant text node never reaches layout.

Cons

  • Hides text, so the row must say clearly that it was cut and search must still match the hidden part.

What we pick: Default to no wrap with horizontal scroll, and truncate every row at 2,000 characters with an expand control: the fixed-height model stays exact and no row can blow up layout. Offer wrapping as a toggle, implemented with computed heights in the worker, because monospace text makes the row count arithmetic. Measuring is only the fallback for the rare line with wide characters.

The arithmetic only matches the browser if the browser also wraps at the exact column. Normal wrapping breaks at spaces, and overflow-wrap: anywhere breaks inside a word only when the word alone can't fit, so rows end at different columns. line-break: anywhere allows a break after any character, and white-space: break-spaces stops trailing spaces from hanging past the edge, so every full row holds exactly cols characters; a test that compares computed and measured rows on sample lines keeps that promise honest.

With wrapping on, the line at a given pixel offset is found by binary search over the prefix sums (rowsBefore[n]). The worker keeps them in a Uint32Array and ships the slice for the visible region to the main thread.

Two ways to show a long line. On the left, no wrap: four rows, each 20 pixels, and line 2, a long JSON object, is cut at the right edge and scrolls sideways; a row's top is its number times 20, and lines are cut at 2,000 characters. On the right, wrap with computed heights: line 2 takes three rows because its JSON has 63 characters and the message column fits 22, so ceil of 63 over 22 is 3, and offsets come from prefix sums.no wrap, cut at 2,000 charsL1 GET /cart 200L2 {"order":{"id":88,"iteL3 retry 1/3 paymentsL4 POST /order 201scrolls sidewayswrap, heights computedL1 GET /cart 200L2 {"order":{"id":88,"ite ms":[{"sku":"A1","qty" :2}],"total":42.5}}L3 retry 1/3 paymentsL4 POST /order 201row top = n x 20 pxsame height for every rowL2: ceil(63 / 22) = 3 rowsoffsets: prefix sums
Without wrapping, the window math is one division. With wrapping, monospace text still lets the worker compute every line's row count from its length, so jumps and the scrollbar stay exact without rendering anything.

Check your understanding · Exercise 5

The reader turns on wrapping and then resizes the window from 1,200 px to 800 px wide. What must happen? Select all that apply.

Inside a line: ANSI colors, JSON, and timestamps

Build tools color their output with ANSI escape codes: invisible byte sequences such as ESC[31m (switch to red) and ESC[0m (reset) that terminals interpret. Shown raw, a line reads [31merror[0m: test failed; shown well, "error" is red. Service logs, on the other hand, are often JSON objects on one line, and every line has a timestamp that different readers want in different zones. All three turn stored bytes into what the row shows, and all three must be cheap, because they run for every row that scrolls into view.

The codes that matter for logs are SGR codes (Select Graphic Rendition, the ones ending in m), with parameters separated by semicolons. 1 is bold and 0 resets. 30 to 37 pick one of 8 basic foreground colors and 90 to 97 their bright versions, while 38;5;n picks one of 256 palette colors and 38;2;r;g;b a 24-bit color. The 40s, 100s, and 48 do the same for the background.

ANSI colors without HTML injection

The tempting shortcut is an npm package that converts ANSI to an HTML string, then dangerouslySetInnerHTML. Now a log line containing <img src=x onerror=...> runs script in your product's origin, with the reader's session. Log text is attacker-controlled: anyone who can make your service print a string (a username, a URL path, a header) can write into the log. The xss lesson covers the general rule; here it means the parser must output data, never markup.

Decision How should the row turn ANSI codes into colors?

Convert to an HTML string and inject it

A library maps codes to span tags in a string; the row sets innerHTML.

Pros

  • Few lines of our code.

Cons

  • One missed escape in the library and log text becomes script.
  • Parsing HTML for each row is slower than creating text nodes.

Parse into tokens, render as text nodes

A small state machine splits the line into { text, fg, bg, bold } tokens.

The row renders each token as a span with class names; the text is a child, so the framework escapes it.

Pros

  • Injection is impossible by construction: text never becomes markup.
  • Tokens are plain data the worker can also produce.

Cons

  • We maintain a parser (about 80 lines) and decide which codes to support.

Strip the codes

Remove every escape sequence and show plain text.

Pros

  • Simplest and safest.

Cons

  • Loses the red and yellow that let readers spot failures in 100,000 lines of build output.

What we pick: Parse into tokens and render them as text nodes with class names. It is safe by construction and fast, and the parser is small. The 16 basic colors map to classes from the design system's theme, so dark and light modes both work; 256-color and 24-bit codes map to a validated rgb() value set through the framework's style object, never through a string of markup.

ts
type Token = { text: string; fg?: string; bold?: boolean }; // fg: a theme class or "rgb(...)"
const CSI = /\x1b\[([0-9;?]*)([@-~])/g; // ESC [ parameters, then one final byte

function parseAnsi(line: string): Token[] {
  const tokens: Token[] = [];
  let style: Omit<Token, "text"> = {}; // styles reset at each line start
  let last = 0;
  for (const m of line.matchAll(CSI)) {
    if (m.index > last) tokens.push({ text: line.slice(last, m.index), ...style });
    last = m.index + m[0].length;
    if (m[2] !== "m") continue; // cursor moves, screen clears: drop them
    const p = (m[1] || "0").split(";").map(Number);
    for (let i = 0; i < p.length; i++) {
      const c = p[i];
      if (c === 0) style = {};
      else if (c === 1) style = { ...style, bold: true };
      else if (c === 22) style = { ...style, bold: false };
      else if (c >= 30 && c <= 37) style = { ...style, fg: BASIC[c - 30] };      // "red", ...
      else if (c >= 90 && c <= 97) style = { ...style, fg: BRIGHT[c - 90] };     // "bright-red", ...
      else if (c === 39) style = { ...style, fg: undefined };                     // default color
      else if (c === 38 && p[i + 1] === 5) { style = { ...style, fg: palette(p[i + 2]) }; i += 2; }
      else if (c === 38 && p[i + 1] === 2) { style = { ...style, fg: rgb(p[i + 2], p[i + 3], p[i + 4]) }; i += 4; }
      // 40 to 47, 100 to 107, and 48 set the background the same way.
    }
  }
  if (last < line.length) tokens.push({ text: line.slice(last), ...style });
  return tokens;
}
Parse SGR codes into tokens. Other control sequences (cursor moves, screen clears) are dropped. palette() maps the 256-color index to a theme class or an rgb() string, and rgb() clamps each value to an integer from 0 to 255.

Two details matter for logs. First, carriage returns: progress bars print \r and redraw the same line many times. A terminal shows only the last redraw, so the decoder first drops the \r of a Windows \r\n line ending, then keeps only the text after the last remaining \r.

Second, state across lines: in a terminal, a color set on one line stays on until reset. Carrying it over would mean parsing from the start of the log to draw line 1,500,000. We reset at every line start instead. If a product needs carry-over, the worker can record the color state at the start of each chunk, so any line is at most one chunk of parsing away.

How a raw line with ANSI codes becomes safe colored text. The raw line is ESC[1;31m, then error colon, then ESC[0m, then an img tag with an onerror handler. parseAnsi turns it into two tokens: the text error colon, red and bold, and the text of the img tag with no style. Each token renders as a span whose child is a text node, so the reader sees the word error in red and the img tag as plain characters. No element is created from the log text.raw bytes from the logESC[1;31merror:ESC[0m <img src=x onerror=steal()>parseAnsi"error:"fg: red, bold" <img src=x ...>"no style, just textrender as text nodes<span class="ansi-red ansi-bold">error:</span><span>" <img src=x ...>"</span> (characters)reader sees: error: <img src=x onerror=steal()>
The parser outputs data, never markup. The img tag survives only as characters inside a text node, so its onerror can never run.

JSON lines: pretty-print on expand

A structured log line like {"ts":"...","level":"error","service":"checkout","msg":"card declined","order":{...}} is unreadable as one 600 character line. The row shows the extracted fields in their columns and the msg as the message; the worker pulls msg out when it decodes a window, which costs microseconds per line. A chevron expands the row into a pretty-printed, collapsible tree. That tree is built only on expand, never during scroll, and for lines over 100 KB the parse runs in the worker so a giant payload can't block a frame.

An expanded row breaks the fixed height for that one line. Rather than give up the simple math, the view keeps a small sorted list of expanded lines and their extra heights. The offset of line n becomes n * 20 plus the extra heights of expanded lines above it. With a handful of expanded rows, a linear pass over that list is cheaper than any tree.

Timestamps: local time, UTC, or relative

The worker parses each line's timestamp once, at ingest, into milliseconds since the epoch, stored in a Float64Array. The row formats it when drawn, with one cached Intl.DateTimeFormat per mode. Toggling between local time and UTC changes the formatter (timeZone: "UTC"), not the data, so it is instant even for 2 million lines.

Show milliseconds (fractionalSecondDigits: 3), because many lines share a second. Label the column with the zone ("UTC" or "UTC+05:30"), since a timestamp without a zone is the source of many wrong incident timelines. For build logs, a third mode shows time since the step started (+00:01:23.456), which answers "what was slow" faster than wall-clock time.

Check your understanding · Exercise 6

A log line contains the text <img src=x onerror=alert(1)> in red ANSI color. Which renderings are safe? Select all that apply.

Jumping: to a time, to a line, and links to share

During an incident the question is rarely "what is on line 1,482,003". It is "what happened at 14:32:05" (because a graph spiked then) or "look at the lines my colleague linked". Both are jumps into a log too big to scroll through by hand.

Jump to a timestamp with binary search

The worker already holds every line's timestamp in a Float64Array. In a single build log, timestamps only go up, so finding the first line at or after 14:32:05 is a binary search: about 21 comparisons for 2 million lines, under a microsecond. No scan, no decoding. The view scrolls that line to the top third of the viewport and briefly highlights it.

ts
function lineAtOrAfter(ts: Float64Array, count: number, target: number): number {
  let lo = 0;
  let hi = count; // answer is in [lo, hi]
  while (lo < hi) {
    const mid = (lo + hi) >>> 1;
    if (ts[mid] < target) lo = mid + 1;
    else hi = mid;
  }
  return lo; // === count means "after the last line"
}
First line at or after a time. Returns the store-relative index.

Two real-world wrinkles. Lines without a timestamp (a stack trace's continuation lines) inherit the timestamp of the line before them at ingest, so the array has no holes. Merged service logs can also be slightly out of order, because several machines with different clocks write into one stream; the server sorts each page by time, so out-of-order lines differ only by the clock skew between hosts, usually milliseconds, and binary search still lands close.

If the target is outside what is loaded, the jump becomes a server request for the page around that time (GET /logs?around=...), which the interface section defines.

Binary search for 14:32:05 over ten timestamps: 32:01, 32:01, 32:02, 32:03, 32:03, 32:05, 32:05, 32:06, 32:08, 32:09. Step 1 checks index 5, 32:05, which is not before the target, so hi becomes 5. Step 2 checks index 2, 32:02, before the target, so lo becomes 3. Step 3 checks index 4, 32:03, before the target, so lo becomes 5. Now lo equals hi at index 5, the first line at or after the target.step 1: mid 5 is 32:05, not before target, so hi = 532:0132:0132:0232:0332:0332:0532:0532:0632:0832:09step 2: mid 2 is 32:02, before target, so lo = 332:0132:0132:0232:0332:0332:0532:0532:0632:0832:09step 3: mid 4 is 32:03, before target, so lo = 532:0132:0132:0232:0332:0332:0532:0532:0632:0832:09lo = hi = 5: first line at or after 14:32:0532:0132:0132:0232:0332:0332:0532:0532:0632:0832:09
Three comparisons for ten lines; about 21 for 2 million. It finds the first matching line even when several lines share the same time, because it keeps going left while the value is not before the target.

Clicking a line number selects it and puts #L1234 in the URL; Shift-clicking another extends it to #L1234-L1260. GitHub's file view made this format familiar, and the code-review-diff guide uses the same anchors for diff lines. The URL changes with history.replaceState, so selecting ten lines doesn't add ten entries to the back button. The line numbers are the absolute ones from the store, so the link survives filters, eviction, and reloads.

Opening a link is where the stream complicates things. The page loads, the hash says line 1,234, and the store has 0 lines so far. The view shows the first lines as they arrive and a small banner, "Going to line 1,234", and jumps as soon as the store's count passes it. Follow mode stays off, because the reader came for line 1,234, not the bottom.

If the line is beyond what the store will hold, or was evicted, the view requests that range from the server. If the log is shorter than the link (the build was retried and printed less), say so instead of silently landing on the last line.

Think first

Why not give each row id="L1234" and let the browser scroll to the hash on its own, like a normal page?

Check your understanding · Exercise 10

A link points to #L900000 of a running build. The page opens and the stream has delivered 12,000 lines so far. What should the viewer do? Select all that apply.

Selecting, copying, and downloading lines that aren't there

A reader drags from line 100 down to line 900 to copy a failing test's output. With native browser selection and a virtualized list, three things go wrong. As they drag past the bottom, the rows near line 100 scroll out and are removed, and the selection's start point disappears with them, so it collapses or jumps.

Ctrl+A selects the page, which holds only the 80 rendered rows of the log. And Ctrl+C copies only what is selected in the DOM, so even a lucky selection holds at most a screenful. Copy has to be rebuilt on top of our own data, like search.

Decision How should selection work in a virtualized log?

Native selection only

Let the browser select text in the rendered rows; do nothing special.

Pros

  • Zero code; partial-line selection works perfectly within a screen.

Cons

  • Breaks for anything taller than the viewport, which is the case people care about.
  • Ctrl+A and Ctrl+C silently copy the wrong amount.

A custom line selection

Selection is two absolute line numbers, anchor and focus, held in state.

Click a line number to set it, Shift-click to extend, drag in the gutter with auto-scroll; selected rows get a background.

On copy, the text comes from the store, not the DOM.

Pros

  • Works across any distance and survives rows being recycled.
  • The same selection drives copy, links (#L100-L900), and "download selection".

Cons

  • Whole lines only; we must build keyboard and pointer handling ourselves.

Both, by context

Inside a line's text, native selection works as usual for partial copies.

In the gutter, or with Shift+arrow keys, the custom line selection takes over.

Pros

  • Native feel for small copies, correct behavior for big ones.

Cons

  • Two selection systems; when both exist, the copy handler must decide which wins (the line selection, if it is set).

What we pick: Both, by context. Readers copy a word from one line all the time, and native selection does that best. For anything spanning lines, the gutter's line selection is the reliable path, and it is the same object that writes the #L range into the URL. The copy handler uses the line selection when one is set, and otherwise lets the browser copy natively.

Making Ctrl+C copy lines that aren't rendered

The copy event lets us replace what goes to the clipboard: call event.preventDefault() and event.clipboardData.setData("text/plain", text). The catch is that setData must happen synchronously, inside the event, and our text lives in the worker, one message away. So whenever the line selection changes, the view asks the worker for its text right away (debounced by 100 ms) and keeps the result ready. When Ctrl+C arrives, the text is already on the main thread.

  • What gets copied: the message text with ANSI codes stripped, lines joined by \n. A setting adds timestamps and line numbers, which is what people want when pasting into an incident document.
  • A size limit: prefetching is fine up to about 20,000 lines (around 2.5 MB of text). Past that, the copy handler shows "Selection too large to copy. Download it instead" with a button, rather than freezing the tab building a 250 MB string.
  • A Copy button: for the toolbar button there is no copy event, so it uses the async Clipboard API, navigator.clipboard.writeText(text), inside the click handler. It needs a secure context (HTTPS), and some browsers also require a user gesture, which a button click provides.
  • Select all: with focus in the pane, Ctrl+A selects all lines in the line selection model (not the DOM), and the status bar says "2,000,000 lines selected" so the next step, copy or download, is a choice the reader makes knowingly.
Sequence of copying lines. The reader Shift-clicks line 900 in the gutter. The main thread asks the store worker for the text of lines 100 to 900, and gets 801 lines, 98 KB, back. When the reader presses Ctrl+C, the copy handler calls setData with that text on the clipboard and the reader sees copied 801 lines. On the failure path, the reader presses Ctrl+A then Ctrl+C on 2 million lines, and the main thread answers that the selection is too large to copy and offers a download.ReaderMainStore workerClipboardShift-click L900text 100 to 900801 lines, 98 KBCtrl+CsetData(text)copied 801 linesCtrl+A, Ctrl+Ctoo large: download
The text is fetched when the selection changes, because clipboardData.setData only works during the copy event. Huge selections are turned into a download instead of a 250 MB string.

Downloading: from the server, not from the tab

The naive download builds a Blob from everything in memory and clicks a link to it. That doubles memory at the worst time (250 MB of store plus 250 MB of Blob), and it only contains what the tab holds, which after eviction is not the full log.

The full log already exists on the server as a file. The Download button is a plain link to GET /builds/123/logs/raw, answered with Content-Disposition: attachment and gzip compression, so the browser streams it straight to disk without the page touching a byte. Repetitive log text usually compresses several times over, so the transfer is much smaller than the file.

Downloading a filtered view or a selection is the one case for a client-built file, since only the client knows the filter. Under 50 MB, the worker joins the lines and returns a Blob, which becomes an object URL for a temporary link (revoked with URL.revokeObjectURL once the download has started; the memory-leaks lesson explains why). Above that, the same filters are sent to the server's raw endpoint as query parameters.

Check your understanding · Exercise 11

The reader selects lines 100 to 900 in the gutter, scrolls to line 5,000, and presses Ctrl+C. What makes the right text land on the clipboard? Select all that apply.

Architecture, data model, and interfaces

Every decision so far pushed work off the main thread. Put together, the component splits into three places that run code: the main thread draws and handles input, a store worker owns the bytes and everything computed from them, and a disposable regex worker runs patterns we don't trust. The main thread never holds more than the lines near the viewport.

PartRuns onResponsibility
LogViewermainThe public component: props, toolbar, layout, keyboard shortcuts, the URL hash and query.
ViewportmainScroll position, the window math (including scaled scrolling and wrapped offsets), and the rows on screen.
LogRowmainOne line: gutter, timestamp, level, ANSI tokens with match highlights, expand control.
ViewStoremainSmall UI state: total count, follow state, current match, line selection, filters, decoded line cache.
TailControllermainPer-frame batching of worker messages and the follow and pause rules.
StoreWorkerworkerFetch or stream, split lines, columns (ts, level, service), filter index, substring search, eviction.
RegexWorkerworkerRuns one regex over chunks it is fed; owns no data; killed by the watchdog when stuck.
LogApiworker and mainThe server endpoints: raw file with ranges, paged queries, stream, download.
Architecture of the log viewer across three threads and a server. The main thread holds LogViewer, which feeds Viewport, and ViewStore and TailController. ViewStore sends window, filter, and search requests down to the store worker. The store worker holds LineStore, with chunks and columns, and Search and filter, with the index and counts. LineStore sends totals and decoded windows up to TailController. The Log API on the right streams bytes into LineStore. LineStore feeds chunks one at a time to the regex worker, a disposable RegexRunner that a watchdog on the main thread can kill.Main threadLogViewertoolbar, URLViewportwindow mathViewStorefollow, matchesTailController1 update a frameStore workerSearch, filterindex, countsLineStorechunks, columnsrequeststotals, windowsLog APIrange, query, SSEbytesRegex workerRegexRunnerkillable, no datafed chunks
Per-line work never touches the main thread. The only things that cross the line between threads are small: requests going down, and totals and windows of about 2,000 decoded lines coming up.

Why these boundaries? The store worker sits where the bytes are biggest: it streams from the network straight into its chunks, so 250 MB never crosses a thread boundary. The main thread asks it for line windows: "lines 48,000 to 50,000, decoded, with tokens and match ranges". A 2,000 line window is about 250 KB of strings and takes a few milliseconds to produce.

The view keeps the current window plus one on each side, and when scrolling nears the edge it requests the next one ahead of time. With a fixed line height, the view knows exactly which lines it will need, so prefetching is accurate and a blank row is rare. When one does appear (a scrollbar drag across a million lines), it shows a dim placeholder for a frame or two, which is far better than a frozen tab.

When the product already has a WebSocket on the main thread, tail bytes arrive there instead. The main thread sets the socket's binaryType to "arraybuffer", doesn't decode anything, and posts each frame's buffer to the store worker as a transferable (worker.postMessage(msg, [buffer])). Transferring moves ownership instead of copying, so a 64 KB batch crosses in microseconds. The main thread's buffer becomes detached (its byteLength drops to 0), which guarantees we don't keep two copies.

Data flow of the log viewer. Bytes and events go from the Log API to the store worker. Totals, windows, and matches go from the store worker to the ViewStore on the main thread. The ViewStore renders about 80 rows, which the reader sees. Back the other way, the reader's scrolls, keys, and queries go to the ViewStore, which sends window, filter, and search requests to the worker, which asks the API for ranges and cursors. The URL's hash and filters also feed the ViewStore.Log APIraw, query, SSEStore workerchunks, indexViewStorewindow cacheLogRow x80DOMbyteswindowsrenderrequestsrangesURL#L, ?level, ?qhash, filtersreaderinputper line: stays in workerper screen: crosses
Big data flows only on the left, between the server and the worker. What crosses into the main thread is per screen: a total, a window of lines, a match list. The URL is an input like any other, so #L48311 and ?level=error rebuild the view.

Check your understanding · Exercise 12

Which of these belong in the store worker rather than on the main thread? Select all that apply.

Data model: what lives where

The data splits by size and by who needs it. The big, per-line data lives only in the store worker, as typed arrays. The main thread holds small, per-screen data, and anything a shared link must reproduce lives in the URL. Nothing per-line ever lives in React state.

ts
// Store worker (per-line, typed arrays)
type LineStoreState = {
  chunks: Uint8Array[];      // ~1 MB each, end on a newline
  starts: Uint32Array;       // byte offset of each line in its chunk
  ts: Float64Array;          // epoch ms, inherited for continuation lines
  level: Uint8Array;         // 0 trace .. 5 fatal, 255 unknown
  service: Uint16Array;      // id into services[]
  services: string[];        // dictionary, usually under a few hundred
  firstLine: number;         // absolute number of the oldest kept line
  count: number;             // lines kept
  visible: Uint32Array | null; // filter index, null when no filter
};

// Main thread (per-screen)
type LineWindow = { from: number; lines: { n: number; tokens: Token[]; ts: number; level: number }[] };
type ViewState = {
  total: number;             // lines in the current view (filtered or not)
  follow: "following" | "paused";
  unseen: number;            // for the "N new lines" button
  windows: LineWindow[];     // current plus neighbours, about 6,000 lines
  search: { id: number; query: string; regex: boolean; matches: Uint32Array; current: number; scanned: number };
  selection: { anchor: number; focus: number } | null; // absolute line numbers
  expanded: Map<number, number>; // line -> extra height in px
};
Store worker: columns, one entry per line. Main thread: a window and UI state.
DataLives inWhy there
Raw bytes, line offsets, columnsStore workerHundreds of MB; must stay off the main thread and out of the garbage collector's way.
Filter index, search matchesStore worker, copied to main as neededComputed from the columns; the main thread needs only the visible slice and the match list.
Decoded windowsMain thread cacheWhat rows draw synchronously during scroll.
Follow state, unseen count, current matchMain thread UI stateDriven by input and scroll events.
Filters, search query, selected rangeURL (?level=error&q=timeout#L120-L180)A pasted link must reproduce the view.
Wrap, timezone mode, show timestampslocalStoragePersonal preferences that should persist across logs, not travel in links.
The data model by place. The store worker holds per-line data: chunks as an array of Uint8Arrays of about 1 MB each; starts and ts as a Uint32Array and a Float64Array; level and service as a Uint8Array and a Uint16Array; and visible, the filter index, as a Uint32Array or null. The main thread holds per-screen data: ViewState with follow, unseen, and selection, and windows of about 6,000 decoded lines. Outside both, the URL holds the line anchor, level, and query, and localStorage holds wrap and time zone preferences.Store worker: per linechunksUint8Array[], ~1 MB eachstarts, tsUint32Array, Float64Arraylevel, serviceUint8Array, Uint16ArrayvisibleUint32Array or nullMain thread: per screenViewStatefollow, unseen, selectionwindows~6,000 decoded linesShared or savedURL#L, ?qlocalStoragewrap, zone
Size decides the place. Anything with one entry per line is a typed array in the worker; anything per screen is ordinary state on the main thread; anything a link must reproduce is in the URL.

Note what is missing: there is no Line object type anywhere. A line is a number. Each structure holds one aspect of all lines (offsets, timestamps, levels), which is called a columnar layout. It is what makes the filter pass fast (it reads two small arrays, not 2 million objects) and what keeps memory near 1 byte per character.

Check your understanding · Exercise 13

Where should the "wrap long lines" setting live, and why? Pick one.

Interface: props, server endpoints, and worker messages

Three interfaces meet in this component: the props the products use, the server API it calls, and the message protocol between the main thread and the store worker. Each should be small and boring.

ts
type LogSource =
  | { kind: "file"; rawUrl: string; streamUrl?: string } // build log
  | { kind: "query"; queryUrl: string; tailUrl?: string; filters: LogFilters }; // service logs

type LogViewerProps = {
  source: LogSource;
  maxBytes?: number;                 // store cap, default 256 MB (64 MB on low-memory devices)
  lineHeight?: number;               // default 20
  initialLine?: number;              // otherwise read from #L1234
  timeZone?: "local" | "utc" | "relative";
  onLineSelect?: (range: { from: number; to: number }) => void;
  renderLineActions?: (line: number) => React.ReactNode; // e.g. "Open trace"
};
The component's props. One component serves the build log and the service log.

For a build log, the server needs only a static file that supports Range requests, plus a sparse line index: the byte offset of every 10,000th line. With it, the client turns "lines 900,000 to 910,000" into one byte range without reading what is before it. The live part uses the same URL with a resume offset.

http
GET /builds/123/logs/raw
Range: bytes=112000000-113999999
-> 206 Partial Content, Content-Range: bytes 112000000-113999999/251234567

GET /builds/123/logs/index
-> 200 { "every": 10000, "offsets": [0, 1251004, 2499870, ...] }

GET /builds/123/logs/stream?from=251234567
-> 200, body stays open while the build runs; ends when the build ends

GET /builds/123/logs/raw?download=1
-> 200, Content-Disposition: attachment; filename="build-123.log", Content-Encoding: gzip
Build log endpoints.
http
GET /logs/query?service=checkout&level=error,warn&q=timeout
    &from=2026-03-02T14:00:00Z&to=2026-03-02T15:00:00Z&limit=5000&cursor=eyJ0cyI6...
-> 200 { "lines": [{ "id": "17e9a..", "ts": 1772460725123, "level": "error",
          "service": "checkout", "text": "..." }], "next": "eyJ0cyI6...", "approxTotal": 48210 }

GET /logs/tail?service=checkout&level=error,warn
Accept: text/event-stream
-> id: 17e9b..
   data: {"lines":[...]}            (one event per 100 ms batch)
Service log endpoints: time-cursor paging and a server-sent events tail.

Paging uses an opaque cursor (the last timestamp plus a tiebreak id), not a page number, because new lines keep arriving and page numbers would shift under the reader. approxTotal is labelled approximate on purpose: an exact count over a large time range is expensive for log backends, and "about 48,000" is enough to size a scrollbar. Errors use one shape, { "error": { "code": "range_not_satisfiable", "message": "..." } }, and the pane shows a retry for transient codes.

ts
type ToWorker =
  | { type: "open"; source: LogSource; maxBytes: number }
  | { type: "window"; reqId: number; from: number; to: number; highlight?: Query }
  | { type: "filter"; reqId: number; filters: LogFilters }
  | { type: "search"; searchId: number; query: Query }
  | { type: "text"; reqId: number; from: number; to: number; withTimestamps: boolean }
  | { type: "append"; bytes: ArrayBuffer }; // transferred, from a main-thread socket

type FromWorker =
  | { type: "total"; total: number; firstLine: number; dropped: number; tail?: LineWindow }
  | { type: "window"; reqId: number; window: LineWindow }
  | { type: "search-progress"; searchId: number; scanned: number; matches: number }
  | { type: "search-done"; searchId: number; matches: Uint32Array } // transferred
  | { type: "text"; reqId: number; text: string }
  | { type: "error"; code: "network" | "too-large" | "decode"; message: string };
Main thread and store worker messages. Every request carries an id for matching replies.

Check your understanding · Exercise 14

Why does service log paging use a cursor instead of ?page=3? Select all that apply.

Performance and accessibility: a busy main thread and a quiet screen reader

The hardest moment for the main thread is a reader scrolling while the tail runs at full speed and a search is counting. Here is where each millisecond of a 16.7 ms frame goes in that moment, as estimates for a 4-core laptop. The runtime-performance lesson explains how to measure these in the Performance panel.

Estimated per-frame cost with the tail at 2,000 lines per second, scrolling, and a search running.
WorkWhereEstimated cost per frame
Split lines, parse timestamps and levels, test filters, scan for matchesStore worker0 ms on main
Receive the latest total message (others are dropped unread by the coalescing)Mainunder 0.1 ms
Update ViewState once and compute the windowMainabout 0.2 ms
Render about 80 rows, reusing DOM nodesMain2 to 4 ms
Style, layout, paint of the visible blockMain and compositor3 to 5 ms
TotalMainabout 6 to 9 ms, inside the frame

Avoiding layout thrash

Layout thrash is reading a layout value (like scrollTop or offsetHeight) right after changing the DOM, which forces the browser to compute layout immediately, many times per frame. A log view is at risk because it both reads scroll position and writes rows. The rules that keep it to one layout per frame:

  • Read scrollTop once in the scroll handler, store it, and do all window math from that number. With fixed heights, no row ever needs to be measured.
  • Measure the character width once, when the font loads (document.fonts.ready), with one probe element. It feeds the wrap math and never changes until the font size does.
  • Move the visible block with one transform: translateY(...) and give the scroll container contain: strict, so changes inside it don't invalidate layout outside it.
  • Reuse row nodes. Key rows by their slot in the window (0 to 79), not by line number, so scrolling by one line updates 80 text nodes instead of unmounting and mounting rows. Line numbers are still shown and used for links; they just aren't React keys.
  • When following, set scrollTop after the render that added lines, in the same frame, and never read scrollHeight in a loop. The total height is known: lines times 20.

Knowing it stays fast

Ship with measurement, because the worst logs only exist in production. Record time to first line, the largest log size opened, store bytes at eviction, search duration by log size, regex watchdog kills, and long frames. In Chromium, PerformanceObserver with the long-animation-frame entry type reports frames over 50 ms with the scripts that caused them, which points straight at the culprit. A dashboard of "p95 search time for logs over 1 million lines" tells you when a change made things worse.

Think first

Keying rows by slot instead of line number is unusual advice in React. What does it cost, and when would you key by line number instead?

Check your understanding · Exercise 15

A profile shows 30 ms frames during a fast tail. Which findings would explain it? Select all that apply.

Accessibility: keyboard, screen readers, and a quiet live region

A log viewer is used by engineers who rely on keyboards and screen readers like anyone else, and virtualization takes away much of what assistive technology normally gets for free: the reader can't browse lines that don't exist. The keyboard-navigation and screen-readers lessons cover the general patterns; three things are specific to logs.

The tail: role=log without a flood

ARIA has a role made for this: role="log", a live region where new content is added at the end, with an implicit aria-live="polite". The live-comments guide shows why that fails on a fast stream: the speech queue floods, and at 2,000 lines per second nothing else can be heard. We use its answer. The rows container keeps role="log" with an aria-label (the role requires an accessible name) but sets aria-live="off", so appended lines are never read automatically.

A separate, visually hidden status element announces summaries instead, at most every few seconds: "12 new error lines" while paused, or nothing at all for info lines. The announcement policy is configurable, with errors only by default, because errors are what someone watching a tail is waiting for.

Keyboard: lines, matches, and selection

KeyAction
Up, DownMove the focused line by one; the view scrolls to keep it visible.
Page Up, Page DownMove by a screenful (40 lines).
Home, EndFirst line; last line, which also resumes following.
Shift with any of the aboveExtend the line selection from the anchor.
Ctrl+F or Cmd+FFocus the search box (a second press goes to the browser).
Enter, Shift+Enter in searchNext and previous match.
Ctrl+G or Cmd+GGo to a line number or a time, only while focus is in the pane, since browsers use this shortcut for find next.
EscapeClear the selection, or close search and return focus to the focused line.

Focus uses one tab stop for the whole line area (a roving focus, not 2 million tab stops). The focused line is a number in ViewState, and the rendered row for it gets tabIndex={0} and real focus. When it scrolls out and its node is reused for another line, focus moves to the container and returns to the row when it renders again, so the screen reader doesn't land on the wrong line.

Each row is labelled for screen readers as "Line 48,311, 14:32:05.123, error, card declined". The number and the level go first, because they are what the listener uses to orient.

The search box gets aria-describedby pointing at its count, and the same polite status region announces the result once the scan settles ("312 matches"), not on every progress update. Moving between matches announces "Match 4 of 312, line 48,311" followed by the line's text. Errors from the regex watchdog are announced with the same polite region, since they follow the reader's own action.

Check your understanding · Exercise 16

A screen reader user follows a busy tail. Which choices keep the pane usable for them? Select all that apply.

Running it in the interview

In 60 minutes you can't cover every chapter in depth, and you shouldn't try. The interviewer is looking for whether you see that this is a data problem before it is a rendering problem, and whether you know what virtualization breaks. A good plan for the time:

MinutesWhat to doWhat to say out loud
0 to 5Ask the scope questions: biggest log, live or not, where search runs, structure, line length."2 million lines is 250 MB. That decides most of the design, so let me confirm it."
5 to 12Draw the wireframe and the three threads: main, store worker, regex worker."Per-line work never touches the main thread. The main thread asks for windows of lines."
12 to 22Storage: byte chunks plus offsets, the memory estimate, the cap and eviction."A line is a number, not an object. About 280 MB compact versus 400 to 500 MB as objects."
22 to 32The tail: transport, per-frame batching, follow and pause, the new lines button."Follow only at the bottom; any scroll by the reader pauses. Eviction compensates scrollTop."
32 to 45Search and filters: worker scan, streaming counts, cancellation, ReDoS and the watchdog, the visible-line index."Find-in-page can't see unrendered lines, so we rebuild find. User regexes run where we can kill them."
45 to 55Pick two of: links and jumps, copy, rendering long lines and ANSI, accessibility. Follow the interviewer's interest."Copy uses a line selection model and text from the store, because native selection loses rows that unmount."
55 to 60Summarize trade-offs and what you'd measure."I'd watch time to first line, p95 search time, and long frames during a tail."

Draw the wireframe first, because it names the features (toolbar, gutter, new lines button, dropped lines notice) you will refer to for the rest of the hour. Then draw the three-thread architecture before any detail. Most weak answers spend 20 minutes on windowing math; say that fixed heights make it one division, name the virtualization pieces you would reuse, and move on to what is specific to logs.

Follow-up questions to expect

  • "What if the log is 10 GB?" The client never holds it. The store becomes a sliding window over server ranges, using the sparse line index; search moves to the server or to a background scan that streams through ranges without keeping them. The UI doesn't change, which is the point of the line-number abstraction.
  • "Why not SharedArrayBuffer so both threads can read the bytes?" It would let the main thread decode lines without a message. But it requires cross-origin isolation (Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers), which breaks many third-party embeds in a large product. Line windows with prefetch are fast enough, so we don't pay that cost.
  • "How do you test this?" A synthetic log generator (2 million lines, ANSI codes, long lines, JSON, a catastrophic regex input) in unit tests for the store and parser, and a performance test in CI that fails if a 2 million line search takes over 1 second or any frame during a simulated tail exceeds 50 ms.
  • "Multiple tabs open on the same tail?" Each tab has its own store and stream; that is acceptable for a log pane. A SharedWorker could share one stream across tabs, but support on mobile browsers is recent and still incomplete, so it is an optimization, not the base design.
  • "What about mobile?" A lower cap (64 MB), wrapping on by default because sideways scrolling is painful on a narrow screen, and server-side search by default for service logs.

Check your understanding · Exercise 17

You have 10 minutes left and haven't discussed search. Which points give the interviewer the most signal? Select all that apply.

Summary

  • A line is a number, not an object. Store raw bytes in chunks that end on newlines, with typed-array columns for offsets, timestamps, and levels: about 280 MB for 2 million lines instead of 400 to 500 MB as objects, as estimates.
  • Per-line work lives in a store worker that owns the bytes; the main thread asks for windows of decoded lines and keeps only those near the viewport.
  • The tail appends in the worker and renders at most once per animation frame. Follow only while at the bottom, pause on any scroll by the reader, show an "N new lines" button, and compensate scrollTop when the oldest lines are evicted.
  • Fixed line height makes the window one division. Default to no wrap with truncation; wrapped monospace heights can be computed, not measured, when the browser is told to break at any character. Very tall logs need scaled scrolling past the browser's height limit.
  • Virtualization breaks find, select, copy, and hash links. Rebuild each over the store: worker search with streaming counts, a line selection model with prefetched copy text, and #L links read and resolved by our code.
  • User regexes can hang a thread. Run them in a disposable worker that owns no data, fed one chunk at a time, behind a watchdog that terminates it.
  • Filters are an index of visible line numbers computed from columns; links and selections always use absolute line numbers.
  • Log text is untrusted: parse ANSI into tokens rendered as text nodes, never HTML, and keep the live region for summaries, not lines.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next