Back to notes

July 22, 2026 · 14 min read

From Bridge to Contract: Flutter Host and Vue H5 in Practice

A firsthand account of dividing responsibilities across a Flutter host, a contract SDK, and a Vue web app—and of handling payments, back navigation, lifecycle, startup, and updates along the way.

It Started with a JavaScript Channel

Recently, I moved the main product experience of a Flutter app into a Vue web app, or H5 as I will call it here. Flutter still owns the WebView and native capabilities, while H5 handles pages, routing, API state, and most of the business experience.

At first, it did not seem complicated. H5 would send some JSON, Flutter would choose a plugin based on action, and the result would travel back the same way.

switch (message['action']) {
  case 'getDeviceInfo':
    return getDeviceInfo();
  case 'buy':
    return buy(message['productId']);
  case 'save':
    return saveToGallery(message['url']);
}

The first version came together quickly. Then the feature list grew, and so did the problems:

  • A newer H5 called an API that an older host did not have, and the mismatch surfaced only at runtime.
  • A canceled purchase was sometimes treated as a failure and sometimes left spinning forever.
  • The page refreshed while Flutter was still waiting to call back into the old page.
  • Android’s Back button exited the whole app even though H5 still had a previous route.
  • Once both the host and H5 could start purchases, their membership state began to affect each other.

That was when I realized the difficult part was not moving a message across the boundary. It was keeping two independently running systems fluent in the same language over time.

A Bridge answers “how does the message get there?” A contract answers “what must both sides understand when it arrives?”

A development desk with a sketch of a Flutter host, contract SDK, and Vue H5 architecture
The Flutter host, contract SDK, and Vue H5 are not three isolated projects. They form one continuous call path.

I Eventually Split It into Three Layers

The structure I ended up with is not complicated. The Flutter host, contract SDK, and Vue H5 each own one kind of responsibility.

Product experience Vue H5 Pages, routing, APIs, and business state
Communication rules Contract SDK Sessions, message format, and capabilities
Platform capabilities Flutter Host Store, permissions, device, and system APIs
QuestionOwnerWhy
How should a page navigate or show an error?H5It knows the current product state.
What shape should a request have?SDKBoth sides must follow the same rule.
How should the platform purchase flow begin?HostOnly the host can call the platform store.
How much entitlement should a purchase grant?H5 backendThat is a business rule, not a platform capability.
Which capabilities does this host support?Handshake resultRuntime facts are safer than guessing from a version number.

I gave the SDK one important constraint: it does not know about specific pages or products, and it does not make business decisions for either side.

It only lets H5 ask, “Does the host support this capability?” and routes the request to the implementation that the host actually registered.

Turning the Bridge into a Contract

Handshake Before Invocation

H5 originally assumed that every host API existed. That worked on my development machine, but it broke as soon as H5 and the app began shipping on separate schedules.

I added a handshake:

1Page loadsWait for the Bridge
2HandshakeSend protocol and SDK versions
3Host respondsReturn session and capabilities
4Invoke as neededDegrade when unsupported

The H5 side only needs to use it like this:

const hybrid = window.HybridSdk.create({ sdkVersion: '1.0.0' })

await hybrid.ready()

if (hybrid.supports('media.library.save')) {
  await hybrid.invoke('media.library.save', payload)
}

During the handshake, the host returns the capabilities it has actually registered, along with basic information such as the platform, app version, language, and theme.

A newer H5 no longer needs to guess what an older host can do or discover the mismatch by throwing an exception. If a capability exists, H5 uses it. If it does not, H5 hides the entry point or falls back to another path.

A page refresh also invalidates the session. The new page must handshake again, and late responses from the old page can no longer act on the current one.

One Envelope for Every Message

A capability list was not enough. As concurrent requests increased, I also needed to know which response belonged to which request and what kind of failure had occurred.

Requests, responses, and events now share one message envelope.

{
  "kind": "request",
  "protocolVersion": "1.0",
  "id": "request-42",
  "sessionId": "session-current",
  "capability": "store.product.query",
  "payload": {
    "productIds": ["example.credit.small"]
  }
}

The useful part is not the appearance of the JSON. It is the small set of fixed rules behind it:

FieldProblem it solves
idConcurrent requests can find their own responses.
sessionIdAn old page cannot keep invoking after a refresh.
protocolVersionThe message format can evolve later.
capabilityEvery capability has a clear, stable name.
payloadParameters are always an object instead of changing shape ad hoc.

A successful response contains only data; a failed response contains only error:

{
  "kind": "response",
  "id": "request-42",
  "success": false,
  "error": {
    "code": "UNSUPPORTED",
    "message": "This capability is not available."
  }
}

The error code drives program behavior. The message is there to help with diagnosis. H5 no longer decides what to do by matching error text.

Changes such as the app moving between foreground and background use event, because they are not responses to an H5 request. For example, the host emits app.lifecycle.changed with a state such as resumed.

This looked like a small exercise in normalizing fields, but it pulled agreements that had been scattered across dozens of action branches into one place.

The SDK Provides Slots, Not Business Logic

Every Flutter capability implements the same interface:

abstract interface class HybridCapability {
  String get name;
  String get version;

  Future<Map<String, Object?>> invoke(
    HybridCapabilityRequest request,
  );
}

The host then installs only the implementations that the current product supports:

final registry = HybridCapabilityRegistry([
  AppInfoCapability(appInfoProvider, appId: businessAppId),
  BrowserExternalOpenCapability(externalBrowser),
  AppPermissionRequestCapability(permissionService),
  StoreProductQueryCapability(storeService),
  StorePurchaseStartCapability(storeService),
]);

This lets each app choose its capabilities instead of forcing every host to depend on a complete set of permission, notification, review, and payment plugins.

If a host allows H5 to save a result to the photo library, for example, it can implement that one operation and explicitly return unsupported for unrelated photo-library or camera requests. An honest unsupported response is more useful than a fabricated success because H5 can give the user accurate feedback.

A Machine-Readable Contract Alongside the Guide

I started with a Markdown integration guide. It explained the reasoning well, but it could not ensure that everyone remembered which fields were required, how long a string could be, or which enum values were valid.

Each capability now also has a JSON Schema:

{
  "name": "store.purchase.start",
  "version": "1.1.0",
  "requestSchema": {
    "type": "object",
    "required": ["productId", "productType"],
    "properties": {
      "productId": { "type": "string", "minLength": 1 },
      "productType": {
        "enum": ["consumable", "nonConsumable", "subscription"]
      }
    }
  }
}

The development sequence became much clearer:

1Write contract
2Implement SDK
3Connect host
4Wrap in H5
5Test boundaries

This process did not slow development down. Once the parameters were explicit, integration lost many of its “I thought you would return it this way” conversations.

Payments Were the Hardest Boundary to Untangle

A payment crosses the H5 page, Flutter, the App Store or Google Play, and the product backend. If any layer does a little more than it should, the boundaries blur quickly.

Stop Guessing Product Type from the Product ID

The first API was simply buy(productId). A product ID cannot tell the host whether the product is consumable, a one-time purchase, or a subscription. Inferring the type from a naming prefix will eventually meet an exception.

I changed the contract so H5 must send the transaction type explicitly:

Contract valueMeaningTypical use
consumableCan be purchased and consumed repeatedlyCredits or virtual items
nonConsumablePurchased once and owned permanentlyA permanent feature unlock
subscriptionRenews on a scheduleWeekly or annual membership

This value only tells the host which purchase path to use. It does not change the product configuration in the store. Whether the product actually exists remains a platform-store fact.

Prices are no longer static numbers from H5 configuration either. H5 asks the host to query the product IDs, and the page displays the localized price and currency returned by the store.

Platform Success Is Not Yet an Entitlement

I split a purchase into four stages:

H5Starts purchaseProduct ID + type
HostPlatform transactionReturns transaction and receipt
BackendVerifies receiptGrants entitlement after verification
HostFinishes transactionOnly after verification

The order matters: finish the platform purchase, verify it on the backend, and only then finish the transaction. User cancellation, platform failure, verification failure, and transaction-finishing failure are four different outcomes. They should not collapse into the same “purchase failed” message, and they must not grant balance before verification.

Separate Host and H5 Purchases by Who Started Them

The project had another easy-to-miss complication: the host had purchase entry points of its own.

I first tried to identify the source by product ID. Restore operations and platform callbacks do not always fit a static product list, however. The stable fact is not “what was purchased,” but “who started this operation.” Every store operation now records its owner: host or H5.

Host and H5 purchase, restore, and finish operations enter the same serial queue:

Current operationMay call the platform storeMay change host-local entitlement
Started by hostYesYes
Started by H5YesNo

An H5 purchase borrows the host’s platform capability, but its result still returns to the H5 backend and H5 state. A host purchase stays entirely inside the host flow.

This solved the part of hybrid payments that worried me most: sharing one underlying purchase stream without accidentally sharing two sets of business state.

The host and H5 share a purchase capability while routing results into separate state spaces
Host and H5 operations share the underlying purchase capability, while state updates and result ownership remain separate.

Making the WebView Feel Like Part of the App

Once the page could open, I spent a surprising amount of time on details that looked small but had a large effect on the experience.

Android Back Asks H5 First

When an Android user presses Back, Flutter does not know whether H5 currently has a dialog or a nested route. Exiting immediately is easy, but the experience is poor.

The flow now looks like this:

AndroidBack pressed
HostAsks H5Can you consume this action?
H5Dialog / route / tabHandle in priority order
Root pageBack again to exitOnly when H5 did not handle it

This is a host-initiated request with a result returned by H5. At this point the Bridge becomes genuinely bidirectional instead of serving only H5-to-Flutter calls.

H5 routes also synchronize with window.history, so page buttons, browser history, and the system Back action eventually converge on the same routing state.

Host Lifecycle Takes Precedence

A normal web page listens to document.visibilityState. Inside a WebView, I found that it did not fully represent the app’s foreground and background state.

Flutter already knows when the app moves to the background, so it emits app.lifecycle.changed:

StateHow H5 interprets it
resumedThe app is in the foreground; checks and refreshes can resume.
inactiveFocus is being lost; avoid starting new work.
pausedThe app is in the background; pause polling and scheduled checks.
detachedThe page is leaving the host; clean up current listeners.

H5 uses the host lifecycle to control version checks and timers. It falls back to visibilitychange only during local browser development.

The change was small, but it stopped H5 from behaving like an independent browser tab and made it follow the app’s actual runtime state.

Startup Improved One Revision at a Time

The first opening of a hybrid page crosses the WebView, static assets, Vue, the handshake, device information, the business session, and home-page data. I originally awaited them in sequence, which left a full-screen loading state hanging around for too long.

I eventually split startup into two tracks:

What the user sees first
VueMount immediately
PageShow skeleton
SectionsFill progressively
The interface appears before connection and data block the first frame
Initialization happening in parallel
BridgeComplete handshake
HostRead environment
APIEstablish session

A few changes made a noticeable difference:

  • Vue renders the shell first instead of letting initialization block the first frame.
  • Home-page sections own their loading states instead of covering the whole app.
  • Multiple callers share the same in-flight session request rather than initializing repeatedly.
  • The host offers retry when the WebView handshake fails; H5 handles business errors after the handshake succeeds.
  • Entering H5 skips unrelated host pages, models, and state initialization.
  • Once H5 is connected, the host stops listeners that existed only as startup fallbacks.

The important part was not making the loading animation more elaborate. It was reducing the serial work the user truly had to wait for.

What Happens to an Open Page After H5 Ships an Update?

H5 can ship independently, but a WebView that is already open does not know that the deployed version has changed.

The build now emits a tiny version file containing only a current build marker such as {"version":"build-abcdef12"}.

The page checks it every few minutes. When the online version differs, it waits until the user has left payment, sign-in, task submission, or another critical flow, then requires a refresh.

SituationCheck?
App is in the foregroundYes
App is in the backgroundNo
App has just returned to the foregroundCheck immediately
A critical operation is in progressDelay the prompt
A newer version is already knownWait for refresh without requesting again

The refresh adds the new version to the URL so the entry file does not keep hitting an old cache. It is less elaborate than a Service Worker, but it is enough to prevent a long-running WebView from staying on an old H5 indefinitely.

Minification Is Not a Security Boundary

A production H5 can disable source maps, remove debug output, minify variable names, and require a successful host handshake before initialization continues.

Those measures reduce accidental misuse, but they do not protect the business itself.

The boundaries I kept are:

  • Sensitive capabilities are available only to trusted web origins.
  • Navigating the WebView to another origin invalidates the old session immediately.
  • External links accept only allowed protocols, with domain restrictions where needed.
  • The SDK validates parameter types, lengths, and enums consistently.
  • Entitlements, balances, receipt verification, and rate limits remain backend decisions.
  • Tokens, payment receipts, and user data stay out of ordinary logs and analytics events.

Here, a web origin means the combination of protocol, host, and port. Allowing the WebView to open a page does not mean that page should automatically receive payment, permission, or device-information capabilities.

Local development can keep small fallbacks such as browser downloads, test device data, and a local proxy when no host is present. They make page development easier, but production never treats those fallbacks as real platform capabilities.

The Boundaries I Test Most Carefully Now

Page tests still matter, but this structure is more likely to break where the three layers meet.

Communication rules Contract SDK
  • Unregistered capabilities return unsupported
  • Sensitive capabilities require a trusted origin
  • Product types and error codes pass through unchanged
Platform boundary Flutter Host
  • H5 purchases do not change host entitlements
  • Android Back reaches H5 first
  • H5 mode skips unrelated initialization
Product experience Vue H5
  • Initialization requests merge into one
  • Missing capabilities degrade gracefully
  • Host lifecycle takes precedence
  • Update prompts avoid critical operations

These tests do not care what color a button is. They make sure the three layers do not slowly stick back together as features continue to grow.

Looking Back

This refactor started as a way for Flutter and Vue to call each other. The real problem it solved was ownership.

I can now reduce the mistakes I made to six rules:

1Handshake firstPublish real capabilities; do not guess from versions
2Use one envelopeRequests, responses, and errors follow one structure
3State the typeThe caller expresses the transaction type directly
4Isolate operationsShare store capabilities, not business state
5Reconnect to the OSBack and lifecycle follow the host
6Reduce waitingSplit parallel work before polishing loading states

I now think of hybrid development as cooperation between two runtime environments, not “a Flutter shell with a website inside it.”

H5 can update pages and business logic independently. The host can upgrade platform implementations independently. The contract SDK keeps both sides fluent in the same requests. As long as those three parts respect their boundaries, hybrid development can grow from a temporary compromise into a system that remains maintainable.

H5 owns the product experience, the host owns platform capabilities, and the contract SDK keeps both sides speaking the same language.