The problem domain
bitdrift's Capture SDK runs inline with your application as a mobile observability layer. Even small per-event costs show up quickly on paths like:
regular logs
spans
HTTP instrumentation
field provider expansion
The allocation pressure on those paths came up in customer feedback, including a suggestion to replace HashMap with ScatterMap. Romain Guy wrote a good post on why HashMap can be a poor fit in some cases, and that was a useful reference point while looking into this.
The first profiling passes showed that the allocation stream was mostly made of small objects:
HashMapinstancesmap entries
intermediate collections used while reshaping data for JNI
PR: https://github.com/bitdriftlabs/capture-sdk/pull/805
Examples from the code
The original design was convenient: fields were carried around as maps, enriched a few times, then converted again at the JNI boundary. For small payloads that looks harmless in code review:
kotlin
val fields = buildMap { put("_method", method) put("_host", host) put("_path", path) } logger.log(level, fields) { message }
What that hides is that we allocate the map itself, allocate entries, copy again into another internal structure later, and then reshape the same data again before crossing JNI.
Why HashMap was the wrong fit
Our field handling leaned heavily on maps, but the logging path does not really need most of what a map gives us. We did not need:
direct/non-sequential access
hashing
bucket management
entry wrappers
full map semantics during intermediate processing
What we mostly needed was an ordered collection of key/value pairs that could be combined and passed down.
The problem with the original shape was not just HashMap itself. It was that map-shaped work kept propagating through the whole path.
The ScatterMap step
The first direction was to replace internal HashMap usage with ScatterMap.
That was worth trying. It confirmed part of the hypothesis, but it did not remove the rest of the cost:
We were still constructing map-like objects in hot paths.
We were still converting those objects again before crossing JNI.
We were still carrying more semantics than we needed.
That is where the work shifted from “use a better map” to “stop carrying maps through this path at all.”
The broader fix
The key change was introducing our own internal ArrayFields type, backed by parallel arrays of keys and values.
Instead of carrying fields around as maps, hot paths now carry our internal field container:
kotlin
class ArrayFields internal constructor( internal val keys: Array<String>, internal val values: Array<String>, ) // Internal helper we added for building ArrayFields fun fieldsOf(vararg pairs: Pair<String, String>): ArrayFields { if (pairs.isEmpty()) return ArrayFields.EMPTY val keys = Array(pairs.size) { pairs[it].first } val values = Array(pairs.size) { pairs[it].second } return ArrayFields(keys, values) }
We built the arrays once with the final size in the common paths. When multiple groups of fields needed to be composed incrementally, we used a small builder and only materialized the final arrays once at the end. At the call site, that changed code from this:
kotlin
// Before val fields = buildMap { put("_method", method) put("_host", host) put("_path", path) } logger.log(level, fields) { message }
to this:
kotlin
// After val fields = fieldsOf( "_method" to method, "_host" to host, "_path" to path, ) logger.log(level, fields) { message }
And for a single field we added a matching helper as well:
kotlin
val field = fieldOf("_method", method) logger.log(level, field) { message }
Once that representation changed, a lot of internal APIs moved with it. Logging gained an overload that accepts ArrayFields directly:
kotlin
fun log( level: LogLevel, arrayFields: ArrayFields, throwable: Throwable? = null, message: () -> String, )
The same pattern was pushed through LoggerImpl, span creation, HTTP request/response field building, resource utilization logging, and Timber integration.
On Android there was a second part to this: the JNI boundary.
Previously, the main log path passed maps across JNI and native code unpacked those Java objects field by field:
rust
let fields = jobject_map_to_fields(&mut env, &fields, LogFieldKind::Ootb)?;
The writeLog path now passes parallel string arrays instead:
kotlin
CaptureJniLibrary.writeLog( loggerId = loggerId, logType = type.value, logLevel = level.value, log = message(), fieldKeys = arrayFields.keys, fieldValues = arrayFields.values, usePreviousProcessSessionId = false, overrideOccurredAtUnixMilliseconds = occurredAt, blocking = false, )
rust
let fields = string_arrays_to_annotated_fields( &mut env, &field_keys, &field_values, LogFieldKind::Ootb, )?;
Benchmarks
We used AndroidX microbenchmarks to measure both runtime and allocation count for the logging paths we cared about. At a high level, the benchmark setup is simple: initialize the logger, exercise one hot path repeatedly, and let the benchmark library handle warmup, iteration count, and measurement.
kotlin
@RunWith(AndroidJUnit4::class) class LogBenchmarkTest { @get:Rule val benchmarkRule = BenchmarkRule() @Test fun logNotMatched5Fields() { startLogger() val fields = buildFieldsMap(5) benchmarkRule.measureRepeated { Capture.Logger.logInfo(fields) { LOG_MESSAGE } } } }
After the optimization changes described above, these were the results against the latest main branch.
Allocation Comparison Report
| Test name | allocs on Main | allocs on PR | % allocs Reduction |
|---|---|---|---|
| trackSpansWithoutFields | 117 | 54 | 53.85% |
| trackSpansWithFields | 3754 | 266 | 92.91% |
| logNotMatchedNoFields | 2 | 0 | 100.00% |
| logNotMatched5Fields | 34 | 14 | 58.82% |
| logNotMatched10Fields | 48 | 17 | 64.58% |
| logNotMatched500Fields | 1148 | 34 | 97.04% |
| logNotMatched5000Fields | 10034 | 77 | 99.23% |
Duration Comparison Report
| Test name | duration (ns) on Main | duration (ns) on PR | % duration Reduction |
|---|---|---|---|
| trackSpansWithoutFields | 39,358 ns | 9,924 ns | 74.77% |
| trackSpansWithFields | 1,173,969 ns | 661,361 ns | 43.62% |
| logNotMatchedNoFields | 12,958 ns | 621 ns | 95.21% |
| logNotMatched5Fields | 18,758 ns | 4,148 ns | 77.87% |
| logNotMatched10Fields | 23,928 ns | 7,566 ns | 68.37% |
| logNotMatched500Fields | 514,746 ns | 319,711 ns | 37.87% |
| logNotMatched5000Fields | 5,243,689 ns | 3,312,847 ns | 36.79% |
Same app comparison
We also compared the same app flow before and after the change while looking at the memory profile and the resulting timeline session. One nice side effect here is that we can use bitdrift to measure bitdrift, using the same product view we expect customers to use when validating behavior in a real app flow.
Validation
One important case was field ordering and overwrite semantics. When fields are combined, the last value for the same key still needs to win. In simplified form:
kotlin
val fields = combineFields( fieldOf("_status", "pending"), fieldOf("_status", "success"), ) logger.log(level, fields) { message }
The expected result is still:
That was verified in gradle-test-app, along with:
field overwrite behavior when combining fields
timeline rendering
spans
regular log ingestion
workflow matching
Conclusion and lessons learned
The useful part of this work was not really the final container type. It was getting stricter about what the logging pipeline actually needs.
We started from a narrow suggestion, replacing HashMap with ScatterMap, and ended up removing map-shaped work from several layers of the path instead. That was the part that moved the numbers.
We learned several lessons from this work that can be applied more broadly:
Use the right tool for the job. It's easy to reach for a standard library structure because it looks close enough, but it's worth looking at other data structures or simpler representations when the path is sensitive enough.
Start with measurement, not assumptions. The
ScatterMaphypothesis was reasonable, but benchmarking early showed it would not be enough on its own.Allocation pressure is invisible in functional tests. Everything still passes, but the SDK quietly does more work per event. CI benchmarks are the only reliable way to catch this class of regression.
Public API ergonomics and internal representation can be decoupled. Callers kept the same interface while the internals stopped paying for unnecessary collection churn.
In SDK code, "small" allocations are rarely small. Multiply any per-event cost across every log call on every customer device and the numbers change fast.
Future work
This change removed a meaningful amount of allocation pressure, but our work isn't done yet. Here are our next steps to continue improving the Capture SDK's JVM allocation.
expose more public or semi-public APIs that can accept
ArrayFieldsdirectly where appropriateremove more remaining map-to-array conversion points
continue shrinking work done at JNI boundaries
expand benchmark coverage for more real-world logging combinations
keep validating changes against both synthetic benchmarks and timeline sessions from real apps
Frequently asked questions
What are JVM allocations and why do they matter for Android SDKs?
Every time Android code creates a new object, the Java Virtual Machine (JVM) allocates memory for it. In most application code, that cost is negligible. In SDK code that runs on every log event, every span, and every HTTP call, small per-event allocations add up quickly, potentially multiplying across millions of calls. Sustained allocation pressure forces the garbage collector to run more frequently, competing with the app for CPU time and pausing execution. On Android, those pauses contribute directly to ANRs (Application Not Responding errors) when they occur on the main thread, and sustained memory pressure from uncollected objects can push the process toward OOM (out of memory) termination.
What is ArrayFields and why does it reduce allocation pressure?
ArrayFields is an internal representation we introduced in the Capture SDK that backs field collections with parallel arrays of keys and values rather than a HashMap. On the common logging path, the arrays are built once at the final size. This removes the cost of hash bucket management, entry wrappers, and intermediate map construction that the previous design paid on every event.
How does bitdrift measure JVM allocations?
The Capture SDK uses AndroidX microbenchmarks to measure both allocation count and wall clock duration across representative logging paths, including logs with no fields, small and large field counts, spans, and HTTP instrumentation. These benchmarks run on every Android PR and compare results against a baseline, making allocation regressions visible before they ship.
Why does allocation count affect logging latency?
Fewer allocations means less work for the garbage collector. When GC pressure drops, the JVM spends less time reclaiming memory on hot paths, which reduces the duration of the logging calls themselves. In the benchmark results from this change, runtime improvements tracked closely with allocation reductions across every measured path.
Does this change affect the public API for Capture SDK users?
No. The public APIs kept its existing ergonomics. The changes were internal: the field representation, the JNI boundary, and the internal logging overloads. Existing call sites continue to work without modification.