Problem RestatementProblem
Adobe asked a frontend system design question: in a PDF viewer, build a search box. The user types a word, the viewer highlights all matches in the whole PDF, shows "match 3 of 27", and Next / Previous buttons (and Enter / Shift+Enter) cycle through matches, scrolling to each one, even on pages that aren't rendered yet. The interviewer discussed pub/sub, React context, and state libraries like Redux, Zustand and MobX.
Components
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
SB["SearchBox - input, counter, next/prev"] -->|"setQuery / next / prev"| STORE["Search store"]
STORE --> VIEW["PdfViewer - virtualized page list"]
VIEW --> PAGE["Page - canvas + text layer + highlights"]
WK["Web worker - text index + search"] --> STORE
STORE --> WK- SearchBox: input (debounced), "i of N", next/prev buttons, and keyboard shortcuts.
- PdfViewer: a virtualized list of pages (only visible pages are rendered).
- Page: draws the page (canvas) plus a text layer (invisible positioned text spans, like PDF.js), and draws highlight boxes for matches on that page.
- Search store:
{ query, matches: [{page, start, end}], activeIndex }.
Searching
- At load time, extract text per page (PDF.js
getTextContent) in a Web Worker, so the UI thread isn't blocked. Build a per-page text string plus a mapping from character offsets to text spans (for highlight positions). - On query (debounced ~200 ms): the worker finds all matches (case-insensitive, normalized whitespace) across pages and returns an ordered list
(page, charStart, charEnd). Cancel an old search if the user keeps typing. - For large PDFs, stream results back page by page, so the counter grows ("27+ matches...").
State and Communication
- Where to keep state: a small store shared by the search box and the pages. Options:
- React Context + useReducer: fine, but any change re-renders every consumer. Avoid it for fast-changing state if many pages subscribe.
- Zustand / Redux with selectors (our choice): each Page subscribes only to its own matches and whether the active match is on it (
select(s => s.matchesByPage[pageNo])), so moving to the next match re-renders just 1–2 pages. - MobX: observable state with fine-grained updates, which also works well.
- Pub/sub view: the store acts as the event bus. The search box publishes
next(), and pages and the viewer react toactiveIndexchanges.
Deep Dive — Jumping to a match on a page that is not renderedDeep dive
A 900-page PDF, virtualised so only the visible pages exist in the DOM. The user searches a word and presses Next to cycle through matches.
Render every page so matches exist in the DOM
Drop the virtualisation and mount all pages, then use ordinary DOM search and scrolling.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
ALL["Render all 900 pages"] --> DOM["Hundreds of thousands of text nodes"]
DOM --> MEM["Memory balloons - the tab becomes unresponsive"]
ALL --> TIME["Initial load takes many seconds"]
MEM --> CRASH["Large documents crash the tab"]It makes searching easy by making the viewer unusable. Virtualisation exists precisely because the document does not fit, so removing it trades the hard problem for a worse one.
Search only the rendered pages
Keep virtualisation and search what is currently mounted.
The viewer stays fast and the feature is wrong: the match count reflects three pages rather than the document, and Next can only reach matches that happen to be on screen. The user's mental model — "find every occurrence" — is not satisfied.
Search the text layer independently of rendering
The text and page geometry come from the PDF itself, so searching does not require a rendered page:
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
PDF["PDF text layer + page metadata"] --> IDX["Search all pages - off the main thread"]
IDX --> LIST["Ordered match list: page, rect, index"]
NEXT["next()"] --> AI["activeIndex = (activeIndex + 1) % N - wraps"]
AI --> PG["Look up the match's page"]
PG --> OFF["Compute scroll offset from known page heights - no render needed"]
OFF --> SCROLL["Scroll the virtualised list"]
SCROLL --> MOUNT["Page mounts, renders its text layer and highlights"]
MOUNT --> CENTER["Scroll the exact match rectangle into view"]
CENTER --> HL["Active match highlighted strongly, others lightly"]- Matches are found in the document, not the DOM, so the count is the true total and Next can reach any of them. Run it off the main thread for large documents so typing stays responsive.
- Page heights come from the PDF metadata, which is what makes the scroll offset computable without rendering anything — the key that unlocks the whole approach.
- Two-stage scroll. Scroll the list to the page, then, once it mounts, centre the exact match rectangle. Trying to do it in one step fails because the rectangle does not exist yet.
- Distinguish the active match with a stronger highlight, or cycling through twelve identical highlights tells the user nothing about where they are.
Debounce the query and search incrementally as the user types — a full-document search per keystroke is the other way this feature becomes slow, even when nothing is being rendered.
Performance and AccessibilityScale
- Only visible pages (plus a buffer) have canvases. Highlights are simple absolutely positioned divs over the text layer.
- The search runs in a worker, and the result list is compact. Memoize per-page highlight rectangles.
- Accessibility:
aria-liveannouncements ("Match 3 of 27"), keyboard shortcuts (Ctrl+F focuses the box, Enter/Shift+Enter navigates, Esc clears), and visible focus. - Tests: unit tests for the store logic (wrap-around, empty results), and integration tests that navigate to a match on page 80.
Wrap-UpWrap-up
Extract page text in a web worker and search it there, producing an ordered list of (page, start, end) matches. Keep query, matches and the active index in a selector-based store (Zustand/Redux or MobX), so only affected pages re-render. Cycle with wrap-around, scroll the virtualized viewer to the target page using known page heights, then scroll the exact highlight into view once the page's text layer renders, with debounced input, keyboard shortcuts and live announcements.