How to check if a javascript file is loaded
In the Console, run performance.getEntriesByType('resource').filter(e => e.name.endsWith('.js')). An entry means the browser requested the file, and responseStatus with transferSize says how that went. A tag in the markup proves nothing. On one test page the markup declared six scripts, the DOM held six, and three ran.
Why check this
A <script> tag in the markup is a request the browser may or may not make, and a request it makes is code that may or may not run. The gap opens on every release that moves a bundle to a CDN, adds a Content Security Policy, or changes a cache-busting hash. Run this check when a feature works locally and does nothing on staging.
The failure it catches is a tag manager that still sits in the HTML while its file returns 404 behind a new path. The page renders, no test fails, and every event the site sends stops arriving.
Prerequisites
- Chrome. This page used version 152.0.7977.76 on Windows. Every browser figure below is one capture on one machine on 2026-09-11.
- Node 22 or later and
puppeteer-core(npm i puppeteer-core). It drives the Chrome you already have. See PerformanceResourceTiming for the fields the steps read. - curl and grep, for the count the HTML declares.
- Save this file as
probe-server.mjs. Six script tags, each failing in a different way, so the three counts have something to disagree about.
// probe-server.mjs run: node probe-server.mjs stop: Ctrl-C
import http from 'node:http';
const page = `<!doctype html><meta charset="utf-8"><title>script probe</title>
<script src="/app.js"></script>
<script src="/missing.js"></script>
<script src="/boom.js"></script>
<script src="http://127.0.0.1:8811/blocked.js"></script>
<script src="http://127.0.0.1:8812/cdn.js"></script>
<template><script src="/in-template.js"></script></template>
<h1>script probe</h1><img src="/logo.png" alt="missing logo">`;
const files = {
'/': ['text/html', page],
'/app.js': ['text/javascript', `console.warn('app.js: cart module is deprecated');
var s = document.createElement('script'); s.src = '/injected.js'; document.head.appendChild(s);
Promise.reject(new Error('app.js: session fetch rejected'));
console.error('app.js: analytics key missing');
window.appRan = true;`],
'/boom.js': ['text/javascript', `throw new Error('boom.js: cannot read config of undefined');
window.boomRan = true;`],
'/injected.js': ['text/javascript', `window.injectedRan = true;`],
'/in-template.js': ['text/javascript', `window.templateRan = true;`],
'/blocked.js': ['text/javascript', `window.blockedRan = true;`],
};
http.createServer((req, res) => {
const f = files[req.url];
if (!f) { res.writeHead(404, { 'content-type': 'text/plain' }); return res.end('not found'); }
const h = { 'content-type': f[0] };
if (req.url === '/') h['content-security-policy'] = "script-src 'self' http://127.0.0.1:8812";
res.writeHead(200, h);
res.end(f[1]);
}).listen(8811, () => console.log('page on http://localhost:8811/'));
// A second origin, standing in for a CDN, with resource timing opened up.
http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/javascript', 'timing-allow-origin': '*' });
res.end('window.cdnRan = true;');
}).listen(8812, () => console.log('cdn on http://127.0.0.1:8812/'));
Steps
- Step 1.
Start the page under test in one terminal. Stop it with Ctrl-C when you are finished.
node probe-server.mjspage on http://localhost:8811/ cdn on http://127.0.0.1:8812/ - Step 2.
List the scripts the markup declares, before any of them runs.
curl -s http://localhost:8811/ | grep -o -E '<script[^>]+src="[^"]+"'<script src="/app.js" <script src="/missing.js" <script src="/boom.js" <script src="http://127.0.0.1:8811/blocked.js" <script src="http://127.0.0.1:8812/cdn.js" <script src="/in-template.js"Six tags. One of them sits inside a
<template>element, which the browser parses as inert content and never fetches. - Step 3.
Count the same page two other ways: the script elements in the live DOM, and the resource-timing entries. Save this as
script-inventory.mjsand runnode script-inventory.mjs http://localhost:8811/.// script-inventory.mjs run: node script-inventory.mjs <url> import { launch } from 'puppeteer-core'; const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe'; const browser = await launch({ executablePath: CHROME, headless: true }); const page = await browser.newPage(); await page.goto(process.argv[2], { waitUntil: 'networkidle2' }); const out = await page.evaluate(() => { const dom = [...document.querySelectorAll('script[src]')].map((t) => t.src); const perf = performance.getEntriesByType('resource').filter((e) => /\.js(\?|$)/.test(e.name)); return { dom, perf: perf.map((e) => `${e.name} status=${e.responseStatus} bytes=${e.transferSize}`), perfDistinct: new Set(perf.map((e) => e.name)).size }; }); console.log(`script[src] in the DOM: ${out.dom.length}`); console.log(`.js resource-timing entries: ${out.perf.length} (${out.perfDistinct} distinct URLs)`); out.perf.forEach((p) => console.log(' ' + p)); await browser.close();script[src] in the DOM: 6 .js resource-timing entries: 7 (6 distinct URLs) http://localhost:8811/app.js status=200 bytes=577 http://localhost:8811/missing.js status=404 bytes=300 http://localhost:8811/boom.js status=200 bytes=383 http://127.0.0.1:8811/blocked.js status=0 bytes=0 http://127.0.0.1:8812/cdn.js status=0 bytes=321 http://localhost:8811/injected.js status=200 bytes=326 http://127.0.0.1:8811/blocked.js status=0 bytes=0Six, six and six, and the three sets are different.
in-template.jsis in the markup and in neither of the other two.injected.jsis in the DOM and in the timing list and in no markup, becauseapp.jscreated it at runtime. The refused script produced two identical entries, which is why seven entries cover six URLs. - Step 4.
Ask the only question that matters: did each file run to its last line. Save this as
script-verdict.mjs.// script-verdict.mjs run: node script-verdict.mjs import { launch } from 'puppeteer-core'; const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe'; const browser = await launch({ executablePath: CHROME, headless: true }); const page = await browser.newPage(); await page.goto('http://localhost:8811/', { waitUntil: 'networkidle2' }); const rows = await page.evaluate(() => { // Each script, and the global it sets if it runs to the end. const probes = { 'app.js': 'appRan', 'missing.js': 'missingRan', 'boom.js': 'boomRan', 'blocked.js': 'blockedRan', 'cdn.js': 'cdnRan', 'in-template.js': 'templateRan', 'injected.js': 'injectedRan' }; const dom = [...document.querySelectorAll('script[src]')].map((t) => t.src); const perf = performance.getEntriesByType('resource'); return Object.entries(probes).map(([n, g]) => { const e = perf.find((p) => p.name.endsWith(n)); return [n.padEnd(15), (dom.some((d) => d.endsWith(n)) ? 'yes' : 'no').padEnd(6), (e ? `status=${e.responseStatus} bytes=${e.transferSize}` : 'no entry').padEnd(24), window[g] === true ? 'ran' : 'did not run'].join(' '); }); }); console.log('script in DOM resource timing global'); rows.forEach((r) => console.log(r)); await browser.close();script in DOM resource timing global app.js yes status=200 bytes=577 ran missing.js yes status=404 bytes=300 did not run boom.js yes status=200 bytes=383 did not run blocked.js yes status=0 bytes=0 did not run cdn.js yes status=0 bytes=321 ran in-template.js no no entry did not run injected.js yes status=200 bytes=326 ranThree of the seven ran.
boom.jsreturned 200 and 383 bytes and still never reached its last line, because it threw on the first one.cdn.jsreports status 0 and did run. - Step 5.
Repeat the two counts against a real site, where the gap is wider.
curl -s --compressed https://developer.mozilla.org/en-US/docs/Web/HTTP | grep -o -E '<script[^>]+src="[^"]+"' && node script-inventory.mjs https://developer.mozilla.org/en-US/docs/Web/HTTP<script src="/static/client/runtime.09248784c518044e.js" <script src="/static/client/5909.489e266d049aa182.js" <script src="/static/client/4585.98853dfcfa18f7de.js" <script src="/static/client/index.39c66696b1722a6d.js" script[src] in the DOM: 4 .js resource-timing entries: 27 (27 distinct URLs) https://developer.mozilla.org/static/client/runtime.09248784c518044e.js status=200 bytes=9006 https://developer.mozilla.org/static/client/4585.98853dfcfa18f7de.js status=200 bytes=24083 https://developer.mozilla.org/static/client/5909.489e266d049aa182.js status=200 bytes=9965 https://developer.mozilla.org/static/client/index.39c66696b1722a6d.js status=200 bytes=14686 https://developer.mozilla.org/static/client/7489.cd806d2f427a883d.js status=200 bytes=1248 …Four declared, four in the DOM, twenty-seven loaded, and every one of the twenty-seven answered 200. The other twenty-three are bundle chunks pulled by dynamic import, so they leave no script element behind. Count the grep matches with
grep -o ... | wc -l, becausegrep -ccounts matching lines and this HTML is one very long line.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| An entry with status=200 and bytes above zero | The file arrived, same origin | Confirm it ran by testing a global or a side effect it owns. |
| status=404 with bytes above zero | The request happened and the body is an error page | Fix the URL. The browser still counts it as a request. |
| status=0 and bytes=0 | The browser refused it or the connection failed | Read the console. A CSP refusal has no network attempt behind it. |
| status=0 with bytes above zero | A cross-origin response, status withheld | Normal for a CDN. Judge by bytes and by the side effect, not by the status. |
| No entry at all | The browser never requested it | Check whether the tag is inside <template>, or added after your snapshot. |
Common mistakes
What to check next
- How to check console errors on a website: the message that names the directive behind a status 0 with zero bytes.
- How to check which third party scripts a page loads: the same resource list, grouped by who owns each file.
- How to check subresource integrity: the other way a script arrives with a 200 and is never executed.
- How to check number of requests on a page: the whole resource list, not the scripts alone.
- How to check render blocking resources: which of the loaded scripts hold up the first paint.
FAQ
How do I check if a script is loaded in JavaScript?
Test a name the script owns, such as typeof window.jQuery !== 'undefined'. For files with no global, look the URL up in performance.getEntriesByType('resource'). The first answers whether it ran, the second whether it arrived.
Does querying the script tag prove the file loaded?
No. document.querySelector('script[src*="analytics"]') finds the element whether the request returned 200, returned 404, or was refused. On the page above all six tags were present and three of the files ran.
How do I know a script finished rather than started?
Have it set a flag on its last line, or attach a load handler before you insert it. An exception inside the file stops execution without changing its 200 response or its transfer size.
Why does the resource list hold more scripts than the HTML?
Because scripts fetch scripts. The MDN article declares four files and loads twenty-seven, the rest of them bundle chunks pulled by dynamic import after the first one runs.
Verified
Verified by Maks VernyChrome 152.0.7977.76curl 8.21.0node 22.23.2puppeteer-core 25.10.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate8 minpublished updated Maks Verny