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 of localhost. 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 /health a 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.connected should be true.
🔁

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.000 prints reliably on every ESC/POS printer.
  • Avoid emoji and uncommon Unicode in text blocks; 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).

  • text blocks wrap to the width the block actually prints at: 48 characters on 80mm, 32 on 58mm, halved for size: "large", wider for "small". Indented lines keep their indent on continuation lines.
  • row blocks wrap the left label across lines and keep right on 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 paperWidth matches 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.