Mobile Computing · Cross-Platform Development

Shipping Shared Code Outside the Release Cycle

Delivering the shared layer as a replaceable bundle decouples it from the native shell, which turns every release into a matrix of shell and bundle versions and every rollback into a data-compatibility problem.

The shared layer of a cross-platform application is, by construction, the part that changes most often. Business rules, screen layouts, copy, and validation move weekly; the native shell that hosts them moves only when the store build ships. The temptation is therefore to make the shared layer replaceable at run time: download a new bundle, load it instead of the embedded one, and ship a fix without a review cycle.

That capability exists and is used, but it converts a single-version system into a two-dimensional one. The installed shell has a version, the loaded bundle has a version, and every pair is a configuration that can occur in the field. The engineering work is not the download mechanism, which is straightforward; it is making the pair space safe.

What may be replaced, and what may not

The first constraint is not technical. Both stores restrict applications that download and execute code that changes the application’s features. The distinction that has held in practice is between 2 categories: code that the store reviewed as part of the submitted binary, and code that arrives later to alter behaviour. Interpreted bundles that ship inside the binary and are updated later sit on the wrong side of that line for iOS, which is why the technique is used there only within narrow bounds, while Android has explicit mechanisms for delivering modules after install through the store itself.

The technically safe form is to keep the replaceable layer to content and configuration interpreted by native code that is part of the reviewed binary: a description of a screen, a set of rules, a layout, all evaluated by an interpreter already present. That is a real capability and it covers much of what teams actually want to change quickly. It is also a different thing from replacing arbitrary logic, and conflating the two is how a release process acquires a policy problem.

Android’s dynamic delivery, by contrast, moves the update through the store: feature modules are delivered on demand or on update, reviewed as part of the bundle, and installed by the platform. That is a legitimate path for code, and it still produces the version-skew problem described below, because a module may be installed at a different time from the base.

The shell is the ABI

Whether the replaceable unit is a bytecode bundle, a configuration document, or a downloaded module, it binds to the native shell through an interface. That interface is the ABI, in the sense that matters here: the set of host functions the bundle may call, with their names, arities, and semantics. It is a port in the sense used by The Portable Core Boundary, with the difference that its implementation is fixed for the life of the installed shell while the caller can be replaced.

A bundle compiled against host interface version 3 that calls a function introduced in version 4 will fail at load time or, worse, at the moment the function is first invoked on a code path that only some users reach. A bundle that assumes a host function returns a value synchronously when the installed shell returns it asynchronously will produce a type error deep inside a screen.

The discipline that prevents this is to make the host interface append-only within a major version and to have the bundle declare the minimum interface version it requires. The shell then refuses to load a bundle whose requirement exceeds what it provides, rather than loading it and failing later. Refusing at load is a controlled degradation: the application can fall back to the embedded bundle. Failing at first invocation is a crash in the field with a stack trace that names a host function and nothing about why.

Append-only is the cheap half. The expensive half is never changing the meaning of an existing function, because a bundle compiled against version 3 will keep calling it with the old expectations. When a function’s semantics must change, it gets a new name and the old one is retained until the minimum supported bundle version rises past it. That retention has a cost in shell size and in the surface that must be tested, and it is the price of a stable contract.

The version matrix

With N shell versions in the field and M bundle versions published, there are up to N × M pairs, so 3 shell versions and 5 bundles give 15 configurations. In practice the matrix is sparse: a shell version is retired when its share falls below a threshold, and a bundle is superseded within days. But the sparse matrix is not the risk. The risk is the pair that is rare and broken.

Three pair classes need explicit handling.

New bundle, old shell. The bundle requires a host function the shell does not have. Handled by the declared minimum interface version and the fallback to the embedded bundle. This is the common case and the one that is easiest to get right.

Old bundle, new shell. A shell update installs, the previously downloaded bundle remains, and the bundle now runs against a host whose behaviour changed in ways the bundle does not expect. The mitigations are the append-only rule above and the practice of invalidating downloaded bundles when the shell’s version crosses a boundary the bundle cannot tolerate. Invalidating is a blunt instrument and it is the correct default: the embedded bundle is always compatible with the shell that contains it. The same reasoning applies to vendor SDKs inside the shell, where a version pin has to be explicit rather than resolved, as Wrapping a Native SDK Behind a Shared Interface describes.

Data written by one, read by the other. This is the class that causes lasting damage, and it is discussed below.

Rollback is a data problem, not a code problem

The instinct on detecting a bad bundle is to roll back: stop serving it, and have clients revert to the previous bundle or to the embedded one. The code part works. The data part is where rollback breaks.

Suppose bundle version 12 adds a field to the persisted state and starts writing it. The bundle is rolled back to version 11, which does not know the field. Version 11 reads the state, ignores the unknown field, and writes the state back without it, destroying the data version 12 wrote. When version 12 is eventually served again, that data is gone. If the field held a user’s choice, the user’s choice has been silently reverted; if it held a migration marker, the migration may run twice.

The rules that prevent this are the same ones that apply to any schema migration, and they are not optional when bundles can move backwards in time.

Reads must tolerate unknown fields, which means the persisted form is a map-like structure rather than a fixed record whose layout is compiled in. Writes must preserve unknown fields, which means an update reads the existing value, modifies the known parts, and writes back the remainder unchanged. Migrations must be additive and idempotent: adding a field with a default is safe in both directions; renaming or removing a field is not, because the older bundle cannot interpret the new name and the newer bundle cannot find the old one.

Destructive migrations, where a field is removed and its content discarded, can only happen once no bundle that reads the old field can still run. That condition is a property of the served bundle history, not of the current bundle, and it requires knowing which bundles are still reachable by rollback. A conservative rule is to never discard, and to let the field become unread by every current version while remaining present in stored data.

Crash loops and the rollback trigger

A bundle that crashes on launch produces a loop: the shell loads the bundle, the bundle crashes, the shell restarts, loads the same bundle. The application is unusable and the user cannot reach any UI to fix it.

The mechanism that prevents this is a launch record written before the bundle is evaluated and cleared after the first frame is produced. If the shell starts and finds a record that was not cleared, it increments a counter; past a threshold of 3 failed launches, it discards the bundle and reverts. The threshold is a judgement: one failed launch is common for unrelated reasons such as a crash in a native SDK, and a count of 2 or 3 distinguishes a bundle fault from a coincidence. The counter and the record must be written durably before evaluation, or a crash during the write leaves no evidence.

// Called before the downloaded bundle is evaluated. A count left behind by a
// previous launch means that launch never reached the first frame.
async function beforeEvaluate(store: KV): Promise<void> {
  const count = Number((await store.get('launch-count')) ?? '0') + 1;
  if (count > 3) {
    await store.delete('downloaded-bundle'); // fall back to the embedded one
    await store.set('launch-count', '0');
    return;
  }
  await store.set('launch-count', String(count));
}

// Called once the first frame is on screen; a clean launch clears the record.
async function afterFirstFrame(store: KV): Promise<void> {
  await store.set('launch-count', '0');
}

The subtlety is that the shell must be able to reach the fallback without the bundle. That means the fallback path must be native, must not depend on any code the bundle provides, and must be exercised in testing. A fallback that is only ever reached in an emergency and is never tested is a fallback that does not work.

Bundle format and the cost of interpretation

If the replaceable unit is code, its format determines both the load cost and the compatibility surface.

Source text must be parsed at launch. Parsing is fast relative to a network request and slow relative to a frame: a 1 MB source bundle has to be parsed before the first screen can be built, and that parse sits on the critical path. Precompiled bytecode removes parsing and replaces it with a verification pass and a load of the constant pool, which is cheaper but still not free. A format with a stable, documented instruction set, such as WebAssembly, has the additional property that it can be validated before execution, which matters when the bundle arrives over a network.

The compatibility surface differs by format as well. Bytecode is tied to the engine’s version: a bundle compiled by a newer engine may use opcodes the installed engine does not implement, which is a load-time failure with the same shape as the host-function problem and needs the same declared-minimum treatment. Source text is more forgiving, at the cost of the parse.

Either way, the bundle should be verified before evaluation: a signature over the payload checked against a key embedded in the shell. Without verification, the update channel is an arbitrary code execution channel, and the fact that the transport was authenticated does not help if the bundle is cached on disk and can be replaced there.

What the matrix costs

The honest accounting is that over-the-air delivery of shared code trades release latency for a permanent testing obligation. Every shell version in the field multiplies the configurations to test, which is how a handful of versions produces the 15 pairs above and why the matrix is pruned rather than covered.

The trade is worth it for a narrow class of changes: copy, layout, feature flags, and rules that the shell’s interpreter can evaluate, delivered to a large installed base where a store review cycle would delay a fix by days. It is not worth it for changes that require new native capability, because those cannot reach the field without a shell update anyway, and shipping a bundle that depends on them converts a missing feature into a crash.

The alternative is to shrink the shell’s release cycle instead: modularise so that a change to the shared layer does not require rebuilding or re-reviewing the native parts, and ship through the store. That keeps one version in the field, keeps the store as the trust boundary, and gives up the days of latency. Most applications that attempt over-the-air delivery of substantial code end up somewhere between these two positions, delivering configuration and content out of band and code through the store, which is a stable place to be. The parts of the system that make either approach safe, the declared interface version, the append-only contract, and the tolerant data format, are the same parts that make the system maintainable without any out-of-band delivery at all, and they are worth building first.

References

  1. Apple App Store Review Guidelines — Apple
  2. Android App Bundles: Dynamic feature modules — Android Developers
  3. WebAssembly Core Specification — W3C