Versioning APIs Without Breaking Old Mobile Apps
Strategies for API versioning that keep old mobile app versions functional, covering URL versioning, header versioning, additive changes, and deprecation policies.
Unlike web apps, you cannot force-update mobile clients. The version a user installed 18 months ago is the version they will use until they update or uninstall. Every breaking API change risks bricking those clients. This post covers versioning strategies that prevent that.
Related: Designing Event Schemas That Survive Product Changes.
See also: Mobile Analytics Pipeline: From App Event to Dashboard.
Context
Mobile apps have a long tail of active versions. At any given time, a large-scale app may have 20+ versions in the wild, each making assumptions about API response shapes, field types, and endpoint availability. The backend must evolve without invalidating those assumptions.
Problem
Design an API versioning strategy that:
- Allows backend evolution without breaking existing mobile clients
- Minimizes the number of concurrently supported API versions
- Provides clear deprecation paths
- Does not impose excessive maintenance burden on backend teams
Constraints
| Constraint | Detail |
|---|---|
| Update lag | 30-40% of users are on versions older than 6 months |
| App store review | Updates take 1-3 days for iOS; Android varies |
| Force update fatigue | Forcing updates too often causes uninstalls |
| Backend team size | Cannot maintain more than 3 major API versions simultaneously |
| Testing burden | Each supported version needs integration tests |
Design
Versioning Approaches
1. URL Path Versioning
GET /api/v1/users/123
GET /api/v2/users/123
| Aspect | Detail |
|---|---|
| Pros | Explicit, easy to route, easy to monitor per version |
| Cons | Duplicates endpoint definitions, increases routing complexity |
| Best for | Major breaking changes (response shape overhaul) |
2. Header Versioning
GET /api/users/123
Accept-Version: 2
| Aspect | Detail |
|---|---|
| Pros | Clean URLs, version negotiation possible |
| Cons | Less visible in logs, harder to test in browsers |
| Best for | Minor version bumps within a major version |
3. Additive-Only Changes (Recommended Default)
Instead of versioning, design APIs to only make additive changes:
- Adding a field: Always safe. Old clients ignore unknown fields.
- Removing a field: Never remove. Deprecate and stop populating after all active clients have migrated.
- Changing a field type: Add a new field with the new type. Keep the old field.
- Adding an endpoint: Always safe.
- Removing an endpoint: Route to a stub that returns a meaningful error with an upgrade prompt.
// Client-side: ignore unknown fields
val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
// This adapter ignores unknown JSON keys by default
val adapter = moshi.adapter(UserResponse::class.java)Recommended Hybrid Strategy
- Default to additive changes for 90% of API evolution.
- Use URL path versioning for major structural changes (e.g., v1 to v2 of the user model).
- Use header versioning for minor variations within a major version (e.g., v2.1 response includes a new computed field).
- Limit to 2 concurrent major versions: current (v3) and previous (v2). All older versions return a forced upgrade response.
Version Resolution on the Server
resolve_version(request):
url_version = extract_from_path(request.url) // e.g., "v2"
header_version = request.header("Accept-Version") // e.g., "2.1"
if url_version is null:
return LATEST_VERSION
major = url_version
minor = header_version or LATEST_MINOR[major]
if major < MINIMUM_SUPPORTED_VERSION:
return 410 Gone {"message": "Please update your app", "store_url": "..."}
return (major, minor)
Response Transformation Layer
Instead of maintaining separate handler code per version, use a transformation layer:
Request -> Version Resolution -> Current Handler -> Response Transformer -> Client
The current handler always produces the latest response format. The transformer adapts it for older versions:
transform_response(response, target_version):
if target_version == CURRENT_VERSION:
return response
transformers = get_transformers(CURRENT_VERSION, target_version)
result = response
for transformer in transformers (reverse chronological):
result = transformer.apply(result)
return result
Example transformers:
- v3 to v2: Remove
loyalty_tierfield, renamefull_nametoname - v2 to v1: Flatten nested
addressobject to top-level fields
Client-Side Version Management
object ApiVersionConfig {
const val MAJOR_VERSION = "v2"
const val MINOR_VERSION = "2.1"
fun addVersionHeaders(builder: Request.Builder): Request.Builder {
return builder
.header("Accept-Version", MINOR_VERSION)
.header("X-App-Version", BuildConfig.VERSION_NAME)
.header("X-Platform", "android")
}
}Deprecation Policy
| Phase | Duration | Action |
|---|---|---|
| Active | Indefinite | Fully supported, receives new features |
| Deprecated | 6 months | Functional, but no new features. Deprecation header in responses |
| Sunset | 3 months | Returns warnings in responses, analytics track usage |
| End of life | After sunset | Returns 410 Gone with upgrade instructions |
Deprecation communicated via response headers:
Deprecation: true
Sunset: Sat, 01 Jun 2026 00:00:00 GMT
Link: <https://api.example.com/docs/migration-v3>; rel="successor-version"
Trade-offs
| Decision | Upside | Downside |
|---|---|---|
| Additive-only default | No versioning overhead for most changes | Response payload grows over time with deprecated fields |
| Response transformers | Single handler code path, reduced duplication | Transformer chain complexity, testing surface |
| 2 major version limit | Bounded maintenance burden | Aggressive for apps with slow update adoption |
| 410 for EOL versions | Forces upgrade, reduces support surface | Users on old devices may be unable to update |
| Header-based minor versions | Flexible, clean URLs | Easy to forget to set headers, harder to debug |
Failure Modes
- Transformer bug: A transformer drops a required field for an old version. Mitigation: version-specific integration tests that validate response schemas.
- Premature deprecation: Sunsetting a version while 10% of users are still on it. Mitigation: track version distribution in analytics before setting sunset dates.
- Client ignoring deprecation headers: Old clients never surface upgrade prompts. Mitigation: embed a version check in the app's startup flow that compares against a minimum version endpoint.
- Response bloat: Years of additive changes make responses 5x larger than necessary. Mitigation: periodic major version bumps that prune deprecated fields.
Scaling Considerations
- The response transformer layer adds latency proportional to the version gap. Optimize by pre-computing transformations for common version pairs.
- Cache transformed responses separately per version. A CDN cache key must include the API version.
- At scale, version distribution analytics drive deprecation timelines. Instrument every API call with the client's app version.
Observability
- Track: request distribution by API version, error rate per version, response size per version, deprecation header acknowledgment rate.
- Alert on: traffic from EOL versions exceeding threshold (indicates force-update failure), error rate spike on a specific version (indicates transformer bug).
- Dashboard: version adoption curve over time, showing how quickly users migrate to new versions after release.
Key Takeaways
- Default to additive-only changes. Most API evolution does not require versioning.
- When versioning is needed, use URL path versioning for major changes. It is explicit and easy to monitor.
- Use response transformers instead of duplicating handler code. One source of truth, multiple output formats.
- Track version distribution rigorously. Deprecation timelines must be data-driven, not guessed.
- Always provide a graceful degradation path. A 410 Gone with an upgrade link is better than a cryptic parse error.
Further Reading
- Designing Rate Limiting for Mobile APIs: Rate limiting strategies for APIs consumed by mobile clients, covering token bucket algorithms, client identification, degradation modes,...
- Designing an Experimentation Platform for Mobile Apps: System design for a mobile experimentation platform covering assignment, exposure tracking, metric collection, statistical analysis, and ...
- Designing Idempotent APIs for Mobile Clients: How to design APIs that handle duplicate requests safely, covering idempotency keys, server-side deduplication, and failure scenarios spe...
Final Thoughts
API versioning is a social contract with your users. Break it, and they lose trust. Maintain too many versions, and your team loses velocity. The right strategy balances both by making most changes non-breaking and versioning only when unavoidable.
Recommended
Designing an Offline-First Sync Engine for Mobile Apps
A deep dive into building a reliable sync engine that keeps mobile apps functional without connectivity, covering conflict resolution, queue management, and real-world trade-offs.
Jetpack Compose Recomposition: A Deep Dive
A detailed look at how Compose recomposition works under the hood, what triggers it, how the slot table tracks state, and how to control it in production apps.
Event Tracking System Design for Android Applications
A systems-level breakdown of designing an event tracking system for Android, covering batching, schema enforcement, local persistence, and delivery guarantees.