Skip to content

PATTERN Cited by 1 source

Shim for dual-stack A/B testing

Intent

Migrate a deeply-embedded library dependency (e.g. an internal fork of an OSS project years behind upstream) without a one-shot upgrade, by building a thin proxy layer that lets two versions of the library coexist in the same statically-linked binary, with per-call runtime dispatch chosen by a flavor flag — so consumers can A/B test the new version against the legacy one and roll it out or roll it back at experiment granularity.

Motivation

Some migrations are too risky for a big-bang upgrade:

  • The library is in the critical path (RTC, rendering, crypto).
  • The user population is diverse (billions of users across many devices / OS versions / networks), so regressions manifest in long-tail subsets invisible at launch.
  • Rollback is expensive (a full binary ship cycle) and may leak state (e.g. serialized data formats that changed in the new version).
  • The binary must remain single (no dynamic loading of variants due to build-graph / binary-size / distribution constraints).

In those cases you need both versions live at once in the same binary, gated by an experiment framework, so you can test the upgrade on small cohorts and delete the legacy path only when the new path is proven.

Structure

  1. Interpose a shim at the lowest practical layer between consumers and the library. Too-high layers duplicate orchestration code (large binary-size tax); too-low layers don't capture enough surface area. Meta's WebRTC case: shim between webrtc::* and the call-orchestration library, for 87% binary-size savings (5 MB vs 38 MB uncompressed). See concepts/binary-size-bloat.
  2. Link both versions statically into the same binary. This immediately produces thousands of C++ ODR violations.
  3. Resolve ODR via symbol renamespacing — rewrite each copy's namespaces to a flavor-specific prefix (webrtc_legacy::, webrtc_latest::), move non-namespaced globals into namespaces or suffix them, resolve macro collisions, share innocuous internal modules.
  4. Expose a unified API from the shim — e.g. webrtc_shim::* — that consumers target. Preserve backward compatibility to the legacy namespace via using declarations so existing call sites aren't rewritten.
  5. Dispatch at runtime via template specializations driven by a global flavor enum set at app startup. Each call to the shim chooses one flavor's implementation; DRY shared logic lives in generic templates.
  6. Generate the shim mechanically. With thousands of classes + structs + enums + constants to proxy, hand-writing adapters is a multi-year project on its own. Use AST-based code generation for baseline scaffolding; hand-tune the complex cases (factory patterns, static methods, raw-pointer ownership transfers).
  7. Handle injected components via build-system duplication. Some internal components have deep library-internal dependencies and cannot be shimmed source-level ("proxying WebRTC against itself"). Instead, use a build system like Buck to duplicate the target at different namespaces, exposing symbols for both flavors through a single header.
  8. A/B test app-by-app. Flip the flavor enum per experiment; monitor regression signals (CPU, crash rate, product metrics); mitigate; ship.
  9. Retire the legacy path per app. Once the latest flavor is proven for an app, delete the legacy calls in that app. The shim stays in production as the upgrade-A/B substrate for future upstream releases — see patterns/fork-retirement-via-ab-test.

Canonical instance: Meta × libwebrtc (2026-04-09)

  • Scope: 50+ RTC use cases — Messenger, Instagram, Cloud Gaming, Meta Quest VR casting.
  • Shim size: > 10,000 new lines; hundreds of thousands modified across thousands of files.
  • Binary-size cost: 5 MB uncompressed at the WebRTC layer, vs 38 MB at the call-orchestration layer (87% reduction from layer choice alone).
  • Velocity: hand-written 1 shim/day → 3–4 shims/day with AST-based codegen.
  • Version progression: launched webrtc/latest at M120, currently at M145 — "living at head" after years of being behind.
  • Outcomes: up to 10% CPU drop, up to 3% crash-rate improvement, 100–200 KB compressed binary-size reduction from the upstream's own efficiency wins, deprecated libraries retired.

Consequences

  • Pays the binary-size tax of carrying both versions — even at the optimal shim layer, two copies of a large library is non-trivial. Acceptable for migration-window binaries; must be reconsidered for permanent dual-stack if size budgets are tight.
  • Requires shared testing discipline — CI must test both flavors. Test-matrix doubles.
  • Renamespacing scripts become part of the release pipeline — each new upstream release needs re-namespacing for the legacy coexistence to hold; that script is a long-lived asset, not a one-off tool.
  • AST codegen quality bounds shim velocity. For symmetric APIs codegen works near-zero-touch; for factory patterns, static methods, raw pointer semantics, and ownership transfers, engineers refine the generated baseline.
  • Injected components need a second mechanism — Buck + macro duplication covers this case and is one more moving part to maintain.
  • The shim becomes permanent infrastructure. Once the pattern works for one migration, it's the natural substrate for every future upgrade. That's often a win; plan for the shim to become a long-lived dependency.

Why not dynamic linking instead

Dynamically loading two copies of the library as separate shared libraries sidesteps ODR entirely (different address spaces, no linker conflict). Meta rejected this for WebRTC due to "application build graph and size constraints" — the Messenger / Instagram / Quest apps ship single statically-linked binaries through app-store distribution channels. In other contexts (server-side daemons, desktop apps with looser size budgets) dynamic linking may be preferable and this whole pattern is unnecessary.

Seen in

Last updated · 319 distilled / 1,201 read