The constraint that shapes the whole design
The starting fact is non-negotiable: a web page cannot open a connection to a Bluetooth Classic thermal printer. Those printers use the Serial Port Profile over Bluetooth Classic, and browsers do not expose it. Web Bluetooth, where it exists, only speaks Bluetooth Low Energy, which is a different profile that receipt printers do not implement.
So the architecture is forced: something native has to own the printer connection, and the web app has to reach it through a bridge. The design goal is to make that bridge as thin and safe as possible, so the web app barely changes and the native layer does the printer-specific work. Upeosoft's open-source UpeoRetail Print connector (github.com/Upeosoft-Limited/upeoretail-bluetooth-printer-connector) is a working reference for this pattern, and the sections below trace how it is put together.
The WebView host and the injected bridge
The native app hosts the web application in a full WebView - loading indicators, error recovery, pull-to-refresh, the usual host concerns - so it behaves like a normal app to the user. Into that WebView it injects a bridge object the page can call.
- The page calls something like window.UpeoRetailPrinter.postMessage(JSON.stringify({ type: 'PRINT_RECEIPT', payload })).
- The native side receives the string, parses it, and dispatches on the message type.
- A small helper (a printReceipt() function) can wrap that call so web developers pass a plain receipt object.
- The bridge is one-directional for printing: page to native. That is all the print path needs.
Guarding the bridge: whitelist and validate
A bridge that any loaded page can call is a liability. Two guards keep it safe. The first is host whitelisting: the native side only honours print messages when the WebView is actually showing a page from an allowed origin. If someone navigates the WebView to an untrusted site, the bridge ignores it. The second is payload validation: before acting, the native side checks the message type and validates the receipt JSON against the shape it expects, rejecting anything malformed or unexpected.
These are not heavyweight controls, but they matter. They turn an open channel into a narrow, predictable one that only trusted pages can use and only for the one thing it is meant to do. In a device that sits on a shop counter, that discipline is what keeps the printer from becoming an accidental attack surface.
From structured JSON to ESC/POS bytes
The web app should not know or care how a thermal printer works. It sends a clean description of the receipt: business header details, itemised lines, totals, and optionally a QR code. The native layer owns the translation into ESC/POS - the command language thermal printers speak - using a generation library and the configured paper width.
Keeping ESC/POS generation on the native side has real benefits. The web code stays portable and printer-agnostic. Supporting both 58mm and 80mm paper becomes a native concern the page never sees. And raw printer bytes never cross the bridge, so the message contract stays clean and safe. The native side lays out the header, aligns the item columns, renders the QR, and appends a paper cut, producing a proper receipt rather than a wall of text.
Making the Bluetooth link dependable
Bluetooth Classic links to inexpensive thermal printers are flaky by nature. The printer sleeps, drifts out of range for a second, or drops the link after inactivity. A connector that assumes a persistent connection will fail the first print after any quiet spell.
The fix is to reconnect before each print job rather than trust an existing connection. On modern Android there is a further nicety: because you are printing to an already-paired device, you can avoid the location-scanning permission dance that unpaired discovery requires - the app works with paired devices only. Pairing happens once in Android settings; after that the connector selects the saved printer and re-establishes the link per job. Small as it sounds, this reconnection discipline is the difference between a printer that always works and one that mysteriously needs a restart.
Local, self-contained, and easy to reason about
The last principle is to keep the printing path entirely local and self-contained. No internet is required to print, no third-party print app sits in the chain, and there are no external dependencies for the ESC/POS generation. The receipt data comes from the page, the bytes are generated on-device, and they go straight to the paired printer over Bluetooth.
That self-containment is what makes the whole thing reliable and debuggable. There are only a few moving parts - WebView host, guarded bridge, ESC/POS generator, Bluetooth service - each with a clear job. It is a small pattern, but it solves a genuinely hard constraint cleanly, and it is the kind of pragmatic engineering we favour: the least machinery that makes the real-world case work. If you need a web app to drive hardware the browser cannot reach, this bridge pattern is a good place to start, and we are happy to help you build or adapt it.
