Core concepts
Flutter engines, Dart isolates, and platform channels - the foundations of every add-to-app design decision.
Before you can make sound architectural decisions about an add-to-app project, there are three things from the Flutter docs you really need to understand:
- what a Flutter engine actually is,
- how Dart's isolate model limits what can be shared,
- and what platform channels do and don't give you.
In our experience, most of the surprises that hit add-to-app teams in production trace back to one of these three being only half-understood.
Flutter engines
A Flutter engine is the runtime that executes your Dart code, drives the rendering pipeline (through Skia or Impeller), and talks to the host platform. Each FlutterEngine owns its own Dart isolate, its own rendering surface, and its own platform channel infrastructure. A standalone engine also loads its own snapshot and GPU context, while engines spawned from a group share those (more on that below).
From the host app's point of view, an engine is a heavyweight object. Starting one does real work - setting up the Dart VM (shared at the process level), loading the AOT snapshot, spinning up rendering resources - and if you do that on demand, the user will notice the delay.
From engine to screen
A pure Flutter app has exactly one engine, created at launch, and every screen is just a route inside that engine's Navigator. Add-to-app breaks that assumption. The host app owns the screen stack now, and a "Flutter screen" is really a native container - a FlutterViewController on iOS, a FlutterActivity or FlutterFragment on Android - that attaches a Flutter engine to a rendering surface.
There is one rule that drives every decision that follows: an engine drives one rendering surface at a time. It has a single isolate, a single widget tree, a single Navigator. So the number of engines you need isn't "one per app" or "one per route" - it's however many Flutter surfaces have to be alive at the same time, and that depends on your navigation flow.
One Flutter section, visited sequentially → one engine
If Flutter lives in a single area the user enters, moves around in, and exits - never needing a second Flutter surface beside it - one engine is enough. You cache it, attach it when the user enters the Flutter section, and let Flutter's own Navigator handle screen-to-screen movement inside. The engine and its state are reused every time the user comes back.
This is the cheapest setup, and it's the one Flutter's "cached engine" guidance assumes. The trade-off is that there's exactly one Flutter world: every Flutter screen shares one isolate, one navigation stack, one pool of state. That works well as long as Flutter owns a contiguous flow and native screens don't get interleaved between Flutter ones.
Native and Flutter interleaved → multiple engines
The moment your stack interleaves the two - native A pushes Flutter B, which pushes native C, which pushes Flutter D - you can have two Flutter screens alive at once: B sitting in the back stack, holding its state and its rendering surface, while D is on top. One engine drives one surface at a time, so it can't keep B alive and render D - each live Flutter screen needs its own.
B and D coexist in the back stack, so two engines are live at the same time. This is the model Inlay builds around: every native container hosting Flutter (a full-screen view controller or activity, or a transparent dialog container) gets its own engine, created on the way in and destroyed on the way out. Flutter screens pushed inside that container, through ordinary Flutter → Flutter navigation, share its engine. We call this an engine per container throughout these docs. Each container is fully independent - its own navigation state, its own isolate - and the price you pay for that is more engines.
Embedding Flutter inside a native screen → an engine per view
So far each Flutter surface has filled a whole screen. Embedding is the case where a Flutter view is mounted as a component inside a native screen - a Flutter-rendered card in a native list, a Flutter chart on an otherwise-native dashboard, two Flutter tabs in a native tab bar that both stay resident.
An embedded view is still a rendering surface, and the one-surface-per-engine rule still applies: N Flutter views visible on the same native screen means N engines alive at once. This is the most engine-intensive pattern of the three, because all of those engines run concurrently on a single screen - and the engine count is the smallest of its problems. Sizing, gesture, and lifecycle coordination are covered on the Embedding page.
Why running many engines is affordable: FlutterEngineGroup
An engine per screen might sound very expensive given how heavy an engine is, and with a naive FlutterEngine() created for every screen it would be. FlutterEngineGroup is what makes it affordable: engines spawned from the same group share their common resources - Flutter's docs name the GPU context, font metrics, and the isolate group snapshot - so each additional engine costs on the order of 180 kB and a fraction of the cold-start time, not the full price of the first one. (The Dart VM itself is shared by every engine in the process, group or not.)
What the group does not share is isolate state. Every engine in it still gets its own isolate and heap (see Dart isolates below). So the group makes multiple engines cheap to run, but it does nothing to let them share Dart memory - the next two sections deal with the consequences.
Inlay manages a FlutterEngineGroup for you and creates one engine per
native container (each screen or dialog pushed from native) - consecutive
Flutter screens inside the same container share its engine - so the
multi-engine model above is the default; you never construct or cache engines
by hand.
Dart isolates
An isolate is Dart's unit of concurrency, and it behaves differently from a thread in most languages: isolates don't share memory. Each one has its own heap, and one isolate's garbage collector never has to coordinate with another's. They communicate only by passing messages over ports, and those messages are copied (or transferred), never shared by reference.
Every Flutter engine runs your Dart entrypoint in its own isolate. For a multi-engine add-to-app project this is the fact everything else hinges on: two Flutter screens backed by two engines cannot see each other's Dart objects. A singleton, a global, a static field, an in-memory cache you populate in one engine - none of it exists in the other. Each engine got its own copy of the program state when it started.
This rules out the first instinct many developers have - just put the value in a global and read it from the other screen. There is no shared global to read. Anything that needs to be visible across engines has to live somewhere both isolates can reach, and the only place they can both reach is the host platform. The data has to cross a boundary, and in practice that means a platform channel. (FFI, the other route into native code, is covered on the next page, but it doesn't help with shared state.)
The Dart VM itself is shared at the process level, and within a
FlutterEngineGroup so are the GPU context, font metrics, and the isolate group
snapshot. What each engine still pays for is its own isolate heap
and its own view/surface - that is where the roughly 180 kB per engine goes.
Platform channels
A platform channel is the bridge between Dart and the host platform - Swift or Objective-C on iOS, Kotlin or Java on Android. Dart sends a message, the native side handles it and optionally replies. It's the mechanism behind almost every plugin, and in an add-to-app project it's how Flutter and your native code actually talk.
There are two properties of channels that shape everything you build on them.
First, they're asynchronous. Every call is a round trip that completes on a future; there's no synchronous "read this value right now" across the boundary. If you need a value from the other side, you await it. Second, they're message-passing, not shared memory. Arguments and return values are serialized through a message codec, copied across, and decoded on the far side - you move data, you don't share references to it. The default codec understands only a fixed set of types (numbers, strings, booleans, lists, maps, and typed byte buffers such as Uint8List), so anything richer has to be flattened into those.
Channels are also scoped to an engine. Each FlutterEngine has its own binary messenger, so a channel registered on one engine is invisible to another. The native side is the only place all engines meet, which makes the platform the natural hub for cross-engine state: a write from one engine goes down to native, and native fans it back out to every other engine.
Two pitfalls follow directly from these properties. The codec is untyped, so by default you're passing dynamic maps and trusting both sides to agree on the shape - and this breaks silently as soon as the Dart and native sides drift apart. That's why Inlay generates typed code for everything crossing the boundary instead of letting you hand-write string-keyed maps. And serialization isn't free: chatty, fine-grained calls or large payloads crossing on every frame can add up, so channels are a good fit for discrete events and state changes, but not for a hot path.
How does our framework abstract those concepts?
These three concepts are the problems a production add-to-app project solves over and over. Inlay's job is to take each one off your plate, so you can work in terms of screens and state instead of engines, isolates, and channels. Here's how each one maps onto the framework.
Engines: managed for you, one per container
Inlay owns a single FlutterEngineGroup for the whole process and spins up one engine per native container: every screen or dialog pushed from native gets a container with its own engine, the engine lives as long as the container does, and consecutive Flutter screens pushed inside it share the engine. That's the multi-engine model from the interleaved navigation scenario above, made the default - so interleaved native/Flutter stacks just work, without you counting how many surfaces are live. (Partial-screen views inside a native screen are a different matter - see Embedding.)
Because every engine comes from the same group, the per-engine cost stays low, and a hidden prewarmed engine keeps the first navigation feeling instant. You never call FlutterEngine(), cache an engine, or wire up a FlutterViewController / FlutterActivity by hand - that all sits behind InlayNavigator. Every engine runs the same Dart entrypoint (inlayMain by default, changeable with setDartEntrypoint), so there's one place to bootstrap your app no matter which screen the engine ends up rendering. See Getting started for the prewarming controls.
Isolates: shared state lives on the platform, not in Dart
Each engine is its own isolate with its own heap, so there's no shared Dart memory to put cross-screen state in (the Dart isolates constraint). Inlay's answer is KeyValueStorage: a single in-memory store that lives on the platform side - the one place every isolate and native scope can reach - with each engine and each NativeStorageScope holding a thin synchronized view of it. A write from any engine is broadcast to all the others (the originator is skipped, to avoid loops), so screens stay consistent without ever sharing an object reference. You define typed @InlayStore schemas and read and write fields; the cross-isolate plumbing stays invisible. See State management for the full model.
Platform channels: generated, type-safe, and hidden
Every hop between native and Flutter in Inlay - pushing a route, popping back, reading or writing a stored value - rides a platform channel, but you never hand-write one. The fixed transport channels are generated with Pigeon, while the typed route and store classes built from your annotated schemas come from Inlay's own generator, which serializes your values across that transport. Either way the message shapes are typed identically on the Dart, Swift, and Kotlin sides, so the untyped-dynamic-map pitfall described above cannot happen. Back-gesture and pop coordination between the two navigation stacks is handled by InlayBackButtonDispatcher and InlayNativePopGestureObserver. The channel is still there underneath, doing async message-passing exactly as described above - Inlay just gives it a type-safe, code-generated surface. See Navigation for the API this produces, and Native-Flutter communication for how the two generation layers fit together.
The pattern throughout: Inlay doesn't hide the fact that these mechanisms exist - it hides the boilerplate and the foot-guns. Understanding the three concepts on this page is still what lets you reason about performance, state, and threading when something non-trivial comes up.