Introducing the Wiley x Valyu HLS Startup Program
Valyu
A long row of deep-plum blocks on a light grid, ending in a single bright citron block.
Engineering

CI was the slowest part of shipping. Here’s how we fixed it.

Alexander Ng
Share this article

On this page
TL;DR
  • CI was one of the biggest limits on how fast Valyu ships. Our slowest PR check timed out 14 times in eight days.
  • In three weeks we made about 50 changes across five codebases: deleting work nothing used, running tests in parallel, skipping work already tested, fixing waits and flaky tests, and changing how we push.
  • CI spend on our three largest codebases fell 55%. Our slowest check got 72% faster, and time spent waiting on PR checks fell 54%.
  • Over the same weeks, the team merged 1.9× as many PRs a week.
  • The biggest single fix was turning off code coverage that nothing read, which cut that test step by 87%.

Every pull request has to wait for CI before it can merge, and every merge waits again before it deploys. When a check is too slow to sit through, people switch to other tasks. When it fails, they have to come back, fix it, push again and wait for the whole run again. We build our own search database and data warehouse in-house and make upgrades to them all the time. This means the team ships several hundred PRs a week. At that pace, CI is one of the largest limits on how fast we can iterate.

We spent three weeks measuring where the time and money went across our five main codebases, then cut it.

CI runner spend on our three largest codebases
−55%
monthly, before vs after
Time for our slowest PR check
−72%
median
Time the team waits on PR checks each week
−54%
five codebases
PRs merged a week, in the same period
1.9×
whole org

Where the time went.

We pulled every CI job from the last month out of the GitHub Actions API and ranked them by runner size times minutes. A handful of large jobs took most of the time and most of the money, and in each one we timed every step.

The worst offender was the test suite for our Search and DeepResearch infrastructure: about 3,300 tests run one file at a time on a 2-core runner, followed by a conformance check against our public SDKs. It was our slowest check. In eight days it timed out 14 times, and in 13 of those the tests had already passed.

Every slow check got faster, by 24 to 72%.
Median time to finish after the changes, as a share of the time before (before = 100%).

Median time after the changes as a percentage of the time before, for seven CI checks.

  • Search and DeepResearch infraDeepResearch tests, on PRs
    Before: 100 %
    After: 28 %
    Change: −72%
  • Search and DeepResearch infraDeepResearch tests, on merge
    Before: 100 %
    After: 38 %
    Change: −62%
  • Valyu platformPlatform tests
    Before: 100 %
    After: 28 %
    Change: −72%
  • Indexing servicesDeploy
    Before: 100 %
    After: 67 %
    Change: −33%
  • Indexing servicesPR checks
    Before: 100 %
    After: 76 %
    Change: −24%
  • Search databasePR checks
    Before: 100 %
    After: 48 %
    Change: −52%
  • WarehousePR checks
    Before: 100 %
    After: 70 %
    Change: −30%
GitHub Actions job history, re-runs removed. Before: 8 to 14 days before each change. After: 7 to 13 days after, and the first runs with all changes in place for the DeepResearch tests on PRs.

What it adds up to.

A faster check matters in proportion to how often people wait for it. Multiplying each codebase's PR check runs per week by the median wait gives the total time the team spends waiting on CI each week. After the changes, that total is 54% lower.

The team waits 54% less for CI each week.
Weekly time spent waiting on PR checks after the changes, as a share of the time before (before = 100%).

Weekly PR check waiting after the changes as a percentage of the time before, by codebase.

  • Search and DeepResearch infra
    Before: 100 %
    After: 28 %
    Change: −72%
  • Valyu platform
    Before: 100 %
    After: 33 %
    Change: −67%
  • Indexing services
    Before: 100 %
    After: 76 %
    Change: −24%
  • Warehouse
    Before: 100 %
    After: 70 %
    Change: −30%
  • Search database
    Before: 100 %
    After: 48 %
    Change: −52%
PR check runs per week times median wait per run. Run counts include drafts, so treat the total as an upper estimate.
The average PR check is faster in every codebase.
Mean time for the required CI workflow on a pull request, as a share of the time before (before = 100%).

Mean PR check time after the changes as a percentage of the time before, by codebase.

  • Search and DeepResearch infra
    Before: 100 %
    After: 42 %
    Change: −58%
  • Valyu platform
    Before: 100 %
    After: 34 %
    Change: −66%
  • Indexing services
    Before: 100 %
    After: 68 %
    Change: −32%
  • Search database
    Before: 100 %
    After: 81 %
    Change: −19%
  • Warehouse
    Before: 100 %
    After: 88 %
    Change: −12%
First-attempt, green pull request runs. Before: one to two weeks before the changes. After: 28 Oct to 3 Nov 2026.

What we changed.

About 50 changes, in five kinds. The cheapest wins came from asking what used each piece of work before we tried to make it faster.

Delete work nothing used.

Before speeding anything up, we asked what read its output.

Code coverage collected on every test run, read by no one, turned off in CI
−87% test step
Images built at release instead of on every merge (only 8% were ever deployed), and a smoke build nothing used deleted
−87% image build spend
Large build artefacts left out of the test checkout
−84% checkout time
One image rebuilt only when its own inputs change
skipped on 29% of PRs

Run it faster.

Parallelism where the work is parallel, and native hardware where we were emulating.

Work-stealing scheduling for the Python test suite
−48%
Parallel Rust test runner, per-package test selection and a cache key that matches
−66% time, −58% compute
Native arm64 builds for serverless packages instead of emulation
−65% compute per build
Our slowest test suite split into parallel shards, with checks in their own job
−53%
An indexing test batch run on four workers
−60%
A faster Python package installer in place of a slow cache
−41%

Skip work already done.

Every skip falls back to a full run when its inputs are unknown.

Skip CI on main when the PR already tested the exact same tree
49–67% of merges skip
Skip nightly benchmarks when nothing has changed since the last green run
about 1 in 3 nights
Skip slow test groups on PRs whose changes cannot reach them, from the import graph
−20% suite time on most PRs
Heavy jobs wait until a draft PR is marked ready
one full run per PR

Fix waits, hangs and flakes.

A timeout is often a stall, and a flake is sometimes a bug.

Real-time sleeps in tests replaced with an injected clock
−93% on the five slowest files
A conformance case that lost a race and stalled until it timed out, fixed and bounded
14 → 0 timeouts
Flaky tests traced to their cause: races, unordered queries, clock skew
−77% re-runs
One "flaky" search database test turned out to be a real recall bug
fixed in the database
Cloud API throttling retried with back-off, never re-sending a call that succeeded
throttled calls retried

Change how we push.

The rest of the waste was process.

Draft first, local checks, one push per batch of commits
8–9% of spend was replaced runs
Release PRs open as drafts and run the full suite once
−51% release PR compute
A bot that pushes only when its change is new
−62% bot pushes

What did not work.

Several obvious fixes actually made CI slower or more expensive, so every change ran in CI several times to validate them before it stayed. A 16-core runner for our largest Python suite would have doubled its cost to save seconds. Splitting our Ray tests into shards added 24% more runner minutes and did not shorten the wait. Moving our Rust tests from 8 cores to 4 cut compute by a quarter but made the check 51% slower, so it stayed on 8. A cached image builder was slower on every run we tried. Splitting work into shards helps the wait and can cost more money, because each shard repeats checkout, install and compile; we only kept a split when the total runner minutes made sense too.

How we work now.

The last 8 to 9% of wasted CI came from how we pushed changes. A push that a newer push replaced within minutes still paid for most of its run. Release PRs re-ran the full suite every time anything merged. Additionally, bots that bump image versions in our deploy config pushed to their PRs after every image build, even when the change was identical to the last one, and each push started a full CI run. The fix was an organisation-wide change in behaviour, not code: open a PR as a draft, run the local checks, push once per batch of commits, and mark it ready when it is done. This meant heavier jobs wait to be ready instead of repeatedly running CI.

Where this leaves us.

CI sets the pace for everything else we build, so we now treat it like any other part of the product: it has a cost, a latency and a failure rate, and we measure all three. Most of the gain came from deleting work that nothing used and from fixing the stalls and flakes that made people re-run checks. Bigger machines added little. The team now merges nearly twice as many PRs a week as it did in September while waiting only about half as long for CI. Going forward, every new check in the pipeline has to answer two questions before it ships: what reads its output coverage, and what it costs us to wait for it.

Valyu Add

Join 12,000+ professionals and knowledge workers.

Valyu Add is a free weekly research briefing for builders, investors and operators. Every issue is sourced, cited and verified with Valyu DeepResearch.