This article is brought to you by Datawrapper, a data visualization tool for creating charts, maps, and tables. Learn more.

All Blog Categories

How we saved time (and money!) on continuous integration

Portrait of Jack Goodall
Jack Goodall

Hi, I’m Jack from the Datawrapper app team, and today I’ll be showing the steps we took to make our continuous integration workflows 80% faster.This post dives into DevOps and CI, so if you're familiar with those concepts you'll feel right at home!

Towards the end of last year we started to get a feeling you might recognize. We were spending too much time waiting forcontinuous integration (CI) tasks to complete, often even longer than it had taken to make the change in the first place.

There are 56 CI workflows in our monorepo, but one runs particular often: the CI for our main SvelteKit web app. This workflow is triggered between 50 and 100 times every workday, and each run was taking approximately 17(!) minutes.

The workflow is made up of 7 steps:

As you can see, we were spending almost 5 minutes just installing and building dependencies. But with a little effort, we were able to reduce the time of our CI workflows by 80% and the cost by 45%. These are the four steps that got us there:

Step 1: Switch from NPM to PNPM
Step 2: Cache dependencies and builds
Step 3: Split workflows
Step 4: Switch from Github runners to Blacksmith runners

Step 1: Switch from NPM to PNPM

Our code is organized in a monorepo and we’d been using NPM as our package manager. Unfortunately, NPM was leading us to install the same dependencies over and over across different packages of the monorepo.

Take the following simplified example: We have a big App package containing several visualization packages, each with the code for a different visualization type or family (line charts, tables, maps, etc…). Each of those visualization types contains a color legend package as well.

With NPM, each of these packages was independently installing its own version of Svelte. We could save ourselves a lot of work and disk space by installing each dependency only once:

Conveniently, this is exactly how PNPM works. It also speeds up installs by simultaneously resolving, fetching, and linking dependencies. After migrating our monorepo to PNPM, setting up the environment for CI became 74% faster:

🎁

As a bonus, the switch away from NPM eliminated the need for many of our custom scripts — particularly one called install-package that we used to build packages after ensuring that all their dependencies were installed and built in the correct order.

With PNPM, the --filter flag now provides this exact functionality and much more! For example pnpm --filter app... build will build all of app's dependencies — including its dependencies’ dependencies — and then build the app itself.

Step 2: Cache dependencies and builds

Our raw installation speed was now much faster! But we could save even more time.

In general, dependencies only rarely change between PRs. If we change one line of a component between CI runs, ideally we’d just reuse its dependencies instead of reinstalling and rebuilding them every time.

Thankfully, PNPM downloads all dependencies to a global store and symlinks them into the different packages of our repo instead of keeping them in a bunch of spread out node_modules as NPM does. This makes caching them much easier:

name: 'Setup node environment'
description: 'Setup node and pnpm'
runs:
    using: 'composite'
    steps:
        - name: Install pnpm
          uses: pnpm/action-setup@v4

        - uses: actions/setup-node@v4
          with:
              node-version-file: '.nvmrc'

+       - name: Get pnpm store directory
+         shell: bash
+         id: pnpm-store
+         run: echo "path=$(pnpm store path)" >> "$GITHUB_OUTPUT"

+       - name: Cache pnpm store
+         uses: actions/cache@v4
+         with:
+             path: ${{ steps.pnpm-store.outputs.path }}
+             key: >-
+                 pnpm-${{ runner.os }}-${{ github.workflow }}-${{ github.job }}-${{
+                     hashFiles('pnpm-lock.yaml')
+                 }}
+             restore-keys: |
+                 pnpm-${{ runner.os }}-${{ github.workflow }}-${{ github.job }}-
+                 pnpm-${{ runner.os }}-

To cache dependencies, we run pnpm store path to get the location of the PNPM store, then pass it as the path argument to the actions/cache action and use a hash of the lockfile as the cache key. This tells the action to save the contents of the PNPM store at the end of the CI run and to reuse it for as long as the pnpm-lock.yaml file hasn't changed.

We can do something similar for our shared libraries. After extracting the build steps to a reusable composite action, we cache the result of the build based on both the contents of pnpm-lock.yaml and the source code of the library.

name: 'Build and cache libs'
description: 'Builds `shared` libraries and caches the results.'

runs:
    using: 'composite'
    steps:
        - name: Cache build artifacts for shared
          id: cache-shared
          uses: actions/cache@v4
          with:
              path: libs/shared/dist
              key: >-
                  ${{ runner.os }}-build-shared-${{hashFiles(
                    'pnpm-lock.yaml',
                    'libs/shared/**',
                    '!libs/shared/dist/**'
                  )}}

        - name: Install workspace root dependencies
          shell: bash
          run: pnpm -w install

        - name: Install shared dependencies
          shell: bash
          run: pnpm --filter @datawrapper/shared install

        - name: Build shared
          if: steps.cache-shared.outputs.cache-hit != 'true'
          shell: bash
          run: pnpm --filter @datawrapper/shared run build

So how are CI times looking now?

By switching to pnpm and caching installs and builds, we’ve cut our time to set up the environment down to just 35 seconds — an 87.5% improvement!

Step 3: Split workflows

A big reason for the remaining run time was that our workflow still ran each step in succession. Originally, the setup already took so long that parallelizing things wouldn't have made a difference.

But with the setup now being nice and speedy, we can afford to run it multiple times, which lets us run each of these jobs in parallel.

That gets us a 67% improvement in real time, or how long it takes for the longest job to finish. But in billable time it still comes to around 18 minutes, back up to about the same as our original setup without caching.

So we’ve traded 67% time savings for 39% more cost.

Is there any way to get that money back?

Step 4: Switch from GitHub runners to Blacksmith runners

Enter Blacksmith! A service that claims to be a drop-in replacement for GitHub Actions, but twice as fast and twice as cheap.

Switching to Blacksmith was as easy as promised — we logged in with GitHub, added our repository, and replaced all our runners and caches with Blacksmith’s versions.

- runs-on: ubuntu-latest
+ runs-on: blacksmith-4vcpu-ubuntu-2204

- uses: actions/cache@v4
+ uses: useblacksmith/cache@v5
🧙

The migration wizard even managed to swap most of these automatically, though it only detected our workflow files and missed our composite actions.

Jobs run 22.14% faster on Blacksmith compared to Github Actions on average. Not quite the promised 2x speed up, but definitely a big improvement. The cost difference is even more significant — over the course of a month we spend 45% less on CI after switching to Blacksmith.

Less waiting, more building

While our fencing skills have taken a serious hit, these changes have made a huge difference to how fast we can go from creating a PR to merging it. With an 80% reduction in CI times and a significant cut in costs, we're now spending less time waiting and more time building. If your CI runs feel like an endless waiting game, I hope these changes help you shave off some precious minutes.


Now that we've sped up everything else, linting is the slowest part of our workflow. If you’ve got any tips to speed up linting in large Typescript monorepos — please let us know at hello@datawrapper.de! I want to thank my coworkers Pascal and Toni for their collaboration on the migration to PNPM as well as our team lead Marten for reviewing drafts of this blog post.

Portrait of Jack Goodall

Jack Goodall (he/him) is a software developer in Datawrapper’s App team. He’s keen on squashing bugs and optimizing performance. When he’s not clacking away at his keyboard, he enjoys skiing, photography, rock climbing, and twiddling his mustache. Jack lives in Lyon.

Sign up to our newsletters to get notified about everything new on our blog.