In many web applications, there are times when we need to generate and download PDF files on the client-side. However, for the sake of showing a live preview to users, PDF generation may block the main thread and cause poor user experience. This is where web workers come into play. In this article, we will explore how to use @react-pdf/renderer inside a web worker to render PDF files in a non-blocking way.
What is react-pdf?
@react-pdf/renderer is an open-source library that enables developers to create and render PDF documents using React components, which allows developers to use a familiar declarative syntax. @react-pdf/renderer provides a set of built-in components for creating basic PDF elements such as text, images and links. It’s engine-agnostic and can run both inside the browser and NodeJS.
What are Web Workers?
A web worker is a JavaScript process that runs separate from the main thread. It allows us to perform long-running tasks without in a non blocking fashion. This can improve the overall performance and user experience of an application. Web workers can communicate between each other and the main thread via messages. A Web worker should primarily be used for long-running tasks, as managing them and sending data has a performance overhead.
Final Result
Checkout the repo here or the stackblitz demo here
How it all comes together?
Before we get into the details of using web workers, let’s start by creating a simple react pdf component. Here’s an example:
import { Page, Document, Text } from '@react-pdf/renderer';
export type PDFProps = {
title: string;
author: string;
description: string;
};
export const PDF = (props: PDFProps) => (
<Document title={props.title} author={props.author} subject={props.description}>
<Page>
<Text>Hello, World!</Text>
</Page>
</Document>
);
This component creates a PDF document with a single page that says, “Hello, World!”.
Creating a RenderPDF Function
Now that we have a PDF component, we need a way to convert it to a PDF file. We can do this using the toBlob
method of the pdf
function provided by @react-pdf/renderer
. Here’s an example of a function that does this:
import { createElement } from 'react';
import type { PDFProps } from './PDF';
export const renderPDF = async (props: PDFProps) => {
const { pdf } = await import('@react-pdf/renderer');
const { PDF } = await import('./PDF');
return pdf(createElement(PDF, props)).toBlob();
};
This function takes a PDFProps object as an argument, creates a PDF element using React.createElement
, and then passes it to the pdf
function provided by @react-pdf/renderer
. Finally, it returns a Blob object representing the PDF file.
Creating a Web Worker
Now that we have a way to create PDF files, we can create a web worker to run the renderPDF
function in a separate thread. Here’s an example of a web worker that uses comlink to expose the renderPDF
function as an asynchronous function:
import { expose } from 'comlink';
import type { PDFProps } from '../PDF';
import './workerShim';
let log = console.log
const renderPDFInWorker = async (props: PDFProps) => {
try {
const { renderPDF } = await import('../renderPDF');
return URL.createObjectURL(await renderPDF(props));
} catch (error) {
log(error);
throw error;
}
};
// for debugging purposes - will override the log method to the callback
const onProgress = (cb: typeof console.info) => (log = cb);
// easier way to expose function from workers using comlink
expose({ renderPDf, onProgress });
export type WorkerType = {
renderPDf: typeof renderPDf;
onProgress: typeof onProgress;
};
This code imports the renderPDF
function we created earlier, and exposes the renderPDFInWorker
as an asynchronous function, which returns the blob URL using comlink. The onProgress
function is used for debugging purposes to pipe the output back to main thread console.
Integrating it into the application
By now having this simple async function, there is no requirement to use React as the main thread ui framework. Theoretically, it could be used in a jQuery or vanilla JS application.
The react implementation would look like this:
import { useEffect } from "react";
import { useAsync } from "react-use";
import { proxy, wrap } from "comlink";
import type { WorkerType } from "./workers/pdf.worker";
import Worker from "./workers/pdf.worker?worker";
export const pdfWorker = wrap<WorkerType>(new Worker());
// hook up the debugging inside the main thread
pdfWorker.onProgress(proxy((info: any) => console.log(info)));
export const useRenderPDF = ({
title,
author,
description,
}: Parameters<WorkerType["renderPDFInWorker"]>[0]) => {
const {
value: url,
loading,
error,
} = useAsync(
async () => pdfWorker.renderPDFInWorker({ title, author, description }),
[title, author, description]
);
// clean up the blob after url is no longer returned
// otherwise it will cause memory leak
useEffect(() => (url ? () => URL.revokeObjectURL(url) : undefined), [url]);
return { url, loading, error };
};
To import the PDF worker, this example uses vite’s query suffixes import syntax. After that, the worker is wrapped by comlink.
The useRenderPDF
hook now takes in the PDF component props as its argument. It then uses the useAsync
hook to call the renderPDFInWorker
function, passing the component props to it and returning a value, error and loading state.
Finally, the useEffect
hook is used to clean up the URL object created by the renderPDFInWorker
function to prevent memory leaks.
Improvements & Considerations
To minimize communication overhead, it was explicitly opted to send the component props instead of the complete v-dom, and thus this implementation is not as flexible as the non web worker implementation.
A potential improvement could therefore be to make it easier to use renderPDFInWorker
with multiple different PDFs via the use of the factory pattern and inline workers.
The implementation was only tested in vite and required shims to make @react-pdf/renderer
work inside a web worker, which may need to be adapted to work in other bundlers
In vites current setup, it doesn’t allow even ESM web workers to share code chunks with the main bundle. Thus, the bundle will include @react-pdf/renderer
, react and all other libraries that both the PDF component and the main bundle uses twice.
Conclusion
In this article, we’ve explored how to use @react-pdf/renderer
inside a web worker to generate PDF files in a non-blocking way. We’ve used the comlink library to communicate with the worker and create a promise-based API wrapper.
Top comments (8)
Hi @simonhessel ,
Thank you for this great article and I have a few questions about the rendering process.
When I enter some text into the document the PDF blinks and this is due to the rendering process i guess and this gives a bad user experience. is there a way to make the document not to blink ?
Thank you.
Hi,
Thank you!
You can prevent the flashing during PDF rendering by replacing the default browser renderer (e.g., iframe) with a solution like react-pdf, which uses canvas and
pdfjs
to render the file. It includes anonLoadSuccess
prop that calls a function once rendering is complete.For a smoother experience, you can display the previous PDF version until the new one is fully pre-rendered. You can see an example in the official react-pdf REPL, and here’s a relevant code snippet from the repo.
Best,
Simon
Nice article ! If i implement this approach, will my bundle chunk became smaller ? Now now i have 1.6 mb size
if someone can figure out how to implement this on next would be awesome, no succes from my side yet
@davidflr1 please find the same example using next here github.com/shkreios/nextjs-pdf-ren...
hey Simon, i am facing an error saying - DOMException: Failed to execute 'postMessage' on 'Worker': Symbol(react.element) could not be cloned. How to resolve this? Thank you
@bdamor5 to me this sounds like that react is not available inside the webworker. Did you use the example setup from the blogpost?
If not make sure that whatever bundler you are using that react is part of both the main & worker bundle.
i implemented this but when i click the download button the ui is freezing, can you tell me why?