Sixth post in the Carific.ai series. Previous posts: Auth System, AI Resume Analyzer, Structured AI Output, Profile Editor Type Safety, and Domain Model Architecture.
The PDF preview flickered every time I typed. Not a subtle flicker - a full white flash that made the whole interface feel broken. Users would be editing their resume on the right, and the preview on the left would flash repeatedly like a broken fluorescent light.
This is the story of how I built a PDF viewer for Carific.ai using the dual-document pattern from react-pdf's official REPL, and how code review caught memory leaks I completely missed.
The entire codebase is MIT licensed. Every line of code in this post is live in the repo.
The Mission
I needed a live PDF preview that:
- ✅ Updates in real-time as users edit their resume
- ✅ No flickering during updates
- ✅ Zoom controls (50% to 300%)
- ✅ Multi-page navigation for longer resumes
- ✅ No memory leaks (blob URLs properly cleaned up)
- ✅ Production-ready performance
Sounds straightforward, right? The flickering alone took me down a rabbit hole that taught me more about React rendering than I expected.
The Stack (Actual Versions)
{
"next": "16.0.10",
"react": "19.2.3",
"@react-pdf/renderer": "4.3.1",
"react-pdf": "10.2.0",
"pdfjs-dist": "5.4.449",
"use-debounce": "10.0.6"
}
Chapter 1: The Flicker Problem
The Naive Approach
My first implementation was the obvious one-use @react-pdf/renderer's <PDFViewer> component:
// ❌ This flickers on every update
export function PDFViewerClient({ data }: PDFViewerClientProps) {
const [debouncedData] = useDebounce(data, 500);
const document = useMemo(
() => <ResumeTemplate data={debouncedData} />,
[debouncedData]
);
const viewerKey = JSON.stringify(debouncedData);
return (
<PDFViewer
key={viewerKey} // Force remount on data changes
style={{ width: "100%", height: "100%" }}
showToolbar={false}
>
{document}
</PDFViewer>
);
}
The problem: Every time debouncedData changed, the entire PDFViewer component remounted. Full white screen, then the new PDF appeared. Terrible UX.
Why Force Remount?
I was using key={viewerKey} because without it, @react-pdf/renderer threw cryptic errors when the document structure changed:
Error: Minified React error #130
Eo is not a function
Not helpful. After digging through GitHub issues, I learned that @react-pdf/renderer's <PDFViewer> doesn't handle dynamic document updates well. The workaround? Force a complete remount with a changing key prop.
It worked, but the flicker was unacceptable. There had to be a better way.
Chapter 2: The Dual-Document Pattern
I found the solution in react-pdf's own REPL source code. They use a clever pattern: render two documents simultaneously during transitions.
How It Works
- Keep the old PDF visible (at 50% opacity)
- Render the new PDF invisibly (absolute positioned)
- Wait for the new PDF to fully render
- Swap atomically when ready
No white flash. No flicker. Just a smooth fade transition.
The Implementation
"use client";
import { useState, useEffect, useRef } from "react";
import { pdf } from "@react-pdf/renderer";
import { Document, Page, pdfjs } from "react-pdf";
import { useDebounce } from "use-debounce";
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
export function PDFViewerClient({ data }: PDFViewerClientProps) {
const [debouncedData] = useDebounce(data, 300);
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [previousPdfUrl, setPreviousPdfUrl] = useState<string | null>(null);
const [isRendering, setIsRendering] = useState(false);
const pdfUrlRef = useRef<string | null>(null);
const previousPdfUrlRef = useRef<string | null>(null);
// Generate PDF blob URL
useEffect(() => {
let cancelled = false;
let generatedUrl: string | null = null;
let urlSetToState = false;
const generatePdf = async () => {
setIsRendering(true);
setError(null);
try {
const blob = await pdf(
<ResumeTemplate data={debouncedData} />
).toBlob();
const objectUrl = URL.createObjectURL(blob);
generatedUrl = objectUrl;
if (!cancelled) {
urlSetToState = true;
setPdfUrl(() => {
pdfUrlRef.current = objectUrl;
return objectUrl;
});
} else {
URL.revokeObjectURL(objectUrl);
}
} catch (err) {
if (!cancelled) {
console.error("PDF generation error:", err);
setError(
err instanceof Error ? err.message : "Failed to generate PDF"
);
setIsRendering(false);
}
}
};
generatePdf();
return () => {
cancelled = true;
// Only revoke if URL was created but never set to state
if (generatedUrl && !urlSetToState) {
URL.revokeObjectURL(generatedUrl);
}
};
}, [debouncedData]);
const handleRenderSuccess = () => {
setPreviousPdfUrl((prev) => {
if (prev && prev !== pdfUrl) {
setTimeout(() => URL.revokeObjectURL(prev), 500);
}
previousPdfUrlRef.current = pdfUrl;
return pdfUrl;
});
setIsRendering(false);
};
const isFirstRender = !previousPdfUrl;
const shouldShowPrevious =
!isFirstRender && isRendering && previousPdfUrl !== pdfUrl;
return (
<div className="relative w-full h-full">
{/* Previous PDF (faded) */}
{shouldShowPrevious && previousPdfUrl && (
<div className="opacity-50 transition-opacity duration-200">
<Document file={previousPdfUrl} loading={null} error={null}>
<Page
pageNumber={currentPage}
width={containerWidth * zoom}
renderTextLayer={false}
renderAnnotationLayer={false}
/>
</Document>
</div>
)}
{/* New PDF (invisible until ready) */}
<div
className={shouldShowPrevious ? "absolute top-0 left-0 right-0" : ""}
>
{pdfUrl && (
<Document
file={pdfUrl}
loading={null}
error={null}
onLoadSuccess={handleDocumentLoadSuccess}
>
<Page
pageNumber={currentPage}
width={containerWidth * zoom}
onRenderSuccess={handleRenderSuccess}
renderTextLayer={true}
renderAnnotationLayer={true}
/>
</Document>
)}
</div>
</div>
);
}
The Magic Moment
The key is onRenderSuccess. This callback fires only when the new page is fully rendered. That's when we:
- Move the current
pdfUrltopreviousPdfUrl - Set
isRendering = false - The old document disappears, new one becomes visible
Zero flicker. Smooth as butter.
Chapter 3: Adding Zoom Controls
With the flicker solved, I added zoom functionality using shadcn/ui components:
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import { ZoomIn, ZoomOut, RotateCcw } from "lucide-react";
const [zoom, setZoom] = useState<number>(1);
const onZoomIn = () => setZoom((prev) => Math.min(prev + 0.25, 3));
const onZoomOut = () => setZoom((prev) => Math.max(prev - 0.25, 0.5));
const onResetZoom = () => setZoom(1);
return (
<div className="flex items-center justify-between h-16 px-5 gap-3 bg-background border-t w-full">
<ButtonGroup>
<Button
onClick={onZoomOut}
disabled={zoom <= 0.5}
title="Zoom Out"
variant="outline"
size="icon-sm"
>
<ZoomOut />
</Button>
<div className="flex items-center justify-center px-3 text-sm font-medium border-y bg-background min-w-[60px]">
{Math.round(zoom * 100)}%
</div>
<Button
onClick={onZoomIn}
disabled={zoom >= 3}
title="Zoom In"
variant="outline"
size="icon-sm"
>
<ZoomIn />
</Button>
<Button
onClick={onResetZoom}
disabled={zoom === 1}
title="Reset Zoom"
variant="outline"
size="icon-sm"
>
<RotateCcw />
</Button>
</ButtonGroup>
</div>
);
The zoom applies to both documents during transitions, maintaining the smooth effect.
Adding Page Navigation
For multi-page resumes, I added pagination controls:
const [numPages, setNumPages] = useState<number>(1);
const [currentPage, setCurrentPage] = useState<number>(1);
const handleDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
setNumPages(numPages);
setCurrentPage((prev) => Math.min(prev, numPages));
};
const onPreviousPage = () => {
setCurrentPage((prev) => Math.max(prev - 1, 1));
};
const onNextPage = () => {
setCurrentPage((prev) => Math.min(prev + 1, numPages));
};
return (
<div className="flex items-center justify-between h-16 px-5 gap-3 bg-background border-t w-full">
{/* Zoom controls */}
<ButtonGroup>{/* ... zoom buttons ... */}</ButtonGroup>
{/* Page navigation - only show for multi-page PDFs */}
{numPages > 1 && (
<ButtonGroup>
<Button
onClick={onPreviousPage}
disabled={currentPage === 1}
variant="outline"
size="sm"
>
← Previous
</Button>
<div className="flex items-center justify-center px-3 text-sm font-medium border-y bg-background">
Page {currentPage} / {numPages}
</div>
<Button
onClick={onNextPage}
disabled={currentPage >= numPages}
variant="outline"
size="sm"
>
Next →
</Button>
</ButtonGroup>
)}
</div>
);
The onLoadSuccess callback from react-pdf gives us the total page count, and we track the current page in state.
Making It Responsive
The PDF needs to adapt to different screen sizes. I used ResizeObserver to dynamically adjust the width:
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState<number>(600);
useEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentRect.width;
// Subtract padding, enforce minimum width
setContainerWidth(Math.max(width - 40, 400));
}
});
resizeObserver.observe(containerRef.current);
return () => resizeObserver.disconnect();
}, []);
return (
<div ref={containerRef} className="relative w-full h-full">
{/* PDF renders at containerWidth * zoom */}
<Page width={containerWidth * zoom} />
</div>
);
Now the PDF scales smoothly when users resize their browser or switch between desktop and mobile.
Chapter 4: The Memory Leaks I Missed
After implementing the dual-document pattern, I thought I was done. Then code review happened.
Issue #1: Component Unmount Cleanup
The problem: When the component unmounts, pdfUrl and previousPdfUrl blob URLs aren't revoked.
// ❌ State values in cleanup capture initial values (null)
useEffect(() => {
return () => {
if (pdfUrl) URL.revokeObjectURL(pdfUrl); // Always null!
if (previousPdfUrl) URL.revokeObjectURL(previousPdfUrl); // Always null!
};
}, []);
Why this fails: Cleanup functions capture values from when the effect runs. Since the effect runs once on mount (empty dependency array), pdfUrl and previousPdfUrl are both null.
The fix: Use refs to track the latest URLs:
const pdfUrlRef = useRef<string | null>(null);
const previousPdfUrlRef = useRef<string | null>(null);
// Update refs whenever URLs change
setPdfUrl(() => {
pdfUrlRef.current = objectUrl;
return objectUrl;
});
// Cleanup with refs
useEffect(() => {
return () => {
if (pdfUrlRef.current) {
URL.revokeObjectURL(pdfUrlRef.current);
}
if (previousPdfUrlRef.current) {
URL.revokeObjectURL(previousPdfUrlRef.current);
}
};
}, []);
Refs always contain the latest value, making them perfect for cleanup.
Issue #2: Effect Cleanup Race Condition
The problem: When debouncedData changes rapidly, the effect cleanup revokes generatedUrl which might be the same URL that's now previousPdfUrl.
// ❌ Race condition
return () => {
cancelled = true;
if (generatedUrl) {
URL.revokeObjectURL(generatedUrl); // Might still be in use!
}
};
Timeline of the bug:
- Generate PDF →
generatedUrl = "blob:abc" - Set to state →
pdfUrl = "blob:abc" - User types → effect cleanup runs
- Cleanup revokes
"blob:abc"❌ - But
"blob:abc"is nowpreviousPdfUrland still rendering! - Error:
ResponseException: Unexpected server response (0)
The Debugging Journey
This bug was tricky. I initially tried increasing the cleanup delay:
// ❌ First attempt - didn't work
setTimeout(() => URL.revokeObjectURL(prev), 500);
Still got errors. The problem wasn't timing-it was that I was revoking URLs that were actively in use. The error appeared randomly during rapid typing because it depended on the exact timing of effect cleanups vs. render cycles.
After adding console logs to track URL lifecycles, I realized the issue: the effect cleanup was running before the transition completed, revoking a URL that previousPdfUrl still needed.
The fix: Only revoke if the URL was never set to state:
let urlSetToState = false;
if (!cancelled) {
urlSetToState = true;
setPdfUrl(() => {
pdfUrlRef.current = objectUrl;
return objectUrl;
});
}
return () => {
cancelled = true;
// Only revoke if URL was created but never used
if (generatedUrl && !urlSetToState) {
URL.revokeObjectURL(generatedUrl);
}
};
This flag ensures we never revoke URLs that are actively being used in component state.
The Complete Memory Management Strategy
| URL | Lifecycle | Cleanup Trigger | Delay |
|---|---|---|---|
generatedUrl (unused) |
Effect scope | Effect cleanup | Immediate |
generatedUrl (used) |
Effect scope | Never by effect | - |
pdfUrl |
Current render | Never during render | - |
previousPdfUrl |
Transition | After render success | 500ms |
| All URLs | Component unmount | Unmount cleanup | Immediate |
The Final Architecture
components/pdf/
├── pdf-preview.tsx # Wrapper with download button
├── pdf-viewer-client.tsx # Main viewer (284 lines)
└── resume-template.tsx # PDF document template
Key Features:
✅ Dual-document rendering (no flicker)
✅ Zoom controls (50% - 300%)
✅ Multi-page navigation
✅ Responsive width with ResizeObserver
✅ Memory leak protection
✅ Error handling with user-friendly messages
✅ Loading states
✅ shadcn/ui integration
TL;DR - What I Learned
| Problem | Solution |
|---|---|
| PDF flickers on updates | Dual-document pattern: render new PDF invisibly, swap when ready |
| When to show previous document | !isFirstRender && isRendering && previousPdfUrl !== pdfUrl |
| Blob URL cleanup on unmount | Use refs, not state values (closures capture initial values) |
| Effect cleanup race condition | Track urlSetToState flag, only revoke unused URLs |
| Smooth transitions | 500ms delay before revoking old URLs |
| Debounce timing | 300ms is optimal for typing without lag |
Production Readiness Checklist
Before deploying, I verified:
- ✅ Test on different screen sizes (mobile, tablet, desktop)
- ✅ Test with large PDFs (10+ pages, complex layouts)
- ✅ Test error scenarios (invalid data, network issues)
- ✅ Verify zoom functionality (50% to 300% range)
- ✅ Check browser compatibility (Chrome, Firefox, Safari, Edge)
- ✅ Test rapid typing (debounce prevents excessive renders)
- ✅ Monitor memory usage (blob URLs cleaned up properly)
- ✅ Verify dark mode support
Why Open Source?
Building in public means showing the messy parts. The bugs I missed. The code review feedback that caught memory leaks. The refactors that made the code better.
Carific.ai is MIT licensed. That means:
- ✅ Use it in your projects
- ✅ Fork it and make it your own
- ✅ Learn from the code (and my mistakes)
- ✅ Contribute back if you want
The repo: github.com/ImAbdullahJan/carific.ai
⭐ If you find this useful, consider starring the repo - it helps others discover the project!
What's Next for Carific.ai
The PDF viewer is just one piece. Here's what's coming:
- 🤖 AI-powered resume analysis - Actionable feedback on content, formatting, ATS compatibility
- 📊 Resume scoring - Industry-specific scoring with improvement suggestions
- 🎨 Multiple templates - Professional, creative, ATS-optimized designs
- 💾 Version history - Track changes and revert to previous versions
I'll be documenting each feature as I build it. Follow along if you want to see how an open-source project evolves.
Your Turn
I'd genuinely love feedback:
- On the code: See something that could be better? Open an issue or PR.
- On the post: Too technical? Missing something? Tell me.
- On PDF rendering: What's your approach? Any war stories with blob URLs?
Building in public only works if there's a public to build with. Let's learn together.
If this post helped you, drop a ❤️. It means more than you know.
Let's connect:
- 🐙 GitHub: @ImAbdullahJan - ⭐ Star the repo if you found this helpful!
- 🐦 Twitter/X: @abdullahjan - Follow for more dev content and project updates
- 👥 LinkedIn: abdullahjan - Let's connect
Sixth post of many. See you in the next one.
Top comments (0)