Field notes

A week of outside use: seven findings, five fixes

Before the release, a team tried Undra for a week, in a proof of concept, and sent us seven findings. Five are fixed and merged, each through a pull request with a decision record and an adversarial review. Two were not taken on. Here is each one, what we measured, and what changed.

The seven findings

FindingWhat happened
Server-sent events are slow on iOSFixed: 7.8x on 4 KB events (release build, on a Mac)
The Swift tests do not build on Xcode 27Not taken on: no reproduction yet
No BazelFixed: Bazel rules and an example workspace
A core is 1.6 MB per Android ABIFixed: a size profile, a setting and gates; the reported core is an estimated 1.42 MB by default
The Android adapter is not our network stackFixed: adapters over the app's own OkHttp
No GraphQLNot taken on: Apollo stays on the platform side
Generated code is noise in reviewFixed: the schema is the API, and undra schema diff reads it

The SSE adapter read a byte at a time

The report measured the iOS ceiling for server-sent events 2.4x below what it should be. The code agreed: the Swift adapter pulled the response body from URLSession.AsyncBytes one byte at a time and handed the parser one byte per call. The Kotlin and TypeScript adapters already read chunks.

The adapter is now the delegate of a data task, and the parser gets whole chunks. Backpressure follows the Kotlin adapter's rule: the task is suspended while events wait for the core, so URLSession stops reading and TCP pushes back.

We measured on a Mac (Apple M5 Pro, macOS 26.5), not on an iPhone or the simulator: a release build of the adapter reading from a local test server. 4 KB events went from 12,600 to between 84,000 and 99,000 a second over three runs (7.8x is the middle run), and 512-byte events by 2.1x; 64-byte events did not move, because the limit there is URLSession's own receive path. A debug build gained 4.2x and 4.3x on the same two sizes, and the report's 2.4x falls inside that range. Separately, once the parser worked on bytes instead of characters, it alone went from 310 to 2,246 MB/s on 4 KB events, in a release harness fed 64 KiB chunks.

The review found two High defects in the fix itself: a cancelled waiter hung and left the request open, and a background URLSession, which takes no task delegate, aborted the app. A background session is now refused with a typed error. The old adapter streamed on one, so that is a behaviour change, stated in ADR-047.

No Bazel

Undra's build was a command line that Xcode and Gradle call: a Bazel repository had nothing to put in its graph, and its lint rules flagged the generated Kotlin. The bazel/ module adds undra_core, undra_bindings and a library rule per language, which run the CLI as a pinned, hermetic, offline tool. examples/bazel tests a core from Kotlin on the JVM, TypeScript on Node and, on macOS, Swift. Lint exclusions written beside every generated tree took ktlint 1.8 from 90 findings to none on the example's generated Kotlin. Android under Bazel is declared and not yet verified: the example's Android core stops at analysis for want of a C++ toolchain, and the roadmap says so.

1.6 MB per ABI

Nothing gated a native core's size. iOS and Android release builds now use a profile at opt-level = "s" that keeps the four crates of the call path at full speed, and three gates hold the hello world: 898,176 bytes for arm64-v8a and 960,776 for x86_64 (stripped, uncompressed) against 1.2 MB, and an iOS slice of 784,086 against 900 KB. For a team with a hard byte budget, opt_level in the [android] or [ios] table of undra.toml chooses "s" (the default), "z" (smallest) or "3" (fastest).

We did not have the reported core, so we estimated where it lands: the hello world's share plus the rest, scaled by how much the playground shrinks. It comes to about 1.42 MB per ABI with nothing changed (1.38 to 1.46 MB, since the tool rounds 1.55 to 1.65 million bytes to "1.6 MB") and about 1.18 MB at opt_level = "z" (1.14 to 1.21 MB), so "z" reaches 1.2 MB narrowly and not for every core of that size. "z" has a cost we measured on the playground's device benchmark, against "3", which the default matches within noise: a synchronous call 1.33x slower on the iPhone simulator and 1.58x on the Android emulator, not on a phone. The answer is a setting with its price documented, not a promise.

Inside the app's network stack

The default Android adapter used HttpURLConnection. An app's token refresh, interceptors, tracing and certificate pinning live in its own OkHttpClient, an object the platform's HttpURLConnection cannot reach, so a request from the core carried no token, appeared in no trace and was not pinned. The optional okhttp-adapters module puts Http, WebSocket and SSE over the app's own client with one call; on iOS the app's URLSession serves all three. Two limits remain. OkHttp itself leaves a client's network interceptors and event listener out of a WebSocket upgrade, so tracing written that way will not see the core's WebSockets; the docs say so. And the SSE adapter refuses a background URLSession while the WebSocket adapter does not yet, which is on the roadmap. The review's High: in the old install order, a queued offline request could leave through the default adapter, without the token and without the pin.

Generated code in review

Two features had produced about 1,400 lines of bindings. The count was right: one feature of the playground is 130 to 250 generated lines per platform. We kept the code, since the project's rules require generated code to read as a platform engineer would write it, and moved review to the schema. Generated files are marked for GitHub to collapse, and undra schema diff prints one line per API change, breaking or additive. On the command line's test fixture, a to-do core with thirteen API changes, the generated bindings changed by 581 lines, the schema file by 77, and the diff printed 13.

What we did not take on

Xcode 27. The Swift test target was reported not to build on it. Our machines and CI run Xcode 26, so we could not reproduce it; it needs the crash log. It stays on the roadmap until it reproduces.

GraphQL. Undra has no GraphQL layer, and an app built around Apollo keeps Apollo on the platform side. Whether Undra should have more is a question on the roadmap, not a plan.

What a week taught us

Three of the five were places where our tests were green and a real app was not: a ceiling nobody had measured, a build system we did not use, a size we did not gate. What the five reviews left open is on the roadmap: OkHttp pinning end to end, Android under Bazel, a byte-reproducible build.