From 100% CPU to Smooth Sailing: How We Fixed Jenkins Android Build Overload and Heartbeat Timeouts with Minimalistic Resources
com.atlassian.confluence.content.render.xhtml.migration.exceptions.UnknownMacroMigrationException: The macro 'html' is unknown.

From 100% CPU to Smooth Sailing: How We Fixed Jenkins Android Build Overload and Heartbeat Timeouts with Minimalistic Resources

image-20260528-101834.png

Every DevOps engineer and mobile developer has been there: you push a critical update, trigger the CI/CD pipeline, and wait. But instead of a green checkmark, your Jenkins agent grinds to a painful halt. The logs freeze, the server becomes unresponsive, and the build eventually crashes with cryptic infrastructure failures, agonizingly slow log streams, or HEARTBEAT_INTERVAL timeout errors.

When our team ran into this exact wall while building our Android application, our monitoring tools painted a grim picture: 100% CPU utilization, a skyrocketing load average, and a server fighting for survival.

Here is the story of how we diagnosed the underlying issue, unmasked the hidden performance killer in our native builds, and optimized our build arguments to transform an overloaded server into a stable, highly efficient deployment machine.


Important Note

The optimizations described in this article (--max-workers=1 and -Pj=1) worked extremely well when running a single Android executor on a single Jenkins node.

However, during testing we observed that increasing the Jenkins executor count to two or more on the same node caused the server load average to rise dramatically again, often exceeding 20+, with CPU utilization returning close to 100%.

While limiting Gradle workers and native compiler jobs significantly reduced resource contention within a single build, it could not eliminate the hardware limitations of the server. Multiple Android builds running simultaneously continued to compete for CPU, memory, and filesystem resources.

After extensive testing, no software-level tuning provided a reliable solution for running multiple Android executors on the same machine. The most effective and scalable solution was:

  • One Android Executor per Node

  • Scale horizontally by adding more Android build agents

  • Avoid increasing executor count on the same machine

In short:

Configuration

Result

Configuration

Result

1 Executor + 1 Node

Stable and predictable

2+ Executors on same Node

High load, resource contention, instability

Multiple Nodes with 1 Executor each

Recommended scaling strategy

The Symptom: Server Strangulation & Heartbeat Timeouts

Our automated pipeline was configured to run a standard dual production build output (generating both an APK for internal testing and an AAB for the Google Play Store) using the native Gradle wrapper:

./gradlew :app:assembleRelease :app:bundleRelease --console=plain

On paper, this is a standard command:

  • ./gradlew: Utilizes the project-locked Gradle Wrapper to ensure environment consistency.

  • :app:assembleRelease: Compiles and packages the release Android Application Package (APK).

  • :app:bundleRelease: Compiles and packages the final Android App Bundle (AAB).

  • --console=plain: Disables the dynamic, real time UI terminal processing in favor of flat, plain text streams ideal for capturing clean logs in Jenkins.

Yet, despite stripping down the logs, the Jenkins build environment routinely became sluggish. The server UI would lag heavily, and real time build logs would completely stall. Frequently, the server dropped connection entirely, throwing errors indicating that the internal runner had missed its expected HEARTBEAT_INTERVAL check in due to processing lag.

image-20260528-103221.png

The specifications of our machine were modest but standard for a mid tier CI/CD node:

Resource

System Configuration

CPU

4 vCPUs (Ubuntu Linux)

Memory

8 GB RAM

The Breakthrough: How We Monitored the Server Load in Real Time

Because the Jenkins web interface completely froze during the build, we couldn't see what was happening inside the server. We needed a simple, lightweight way to record the server's health status into a file so we could inspect it later.

If you want to replicate our diagnosis on your Linux/Jenkins server, here is the exact beginner friendly method we used:

Step 1: Create a Monitoring Script

We logged into our Ubuntu Linux build server via terminal and created a small automated script. This script asks the server for its performance metrics every 5 seconds and saves them to a file.

Create a script file named monitor.sh:

Bash nano monitor.sh

Paste this simple script inside:

#!/bin/bash # A simple loop to track server health echo "Starting build monitor..." while true; do echo "=== Timestamp: $(date) ===" >> /var/lib/jenkins/loadAverage.txt # 'uptime' shows the system load average uptime >> /var/lib/jenkins/loadAverage.txt echo "-----------------------------------" >> /var/lib/jenkins/loadAverage.txt # Wait 5 seconds before checking again sleep 5 done

Step 2: Run the Script in the Background

Make the script executable and start it up right before running a fresh Jenkins build:

chmod +x monitor.sh ./monitor.sh &

(The & at the end is a Linux trick that keeps the script running safely in the background, freeing up your terminal).

Step 3: Monitor the Output Live

To see the load numbers ticking in real time while Jenkins ran, we used the Linux tail command:

tail -f /var/lib/jenkins/loadAverage.txt

The Investigation: Unmasking clang++ and System Overload

When the build hit its peak, our log file (loadAverage.txt) revealed a terrifying spike:

Plaintext
=== Timestamp: Thu May 28 15:30:00 UTC 2026 === load average: 9.94 13.15 24.44 cpu: 100% memory free: ~600MB

To understand why a 4 core machine was collapsing, we need to look closely at Load Average.

Load average tracks the number of tasks currently using the CPU or waiting in line for their turn. For a machine with 4 physical/virtual cores, a load average of 4.0 means the CPU is at perfect 100% capacity. Anything beyond 4.0 means processes are actively stalling, piling up in a traffic jam, and starving each other of system time. Our load average of 9 to 12+ meant our system was operating at 2x to 5x its physical capacity!

When we looked deeper into the active system processes, the true culprit wasn’t Gradle itself it was a swarm of processes labeled clang++.

root@jenkins-v4-ubuntu-s-2vcpu-2gb-blr1-01:~# top top - 08:48:16 up 5 days,  6:47,  1 user,  load average: 13.35, 11.35, 8.15 Tasks: 177 total,  15 running, 162 sleeping,   0 stopped,   0 zombie %Cpu(s): 96.1 us,  3.9 sy,  0.0 ni,  0.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st MiB Mem :   7941.2 total,    352.1 free,   7113.7 used,    785.6 buff/cache MiB Swap:   4096.0 total,   2833.9 free,   1262.1 used.    827.5 avail Mem     PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND  755587 jenkins   20   0 5773048   2.6g  26732 S  64.0  33.9   7:32.24 java  758446 jenkins   20   0  844616 749608  54980 R  24.3   9.2   0:41.54 clang++  759269 jenkins   20   0  406600 320696  52936 R  24.3   3.9   0:05.21 clang++  759347 jenkins   20   0  203316 108636  41804 R  24.3   1.3   0:01.09 clang++  759320 jenkins   20   0  268396 192460  86660 R  24.0   2.4   0:02.52 clang++  759324 jenkins   20   0  262460 186156  86304 R  24.0   2.3   0:02.36 clang++  759334 jenkins   20   0  236472 147248  47180 R  24.0   1.8   0:01.76 clang++  759355 jenkins   20   0  219052 139592  82464 R  24.0   1.7   0:00.75 clang++  759328 jenkins   20   0  259312 169660  48108 R  23.7   2.1   0:02.30 clang++  759336 jenkins   20   0  259100 182460  86136 R  23.7   2.2   0:01.77 clang++  759345 jenkins   20   0  244548 163288  80592 R  23.7   2.0   0:01.06 clang++  758449 jenkins   20   0  670888 572552  56468 R  23.3   7.0   0:42.22 clang++  759332 jenkins   20   0  239824 150996  47448 R  23.3   1.9   0:01.80 clang++  759360 jenkins   20   0  154968  62784  39496 R   9.3   0.8   0:00.28 clang++  759362 jenkins   20   0  191200 106676  76356 R   7.0   1.3   0:00.21 clang++      17 root      20   0       0      0      0 I   0.3   0.0   4:12.41 rcu_preempt

What is clang++?

If your app relies on a heavy native layer such as a modern React Native application, an Expo project with custom native plugins, or an application utilizing the Android NDK (Native Development Kit) your codebase uses underlying C++ modules.

clang++ is the compiler responsible for turning that native C++ code into machine code your phone can understand. Because C++ compilation is incredibly math heavy, each spawned clang++ process consumes a massive amount of raw CPU power and RAM.

By default, Gradle operates under a friendly philosophy: Use every resource available to finish the job as fast as possible. Out of the box, it automatically assumes it can aggressively spin up multiple background workers and native compiler jobs simultaneously. While this works beautifully on a high end 16 core developer laptop, it acts as an unintentional crash trigger against a restricted 4 core CI server.

The Myth of Parallelism: When More Workers = Slower Builds

The most common mistake in pipeline design is a simple assumption:

More Parallel Workers = Faster Builds

When a system runs out of CPU and RAM, excessive parallelism causes a severe performance drop known as Process Thrashing.

Instead of spending useful CPU cycles compiling our actual code, our 4 cores spent all their time performing context switching constantly pausing one clang++ process, saving its state, loading another process, running it for a millisecond, pausing it again, and repeating. The machine was spending more energy managing its own chaotic process traffic jam than actually compiling the application, resulting in the massive lag and missed heartbeats we observed.

Why Increasing the Timeout Limit Fails

When faced with this problem, a common workaround is to simply increase the Jenkins HEARTBEAT_INTERVAL timeout value in the system settings. While this keeps the connection alive longer and might temporarily stop the pipeline from throwing an immediate error, it is merely a band aid. It does absolutely nothing to solve the underlying server load the machine remains completely choked, slow, and structurally unstable.

The Strategy: Forcing Resource Discipline

To bring stability back to our infrastructure, we had to strictly limit the parallelism of both the high level task manager (Gradle) and the low level native compiler (clang++). We achieved this by introducing two simple arguments into our build command: --max-workers and -Pj.

1. Controlling Gradle with --max-workers

The --max-workers flag sets a hard ceiling on the number of concurrent tasks Gradle can execute at any given second.

./gradlew assembleRelease --max-workers=1

By forcing --max-workers=1, we told Gradle to process our tasks one after the other. This dramatically lowers the memory footprint and stops multiple tasks from independently demanding heavy resources at the same moment.

2. Controlling the Native Compiler with -Pj

While --max-workers manages top-level Gradle execution, native C++ compilation loops often ignore it. In many native environments (like React Native/NDK extensions), the build scripts accept a project property argument specified as -Pj.

This argument injects a configuration parameter directly down into the underlying native build tools (like make or Ninja), telling them how many concurrent parallel compiler jobs to run.

-Pj=1

Setting -Pj=1 explicitly overrides the native automation, forcing the environment to compile precisely one C++ native file at a time, eliminating the sudden swarm of multiple running clang++ processes.

The Fix: Implementation and Command Layout

We updated our production build execution script inside our Jenkins pipeline file to explicitly enforce structural discipline:

./gradlew :app:assembleRelease :app:bundleRelease \ --console=plain \ --max-workers=1 \ -Pj=1

Tuning Framework for CI Servers

If you are managing your own self hosted or cloud runner instances, here is a recommended rule of thumb to avoid server crashes based on your resource allocations:

Runner CPU Count

Suggested Concurrency Settings

Expected Outcome

2 Cores

--max-workers=1 -Pj=1

Safe baseline; avoids out of memory errors.

4 Cores (Our Node)

--max-workers=1 (or 2) -Pj=1

Maximum stability; prevents lag and heartbeat drops.

8+ Cores

Scale upward iteratively while tracking top

Optimized for speed using high resource environments.

Results: Before vs. After

The transformation of our build logs and server health metrics after rolling out these changes was immediate.

Before Optimization

  • System Behaviour: Extreme terminal UI lag, intermittent HEARTBEAT_INTERVAL connection drops, frozen pipeline progress.

  • Build Duration: 32 – 38 minutes (unstable, frequently crashing midway).

  • Server State (loadAverage.txt): ```text

    load average: 9.94, 10.55, 17.34

    CPU utilization: 100%

    Active Processes: [clang++, clang++, clang++, clang++, gradlew, java]

    image-20260528-073033.png
    image-20260528-072841.png
    image-20260528-072959.png

 

After Optimization

  • System Behaviour: Fluid, readable console log updates, 100% stable runner to master communication, predictable and consistent pipeline execution.

  • Build Duration: 32 – 38 minutes (100% stable, zero crashes).

  • Server State (loadAverage.txt):

    load average: 2.30, 3.94, 3.91 (Healthy Threshold for 4 Cores)
    CPU utilization: 55% - 70%
    Active Processes: [clang++ (single instance), gradlew, java]

    image-20260601-115856.png
  • image-20260601-120135.png
    image-20260601-120411.png

 

Note on Strategy: Our goal here wasn't speed optimization it was graceful resource utilization. We realized that trying to force a 4-core machine to compile faster by overloading it was a losing battle. Instead, we shifted our focus to keeping the CPU stable and preventing crashes, which allowed our 32-38 minute build to complete reliably without choking the server.

Key Takeaways

Optimizing CI/CD pipelines is not always about making builds more parallel.

Sometimes the fastest path to stability is reducing concurrency and aligning build behavior with available hardware resources.

For our Jenkins Android build server:

  • Limiting Gradle workers reduced CPU contention.

  • Limiting native compiler jobs prevented clang++ storms.

  • Server load average dropped from 13–14+ to around 3–6.

  • Jenkins became stable and responsive again.

  • Resource Grace over Raw Speed: Optimization doesn't always mean making a build faster. True optimization means utilizing system resources gracefully so the server remains stable, responsive, and predictable.

Most importantly, we learned that for Android builds on resource-constrained infrastructure, horizontal scaling with multiple single executor nodes is often more effective than increasing concurrency on a single machine.

Sometimes the best optimization is not running more things at once, it's running the right number of things at the right time.