DEV Community

Simon Hessel
Simon Hessel

Posted on • Edited on

Creating PDF Files Without Slowing Down Your App

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>
);
Enter fullscreen mode Exit fullscreen mode

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();
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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 (6)

Collapse
 
sriram_vemparala_e7c93924 profile image
sriram vemparala

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.

Collapse
 
simonhessel profile image
Simon Hessel

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 an onLoadSuccess 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

Collapse
 
davidflr1 profile image
David Flores

if someone can figure out how to implement this on next would be awesome, no succes from my side yet

Collapse
 
simonhessel profile image
Simon Hessel

@davidflr1 please find the same example using next here github.com/shkreios/nextjs-pdf-ren...

Collapse
 
bdamor5 profile image
Bhavesh Damor • Edited

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

Collapse
 
simonhessel profile image
Simon Hessel

@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.