Building a Crash-Proof PDF Reader for Large Documents in React Native
Learn an architecture for rendering massive multi-thousand-page PDFs in React Native, with lazy bookmark trees, search, and stable navigation on low-end devices.
This article is published in English.
Sources: Rendering and Navigating Large PDFs in React Native Without Crashing the App
A five-page PDF is trivial to show on screen in a React Native app. A document that stretches across hundreds or thousands of pages, carries deeply nested bookmarks, and links between sections internally is an entirely different challenge — one that can quickly turn sluggish, unresponsive, or outright crash-prone on less powerful hardware. This guide walks through an architecture that addresses that challenge directly: rendering large PDFs efficiently, jumping to arbitrary pages, browsing and searching nested bookmark trees, and keeping the whole experience stable even on constrained devices.
In one test case, a PDF ran past 1,900 pages and included a bookmark tree with many layers of nesting. Trying to expand that entire tree in one go put serious strain on memory. The sections below describe the approach that resolved these issues.
What react-native-pdf Does — and Doesn't — Give You
Before settling on companion libraries, it helps to know exactly where the built-in capabilities of react-native-pdf stop, because that boundary drives the rest of the design:
- Through its
onLoadCompletecallback, it does expose raw bookmark information (via atableContentsparameter) on iOS and Android — but not on Windows, and definitely not in a form you could put on screen directly. It's raw, unstructured data that still needs parsing and rendering on your end. - It does offer a generic tap callback for links (
onPressLink), but this callback can't tell an internal jump within the document apart from an outbound URL, and it doesn't reliably supply the target page number for internal links. - It provides no built-in full-text search inside the document. This isn't a shortcoming specific to this library — genuine in-document text search is essentially absent from the open-source React Native PDF landscape right now. The few tools that do support it, Nutrient (previously known as PSPDFKit) being the most recognized, are commercial SDKs with licensing costs per platform that go well beyond what most small teams or personal projects can afford.
Given that, the practical path to dependable, cross-platform bookmark and link data is to pull it out independently instead of depending on the renderer's raw callback output — which is exactly the role pdf-lib plays, discussed next. Searching text inside the PDF content itself is not something this architecture attempts; what it does tackle is searching bookmark titles, a considerably simpler task that's fully achievable with open-source tooling.
Choosing the Packages
The setup relies on three packages, each responsible for one narrow task:
react-native-pdf handles rendering by using the platform's native PDF engine instead of converting pages into JavaScript-based images — this native approach is what keeps memory consumption reasonable when dealing with a large file. It's responsible for showing pages, supporting zoom, firing page-change events, and letting you jump to a page programmatically.
react-native-blob-util is a dependency that react-native-pdf itself relies on for file access — it takes care of downloading, caching, and reading the PDF that gets displayed. It isn't an optional extra; it's a requirement baked into how react-native-pdf works internally.
pdf-lib addresses the gap outlined earlier. Rather than rendering anything, it lets you read and manipulate a PDF's internal structure directly: the document catalog, the page tree, and — crucially for this use case — the outline dictionary, which is where bookmarks actually live inside a PDF, along with the link annotation objects attached to individual pages, where both internal and external links are stored. Because react-native-pdf doesn't surface any of this in a structured or reliable way, pdf-lib is used to traverse that internal structure a single time and produce a clean JSON representation of the bookmark tree — title, target page, and nested children — plus a resolved set of link destinations, all independent of the native renderer's platform-specific behavior. This extraction step should run once rather than on every render, a point covered in more detail in Step 6.
Expo note:
react-native-pdfdepends on native modules, so it won't run inside the standard Expo Go client. A custom development build or a bare/native project is required.
The Architecture
The system is organized into three layers, each isolated from the internal workings of the others:
- PDF Viewer — tracks the current page, total page count, loading state, and any error state.
- Reader Toolbar — displays current-page details and provides access to the bookmark panel.
- Bookmark Panel — manages search input, the expandable bookmark tree (built from data extracted via
pdf-lib), and communicates back a single piece of information: which page the user selected.
The bookmark panel has no awareness of how the PDF itself gets rendered, and the PDF viewer has no knowledge of how bookmark search is implemented. This one-way flow — the panel selects a page, and the screen instructs the viewer to navigate there — is what prevents actions like typing into a search field or expanding a tree node from triggering an expensive re-render of the PDF component itself.
Step 1: Structure the Bookmark Data
Every bookmark you work with needs three things: a title, a target page, and an optional array of child bookmarks — a straightforward recursive structure that lines up naturally with what the pdf-lib extraction step hands back. The detail that matters most here is that each bookmark must carry a stable, unique identifier, and that identifier cannot simply be its position in the array. Once a user starts typing into search or toggling branches open and closed, a given bookmark's position in the rendered list will shift around. If you rely on array index as an id, React loses the ability to tell one row from another, expand/collapse state gets attached to the wrong node, and FlatList starts recycling rows incorrectly.
Step 2: Search Without Losing Context
Since a matching bookmark is frequently nested several layers into the tree, your search logic needs to walk the structure recursively rather than only scanning top-level titles. Just as important: whenever a child node matches the search term, you need to retain its parent in the results too, even if the parent's title itself doesn't match anything. Skip this and a deeply nested hit will appear floating with no indication of where it actually lives in the document.
To keep this responsive, wrap the filtering logic in a memoization hook so it only reruns when either the search string or the underlying bookmark data changes. For trees with a large number of entries, add debouncing on the text input as well, so you're not re-filtering the entire structure on every single keystroke.
Step 3: Flatten Only the Visible Nodes
FlatList is the right tool here for performance reasons, but it expects a flat array, not a nested tree structure. So you need a pass that walks your bookmark tree and produces a flattened array containing only the nodes that should currently be visible — a node's children only get included if that node is currently expanded. Each flattened entry should also carry a depth number so you know how far to indent it visually.
This step is arguably the most consequential performance decision in the whole implementation. If you flattened the whole tree and just hid the rows belonging to collapsed branches, FlatList would still be holding onto thousands of rows that never appear on screen. By only flattening what's currently expanded, a bookmark tree with thousands of entries can end up handing FlatList just a few dozen rows at any given moment.
Step 4: Build the Bookmark Panel
Implement the panel as a Modal layered above the PDF viewer rather than a screen that replaces it, and constrain its height to roughly 80% of the display instead of letting it take over the full screen. This leaves a visible sliver of the PDF beneath it for orientation, and gives you room for an obvious close button instead of making the panel feel like an entirely separate screen.
Inside the modal you'll want: a search field wired to the logic from Step 2; a FlatList rendering the flattened rows produced in Step 3, configured with windowing props — initialNumToRender, maxToRenderPerBatch, windowSize, and removeClippedSubviews — so that only rows near the visible area are ever actually mounted; and a fallback empty state for when a search returns nothing. Each row should expose two distinct tap targets: tapping the row body selects that page and dismisses the panel, while tapping a small expand icon toggles its children, so that opening a branch never accidentally triggers navigation. Wrap each row component in memo so that expanding one branch doesn't force a re-render across the rest of the list. Additionally, whenever the search field isn't empty, automatically expand any branch containing a match, so users aren't left digging through collapsed nodes to find the very result they searched for.
Step 5: Build the PDF Reader Screen
This screen is where the actual rendering happens, via the Pdf component. Use the page-changed callback to keep track of the current page and total page count, display the library's built-in loading indicator while the document loads, and hook into the error callback so that, on failure, the screen swaps to a clear "couldn't open this document" message with a retry option — don't let a loading spinner spin forever with no fallback.
Whenever you navigate to a page from a bookmark, clamp the target page number so it falls between 1 and the known total page count. This guards against a broken or outdated destination — whether it originated in the bookmark data itself or from a resolved internal link — trying to send the viewer to a page number that simply doesn't exist. It's also worth turning on caching for the PDF source, so a document that's already been downloaded isn't re-fetched every single time this screen mounts.
As for visual styling, that's naturally going to depend on your app's own design system — but structurally, the things that matter for usability are keeping the panel capped near 80% height, providing an obvious way to close it, giving each row (particularly its expand icon) a comfortable tap target, and designing a calm, clearly distinguishable error state.
Step 6: Extract Once, Not on Every Render
The pdf-lib extraction step covered earlier should run exactly once, outside of the render cycle — not inside a component that re-renders every time the user searches or expands a branch. Cache the resulting bookmark JSON instead of recomputing it repeatedly, keep only the lightweight fields (ids, titles, page numbers) in React state, avoid ever storing raw PDF byte data in state, and clean up any temporary resources once the screen unmounts. If your backend is in a position to run this extraction step instead, that's typically the cleaner approach — the mobile app then only needs to receive a small JSON payload.
Step 7: Preserve the User's Position
Closing the bookmark panel or rotating the device shouldn't reset the reader to the first page. At a minimum, you need to retain the current page, the most recently expanded bookmark branch, and the panel's scroll offset. Store these values at the level of the parent screen or the navigation state, rather than inside the bookmark panel component itself — that way they survive even if the panel unmounts.
Step 8: Handle Errors Gracefully
Plan for real-world failures, not just the happy path: file URLs that are missing or have expired, documents that are corrupted or password-protected, devices running low on storage, network connections that drop mid-download, bookmarks pointing at invalid pages, or links that simply won't open. Whatever happens, avoid leaving the user staring at a spinner that never resolves. If a document has no bookmarks, say so directly — something like "This document does not contain bookmarks." If a search returns nothing, show "No bookmarks found." A well-defined empty or error state is far better than a screen that merely looks like it's stuck.
Step 9: Test the Real Failure Scenarios
Don't validate your implementation against a single convenient PDF. Broaden your testing across these dimensions:
- Documents: files with no bookmarks at all, files running into hundreds of pages, deeply nested bookmark hierarchies, large embedded images, password protection, intentional corruption, internal links, external links, and titles containing special characters
- Navigation: jumping to root-level and deeply nested bookmarks, the very first and last page, an out-of-range page number, reopening the panel after closing it, searching for a nested entry, and a search that yields no results
- Devices: a low-memory Android phone, a recent flagship device, both iPhone and iPad, portrait and landscape orientation, and transitions between background and foreground
- Network conditions: working offline with an already-cached document, losing connectivity partway through a download, retrying after the connection returns, and an expired or unreachable remote file URL
Testing on physical hardware matters more here than in most feature work — an emulator simply won't reproduce the memory pressure and storage constraints you'll see on an actual budget device.
Frequently Asked Questions
Why not just render every page as an image? A native rendering engine draws only the content currently needed on screen, rather than forcing React to manage a separate image component for every one of a thousand pages.
Why use pdf-lib instead of the bookmark data react-native-pdf already provides? Because that built-in data is unprocessed, behaves inconsistently across platforms (Windows returns none whatsoever), and offers no dependable way to resolve internal link destinations. pdf-lib parses the PDF's outline and link-annotation structures directly, in a single pass, giving you clean, predictable data you fully control.
Is full-text search within the document itself possible? Not with a purely open-source toolchain at this point — that capability is currently reserved for commercial SDKs. The technique described here covers search over bookmark titles, which is a narrower problem you can fully solve on your own.
Should every branch in the bookmark tree expand automatically? No — expand only the branch the user actively opens. On devices with limited memory, it's worth restricting the UI so only one root-level branch can stay expanded at any given time.
Does any of this guarantee the app will never crash? No approach can make that promise across every possible document and device combination. The aim is to reduce risk: avoid rendering eagerly, keep stored state minimal, extract data only once, virtualize the bookmark list, and explicitly handle every failure mode.
Final Thoughts
Getting performance right isn't a matter of picking one library — react-native-pdf handles rendering, pdf-lib supplies trustworthy bookmark and link information, and true stability comes from how you manage state, virtualization, and error handling around both of them.
The practical rules to follow:
- Run bookmark and link extraction once through
pdf-lib, never as part of a render path. - Never render the entire bookmark tree at once — flatten and display only the expanded portions.
- Back the bookmark list with a virtualized
FlatList. - Keep raw PDF byte data out of React state entirely.
- Validate a page destination before navigating to it.
- Persist only the minimal state needed to restore the reader's position.
- Test using genuinely large documents on genuinely constrained, low-memory devices.
- Always surface a clear error or empty state — never leave an infinite loading spinner on screen.