Plugins in a multi-engine setup

How Flutter plugins register per engine, why iOS add-to-app never registers them for you, and which plugin categories misbehave when engines come and go.

There is a failure that hits almost every add-to-app team, usually a few weeks after everything seems to already work: the Flutter module runs fine standalone, runs fine on Android inside the host, and then every call to shared_preferences, url_launcher, or any other plugin with native iOS code throws MissingPluginException in the iOS host app. Nothing about the plugin's documentation warns you, because a regular Flutter app never sees this - it has one engine, and the app template registers plugins on it at startup.

Add-to-app changes both halves of that sentence. There are many engines, and there is no template doing the registering. This page explains the mechanics, shows how Inlay handles them, and covers the harder question that registration alone doesn't solve: what happens to plugins when engines are created and destroyed per screen.

How plugin registration actually works

A plugin's native half attaches to one engine at a time. Registration means walking the module's plugin list and attaching each plugin to a specific engine's binary messenger - that's what the generated GeneratedPluginRegistrant does. It is generated into your module, not into the framework, so no plugin (Inlay included) can call it for you; only code that can see your module's dependency list can.

The two platforms treat this completely differently:

AndroidiOS
Who registers pluginsThe Flutter embedding, automatically - every FlutterEngine looks up GeneratedPluginRegistrant reflectively when it's created.Nobody. The host must call GeneratedPluginRegistrant.register(with:) on every engine itself.
What happens if you do nothingPlugins work on every engine.Every plugin call fails with MissingPluginException, on every engine.
Where the registrant livesGenerated into the module's Android project, found via reflection.The FlutterPluginRegistrant module: a Swift package target inside FlutterNativeIntegration (SwiftPM), a pod (CocoaPods), or FlutterPluginRegistrant.xcframework (prebuilt frameworks). Either way, import FlutterPluginRegistrant.

This asymmetry is easy to miss precisely because Android keeps working. Teams that develop on Android first often discover the iOS half from a crash report.

Registering plugins on Inlay's engines

Inlay creates engines internally - one per native container (each screen or dialog pushed from native, or explicitly pushed into a new container from Flutter), plus a hidden prewarmed one - so the host can't reach them directly. setOnEngineCreated is the hook for that: the callback runs exactly once for every engine Inlay creates, right after the engine starts.

// AppDelegate - before start(), so the prewarmed engine is covered too.
import FlutterPluginRegistrant

InlayNavigator.shared.setOnEngineCreated { engine in
    GeneratedPluginRegistrant.register(with: engine)
}
InlayNavigator.shared.start()

Set the callback before start(). The prewarmed engine runs your Dart entrypoint just like a visible one, so any plugin touched during startup (before runApp) needs registration there too. If a prewarmed engine already exists when the callback is set, Inlay invokes the callback on it immediately.

Engine churn: the part registration doesn't solve

Registration gets a plugin attached; it doesn't change what the plugin assumes about its lifetime. Most plugins were written against the single-engine mental model: one engine, created at launch, alive until the process dies. How far your setup departs from that depends on the engine strategy you chose: a single cached engine never churns, so this whole section is moot for it. But any engine-per-container model - which is what Inlay uses, creating an engine when a container is pushed and destroying it on pop - means plugins attach and detach constantly, and each live container holds its own independent instance of every plugin.

How much that matters depends on the kind of plugin:

  • Stateless call-through plugins (package_info_plus, device_info_plus, url_launcher, share_plus) are safe. Every call is a self-contained round trip to a platform API; nothing survives between calls, so nothing is lost when the engine dies.
  • Plugins with per-screen state (video_player, audio players, camera previews) work, with two caveats. Their state lives and dies with one container's engine, so nothing carries over to the next Flutter screen - and during a push or pop transition two engines are briefly alive at once, which matters for exclusive resources like the camera or an audio session. Keep screens that hold exclusive hardware in a single Flutter screen, or native.
  • Plugins wrapping process-global SDKs (firebase_core and friends) attach per engine but configure a native singleton. Initialize the SDK once on the native side at app startup instead of letting the first Flutter screen do it - per-engine initialization from Dart is at best redundant work on every push, and at worst a race between screens.
  • Background-execution and callback plugins (firebase_messaging background handlers, workmanager, geolocation streams) are the genuinely incompatible category. They assume one long-lived engine to deliver callbacks into. A per-screen engine is gone by the time the callback fires, and some of these plugins spawn their own headless engine that knows nothing about your entrypoint wiring. Treat their Dart APIs as unusable in this model.

Working with single-engine assumptions instead of against them

The pattern that holds up in production is to use plugins for per-screen concerns and the native side for app-level concerns. A Flutter screen calling share_plus or playing a video is using exactly the lifetime a per-screen engine gives it. Push notifications, background sync, location tracking, analytics, session state - anything that must outlive a screen - belongs to the host app, which then exposes what Flutter needs over the typed boundary: state through stores, actions through native routes. That split also survives the migration endgame better than a pile of plugin workarounds, because app-level services already have a single owner.

One habit worth copying from the example app: put one plugin-backed value on an early Flutter screen (the example renders the host app's version via package_info_plus) and assert it in a native UI test. It's a single assertion, but it catches a whole class of regressions - a plugin added without registration, a broken registrant after a Flutter upgrade - on the platform where nothing else would warn you.

On this page