The Lombok & Builder Pattern Hidden Cost

What nobody tells you about @Builder Performance is that the performance of lombok @Builder is one of those things that are hardly ever questioned. The annotation offers neat syntax, improves readability, and does away with lengthy constructors. In many codebases, the annotation becomes a standard way of constructing DTOs, requests, and even domain models.

Yet neat syntax doesn’t always mean zero allocation, not in every case.

In terms of logic, every call to the builder.build() generates an allocation of a temporary builder object. In a high-throughput microservice, this object churn can quickly saturate the Young Generation, triggering frequent, stop-the-world Minor GC pauses. But JVM optimization techniques, such as escape analysis, could eliminate such an allocation in a simple POJO. Modern Just-In-Time (JIT) compilers leverage Escape Analysis (EA) to determine if an object’s scope is restricted strictly to the executing thread. If the builder instance never “escapes” the method body, the JIT compiler can optimize away the heap allocation entirely via scalar replacement, allocating the fields directly to the CPU registers or stack instead. Indeed, our JMH tests proved that there is no measurable allocation difference between a regular constructor and @Builder in a simple 4-field class. That cost exists elsewhere: @Singular and complex domain classes with lots of fields.

This post is not about convincing you not to use @Builder anymore. Rather, it is about analyzing the actual performance of Lombok’s annotation. Specifically, we’ll examine which optimizations are applied, when @Singular becomes different, how to analyze GC logs, and what approach to use, all based on JMH test results.

How Lombok @Builder Generates Code Behind the Scenes 

Here’s what we write:

@Builder
public class UserResponse {
private String id;
private String email;
private String displayName;
private boolean active;
}

Clean. Readable. Here’s what Lombok actually generates. Run delombok on any moderately sized @Builder class, and we’ll count 40-60 lines of generated code for what looked like 5:

public class UserResponse {
public static UserResponseBuilder builder() {
return new UserResponseBuilder();
}
public static class UserResponseBuilder {
private String id; private String email;
private String displayName; private boolean active;
public UserResponseBuilder id(String id) { this.id=id; return this; }
// ... one setter per field ...
public UserResponse build() {
return new UserResponse(id, email, displayName, active);
}
}
}

Each time UserResponse.builder() is invoked, a temporary builder instance may be created before the final object is returned by build(). This frequently introduces an additional object during construction. Whether the JVM can eliminate this intermediate step depends on the object’s structure and the specific JDK version in use. To understand how memory consumption relates to JVM behavior, yCrash examines the components that contribute to a Java object’s complete memory footprint.

Fig: As request volume increases, Lombok @Builder performance pressure grows, especially builders with @Singular trigger more frequent Young GC than constructor or static factory approaches.

How Lombok @Builder Impacts Heap Memory and Garbage Collection

Without @Builder, object creation is a single allocation:

Client -> Constructor -> MyObject (1 heap allocation)

With @Builder, the theoretical flow is:

Client -> MyObject.builder() -> BuilderObject (temp) -> .build() -> MyObject (final)

Let’s put this allocation pressure onto the JVM memory areas. We know we’ve got the heap split into young generation (all new objects are initially allocated here and if objects die young they are garbage collected during Minor GC) old generation (long-lived objects are promoted here and collected by Major GC, which has longer pauses) and metaspace (this holds class data and is off-heap).Builder instances end up in the young generation as they should be short lived.If we’re allocating them quickly, as we are during a high-throughput mapping loop, Minor GC gets to work more frequently.The collected objects for the @Singular methods (lists or sets) don’t become collected during Minor GC, however, because they end up becoming part of the final object and have a tendency to be promoted to the old generation as the instance lives on.

After using the build() method to create a builder, it should be disposed of when it is no longer needed. At times, the JVM notices when the builder’s scope ends, then tucks it briefly into the stack frame much like regular values. Testing basic types showed this pattern clearly, yet even now long chains of object links still block improvements in similar ways.

Additionally, transient builders will not be easy to find in heap dumps unless the garbage collection logs are checked. Builders’ footprint in the garbage collection logs will be much less than in the heap dumps. 

When Lombok @Builder Causes Performance Issues

Although @Builder itself is not particularly slow, there are some situations where performance gets worse: In a basic setup, performance is actually good under JVM. The more complex it becomes, the worse the performance gets. An optimization might fail, when builds are deeply nested; the compiler has to deal with the more stress each time starting up. When method calls come in unsorted order, handling their synchronization is difficult.

1. Lombok @Builder Performance in API Response Mapping Loops

return users.stream()
.map(u -> UserResponse.builder() // new BuilderObject every iteration
.id(u.getId()).email(u.getEmail())
.build()) // BuilderObject becomes garbage
.collect(toList());

If you have 500 items in the list, but are getting 50 requests per second, you could be making as many as 25,000 temporary builder objects per second! The places where the map is hot, typically will not delete all those object setups

2. Why @Singular Creates Additional Memory Allocations 

@Builder
public class SearchResult {
@Singular
private List<String> tags;
}

@Singular‘s cost is not ambiguous; it is deterministic: Lombok’s implementation (Guava or JDK path) always produces at least one extra collection copy on every build() call. On the Guava path, an immutable list is created. 

For Collections.unmodifiableList() in the JDK path, the accumulated list is wrapped by unmodifiableList(). These objects cannot be optimized out from the JVM since they exist in memory, and yCrash gives an in-depth discussion on Java Collection object memory overheads.

3. Performance Impact of toBuilder() on Large Domain Objects 

Let’s impose this allocation pressure onto the areas of the JVM memory. We have already know that we will have young generation (all new objects are allocated here initially and when objects die young enough, they are garbage collected in Minor GC), old generation (long lived objects are promoted to old generation and are collected by Major GC that has longer pauses), metaspace (this contains class data, is outside of the heap). The Builder instance is allocated in young generation as they should be short lived objects. We can find that the Minor GC collects more frequently if we are allocating Builder instances frequently in a high-throughput mapping loop. In contrast, collected objects of @Singular methods (lists or sets) cannot be collected by Minor GC because they would become a part of the final object and could be promoted to old generation for the reason that the instance will live long enough.

Lombok @Builder Performance Benchmark Results (JMH) 

Let’s set the theory aside and measure.

JMH Benchmark Setup and Test Environment 

We executed benchmarks using Windows 10 and the IntelliJ IDEA terminal. JVM: OpenJDK 17 – mature enough for reliable escape analysis and JIT optimizations. Measurement framework: JMH, version of Lombok: 1.18.x. Each benchmark began with 5 warmup runs and then proceeded with 10 measurement runs using the avgt strategy. Collected metrics: gc.alloc.rate, gc.alloc.rate.norm, gc.count, gc.time.

The heap limits of the test setup were -Xms128m -Xmx128m, which is a small amount for a production environment and represents conditions that make observing the behavior of garbage collection possible. In practice most real services would use larger heaps, where the effects of garbage collection would be less noticeable due to the lower level of developer-created object allocations.

Lombok @Builder Performance Test Scenarios 

Each of the four approaches was used to create objects of the same 4 field POJO and included ConstructorMapping, StaticFactoryMapping, LombokBuilderMapping and LombokBuilderWithSingularMapping.

JMH Benchmark Results 

Approachns/opAlloc/opAlloc rate (MB/s)GC time (ms)GC count
Constructor mapping3.177 ± 0.08532 B/op9,099 ± 1,376155159
Static factory mapping3.300 ± 0.35532 B/op9,164 ± 824154172
Lombok @Builder mapping3.127 ± 0.04032 B/op9,805 ± 174161165
@Builder + @Singular25.629 ± 25.177104 B/op9,607 ± 1,096152162

Fig: Real JMH output, ns/op summary results (OpenJDK 17, Lombok 1.18.x).

Analysis of Lombok @Builder Benchmark Results 

Constructions (3.177 ns/op), static factories (3.300 ns/op) and @Builders (3.127 ns/op) performed almost identically, all with a memory allocation of 32 B/op per call. The optimization from Java 17’s escape analysis eliminated the builder object for this simple 4-field POJO. There may be significant differences when running Java 11 and earlier, as those versions apply less aggressive escape analysis optimizations.

The real costs were in @Singular and the ns/op figure of (25.629 + / – 25.177) represents only part of the picture by itself. The margin of error appears to be very close to the mean, which suggests that the real costs could range anywhere from approximately 0.5 nsec/op to ~ 50 nsec / op. Therefore, the ns/op by itself is not a good measure here.

The real evidence is in alloc/op, 104 B/op, which is 3.25x the allocation of a simple constructor, and is consistent and deterministic with ±0.001 error margin. The JVM cannot eliminate the collection copies because they are real objects in the heap. In yCrash’s post on achieving high throughput with garbage collection, the overhead of this type of allocation reduces available throughput for GC.

Limitations of this testing were that all results were derived from OpenJDK 17 using a 128MB heap in an isolated microbenchmark. Results will vary by JDK version (11, 17, 21), GC algorithm and heap size. There is a lot of variability in escape analysis behaviour.

GC Log Analysis of Lombok @Builder Allocations 

While the benchmark was running, we kept GC logging active:

-Xlog:gc*:file=gc-jmh.log:time,uptime:filecount=5,filesize=20m

These are not representative of production conditions; they are real GC log lines captured during the JMH run with a -Xms128m -Xmx128m heap limit. The artificially small heap makes GC activity more frequent than it would be in a typical service:

Fig: Real GC logs captured during the JMH benchmark run with a -Xms128m -Xmx128m heap limit.

Let’s apply this allocation pressure to the different regions of JVM memory. As we already knew we are having young generation (where all new objects will be allocated initially and will be garbage collected in Minor GC if the objects have shorter lifetime), old generation (where objects with longer life time will be promoted and garbage collected by Major GC which will take longer pauses), metaspace (contains class information, placed outside the heap). Builder object will be allocated in young generation as they should be objects with short life time. We will notice that Minor GC happens more often when we allocate Builder object with high frequency in a high-throughput mapping loop. But objects that are collected in @Singular method cannot be collected by Minor GC because they become part of final objects and can be promoted to old generation because the instance has a long life time.

The Hidden Cost of @Singular in Lombok Builders 

Depending on the Lombok version plus how dependencies are set up, @Singular acts in one of two fixed ways

Guava path

Each time the build runs, a new guava path is created, and the sudden appearance of that list strains memory. With every call, another unused object remains in the background. That small additional step can quickly accumulate if left unchecked. Another chunk of data gets allocated, just like that.

JDK path

When you mark a collection field with @Singular, Lombok shifts away from direct assignments. Instead, during .build(), it creates duplicates of the data. These standard JDK collections become locked down – wrapped by Collections.unmodifiableList(). Such wrapping blocks later attempts to alter their structure anywhere in code. Two separate objects take shape at once: first comes a short-lived modifiable version – maybe an ArrayList – then right after appears its unchangeable shell. That second layer acts like armor against reordering or additions. Once built, these replicated structures escape the builder entirely. They attach themselves permanently to the created instance. Because they leak beyond local scope, the JIT cannot apply Escape Analysis here. So those chunks remain rooted in heap memory indefinitely. Each construction carries that same fixed footprint forward without release.

Either way, you pay the price without question – the JVM has no way to skip it. Each time build() runs on a @Singular model:

●      A fresh UserResponseBuilder instance comes into existence

●      A collection structure is created to accumulate items

●      A fresh version emerges when build() runs – locked, untouched by changes. This outcome stands firm, resistant to edits. Each time it wraps up data, what you get cannot shift. Immutability takes shape here, quietly. The result? A snapshot that holds still

●      A fresh version of UserResponse appears at the end

Exactly what we see – 104 B/op and 25.629 ns/op – matches how these structures behave. A lighter option comes through when things take a simpler shape

public static UserResponse of(String id, List<String> tags) {
return new UserResponse(id, List.copyOf(tags));
}

Constructor vs Static Factory vs Record vs Lombok @Builder 

Some things come together just fine on their own

ScenarioRecommendationArchitectural Justification
Config / settings object@Builder – fineThe type requires human-friendly reading and instantiation happens with low frequency. The priority is not being small in size.
Test data setup@Builder – idealThis pattern skips typical build procedures. Because during a trial run input is morphed dynamically the data setup will be simpler.
API response in mapping loopConstructor or static factoryRemoval of unnecessary allocation of intermediates object will shield from garbage collector churn in high-throughput pipelines.
DTO with >10 optional fields, with low throughput@BuilderWell-defined paramaters but keeps the architecture of the codebase clean.
Batch job processing 100K recordsConstructorBypasses all builder overhead; maximizes data pipeline efficiency and throughput.
Immutable value object (Java 16+)RecordEnforces native language-level immutability with optimal JVM layout optimizations.
@Singular on  high-throughput pathMeasure allocation firstAvoids the double-allocation penalty; replace if it appears in allocation profiles.

Fig : Lombok @Builder performance decision framework, when to use each approach

How to Optimize Lombok @Builder Performance 

When it comes to @Builder being in hot paths, we are willing to use it because of the readability and the context of how it was created.

1. Use Lombok @Value for Better Runtime Efficiency 

Employing @Value enables the creation of a final class equipped with an all-arguments constructor, while automatically generating the equals() and hashCode() methods. This approach is significantly more efficient at runtime, especially when working with immutable DTOs.

@Value
public class UserResponse {
String id; String email; boolean active;
}

2. Use @Builder Only at API Boundaries 

Only make use of @Builder to create the public API and internally use the all-args constructor on the hot path for the highest throughput.

3. Measure Builder Allocation Costs Before Optimizing 

Don’t be concerned that you have to remove @Builder from all places. According to our benchmarks for modern JDKs (17+), the JVM can optimize the simple instances of @Builder usage through JIT escape analysis. Use async-profiler or JFR to determine where the hot paths are and optimize based on the data you collected. When analyzing the output, focus on two key signals: allocation hot spots (which call sites are responsible for the highest gc.alloc.rate.norm values) and TLAB allocations (Thread-Local Allocation Buffer misses indicate objects too large to fit in the fast-path allocator, forcing them directly onto the heap). A @Singular builder appearing in your top allocation hot spots is a clear signal to refactor that path. Refer to yCrash’s documentation on object pooling and additional methods to mitigate CPU usage caused by GC.

jcmd pid JFR.start duration=60s filename=alloc.jfr settings=profile

Once the JFR recording completes, open the resulting .jfr file in JDK Mission Control (JMC) and navigate to the Memory view. Look specifically for: 

  1. Allocation in new TLAB,  frequent, small allocations that fit inside thread-local buffers, typical of builder churn in tight loops; 
  2. Allocation outside TLAB,  larger allocations that bypass the fast path and land directly on the heap, a stronger indicator of pressure; and 
  3. the top allocation call stack frames,  if your @Singular builder’s .build() method appears here consistently, that is your optimization target.

Lombok @Builder Performance: Key Takeaways 

When using @Builder, remember that it’s an API used at construction-time only. Therefore, it should not be considered a runtime-neutral annotation. Most modern JDKs can perform escape analysis to eliminate the cost of creating an object when using the @Builder. However, using @Singular results in unavoidable and measurable costs on your application.

When Should You Use Lombok @Builder? 

  • Simple POJO, using a modern JDK → using @Builder will likely work out fine; measure and verify.
  • Mapping loops on hot paths, for example, through batch jobs → should typically use either constructors or static factory methods.
  • If you use @Singular on any of the models that you build frequently, please measure them first and determine if you use only @Builder or replace @Builder with an appropriate alternative as indicated by the measured profile.
  • If you use toBuilder() frequently, be aware of and confirm the associated costs by profiling under load.

Unsure how? Try using async-profiler with -e alloc for at least 30 seconds while under load.

Final Thoughts on Lombok @Builder Performance 

However, this article helped us to understand what the real costs are of Lombok @Builder performance. Our JMH tests showed almost identical memory usage across the constructor, static factory, and basic Lombok @Builder under OpenJDK 17. With just four fields in the POJO, the JVM’s escape analysis probably removed any short-lived builder instance. The known allocation cost of @Singular is 104 B/op, which means three times more than for plain constructor, but it’s constant and precise (error margin = ±0.001). As for the ns/op number (25.629 ± 25.177), it looks rather noisy because the error margin almost equals the mean number. However, when it comes to alloc/op, there’s nothing ambiguous here. It is difficult for the JVM to optimize away such allocations, as they are indeed legitimate heap allocations. Short-lived allocation pressure can be seen from the GC logs, but the more convincing proof would come from JMH gc.alloc.rate.norm benchmark metric.

In summary, the issue at hand is not about not using @Builder in your code at all times. The important thing is to ensure that you use @Builder when its performance matters.

Share your Thoughts!

Up ↑

Index

Discover more from yCrash

Subscribe now to keep reading and get access to the full archive.

Continue reading