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

// 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

  1. Step 1.

    Start the page under test in one terminal. Stop it with Ctrl-C when you are finished.

    node probe-server.mjs
    
    page on http://localhost:8811/
    cdn on http://127.0.0.1:8812/
  2. 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.

  3. 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.mjs and run node 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=0

    Six, six and six, and the three sets are different. in-template.js is in the markup and in neither of the other two. injected.js is in the DOM and in the timing list and in no markup, because app.js created it at runtime. The refused script produced two identical entries, which is why seven entries cover six URLs.

  4. 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     ran

    Three of the seven ran. boom.js returned 200 and 383 bytes and still never reached its last line, because it threw on the first one. cdn.js reports status 0 and did run.

  5. 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, because grep -c counts 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

Sign: A one-line check tests responseStatus === 200 and reports every CDN script as not loaded.Cause: responseStatus is 0 for every cross-origin response. On this page cdn.js was served from a second origin with Timing-Allow-Origin set to a wildcard, reported status=0 bytes=321, and ran. The refused script reported status=0 bytes=0 and did not. The status field separates neither case for you.
Sign: A grep of the HTML and a query of the DOM return the same number, so the audit is called clean.Cause: Both returned six here and the two sets differ by two files. The markup holds a tag inside a template element that Chrome never fetches, and the DOM holds a script another script created at runtime. Compare the URLs, never the counts.
Sign: A 200 response is treated as proof the script executed.Cause: boom.js answered 200 with 383 bytes and threw on its first statement, so the global on its last line was never set. Resource timing describes the transfer. Only a side effect the script owns describes execution.
Sign: The number of resource-timing entries is quoted as the number of files.Cause: Seven entries covered six URLs here, because the script Chrome refused produced two identical entries. Deduplicate by name before reporting a count.

What to check next

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.

intermediate8 minpublished updated Maks Verny