perf: parse resolve responses without JSON round-trips#239
Merged
fabriziodemaria merged 2 commits intomainfrom Apr 29, 2026
Merged
perf: parse resolve responses without JSON round-trips#239fabriziodemaria merged 2 commits intomainfrom
fabriziodemaria merged 2 commits intomainfrom
Conversation
`NetworkResolvedFlagSerializer`, `FlagsSerializer` and `convertToValue`
were serializing each parsed `JsonElement` back to a string with
`JsonElement.toString()` and then handing that string to
`Json.decodeFromString(...)` (or to manual unquoting via `replace("\"", "")`).
For every flag we therefore re-tokenized JSON we had already tokenized
once.
This change reads primitives directly via `.jsonPrimitive.content`
(avoiding the `toString()` -> `replace("\"", "")` quote-stripping hack)
and replaces every `Json.decodeFromString(<serializer>, element.toString())`
call with `Json.decodeFromJsonElement(<serializer>, element)`, which is
the supported zero-copy entry point for nested deserialization.
Also pre-sizes the `ResolvedFlag` list to the parsed array size so we
don't grow-and-copy while filling it.
For ~950 flags this removes thousands of `JsonElement.toString()`
allocations + retokenizations per resolve. Behaviour is preserved
including `ResolveReason` enum decoding and existing `flagSchema` /
`value` / `structSchema` paths.
Made-with: Cursor
5 tasks
Add three deserialization tests to lock in equivalence on the resolve hot path touched by this PR: - testDeserializeMultipleFlagsInOneResponse: pins the FlagsSerializer loop with array.size > 1 and the ArrayList(array.size) preallocation. Existing payloads only had a single flag. - testDeserializeAllResolveReasons: round-trips every declared ResolveReason value through the new ResolveReason.valueOf path, guarding against future divergence (e.g. a stray @SerialName). - testDeserializeNestedStructSchema: pins the recursive convertToSchemaTypeValue / decodeFromJsonElement(SchemaTypeSerializer) path two levels deep with mixed leaf types. Existing tests only nest structSchema one level. Made-with: Cursor
vahidlazio
approved these changes
Apr 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the
Json.decodeFromString(<serializer>, element.toString())pattern inSerializers.ktwithJson.decodeFromJsonElement(<serializer>, element), the supported zero-copy entry point for nested deserialization. Also reads primitives via.jsonPrimitive.contentinstead ofJsonElement.toString()+ manual quote stripping.For every flag in a resolve response we were previously serializing each parsed
JsonElementback to a JSON string and re-tokenizing it inside a freshJson.decodeFromStringcall. With ~950 flags this happens many thousands of times per resolve.Investigation context
This is one of two PRs from a perf investigation of the Android SDK using the same load-test client (
swift-load-test-950, 950 flags) we used for the Swift SDK. We instrumented the Android SDK with debug logging on the resolve hot path (network start, network response with elapsed time, cache save with payload size, cache load) and ran the demo app on a Pixel 9a emulator.Bottlenecks identified
Serializers.kt— addressed by this PR.ConfidenceValueenvelopes — 491 KB on Android vs 152 KB on Swift for the same 950 flags. Not addressed here; needs a versioned migration. Tracked as future work.activate()contention — not a problem on Android: the in-memory cache is a single@Volatilereference (no blocking lock, unlike Swift'sDispatchQueue.syncwe had to fix in confidence-sdk-swift#245).Measured impact (950 flags, Pixel 9a emulator)
Three different scenarios — they exercise different code paths and the win is very different in each:
The case to ship this is mostly the cold/fresh-JVM cliff — at 950 flags the baseline takes 4–5 s the first time the OS-reaped process is restarted, which is what users feel as "app is slow on launch". The steady-state same-session number (~244 ms baseline) is already fine for most apps; the ~24% it picks up is a free bonus.
The cache save/load also speed up because the on-disk cache is read back through the very same
NetworkResolvedFlagSerializerandconvertToValuepaths.What changed in
Serializers.ktflag,variant,reason,shouldApplyvia.jsonPrimitive.content/.jsonPrimitive.booleaninstead ofJsonElement.toString()+.replace("\"", "")/Json.decodeFromString<...>(... .toString()).Json.decodeFromString(<serializer>, element.toString())call withJson.decodeFromJsonElement(<serializer>, element)— both insideNetworkResolvedFlagSerializer,FlagsSerializerand the recursive helpers inconvertToValue/convertToSchemaTypeValue.ResolvedFlaglist to the parsed array size inFlagsSerializer.deserialize(one less grow-and-copy).ResolveReasonis decoded withResolveReason.valueOf(...)from.jsonPrimitive.content. The default kotlinx-serialization enum decoder uses the enum name and the enum has no@SerialNameannotations, so this is behaviourally identical and avoids depending on the reifieddecodeFromJsonElement<T>extension for an enum that isn't@Serializable.The on-disk JSON format is unchanged and so are existing
flagSchema/value/structSchemapaths.Test plan
./gradlew :Confidence:compileDebugKotlin./gradlew :Provider:testDebugUnitTest(the test target CI runs)./gradlew ktlintCheck./gradlew assembleDebugflag,variant,reason,shouldApply,flagSchemaand nestedvalueparse identically (logs from the demo app's resolve tester payload look unchanged)Future work
{"<type>": <value>}envelopes wrap every leaf). Today the on-disk cache for 950 flags is 491 KB; on Swift the same cache is 152 KB. A flatter, versioned cache format with a backward-compatible read path will follow once we pick the design (untagged values vs. preserved-schema vs. shorter type tags).