Why Protobuf for SDUI?
Server-Driven UI (SDUI) systems rely on a backend sending a tree of UI components that the client renders dynamically. Most implementations use JSON as the transport format. While JSON is human-readable and flexible, it comes with performance costs that matter in mobile environments: verbose payloads, ambiguous types, and expensive parsing.
Protocol Buffers (Protobuf) offers a strongly-typed, binary-serialized alternative that dramatically reduces payload size and parsing time — both critical for SDUI, where every screen load depends on downloading and processing a component tree.
This document presents a proven approach to adopting Protobuf for SDUI, including schema design, migration strategies, and real benchmark data.
Key Advantages
1. Dramatically Smaller Payloads
| Metric | JSON | Protobuf | Improvement |
|---|---|---|---|
| Payload size (small screen) | 2,501 bytes | 593 bytes | 76% smaller |
| Projected size (~30 components) | ~12–18 KB | ~3–4 KB | ~75–80% smaller |
On unstable 3G/4G connections, smaller payloads mean fewer retransmissions and faster time-to-render.
2. Faster Parsing
| Metric | JSON | Protobuf | Improvement |
|---|---|---|---|
| Full pipeline (parse + domain mapping) | 0.0433 ms | 0.0249 ms | 1.7x faster |
| Projected (~30 components) | ~8–15 ms | ~1–2 ms | ~85–90% faster |
Protobuf achieves this through:
- No intermediate tree: Streaming parse directly into typed objects
- Integer field IDs: No string key comparisons
- Zero ambiguity: Type resolved by field number, not discriminator strings
-
Reduced allocations: No
JsonObject,JsonArray, orJsonPrimitiveintermediaries
3. Compile-Time Type Safety
The oneof construct gives you polymorphic dispatch without runtime string matching. The compiler guarantees every component type is handled — missing a case is a build error, not a runtime crash.
4. Built-In Backward Compatibility
Protobuf's wire format is designed for evolution:
-
Adding a component: New field in
oneofwith a new field number. Old clients silently ignore it. - Adding a field to a component: New field number, default value used by old clients.
-
Removing a field: Mark as
reserved. Never reuse field numbers.
No versioned endpoints, no breaking changes, no coordinated deploys.
5. Multi-Platform from a Single Schema
One .proto file generates native types for every platform:
protoc --java_out=lite:. sdui.proto # Android
protoc --swift_out=. sdui.proto # iOS
npx buf generate sdui.proto # Web (TypeScript)
protoc --python_out=. sdui.proto # Backend tooling
Every team works with idiomatic, generated types — no manual model classes, no drift between platforms.
Schema Design for SDUI
The Polymorphism Challenge
SDUI's core challenge is representing heterogeneous lists of components. In JSON, a discriminator field like "component": "button" handles this. In Protobuf, the oneof construct provides type-safe polymorphism:
syntax = "proto3";
package sdui.v1;
message SDUIContent {
string flow = 1;
repeated SDUIScreen screens = 2;
SDUIConfig config = 3;
map<string, string> initial_state = 4;
}
message SDUIScreen {
string id = 1;
string title = 2;
SDUIHeadButton button = 3;
repeated SDUIComponent components = 4;
}
message SDUIComponent {
string name = 1;
string show_if = 2;
oneof component {
SDUIText text = 10;
SDUIButton button = 11;
SDUIInput input = 12;
SDUIContainer container = 13;
SDUIToggle toggle = 14;
SDUIDropDown drop_down = 15;
SDUIRadio radio = 16;
SDUICheckbox checkbox = 17;
SDUIAlert alert = 18;
SDUIImage image = 19;
SDUIBanner banner = 20;
}
}
Design Decisions
| Decision | Rationale |
|---|---|
oneof component for polymorphism |
Compile-time type safety; no ambiguous parsing |
repeated SDUIComponent children in Container |
Enables recursive nesting (tree structure) |
map<string, string> for initial_state
|
Simple representation for dynamic key-value state |
Field numbers ≥ 10 for oneof components |
Reserves 1–9 for future common fields |
| Lite runtime on mobile | Smaller binary footprint (~300 KB vs full runtime) |
Adopting Protobuf: Regardless of Your Architecture
Whether you have a monolith, microservices, or a BFF layer, Protobuf can be adopted incrementally. Here are three strategies, from lowest effort to highest benefit.
Strategy 1: Translation Layer (Zero Backend Changes)
If your backend already serves JSON, add a lightweight translation service between your backend and clients:
┌──────────────┐ JSON ┌─────────────────┐ Protobuf ┌──────────┐
│ Your Backend │───────▶│ Translation BFF│────────────▶│ Clients │
│ (unchanged) │ │ │ │ │
└──────────────┘ └─────────────────┘ └──────────┘
The translation layer:
- Receives JSON from your existing backend
- Converts to Protobuf binary using
JsonFormat.parser().ignoringUnknownFields() - Serves binary protobuf to clients
Key benefit: ignoringUnknownFields() means your backend can evolve freely. New JSON fields not yet in the .proto schema are silently ignored — no breaking changes.
// Example: Ktor translation endpoint
post("/translate") {
val jsonBody = call.receiveText()
val builder = SDUIContent.newBuilder()
JsonFormat.parser().ignoringUnknownFields().merge(jsonBody, builder)
val proto = builder.build()
call.respondBytes(proto.toByteArray(), ContentType("application", "x-protobuf"))
}
When to use: You want performance gains on the client without touching the backend. Ideal for legacy systems or when backend changes require long approval cycles.
Strategy 2: Dual Format with Content Negotiation
Serve both formats from the same endpoint using the Accept header:
GET /v1/screens/{screenId}
Accept: application/x-protobuf → binary protobuf (new clients)
Accept: application/json → JSON (legacy clients)
(no Accept header) → binary protobuf (default)
When to use: You're migrating clients incrementally. New app versions use Protobuf; older versions still get JSON. No forced upgrades, no coordinated releases.
Strategy 3: Native Protobuf Backend
The backend generates Protobuf directly. Maximum performance, no translation overhead:
┌──────────────┐ Protobuf ┌──────────┐
│ Your Backend │───────────▶│ Clients │
│ │ │ │
└──────────────┘ └──────────┘
When to use: Greenfield SDUI systems, or after the translation layer has proven the approach and you're ready to go all-in.
Client Integration
Android (Kotlin)
Gradle setup:
plugins {
id("com.google.protobuf") version "0.9.4"
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:3.25.2"
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
create("java") {
option("lite") // Use lite runtime for mobile
}
}
}
}
}
dependencies {
implementation("com.google.protobuf:protobuf-javalite:3.25.2")
implementation("com.squareup.retrofit2:converter-protobuf:2.9.0")
}
Retrofit API:
interface SDUIApi {
@GET("screens/{screenId}")
suspend fun getScreen(
@Path("screenId") screenId: String,
@Header("Accept") accept: String = "application/x-protobuf",
): SDUIContent
}
Retrofit's ProtoConverterFactory handles deserialization automatically.
Mapping to your domain layer:
// Proto → Domain mapping preserves your existing rendering pipeline
fun SDUIContent.toDomain(): ScreenContent =
ScreenContent(
flow = flow,
screens = screensList.map { it.toDomain() },
config = if (hasConfig()) config.toDomain() else null,
initialState = initialStateMap.toMutableMap(),
)
// Polymorphic dispatch via oneof — replaces JSON discriminator logic
fun SDUIComponent.toRenderable(): Renderable? =
when (componentCase) {
SDUIComponent.ComponentCase.CONTAINER -> ContainerComponent(container.toDomain())
SDUIComponent.ComponentCase.BUTTON -> ButtonComponent(button.toDomain())
SDUIComponent.ComponentCase.TEXT -> TextComponent(text.toDomain())
// ... other components
SDUIComponent.ComponentCase.COMPONENT_NOT_SET, null -> null
}
iOS (Swift)
import SwiftProtobuf
let url = URL(string: "https://api.example.com/screens/home")!
let data = try await URLSession.shared.data(from: url).0
let content = try Sdui_V1_SDUIContent(serializedBytes: data)
Web (TypeScript)
import { SDUIContent } from './generated/sdui_pb';
const response = await fetch('/screens/home', {
headers: { 'Accept': 'application/x-protobuf' }
});
const bytes = new Uint8Array(await response.arrayBuffer());
const content = SDUIContent.fromBinary(bytes);
Schema Distribution
Clients need access to .proto files for code generation. Options:
| Approach | Complexity | Best For |
|---|---|---|
Serve via HTTP (GET /proto/sdui.proto) |
Low | Small teams, rapid iteration |
| Git submodule | Medium | Multiple repos, version pinning |
| Package registry (Maven/npm/CocoaPods) | Medium | Published artifacts with versioning |
| Buf Schema Registry (BSR) | High | Large orgs, breaking change detection |
A simple HTTP endpoint works well to start:
# Any platform can download and generate
curl -o sdui.proto https://api.example.com/proto/sdui.proto
protoc --swift_out=. sdui.proto
CI/CD Integration
Build Impact
| Aspect | Impact | Mitigation |
|---|---|---|
| Build time | +5–10s for generateProto
|
Incremental — only runs when .proto changes |
| App size | +300–400 KB (protobuf-lite) | Smaller than JSON libraries; offset by network savings |
| Build cache | Proto generation is cacheable | Gradle/Bazel build cache handles this |
| CI pipeline | New validation step |
buf lint + buf breaking
|
Recommended CI Pipeline
Source → Proto Lint (buf) → Generate Code → Compile + Tests → Static Analysis
↓
Breaking Change Check (buf breaking --against main)
The buf breaking step prevents accidental backward-incompatible changes from reaching production.
Debugging & Developer Experience
Binary formats are harder to inspect than JSON. Here's how to maintain good DX:
| Tool | Use Case |
|---|---|
/json debug endpoint |
Human-readable view of the same data |
| Charles Proxy / Proxyman | Auto-decode if .proto file is registered |
protoc --decode |
CLI-based decode of captured binary |
| Logging interceptor (debug builds) | Decoded payload in logcat/console |
Learning Curve
| Topic | Difficulty |
|---|---|
Basic .proto syntax |
Low |
oneof, repeated, map
|
Medium |
| Gradle/build tool setup | Medium |
| Binary debugging | High (mitigated by tooling above) |
| Schema evolution rules | Medium |
Risks and Mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| Team learning curve | Medium | Pair programming, documentation, gradual rollout |
| Production debugging | High | Dual endpoints; decoded logging in debug builds |
| Field number conflicts | High |
buf breaking in CI; code review discipline |
| App size increase | Low | Protobuf-lite adds ~300 KB |
| Tooling compatibility | Medium | Custom interceptors for observability platforms |
Getting Started in 30 Minutes
Define your schema: Write a
.protofile modeling your SDUI component tree usingoneoffor polymorphism.Generate client code: Use
protoc(or Buf) to generate types for your platform.Add a translation endpoint: If you already have a JSON backend, add a single endpoint that converts JSON → Protobuf using
JsonFormat.parser().ignoringUnknownFields().Update your client: Replace your JSON deserialization with Protobuf deserialization. Map to your existing domain models — your rendering layer doesn't need to change.
Measure: Compare payload sizes and parse times. The numbers speak for themselves.
Conclusion
Protobuf is not just a serialization format — for SDUI, it's an architecture upgrade:
- 76% smaller payloads mean faster screen loads on any network
- 1.7x faster parsing means smoother transitions between screens
- Compile-time type safety means fewer runtime crashes from malformed responses
- Built-in versioning means no coordinated deploys when adding components
- Single schema, every platform means no drift between Android, iOS, and Web
The migration doesn't require a rewrite. Start with a translation layer, prove the gains, then go native. Your rendering pipeline stays the same — only the transport changes.
Top comments (0)