DEV Community

Azeem Ullah
Azeem Ullah

Posted on Fully Autonomous

"Agent not running" when it is running: Chrome's local network prompt and localhost helpers

This week I found a bug in my own code by reading another company's support page.

Dynamsoft sell a document scanning SDK. Like a lot of hardware-in-the-browser tools, it works through a small service installed on the user's PC, and the web page talks to it on 127.0.0.1. Their FAQ lists this symptom for Chromium 142 and later: the page keeps asking the user to download and install the service, even though it is already installed and running.

I build the same kind of thing for receipt printers, so I read that and thought: mine probably does this too. It did.

What changed in the browser

Since Chrome 142 (28 October 2025), a public website can't just fetch() something on the user's machine or LAN. Chrome shows a permission prompt first. The Chrome team's post about it calls this Local Network Access.

A few things have moved since then. These come from Chrome's own guidance tracker and the Dynamsoft page:

  • Chrome 145 split the one permission into two. loopback-network is for programs on the same computer, and in site settings it shows as "Apps on device". local-network is for other devices on the LAN. The old name local-network-access still works as an alias.
  • Chrome 147 put WebSocket and WebTransport connections behind the same prompt. If your helper speaks WebSocket and you thought you'd escaped, you haven't.
  • Chrome 156 is planned to remove LocalNetworkAccessRestrictionsTemporaryOptOut, the enterprise policy some IT teams used to switch the whole thing off. If a customer told you "we fixed it with a policy", ask them which one.
  • Firefox 153 now does the same on desktop, with its own prompt.

So this is no longer a Chrome quirk you can wait out.

What your page sees

This is the part I couldn't find written down, so I tested it. Chrome 155 on Windows, my agent listening on 127.0.0.1:17777, and a page on a public https:// origin that had never asked before.

First, the permission state:

for (const name of ['loopback-network', 'local-network', 'local-network-access']) {
  console.log(name, (await navigator.permissions.query({ name })).state);
}
// loopback-network prompt
// local-network prompt
// local-network-access prompt
Enter fullscreen mode Exit fullscreen mode

All three names are accepted, and all say prompt.

Then the request, with a 3 second timeout on an AbortController:

Target Result
127.0.0.1:17777 (agent running) hangs, then my own timeout aborts it after 3 s
127.0.0.1:17778 (nothing listening) TypeError: Failed to fetch after about 10 ms

Two things I didn't expect.

Chrome only asks when something is actually there. A dead port fails straight away, with no prompt.

And while the prompt is open, the request doesn't fail. It just waits. There's no error and no event. The promise sits there until the user clicks something.

Now look at how nearly every "is the helper installed?" check is written, mine included:

async isRunning() {
  try {
    const r = await this._req('GET', '/v1/health', undefined, 1500); // 1.5 s timeout
    return !!r.ok;
  } catch (e) {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

The prompt appears. The cashier is looking at the till, not at the address bar. 1.5 seconds pass, the check gives up, and the app says "Printer helper is not running, please install it". The helper is running. The browser is waiting for a click nobody knows they need to make.

If the user clicks Block instead, it gets worse. From what I've read, Chrome doesn't ask that site again, and every request fails from then on. Dynamsoft's page shows the console message for that case: Permission was denied for this request to access the unknown address space. Your JavaScript can't read that text. It gets the same TypeError as a missing agent.

The fix: ask the browser before you blame the agent

You can't trigger the prompt on demand and you can't clear a block from script. What you can do is tell the three situations apart and show the right message.

async function agentStatus(url = 'http://127.0.0.1:17777/v1/health') {
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), 1500);
  try {
    const res = await fetch(url, { signal: ctrl.signal, targetAddressSpace: 'loopback' });
    return res.ok ? 'running' : 'not_running';
  } catch (err) {
    const perm = await lnaState();
    if (perm === 'denied') return 'permission_denied';
    // an unanswered prompt leaves the request hanging until our own abort
    if (perm === 'prompt' && err.name === 'AbortError') return 'permission_prompt';
    return 'not_running';
  } finally {
    clearTimeout(timer);
  }
}

async function lnaState() {
  if (!navigator.permissions?.query) return 'unsupported';
  for (const name of ['loopback-network', 'local-network-access']) { // 145+, then 142-144
    try { return (await navigator.permissions.query({ name })).state; } catch {}
  }
  return 'unsupported'; // Safari, older browsers
}
Enter fullscreen mode Exit fullscreen mode

Then say something useful for each one:

  • permission_prompt: "Click Allow in the prompt next to the address bar, then print again."
  • permission_denied: "Your browser is blocking the printer helper. Click the icon to the left of the address bar, open Site settings, set Apps on device to Allow, and reload." (Chrome 142 to 144 call it "Local network access".)
  • not_running: only now do you show the install link.

The AbortError check matters. A page whose permission is still prompt but whose agent really is missing gets the fast TypeError, and that should still say "not running".

Other things that bit, or will

  • Iframes. If your app is embedded cross-origin, the outer page has to hand the permission down: <iframe allow="loopback-network; local-network-access" ...>. Same-origin frames are fine.
  • HTTPS only. The Chrome post says the prompt can only be requested from a secure context. A public page on plain http:// has no way to get the permission.
  • Dev machines hide it. A page you open from http://localhost:3000 isn't a public site, so you never see the prompt while developing. It first shows up in production, on someone else's computer.
  • Managed fleets. LocalNetworkAccessAllowedForUrls pre-allows your origin so staff never see the prompt. That's the policy to ask IT for, not the opt-out that's going away.
  • A correction to my earlier post. I wrote that you need to answer the preflight with Access-Control-Allow-Private-Network: true. That header belongs to the older Private Network Access design, which this permission replaced. I still send it and it does no harm, but it isn't what lets you through any more.

What I haven't tested

The "blocked" branch. I ran it against a mocked permission state, and the behaviour I describe for it comes from Dynamsoft's documentation, not from me clicking Block on a real machine. I also haven't run any of this on Firefox 153 or on a Mac. If you see something different there, tell me and I'll update the post.

Where this landed in my own product

I make Thermalink, a paid local print agent and JS SDK for receipt and label printers. Disclosure: I built it. Version 1.0.1 has this fix: tl.status() returns those four states, and print calls throw permission_prompt or permission_denied instead of a misleading not_running.

You don't need it to use anything above. The function is about twenty lines, and it works for any localhost helper: printing, scanning, card readers, scales.

If you maintain one and you've seen different timing from what's in my table, I'd like to compare notes.

Top comments (1)

Collapse
 
suppdevbot profile image
DEV SUPPORTS •

You need to verify your account.

Enter fullscreen mode Exit fullscreen mode

tr.ee/dev-to