Versioning APIs Without Breaking Old Mobile Apps

Dhruval Dhameliya·December 8, 2025·7 min read

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

ConstraintDetail
Update lag30-40% of users are on versions older than 6 months
App store reviewUpdates take 1-3 days for iOS; Android varies
Force update fatigueForcing updates too often causes uninstalls
Backend team sizeCannot maintain more than 3 major API versions simultaneously
Testing burdenEach supported version needs integration tests

Design

Versioning Approaches

1. URL Path Versioning

GET /api/v1/users/123
GET /api/v2/users/123
AspectDetail
ProsExplicit, easy to route, easy to monitor per version
ConsDuplicates endpoint definitions, increases routing complexity
Best forMajor breaking changes (response shape overhaul)

2. Header Versioning

GET /api/users/123
Accept-Version: 2
AspectDetail
ProsClean URLs, version negotiation possible
ConsLess visible in logs, harder to test in browsers
Best forMinor 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

  1. Default to additive changes for 90% of API evolution.
  2. Use URL path versioning for major structural changes (e.g., v1 to v2 of the user model).
  3. Use header versioning for minor variations within a major version (e.g., v2.1 response includes a new computed field).
  4. 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_tier field, rename full_name to name
  • v2 to v1: Flatten nested address object 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

PhaseDurationAction
ActiveIndefiniteFully supported, receives new features
Deprecated6 monthsFunctional, but no new features. Deprecation header in responses
Sunset3 monthsReturns warnings in responses, analytics track usage
End of lifeAfter sunsetReturns 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

DecisionUpsideDownside
Additive-only defaultNo versioning overhead for most changesResponse payload grows over time with deprecated fields
Response transformersSingle handler code path, reduced duplicationTransformer chain complexity, testing surface
2 major version limitBounded maintenance burdenAggressive for apps with slow update adoption
410 for EOL versionsForces upgrade, reduces support surfaceUsers on old devices may be unable to update
Header-based minor versionsFlexible, clean URLsEasy 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

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