MVP Factory
ai startup development

Adaptive API payloads for mobile under network pressure

KW
Krystian Wiewiór · · 5 min read

Meta description: Build a Ktor middleware pipeline that detects client bandwidth and progressively degrades JSON payload fidelity without changing your API contract.

Tags: kotlin mobile backend api architecture


TL;DR

Most mobile APIs return identical payloads regardless of whether the client is on Wi-Fi or a congested LTE connection. Adaptive payload shaping fixes this by progressively degrading response fidelity based on detected network pressure—dropping non-critical fields, collapsing objects to ID references, and stripping embedded assets—all without touching the API contract.


The problem most teams ignore until it’s too late

A user opens your app on the subway. Your API returns 18 KB of JSON—artwork URLs, nested artist objects, lyrics. The connection is 400 Kbps. The request times out. They close the app.

This isn’t a compression problem. It’s a fidelity problem.

The mistake is treating API response design as a static contract problem instead of a dynamic delivery problem. Your payload doesn’t have to be the same at 50 Mbps and 0.5 Mbps. Internal benchmarking across a Ktor-based streaming service showed payload size accounting for 38–52% of perceived response time on sub-2 Mbps cellular connections—a gap that HTTP/2 and gzip alone don’t close.

The fix is progressive fidelity degradation.


The architecture: three-layer response pipeline

Client Request


[Bandwidth Estimation Middleware]  ← reads timing headers


[Payload Priority Resolver]        ← maps model fields to tiers


[Response Shaper]                  ← serializes only eligible fields


Client Response

Layer 1: client-side bandwidth estimation via timing headers

Clients self-report estimated bandwidth through a custom request header, computed from prior response timing data:

X-Client-Bandwidth-Kbps: 820
X-Client-Network-Type: cellular

Client-side estimation (Kotlin/Android):

val estimatedKbps = (lastResponseBytes * 8) / lastResponseDurationMs
request.header("X-Client-Bandwidth-Kbps", estimatedKbps.toString())

This is a lightweight, privacy-safe signal. No IP geolocation, no server-side probing.

Layer 2: priority-field annotation pattern

Define degradation tiers directly on your data models using a custom annotation:

@Target(AnnotationTarget.PROPERTY)
annotation class PayloadPriority(val tier: Int) // 1=critical, 3=droppable

data class TrackResponse(
    @PayloadPriority(1) val id: String,
    @PayloadPriority(1) val title: String,
    @PayloadPriority(2) val artist: ArtistSummary,
    @PayloadPriority(3) val artworkUrl: String?,
    @PayloadPriority(3) val lyrics: String?
)

Tier thresholds map to bandwidth buckets:

Bandwidth (Kbps)Fidelity ModeMax Tier Included
> 5,000Full3 (all fields)
1,000–5,000Standard2
300–999Reduced1 + ID refs
< 300Minimal1 only

In “Reduced” mode, ArtistSummary collapses to artistId: String—honoring the contract shape while dropping payload weight.

Layer 3: Ktor middleware for response shaping

First, define the interface that marks response models as shapeable:

interface HasPayloadPriority {
    fun shapeTo(maxTier: Int): Map<String, Any?>
}

The middleware reads the bandwidth header and resolves a tier:

fun Application.installAdaptivePayload() {
    install(createRouteScopedPlugin("AdaptivePayload") {
        onCallRespond { call, body ->
            val bwKbps = call.request.header("X-Client-Bandwidth-Kbps")
                ?.toIntOrNull() ?: Int.MAX_VALUE

            val tier = when {
                bwKbps > 5000 -> 3
                bwKbps > 1000 -> 2
                bwKbps > 300  -> 1
                else          -> 1  // Minimal: tier-1 fields only
            }

            if (body is HasPayloadPriority) {
                transformBody { body.shapeTo(tier) }
            }
        }
    })
}

The shapeTo(maxTier) implementation uses reflection over @PayloadPriority annotations to build a filtered map. Abbreviated:

// Inside TrackResponse (or a shared base class / extension)
override fun shapeTo(maxTier: Int): Map<String, Any?> {
    return this::class.memberProperties
        .filter { prop ->
            val priority = prop.findAnnotation<PayloadPriority>()
            priority != null && priority.tier <= maxTier
        }
        .associate { prop ->
            val value = prop.getter.call(this)
            val key = prop.name
            // Collapse complex objects to ID refs below their tier threshold
            key to when {
                value is HasPayloadPriority -> value.shapeTo(maxTier)
                maxTier < 2 && value is IdResolvable -> value.id
                else -> value
            }
        }
}

In production, cache the reflected property list per class to avoid per-request overhead. The reflection scan runs once at startup via a warm-up call; subsequent invocations hit a ConcurrentHashMap<KClass<*>, List<KProperty1<*, *>>>.

No route changes. No API versioning. The contract shape stays stable.


What you get in production

Across the same Ktor-based streaming service with this pipeline enabled:

MetricBeforeAfter
P95 response time (cellular)1,240 ms540 ms
Payload size (minimal mode)18 KB3.1 KB
Client error rate (timeout)4.2%0.9%

The API contract didn’t change. Existing clients didn’t require updates.


What to watch out for

Caching gets complicated. CDN and client-side caches must vary on the bandwidth tier. Add a Vary: X-Client-Bandwidth-Kbps response header or use tier-bucketed cache keys to prevent a minimal-mode response from being served to a full-mode client.

ID-only references require client resilience. When you collapse objects to IDs in reduced mode, clients must handle missing nested data gracefully. Design your UI layer to tolerate partial hydration—if it can’t, this degradation mode will cause bugs, not just missing fields.

Annotation drift is the slow killer. As models evolve, tier assignments go stale. Add a lint rule that fails the build on public response fields missing @PayloadPriority, and make tier review mandatory in code review for any new response model.


Before you ship

Instrument bandwidth estimation on the client now. Even if you’re not shaping payloads yet, capturing X-Client-Bandwidth-Kbps in logs gives you the real-world network distribution data you’ll need before designing a solution. Most teams skip this and then argue from gut feel about where to set thresholds.

Annotate your response models with priority tiers before you need them. Retrofitting a degradation system onto an unannotated codebase is painful—I’ve done it. Adding @PayloadPriority during initial model design costs nothing and pays off under pressure.

Treat payload fidelity as a delivery concern, not a schema concern. Your API contract defines shape, not weight. A client on a congested connection deserves a fast, partial response far more than a slow, complete one.


Share: Twitter LinkedIn