Damian Garstecki

back end developer

Crafting Gorgeous PDFs in Spring Boot Using Chromium and Playwright

· 6 min read

Creating visually appealing and maintainable PDFs on the backend has often been a challenge. Traditional tools lag in support of modern HTML and CSS, leaving us to the archaic table design. This guide explores approach of using Chromium and Playwright in a Spring Boot application to produce gorgeous documents using modern HTML/ CSS.

Setting Up Chromium with Playwright

Installing Chromium on the server is not a trivial task. Browser is not a simple dependency that could be easily included in your project. Chromium, which we will be using, is a fully-fledged browser, as has to be downloaded as such. Additionally, it requires a compatible version of the Chromium Driver to interface with the browser. This setup becomes even more complex in a development team using different operating systems like Windows, Mac, and Linux, as each environment demands a different set of binaries. Beyond that, we need some kind of middleware to execute commands on the browser from our code. That seems to be a lot to prepare and maintain...

This is where Playwright comes into play. Playwright is mainly known for e2e UI testing, but we will be using it for different features. Playwright can detect the system that it's running on and download and set up Chromium for us (it could be done even in the runtime!). This saves a lot of time when you're starting and makes things easier later on.

There are other tools like Playwright, namely Cypress or Selenium but Playwright comes with the Java library - that means it also gets us covered on the middleware front.

Let's Build It!

First, we need to add Playwright dependency to our project:

implementation("com.microsoft.playwright:playwright:1.40.0")

With Playwright, we can easily interact with web browsers. Our code is simple because Playwright handles the complicated parts, like setting up the environment and talking to the browser. Here’s what our code looks like:

val pdf = Playwright.create().use { playwright -> // ①
    val browser = playwright.chromium().launch() // ②
    val page = browser.newPage().also { it.setContent(html) } // ③

    page.pdf() // ④
}

Each line does something important:

① Launches new Playwright driver process

② Returns the browser instance

③ Opens a new page and injects our HTML into it

④ Makes a PDF

Simple, right? But wait, there's more to consider... let's check the performance of our implementation 👀

All the tests were made on MacBook Pro with Intel I7-9750H and 16GB of RAM. Benchmark app was running on Amazon Corretto 21.0.1. Numbers are an average from 10 consecutive runs.
StepFirst runWarmed up
2196 ms270 ms
585 ms252 ms
439 ms151 ms
58 ms24 ms

While testing this code we can notice that those 4 lines take 700 ms to execute, and even 3 s on the first run! That's quite a lot! If we see what each line is doing one more time, we will notice that for each request we are starting a new Playwright driver and the browser (① + ②). That can't be good for the performance. So the natural solution to that would be to start the browser once and keep it in the state - problem solved! Although the idea might be decent - we have to keep in mind that Playwright isn’t designed for this kind of work. The creators even warn us about this in their documentation.

No, Playwright is not thread safe, i.e. all its methods as well as methods on all objects created by it (such as BrowserContext, Browser, Page etc.) are expected to be called on the same thread where Playwright object was created or proper synchronization should be implemented to ensure only one thread calls Playwright methods at any given time. Having said that it's okay to create multiple Playwright instances each on its own thread. source

The last part is a hint that we could use. We can create a pool of threads, each with its own Playwright instance. For each request, we use a worker from the pool, and delegate a task to it.

Optimalisation

Thread Pool

Luckily in Java, we got ExecutorService that can easily make the pool for us. Since I'm using Kotlin I would also convert the pool to a dispatcher to use in coroutine contexts.

val dispatcher = Executors.newFixedThreadPool(poolSize).asCoroutineDispatcher()

Using coroutines here makes a lot of sense since there is quite a big chunk of work outside JVM - on the browser itself, and our threads will spend some time waiting for the browser to process data.

val playwright = ThreadLocal.withInitial {
    Playwright.create().chromium().launch().newPage() // ① + ②
}

We move steps ① and ② to the thread initialization, so they don’t slow down each request. It's a huge save - after measurements times look like this:

StepFirst runWarmed up
① + ②--
71 ms3 ms
38 ms7 ms

That's up to 70 times faster on a warmed-up machine and 30 times faster on a cold one. We've moved the heavy lifting to the app's start-up phase. The code is a bit more complex, but the performance gain is huge.

Baked-in Image

We can also speed things up by including the Playwright binaries in our Docker image, so we don't have to download them every time. To achieve this, we set an environment variable (PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=true) instructing Playwright not to download binaries at runtime, and we make certain these files are included in our application's Docker image. We take the binaries from Playwright's official Docker image to ensure compatibility.

FROM mcr.microsoft.com/playwright:v1.40.0-jammy as build

# Your image build phase

ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=true

COPY --from=build /ms-playwright /ms-playwright

Make sure the Docker image version matches the Playwright version in your project. Update both simultaneously for compatibility.

# build.gradle
implementation("com.microsoft.playwright:playwright:1.40.0")
                                                   ☝️ this

# Dockerfile                      👇 and that
FROM mcr.microsoft.com/playwright:v1.40.0-jammy as build

Sumamry

In this guide, we've walked through the steps of using Playwright to generate PDFs from HTML. We enhanced performance by implementing a thread pool and incorporating Chromium binaries into our Docker image.

Check out the full working project on my repository: gitlab.com/garstecki/pdf. You can test it using the demo.html file, which has templates that IntelliJ can understand and run.