Yes, an automated browser can execute JavaScript. A browser-looking User-Agent, a page view or a received JavaScript event does not establish that a visitor is human. The absence of an event does not establish a bot either. Separate what the server received, what the page reported and what the business outcome actually was.
This guide includes a working local lab: a plain HTTP client and two automated browser contexts visit the same controlled page. They use the same chosen header and loopback address. One browser executes JavaScript and the other has it disabled. No Google Ads click, customer site or production detector is involved.
A signal describes an observation, not a person
- User-Agent: a value supplied with the request. Our lab deliberately chooses the same value for different clients. A familiar string is not authenticated software identity.
- JavaScript report: evidence that a particular page script reported in that observed path. A browser automation tool can execute it. A missing report can also involve disabled scripts, collection conditions, loading failures or a failed request; inspect the cause.
navigator.webdriver: a standard indication from cooperating browser automation. It can describe legitimate QA and monitoring. A false or unobserved value is not a certificate of human identity.- Address and browser identity: network and browser records have their own scope. Shared addresses, reassignment and unavailable site data complicate a person-level inference. Use the IP and browser-identity guide.
MDN: User-Agent header; MDN: navigator.webdriver; Playwright: user-agent and JavaScript emulation. Checked 3 November 2026.
Run three controlled clients on loopback
Use Node.js 22 or later. In a new, empty working folder, install Playwright and its Chromium browser with the commands below. Setup downloads dependencies and a browser and writes their package/cache files. These commands are for this separate lab folder, not your production website.
npm init -y
npm install --save-dev playwright
npx playwright install chromiumDownload the local browser-signal lab into that folder, then run:
node browser-signal-demo.mjsThe program binds a temporary server only to 127.0.0.1, selects its own port, and accepts no URL, account or proxy arguments. A Node HTTP request fetches the page as text. Two fresh Chromium contexts use the same User-Agent, with JavaScript enabled and disabled. Browser page requests are restricted to the lab’s own origin. The program closes its browser and server and prints JSON without creating a report file in the working folder. The browser uses temporary profile files.
Inspect the complete lab program
// Fixed loopback lab. No destination, account, proxy or URL arguments.
// Node.js 22+ and Playwright with Chromium installed are required.
import { createServer, get } from 'node:http'
import { once } from 'node:events'
import { createRequire } from 'node:module'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const require = createRequire(import.meta.url)
const HOST = '127.0.0.1'
const USER_AGENT = 'LocalSignalLab/1.0'
const CASES = ['http_request', 'headless_js', 'headless_no_js']
export async function runDemo() {
if (arguments.length) throw new Error('This fixed lab accepts no options.')
const { chromium } = require('playwright')
const observations = new Map()
const blockedRequests = []
const server = createServer(async (request, response) => {
const url = new URL(request.url, `http://${HOST}`)
const id = url.searchParams.get('case')
if (!CASES.includes(id)) { response.writeHead(404).end(); return }
if (request.method === 'GET' && url.pathname === '/landing') {
observations.set(id, {
client: id, peer: request.socket.remoteAddress,
user_agent: request.headers['user-agent'],
javascript_report: false, reported_webdriver: null,
})
response.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
'content-security-policy': "default-src 'none'; script-src 'unsafe-inline'; connect-src 'self'",
})
response.end(`<!doctype html><title>Local signal lab</title>
<p>Controlled local page; no customer data or ad tags.</p>
<script>
fetch('/signal?case=' + ${JSON.stringify(id)}, {
method: 'POST', headers: {'content-type': 'application/json'},
body: JSON.stringify({webdriver: navigator.webdriver})
}).then(response => {
if (!response.ok) throw new Error('Local signal failed');
window.signalRecorded = true;
});
</script>`)
return
}
if (request.method === 'POST' && url.pathname === '/signal' && observations.has(id)) {
try {
let body = ''
for await (const chunk of request) {
body += chunk
if (body.length > 1024) throw new Error('Local signal too large')
}
const data = JSON.parse(body)
if (typeof data.webdriver !== 'boolean') throw new Error('Boolean report required')
Object.assign(observations.get(id), { javascript_report: true, reported_webdriver: data.webdriver })
response.writeHead(204).end()
} catch { response.writeHead(400).end() }
return
}
response.writeHead(404).end()
})
server.requestTimeout = 5000
server.listen(0, HOST)
await once(server, 'listening')
const origin = `http://${HOST}:${server.address().port}`
let browser
try {
await new Promise((complete, reject) => {
const request = get(`${origin}/landing?case=http_request`, {
headers: { 'user-agent': USER_AGENT }, agent: false,
signal: AbortSignal.timeout(5000),
}, response => {
response.resume()
response.on('end', () => response.statusCode === 200 ? complete() : reject(new Error('Local HTTP request failed')))
response.on('error', reject)
})
request.on('error', reject)
})
browser = await chromium.launch({ headless: true, args: ['--no-proxy-server', '--disable-background-networking'] })
for (const [id, javaScriptEnabled] of [['headless_js', true], ['headless_no_js', false]]) {
const context = await browser.newContext({ userAgent: USER_AGENT, javaScriptEnabled, serviceWorkers: 'block' })
try {
await context.route('**/*', route => {
const url = new URL(route.request().url())
if (url.origin !== origin) {
blockedRequests.push(url.origin)
return route.abort()
}
return route.continue()
})
const page = await context.newPage()
await page.goto(`${origin}/landing?case=${id}`, { waitUntil: 'domcontentloaded', timeout: 5000 })
if (javaScriptEnabled) await page.waitForFunction(() => window.signalRecorded === true, null, { timeout: 5000 })
} finally { await context.close() }
}
const rows = CASES.map(id => observations.get(id))
if (rows.some(row => !row)) throw new Error('Local observation missing')
return {
scope: 'fixed loopback lab; not a fraud classifier or ad-platform test',
playwright_version: require('playwright/package.json').version,
browser_version: browser.version(),
same_user_agent: rows.every(row => row.user_agent === USER_AGENT),
same_peer: rows.every(row => row.peer === HOST),
observations: rows,
blocked_external_requests: blockedRequests,
}
} finally {
if (browser) await browser.close()
const closed = new Promise(complete => server.close(complete))
server.closeAllConnections()
await closed
}
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
if (process.argv.length !== 2) {
process.stderr.write('This fixed loopback lab accepts no URL or other arguments.\n')
process.exitCode = 1
} else {
try { process.stdout.write(JSON.stringify(await runDemo(), null, 2) + '\n') }
catch (error) { process.stderr.write(error.message + '\n'); process.exitCode = 1 }
}
}Playwright library installation and browser usage; Node HTTP server and request API. The JSON records the library and browser versions used by each run.
Same header and address, different page reports
The checked reference run used Playwright 1.64.1 and Chromium 156.0.8078.4. All three clients supplied LocalSignalLab/1.0 and the server observed the same loopback peer. Version numbers in your own run may differ; retain them with the evidence.
| Client | JS report | Driver flag | Observation |
|---|---|---|---|
http_request | false | null | Fetched the HTML without executing the page script. |
headless_js | true | true | Automated browser executed the page script and reported automation. |
headless_no_js | false | null | Automated browser had JavaScript disabled; no page report was received. |
null means the page did not report that value. It is not false. Both the plain HTTP request and the JavaScript-disabled browser lack a page report, while the JavaScript-enabled automated browser reports successfully. Neither the shared header nor the presence of a page report separates “human” from “fraudulent.”
Download the checked reference JSON.
Inspect the checked reference output
{
"scope": "fixed loopback lab; not a fraud classifier or ad-platform test",
"playwright_version": "1.64.1",
"browser_version": "156.0.8078.4",
"same_user_agent": true,
"same_peer": true,
"observations": [
{
"client": "http_request",
"peer": "127.0.0.1",
"user_agent": "LocalSignalLab/1.0",
"javascript_report": false,
"reported_webdriver": null
},
{
"client": "headless_js",
"peer": "127.0.0.1",
"user_agent": "LocalSignalLab/1.0",
"javascript_report": true,
"reported_webdriver": true
},
{
"client": "headless_no_js",
"peer": "127.0.0.1",
"user_agent": "LocalSignalLab/1.0",
"javascript_report": false,
"reported_webdriver": null
}
],
"blocked_external_requests": []
}This lab tests collection capability
These are controlled test clients, not malicious visitors. No browser property is hidden, no challenge is bypassed and no ad platform is tested. The result does not measure a production bot rate, a fraud detector’s accuracy, a billed click or a prevented charge.
Trace the actual site before drawing a fraud conclusion
- Keep the raw request record. Preserve the original peer, trusted client-IP field, request time and raw headers. A label derived from a header is another layer of interpretation, not the original fact.
- Check the collection path. In an appropriate controlled journey, inspect page loading, permitted consent state, script execution, errors and the outgoing measurement request. A JavaScript dashboard and origin access logs observe different things.
- Separate repeated rows and identifiers. A reload or repeated request is not automatically another ad click or person. A GCLID string in a supplied log is not a billing receipt.
- Check the business event. Was the form accepted by your server? Was the enquiry qualified in the CRM? A successful browser interaction does not establish a genuine customer. Use the server acceptance and lead-quality guide.
- Review alternative explanations and legitimate automation. Include QA, monitoring, shared networks, script restrictions and integration changes. Test the effect of a proposed rule on legitimate visitors before broad enforcement.
For Google Ads clicks versus collected sessions, use the measurement-gap diagnostic. For analytics spikes, use the GA4 bot-traffic investigation. For actual request patterns, use the server-log investigation. Neither difference is a ready-made bot percentage.
Review addresses with the current local tool
Paste addresses or dated request-log text to inspect repeated sources and distinct observed GCLIDs. The IP builder processes pasted input locally. Network labels provide context; they do not establish that a client was human, automated, malicious or billed.
Free tool
Build your IP exclusion list
Paste addresses from a log, a CSV export or a report, in any layout. You get a clean list in the format Google Ads accepts, with repeats removed and the most active addresses first. Paste a server log to count distinct GCLIDs from each address.
0 entries
Runs in your browser. Nothing you paste is sent anywhere.
The output is a review list, not an applied platform change. Scope and actual confirmation are covered in the IP exclusion guide.
Act on the demonstrated failure
If collection fails, repair that path and repeat the controlled journey. If a form accepts bad input, fix server validation and follow actual qualification outcomes. If recurring traffic warrants protection, review evidence, legitimate-customer effects, the supported action and its destination status. Avoid a rule that treats a browser label or one missing signal as a final fraud verdict.
Google’s filtering and billing adjustments are separate evidence. A suspicious request reaching your site does not establish a new ad charge. Use the invalid-click billing guide for an investigation; Google decides any credit.
Google: native filtering, website visits and billing evidence. The lab does not interact with this service.
Download the browser-signal review worksheet.
Inspect the review worksheet
BROWSER SIGNAL REVIEW
Evidence checklist, not a detector or an ad-platform upload format.
DEFINE THE OBSERVATION
Website, route, period, time zone and source export:
Original peer address and trusted client-IP resolution policy:
Raw User-Agent value and source field:
JavaScript observation: received / absent / unknown; reason known or unknown:
Reported navigator.webdriver: true / false / not observed:
Consent, script loading, browser errors and collection scope:
Request rows versus distinct observed click identifiers:
Business-event reference and actual CRM outcome, if available:
KEEP INFERENCES SEPARATE
A chosen User-Agent value is not verified software or a physical person.
Received JavaScript is evidence of that observed execution/report, not a sale.
No JavaScript report does not establish which failure or client caused absence.
webdriver=true reports cooperating automation, which can include legitimate QA.
False/missing webdriver does not certify human identity or lead quality.
An address can be shared or reassigned; a location/network label is context.
Website observations alone do not identify billed invalid clicks or refunds.
CONTROLLED FOLLOW-UP
Reproduce on an appropriate test environment with a known test event:
Record actual source acceptance, event identity and destination/action:
Inspect tag execution, request delivery and reporting independently:
Check legitimate automation, monitoring and shared-network cases:
Specific rule or integration fix, owner, scope and rollback:
Unresolved facts and follow-up cutoff:
Use supported platform diagnostics for ad charges and actual exclusions.
LOCAL LAB RECORD
Node/Playwright/Chromium versions printed by the lab:
Same chosen User-Agent / same loopback peer observed:
HTTP request, JavaScript-enabled browser and disabled-JavaScript browser results:
The local lab does not test Google filters, production bots or visitor fraud.AdProtektor adds observed-visit context and configurable protection. Evaluate collected signals within their scope and check the actual website action, eligible ad exclusion or configured event handling separately. A risk decision is not proof that every remaining visitor is genuine or that a particular ad charge was prevented.
Start a free AdProtektor trial to review the supported visitor-evidence and protection workflow for your site.
For the next application boundary, use the challenge-and-form acceptance lab. A received JavaScript event, a provider decision and a qualified enquiry remain different observations.