Every Flutter team we consult for eventually asks the same question after a bad release: "can we fix this without waiting for review?" A crash in a payment flow, a wrong API base URL, a null check that only fires on one Android OEM — and the fix sits in App Store review for a day while support tickets pile up.
Over-the-air (OTA) code push answers that question. Shorebird ships patched Dart code to installed apps without a store submission. This tutorial covers how it actually works, how to wire it into an existing app and CI pipeline, what you may and may not patch, and the release discipline that keeps code push from becoming a way to skip QA.
What code push is, and what it is not
Shorebird replaces the Dart runtime in your release build with a fork that can load an updated Dart snapshot at launch. When you shorebird patch, it diffs the compiled Dart code of your current build against the release build already in the store and uploads a small binary patch. Installed apps download it, verify it, and boot on the new code the next time they start.
What that means in practice:
- Patchable: anything written in Dart — business logic, widgets, layout, copy strings, routing, formatting, feature-flag defaults, a fix to a state bug.
- Not patchable: native code and anything the OS resolves at install time — Kotlin/Swift plugin code, new native dependencies, changed permissions or entitlements, new assets in
pubspec.yaml, Dart FFI targets, app icons, the Flutter engine version, or the Dart SDK version.
If a change touches the second list, it needs a real store release. Attempting to patch it is refused by the tooling in most cases, and where it is not, the patch simply will not do what you expect.
Both stores permit this today: Apple's guidelines allow interpreted or downloaded code that does not change the app's primary purpose, and Google Play allows the same within its policy on device and network abuse. Shipping a different app by patch is what gets accounts pulled. Fixing bugs and rolling out already-reviewed behaviour is not.
Install and initialise
curl --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/shorebirdtech/install/main/install.sh -sSf | bash
shorebird --version
shorebird login
From the root of an existing app:
shorebird init
This adds shorebird.yaml to your project and registers it in pubspec.yaml assets:
# shorebird.yaml
app_id: 51ab7bd4-2a3d-4a1b-9d9c-b3f8b5a9a7c1
auto_update: true
Commit shorebird.yaml. The app_id is not a secret; it identifies which app a patch belongs to.
Version pinning matters. Shorebird builds against a specific Flutter revision. Record it so CI and every developer produce identical release artefacts:
shorebird flutter versions list
shorebird release android --flutter-version=3.35.4
Cut a release, then patch it
Code push has exactly two verbs and the distinction is the whole model.
A release is a store build. It carries a Flutter engine, native code, assets, and a baseline Dart snapshot:
shorebird release android --flavor prod --target lib/main_prod.dart
shorebird release ios --flavor prod --target lib/main_prod.dart
Upload the produced .aab / .ipa to the stores as usual. Shorebird keeps the release artefacts server-side — that is what future patches diff against.
A patch targets one specific release version:
shorebird patch android --release-version 4.12.0+318 --flavor prod
shorebird patch ios --release-version 4.12.0+318 --flavor prod
The tool prints the changed byte size and asks for confirmation. Patches are typically tens to a few hundred kilobytes, which is why they land on cellular connections that a full store update would not.
Two rules to write down for the team:
- Never patch a release you cannot rebuild. Tag the exact commit at release time. Patches are built from your working tree, so a patch cut from an unknown tree is unreviewable.
- One release version per patch command. If 4.11.x and 4.12.x are both live and both broken, that is two patch runs.
How updates reach the device
By default Shorebird checks for a patch at launch, downloads it in the background, and applies it on the next launch. So users are one cold start behind. For a critical fix, that is often fine — most users background and relaunch within a day — but do not assume instant propagation.
If you need control, set auto_update: false and drive it yourself with shorebird_code_push:
dependencies:
shorebird_code_push: ^2.0.0
import 'package:shorebird_code_push/shorebird_code_push.dart';
class PatchService {
final _updater = ShorebirdUpdater();
Future<PatchStatus> checkAndDownload() async {
if (!_updater.isAvailable) return PatchStatus.unsupported; // debug/profile build
final status = await _updater.checkForUpdate();
if (status != UpdateStatus.outdated) return PatchStatus.upToDate;
try {
await _updater.update(); // downloads; applies on next launch
return PatchStatus.readyToRestart;
} on UpdateException catch (e) {
// Network failure, or patch rejected. Never block the user on this.
debugPrint('patch failed: ${e.message}');
return PatchStatus.failed;
}
}
Future<int?> currentPatchNumber() async =>
(await _updater.readCurrentPatch())?.number;
}
Call it somewhere unobtrusive — after first frame, not in main() before runApp:
WidgetsBinding.instance.addPostFrameCallback((_) async {
final result = await PatchService().checkAndDownload();
if (result == PatchStatus.readyToRestart && mounted) {
// Optional: nudge, don't nag. Most teams show this only for critical patches.
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Update ready — restart to apply.')),
);
}
});
Never gate the splash screen on a patch download. A user on a dead network must still reach the app.
Make the patch number visible in crash reports
The single most valuable integration is telling your error tracker which patch a device is on. Without it, a crash in 4.12.0+318 is ambiguous: is it the store build or patch 3?
final patch = await ShorebirdUpdater().readCurrentPatch();
await SentryFlutter.init((options) {
options.dsn = dsn;
options.release = 'com.example.app@4.12.0+318';
options.dist = '${patch?.number ?? 0}'; // 0 = unpatched store build
});
For Firebase Crashlytics, set it as a custom key:
await FirebaseCrashlytics.instance
.setCustomKey('shorebird_patch', patch?.number ?? 0);
Also surface it in your in-app diagnostics screen next to the version string. Support conversations get dramatically shorter when a user can read out "4.12.0 build 318, patch 3".
Wiring it into CI
Keep releases and patches as separate, manually triggered workflows. A patch that fires on every merge to main is a way to ship untested code to production.
# .github/workflows/shorebird-patch.yml
name: Shorebird Patch
on:
workflow_dispatch:
inputs:
release_version:
description: 'Release version to patch, e.g. 4.12.0+318'
required: true
platform:
description: 'android | ios | both'
default: 'android'
jobs:
patch:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { flutter-version: '3.35.4' }
- run: flutter pub get
- run: flutter test # a patch still deserves the test suite
- uses: shorebirdtech/setup-shorebird@v1
- name: Patch Android
if: inputs.platform != 'ios'
run: shorebird patch android --release-version "${{ inputs.release_version }}" --no-confirm
env:
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
- name: Patch iOS
if: inputs.platform != 'android'
run: shorebird patch ios --release-version "${{ inputs.release_version }}" --no-confirm
env:
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
Generate the CI token with shorebird login:ci and store it as SHOREBIRD_TOKEN. Note that iOS patches must be produced on macOS; Android patches will build on Linux.
Staged rollout and rollback
Patches are versioned per release and can be promoted through tracks — staging, beta, stable:
shorebird patch android --release-version 4.12.0+318 --track staging
shorebird patch:promote --release-version 4.12.0+318 --patch 4 --track stable
A workable rhythm for a consulting engagement:
- Patch to
staging; internal testers pull it and cold-start twice. - Watch crash-free sessions for that
distin Sentry/Crashlytics for a few hours. - Promote to
stable.
Rollback is the same mechanism in reverse: a bad patch is fixed by shipping the next patch, so keep the revert commit ready before you promote. If a patch is catastrophic, you can also mark it as not-promotable in the console so new devices stop receiving it — but devices that already applied it need a follow-up patch, not a wish.
Testing patches before users see them
Patched code paths do not exist in your debug builds, so test the artefact, not the source:
shorebird preview --release-version 4.12.0+318 --platform android
This installs the exact release build plus the selected patch on a connected device. Run your smoke checklist against it. In our engagements the checklist is short and fixed: cold start, login, the patched flow, one deep link, and one background/foreground cycle.
Two failure modes worth rehearsing once, on purpose:
- Patch download fails offline. App must run on the previous code with no visible error.
- Patch applies mid-session. It should not; verify your restart prompt does not force-close a form with unsaved input.
When not to use code push
Code push is a safety net, not a release process. It is the wrong tool when:
- The change touches native code, plugins, assets, or permissions — it physically cannot patch those.
- You are shipping a feature. Features deserve review, staged store rollout, and store metadata.
- Your app is enterprise-distributed with strict change control; some regulated clients require every binary change to be attested and archived.
- You have no crash telemetry. Patching blind is worse than waiting for review.
Used the way it is meant to be used — hotfixes for Dart bugs, between two real store releases — code push turns a 24-to-48 hour incident into a 30 minute one.
A workable policy
The teams that get value from code push write down four things and stick to them:
- Patches fix bugs. Features go through the stores.
- Every patch has a linked issue, a tagged commit, and a test run.
- Every patch goes to
stagingfirst and is promoted by a second person. - The patch number is in every crash report and on the in-app about screen.
Getting help
We wire code push, CI, and release telemetry into Flutter apps for teams who would rather not learn this during an incident. If you need senior Flutter consultants to set up your release and hotfix pipeline — or to review the one you have — get in touch.