react-pdf performance: Memoization, virtualization, DPI
Table of contents
react-pdf performance — the most common pitfalls like unmemoized props and high-DPI rendering, plus techniques like page virtualization and server-side range requests.
- Memoize the
fileandoptionsprops — both compare with===, so a fresh object on every render triggers a refetch loop. - Cap
devicePixelRatio(e.g.Math.min(2, window.devicePixelRatio)) — 3× rendering uses ~9× the memory of 1×. - Render only the pages the user can see. A page window of 3 is enough for most viewers; reach for
react-window’sVariableSizeListif your PDF has mixed page sizes. - Disable unused layers (
renderTextLayer={false},renderAnnotationLayer={false}) and make sure your server returnsAccept-Ranges: bytesso PDF.js can stream byte ranges.
react-pdf can consume significant memory and CPU, especially with large documents or high-dots-per-inch (DPI) displays. This guide covers the officially recommended optimization techniques.
1. Memoize the file prop
The most common react-pdf performance issue: The file prop uses strict equality (===). If you create a new object each render, the PDF refetches every time:
// BAD — new object every render → infinite refetch loop.function Viewer({ url }) { return <Document file={{ url }} />;}
// GOOD — memoized.function Viewer({ url }) { const file = useMemo(() => ({ url }), [url]); return <Document file={file} />;}
// GOOD — stored in state.function Viewer({ url }) { const [file] = useState(() => ({ url })); return <Document file={file} />;}
// GOOD — string URL (primitives are compared by value).function Viewer({ url }) { return <Document file={url} />;}2. Memoize the options prop
The same issue applies to options:
// BAD — new object every render.<Document options={{ cMapUrl: "/cmaps/" }} />
// GOOD — defined outside component.const options = { cMapUrl: "/cmaps/" };
function Viewer() { return <Document file={file} options={options} />;}
// GOOD — memoized inside component.function Viewer() { const options = useMemo(() => ({ cMapUrl: "/cmaps/" }), []); return <Document file={file} options={options} />;}3. Cap device pixel ratio
On high-DPI displays (Retina, 2× or 3×), PDF.js renders the canvas at the same multiplier — that’s 4× or 9× the pixels, and roughly 4× or 9× the memory. Cap it:
<Page pageNumber={1} devicePixelRatio={Math.min(2, window.devicePixelRatio)}/>For thumbnails, use an even lower devicePixelRatio cap:
<Thumbnail pageNumber={1} width={150} devicePixelRatio={1}/>| DPI | Canvas for 800px wide page | Memory |
|---|---|---|
| 1× | 800 × 1035 | ~3.3 MB |
| 2× | 1600 × 2070 | ~13.2 MB |
| 3× | 2400 × 3105 | ~29.8 MB |
The memory required is width × height × 4 bytes (RGBA, one byte per channel).
4. Render only visible pages
Don’t render all pages at once for large documents. Use a windowed approach:
function VirtualizedPDF() { const [numPages, setNumPages] = useState(null); const [currentPage, setCurrentPage] = useState(1); const windowSize = 3; // Render current page +/- 1.
const visiblePages = useMemo(() => { if (!numPages) return []; const start = Math.max(1, currentPage - Math.floor(windowSize / 2)); const end = Math.min(numPages, start + windowSize - 1); return Array.from({ length: end - start + 1 }, (_, i) => start + i); }, [currentPage, numPages, windowSize]);
return ( <Document file={file} onLoadSuccess={({ numPages }) => setNumPages(numPages)} > {visiblePages.map((pageNum) => ( <Page key={pageNum} pageNumber={pageNum} /> ))} </Document> );}For a full virtual scroll implementation, use a library like react-window or react-virtualized:
import { FixedSizeList } from "react-window";
function VirtualScrollPDF({ numPages }) { return ( <Document file={file}> <FixedSizeList height={800} width={600} itemCount={numPages} itemSize={1035} // Approximate page height. > {({ index, style }) => ( <div style={style}> <Page pageNumber={index + 1} width={600} /> </div> )} </FixedSizeList> </Document> );}FixedSizeList assumes every page is the same height. If your PDF has mixed page sizes (portrait/landscape, A4/Letter, foldouts), switch to VariableSizeList and compute each page’s height from the viewport returned by <Page>’s onLoadSuccess callback.
5. Don’t resize canvas with CSS
Never use CSS width/height or transforms to resize the canvas. This doesn’t change the rendering resolution — it just stretches the pixels, causing blurriness or wasted memory:
// BAD — CSS resize (stretches pixels, doesn't change render resolution).<Page pageNumber={1} />// .react-pdf__Page canvas { width: 400px; } ← in your stylesheet.
// GOOD — use the `width` prop.<Page pageNumber={1} width={400} />6. Disable unnecessary layers
If you don’t need text selection or annotations, disable them:
<Page pageNumber={1} renderTextLayer={false} // Skip text layer. renderAnnotationLayer={false} // Skip annotation layer./>Each layer adds document object model (DOM) elements and processing time.
7. Use renderMode “none” for data-only
This is useful when you only need text or annotation data, not visual rendering:
<Page pageNumber={1} renderMode="none" renderTextLayer={false} renderAnnotationLayer={false} onGetTextSuccess={({ items }) => { // Process text without rendering. }}/>8. Server-side considerations
Ensure your server supports HTTP 206 (Partial Content) for range requests. Range request support allows PDF.js to download only the needed parts of the PDF instead of the entire file:
Accept-Ranges: bytesContent-Range: bytes 0-65535/1234567Without range request support, the entire PDF must download before the first page renders.
Summary checklist
| Optimization | Impact | Effort |
|---|---|---|
Memoize file prop | Critical | Low |
Memoize options prop | High | Low |
Cap devicePixelRatio | High | Low |
| Virtualize pages | High | Medium |
| Disable unused layers | Medium | Low |
Use width prop not CSS | Medium | Low |
| Enable range requests (server) | High | Medium |
| Lower thumbnail DPI | Medium | Low |
How Nutrient Web SDK handles this
Every optimization technique in this guide is handled automatically by Nutrient Web SDK’s WebAssembly (WASM) rendering engine:
// No memoization, no DPI capping, no virtualization, no layer toggling.// All optimizations are automatic.const instance = await NutrientViewer.load({ container: "#pdf-container", document: "document.pdf", // WASM rendering engine handles: // - Progressive loading and render prioritization. // - Automatic memory management. // - DPI-aware rendering without canvas bloat. // - Only visible pages rendered.});Most of the client-side optimizations in this guide — memoizing props, capping DPI, virtualizing pages, disabling layers, managing memory — are handled automatically by Nutrient’s WASM rendering engine. Range requests still depend on your server returning Accept-Ranges: bytes, but Nutrient takes advantage of them automatically when available.
Learn more about Nutrient Web SDK | Migration guide | Contact Sales
FAQ
The file prop is compared by reference (===). If you pass an object literal like <Document file={{ url }} />, React sees a new object on every render and react-pdf reloads the document. Memoize the object with useMemo, lift it to state, define it outside the component, or pass a plain string URL.
devicePixelRatio cap?Math.min(2, window.devicePixelRatio) is the common compromise — sharp on Retina, half the memory of a 3× render on phones. For thumbnails, set devicePixelRatio={1}. If you’re targeting strictly print-quality output, leave it at the native value and accept the memory cost.
For most viewers, the current page plus one above and one below is enough. Start at three and increase only if users see flashes of blank space when scrolling fast. Larger windows trade memory for fewer mid-scroll renders.
react-window if my PDF has different page sizes?Yes — use VariableSizeList instead of FixedSizeList. Compute each page’s height from the viewport you get back in <Page>’s onLoadSuccess (or preload page metadata via pdf.getPage()). Then feed those heights into the list.
Yes. The text layer is what makes the rendered page text-selectable and searchable in the DOM. Only disable it for view-only contexts (thumbnails, previews, screenshots-as-images). If you need search but not selection, keep the text layer and hide it visually instead.
Send a HEAD request and look for Accept-Ranges: bytes in the response. Or open the DevTools Network panel, load the PDF, and check for multiple 206 Partial Content responses with Range: request headers — that’s PDF.js streaming byte ranges. Some content delivery network (CDN) configurations strip Accept-Ranges for gzipped content; serve PDFs uncompressed (Content-Encoding: identity).