Troubleshooting
The handful of issues almost every integration hits, and how to fix each one. If you're stuck on something not here, email [email protected].
Fetch fails on an HTTPS page
Symptom: your fetch to
localhost:9100 fails from a page served over
https:// — often a TypeError: Failed to fetch
— while the same call works from an HTTP page on the tablet.
Cause: Chrome guards requests from public websites to
local servers behind a Private Network Access preflight. The
browser first sends a preflight carrying
Access-Control-Request-Private-Network: true, and unless
the server approves it, the real request is never sent.
Fix: update Cleanter to v1.1.0 or newer. Cleanter
answers the preflight, so HTTPS web apps can call
http://localhost:9100 directly — no proxy, no plain-HTTP
fallback needed. localhost is exempt from the classic
mixed-content block.
Chrome 150 and later need one more step. Answering the preflight is still necessary but no longer sufficient: the browser now also requires the user to grant a Local Network Access permission. If your HTTPS page still cannot reach the bridge on an up-to-date Cleanter, see Nothing reaches Cleanter from an HTTPS site.
Still failing on 1.1.0+? Check the DevTools console:
- An error mentioning private network or preflight — the tablet is running an older Cleanter. Update it from Google Play.
-
Mixed Content — you're calling a LAN IP (e.g.
http://192.168.x.x:9100) instead oflocalhost. Only localhost is exempt; keep the web app and Cleanter on the same tablet.
http://localhost and http://127.0.0.1 are
treated as potentially trustworthy by browsers — the block
you may have hit on older Cleanter versions was the Private Network
Access preflight, not mixed content.
Bridge isn't detected
Symptom: GET /health fails or times out,
so your app thinks Cleanter isn't installed.
Check, in order:
- The server is running — the persistent notification is showing. Since v1.1.0 it starts automatically when the tablet boots and whenever the app opens; on older versions, open Cleanter and tap Start.
- If it doesn't come up after a reboot: Android disables boot autostart for apps that were force-stopped from Settings. Open Cleanter once and it re-arms.
- The web app runs on the same tablet — the server is localhost-only and not reachable from another device.
- You're hitting the right port:
9100. - You're not blocked by the Private Network Access preflight (see above).
-
Give
/healtha short timeout so a missing bridge fails fast instead of hanging:
await fetch("http://localhost:9100/health", { signal: AbortSignal.timeout(1500) });
Nothing reaches Cleanter from an HTTPS site
Symptom: your page works over http://
but the same code over https:// never reaches the
bridge. fetch rejects with an opaque
TypeError: Failed to fetch, and the browser console
shows:
Access to fetch at 'http://localhost:9100/health' has been blocked
by CORS policy: Permission was denied for this request to access
the `loopback` address space.
Cause: from Chrome 150, reaching
localhost from a public HTTPS origin requires the
Local Network Access permission. This replaced the
header-only Private Network Access model. Cleanter already answers
the preflight with
Access-Control-Allow-Private-Network: true, but the
header alone is no longer enough: the person using the device has to
grant the permission.
An http:// page is itself a local-network origin, which
is why development never hits this and production does.
Chrome on Android does not use the words “local network” anywhere the cashier can see. The prompt says “… wants to access other apps and services on this device”, and the setting is listed as Apps on device. Use those words when writing instructions for staff, or they will look for a menu entry that does not exist.
Normally there is nothing to fix.
Make the first request to the bridge inside a tap — a click handler on the Print button, not a probe on page load. Chrome then shows the permission prompt, holds the request open, and once the cashier taps Allow that same request completes. No reload, no settings, one extra tap the first time.
The corollary matters: never call the bridge on page load. A request with no user gesture is denied without a prompt, and the decision sticks — so the prompt never appears when the cashier does press Print.
Give the prompt time. The request stays pending
while the dialog is up, so a short AbortController
timeout will cancel it out from under the user and fall back while
they are still reading. Measured on an Android tablet: a request
answered after a normal pause completed in 23.7s. A
1.2s health-check budget is right for a same-device request and
wrong for one gated on a human, so raise it when
navigator.permissions reports prompt.
A dismissed prompt is not the same as an unanswered
one. If the dialog is closed with the X instead of Allow
or Block, Chrome embargoes the origin: later requests are refused
in milliseconds with no dialog at all, and the embargo survives a
reload. Crucially,
navigator.permissions.query still reports
prompt, so state alone cannot detect it. Measured at
5ms and 16ms, against 23.7s for a prompt that was really shown, so
a refusal faster than a few hundred milliseconds means no dialog
was drawn. Treat that like a denial and send the user to site
settings; telling them to "tap Print again and choose Allow" is a
dead end, because no prompt will ever appear. Reset permissions
clears it.
If the site was already blocked (someone tapped Block, or an early version probed without a gesture), the prompt will not come back on its own. On the device doing the printing:
- Open the site in Chrome and tap the icon to the left of the address.
- Tap Permissions, then either switch Apps on device back on or tap Reset permissions so the prompt appears again.
- Try printing again. No reload is needed: a change made in this panel applies to the next request the open page makes, in both directions.
If it still fails after the permission reads granted,
the problem is no longer the browser. Check that Cleanter's server
is actually running, because a stopped server fails identically.
Resetting permissions is also how you rehearse the experience a new tablet gets, without wiping Chrome or losing anything else on the site.
Detecting it in your app:
const status = await navigator.permissions.query({ name: "local-network-access" });
The state is granted, denied or
prompt. Worth checking, because every cause — app
not installed, server stopped, permission denied — arrives in
JavaScript as the same opaque Failed to fetch. Querying
the permission is the only way to tell them apart and show the right
instruction instead of a generic "printer not found".
503 — printing is blocked
Symptom: POST /print returns
503 with
{ "error": "printer_not_connected", … },
"printer_unreachable", or since v1.2.0 one of
"bluetooth_disabled",
"bluetooth_permission_missing",
"printer_not_paired".
From v1.2.0 the response also carries a fix sentence
aimed at whoever is at the counter, so the quickest path is usually
to show that string and let them act on it. The same reason appears
in GET /health as printer.problem before a
print is even attempted.
Cause & fixes:
- Bluetooth is off, or its permission was denied — Cleanter's home screen shows the problem with a button that fixes it.
- No default printer is selected in Cleanter — open the app and pick one. Since v1.1.0 that's the only setup needed: the first print after a reboot connects to the default printer automatically, even if the app was never opened.
- The printer is off, out of range, or out of paper. Power-cycle it and check the pairing in Android Bluetooth settings.
-
It was paired but dropped. Cleanter tries to auto-reconnect on each
print; if reconnect fails you get
printer_unreachable. Re-toggle the printer and retry. - Confirm connection live via
GET /health—printer.connectedshould betrue.
Treat 503 as retryable in your UI: show "Printer
offline — check it's on" and a Retry button, rather than a hard
error.
Garbled or wrong characters
Symptom: Indonesian text, currency, or accents print
as boxes or the wrong glyphs (e.g. Rp, é,
ñ).
Fixes:
-
Stick to standard Latin characters and digits where you can —
Rp 50.000prints reliably on every ESC/POS printer. -
Avoid emoji and uncommon Unicode in
textblocks; thermal printers render a fixed character set, not full Unicode. - Try another character set. In Cleanter, open Settings → Printing → Character set. Standard (CP437) is the page every ESC/POS printer implements and is the default from v1.2.0; some printers do better on Multilingual or Windows. A printer that silently ignores a page it doesn't support prints the wrong glyph rather than failing, which is why this is a setting.
-
Typographic punctuation pasted from a web editor (curly quotes, en
dashes, ellipses) is converted automatically to its plain
equivalent, so it no longer prints as
?. - If a specific printer still shows wrong glyphs for a character you need, tell us which printer and character at [email protected] — codepage handling is something we tune per device.
Long text and column alignment
From v1.2.0 Cleanter wraps text itself, at spaces. Before that the
printer's firmware broke lines at the column limit, splitting words in
half — a receipt reading …Anti Fog (Sepasan / g).
-
textblocks wrap to the width the block actually prints at: 48 characters on 80mm, 32 on 58mm, halved forsize: "large", wider for"small". Indented lines keep their indent on continuation lines. -
rowblocks wrap theleftlabel across lines and keeprighton the first line, indenting the continuation. Long item names used to be truncated here. - A single word longer than the line — a URL or product code — is split at the boundary, since there is nowhere to break it.
- Make sure
paperWidthmatches the paper actually loaded — flip the width toggle in the homepage builder to see how 58mm vs 80mm reflows.
Still stuck?
Email [email protected] with your printer model, the request payload, and the response you got. Concrete repro details get you a faster answer — and often a new entry on this page.