Choosing between async and defer is the first execution-timing decision in any third-party script integration. The attributes dictate when the browser interrupts its HTML parser to run JavaScript — and in consent-driven architectures that timing boundary determines whether a Consent Management Platform fires before tracking pixels do, whether layout shifts occur mid-parse, and whether Time to Interactive regresses on mid-range devices. Getting the choice wrong does not merely affect performance; it can constitute a regulatory violation if marketing scripts execute before explicit user consent is recorded.

This page covers the precise browser mechanics of both attributes, a decision matrix for common third-party script categories, production-ready implementation patterns for consent-gated injection, and a structured troubleshooting workflow. For the broader context of how attribute selection fits into Script Loading Fundamentals & Priority Optimization, including resource hint architecture and network waterfall control, start with the parent overview.


Prerequisites and When This Applies

Apply the patterns on this page when your project includes:

  • Cross-origin vendor scripts (analytics, A/B testing, advertising, CMPs, chat widgets)
  • Regulatory requirements (GDPR, CCPA, ePrivacy) mandating consent-before-execution ordering
  • Core Web Vitals regressions traced to script execution on the critical rendering path
  • A tag management system (Google Tag Manager, Tealium, mParticle) injecting multiple downstream tags

If you have no external scripts, or if every script is loaded through a bundler and served as a first-party asset, this page’s scope does not apply.

Dependency awareness is the prerequisite. Before selecting an attribute, map each script to three things: (1) whether it reads or writes the DOM, (2) whether other scripts depend on it initializing first, and (3) what consent category it belongs to. Attribute selection without this map produces race conditions that appear only under real-network conditions.


Browser Parser Mechanics: The Execution Model in Precise Terms

Script loading attribute comparison: sync vs async vs defer Three horizontal timeline lanes showing how the HTML parser interacts with script fetch and execution for synchronous, async, and defer attributes. Sync: parser blocks during fetch and execute. Async: parser continues during fetch, then pauses for execution. Defer: parser continues during fetch, execution queued after parse completes before DOMContentLoaded. sync async defer Parse HTML Fetch (blocks) Execute Parse HTML (resumes) Parse HTML (continues) Fetch (parallel) Execute Parse HTML (resumes) ↑ download does not pause parser Parse HTML (uninterrupted) Fetch (parallel) Execute DOMContentLoaded ↑ download does not pause parser Parse Network fetch Execute (interrupts parser) Execute (after parse)

The HTML parser processes markup sequentially. When it encounters a <script> tag with no attribute, it stops, fetches the resource, executes it, and only then resumes. Both async and defer eliminate the fetch-phase block — the download happens in parallel — but their execution timing diverges sharply:

async — execution fires immediately when the download completes, interrupting the parser at whatever point it has reached. If two async scripts compete, whichever finishes downloading first wins. Document order is irrelevant to execution order.

defer — execution is queued. The parser runs to completion, then deferred scripts execute in the exact order they appear in the document, immediately before the DOMContentLoaded event fires. The DOM is fully constructed when deferred scripts run.

<!-- async: parallel fetch, executes upon download — may interrupt parsing at any moment -->
<script src="https://cdn.analytics.example/tracker.js" async crossorigin="anonymous"></script>

<!-- defer: parallel fetch, executes in document order after the DOM is fully parsed -->
<script src="https://cdn.cmp.example/consent-manager.js" defer crossorigin="anonymous"></script>

Two common misconceptions to dispel immediately:

  1. Both attributes trigger parallel network fetches. The difference is only in when execution occurs, not when the download starts.
  2. defer on an inline script (<script defer>content</script>) is ignored. Both attributes only apply to scripts with a src attribute.

Decision Matrix for Third-Party Script Categories

The correct attribute follows from three questions: Does the script touch the DOM? Must it run before or after another script? Is it gated on consent?

Script category Attribute Rationale
Analytics beacons, telemetry async Fire-and-forget. No DOM dependency. No ordering constraint with other scripts.
Consent Management Platform defer Requires full DOM to mount the consent banner and attach event listeners. Must run before DOMContentLoaded so consent state is resolved before downstream scripts initialize.
A/B testing / feature flags async or inline sync Must evaluate before first paint to prevent flicker; often placed inline as a synchronous blocking snippet intentionally.
Ad networks, widget SDKs defer Injects DOM nodes; frequently depends on other initialized SDKs. Execution order matters.
Error tracking (Sentry, Datadog) async Independent, non-blocking, safe to run at any lifecycle point.
Chat widgets defer Requires DOM mount point. Typically depends on a user profile SDK initializing first.
Payment SDKs (Stripe, Braintree) defer DOM-dependent form element enhancement. Sequential execution with form initialization required.

When applying this matrix, consider how it intersects with optimizing the network waterfall for external assets. Overusing defer compresses all execution into a single burst immediately before DOMContentLoaded, creating main-thread contention at the worst possible moment — exactly when the consent UI is trying to render and become interactive.


Implementation: Step-by-Step with Annotated Code

Step 1 — Static script tags with correct attributes

Place deferred scripts in <head>. This starts the download earliest while guaranteeing execution after parse completion. Placing them before </body> only delays discovery.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">

  <!-- CMP must load before DOMContentLoaded; defer preserves document order -->
  <script src="https://cdn.cmp.example/cmp-sdk.js" defer crossorigin="anonymous"></script>

  <!-- Analytics is independent; async avoids occupying the defer execution queue -->
  <script src="https://cdn.analytics.example/tag.js" async crossorigin="anonymous"></script>
</head>
<body>
  <!-- page content -->
</body>
</html>

Use <link rel="preconnect"> for third-party origins your CMP will need immediately after consent resolves. Preconnect establishes TCP/TLS without transmitting identifiable data, making it generally safe for pre-consent use. Pair this with preload and prefetch strategies for scripts that must execute within the first 500ms post-consent.

<head>
  <!-- Warm the connection; no data sent, GDPR exposure is minimal -->
  <link rel="preconnect" href="https://cdn.cmp.example" crossorigin>

  <!-- Do NOT preload marketing scripts before consent is granted -->
  <script src="https://cdn.cmp.example/cmp-sdk.js" defer crossorigin="anonymous"></script>
</head>

Scripts that depend on explicit user consent must never appear in static HTML. Inject them programmatically after the consent-state machine resolves.

Critically: dynamically created script elements are async by default — the defer attribute has no effect on them. To enforce sequential execution among dynamically injected scripts, set script.async = false before appending, or chain injections using onload callbacks.

/**
 * Injects a third-party script after consent is confirmed.
 * Returns a Promise that resolves when the script loads or rejects on error.
 *
 * @param {string} src - Absolute URL of the script to inject.
 * @param {boolean} [ordered=false] - Set true to force synchronous (ordered) execution.
 */
function injectConsentedScript(src, ordered = false) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = src;
    script.crossOrigin = 'anonymous';

    // Dynamically inserted scripts default to async=true.
    // Set async=false to restore document-order execution semantics
    // (browser still fetches in parallel; execution is serialized).
    if (ordered) {
      script.async = false;
    }

    script.onload = resolve;
    script.onerror = (err) => {
      console.error(`[consent-loader] Failed to load: ${src}`, err);
      reject(err);
    };

    document.head.appendChild(script);
  });
}

// Wire to CMP callback — this pattern matches OneTrust, Cookiebot, and custom CMPs
window.addEventListener('consent-granted', async (event) => {
  const { categories } = event.detail;

  if (categories.includes('analytics')) {
    // Independent — let it run async (default)
    injectConsentedScript('https://cdn.analytics.example/tag.js').catch(() => {
      // Non-fatal: log for compliance audit but do not block other scripts
    });
  }

  if (categories.includes('marketing')) {
    try {
      // Ordered: ad SDK must initialize before the retargeting pixel
      await injectConsentedScript('https://cdn.adplatform.example/sdk.js', true);
      await injectConsentedScript('https://cdn.adplatform.example/pixel.js', true);
    } catch {
      // Fallback: record consent failure in audit log
    }
  }
});

Some consent mode implementations require a synchronous configuration object to reach the tag manager before it fires its first event. This is the single legitimate case for a blocking script, and the payload must be kept under 1KB.

<head>
  <!-- BLOCKING intentionally: establishes denied-by-default consent state
       before GTM container bootstraps. Keep this inline and minimal. -->
  <script>
    window.dataLayer = window.dataLayer || [];
    function gtag() { dataLayer.push(arguments); }
    gtag('consent', 'default', {
      ad_storage: 'denied',
      analytics_storage: 'denied',
      functionality_storage: 'granted',
      wait_for_update: 500
    });
  </script>

  <!-- GTM container loads async after the consent defaults are set -->
  <script src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX" async></script>
</head>

Verification Checklist

For GTM-specific render-blocking warnings, the GTM render-blocking diagnostic walks through the exact CMP-GTM consent race condition and its resolution.


Interaction with Sibling Patterns

Pattern How it interacts with async/defer
<link rel="preload" as="script"> Starts the network fetch early but does not change execution timing. A preloaded deferred script still waits for parse completion. Omitting crossorigin on cross-origin preloads causes a second fetch — a common source of wasted bandwidth.
fetchpriority attribute Controls fetch queue position, not execution order. fetchpriority="high" on an async script fetches sooner but still interrupts parsing on completion. Use with care alongside CMP scripts.
Dynamic script.async = false Restores defer-like ordering for programmatically injected scripts. The browser still fetches in parallel; only the execution serialization changes. Useful for SDK + pixel sequencing post-consent.
Priority hints and fetchpriority Priority hints and defer are orthogonal. A defer script with fetchpriority="low" downloads last but still executes before DOMContentLoaded in document order. Use fetchpriority="low" on non-critical deferred analytics to avoid competing with LCP resources.
Consent state synchronization The CMP must resolve before any dynamically injected consent-gated script fires. Deferred CMP scripts guarantee the consent state is readable at DOMContentLoaded; async CMPs introduce a race window where the event may fire before DOM-dependent listeners attach.

Troubleshooting: Named Failure Modes

1. async script causes null reference errors on DOM elements

Symptom: Cannot read properties of null (reading 'addEventListener') in the browser console, triggered by a vendor script. The error correlates with fast network conditions.

Cause: A script marked async completed its download before the parser reached the DOM node it references. On fast connections the race window is small but real; on CDNs with edge-cached scripts it is consistent.

Fix: Change the attribute to defer. If the script must remain async, add a DOMContentLoaded guard inside the script’s initialization callback, or gate the entire script on the DOMContentLoaded event via dynamic injection.


Symptom: The consent banner flashes in after the page is already visible. Users interact with the page before consent is captured.

Cause: The CMP script is deferred but the server is slow to deliver it. By the time it downloads and executes, DOMContentLoaded has already fired and the page is interactive.

Fix: Add <link rel="preload" as="script" href="..." crossorigin> for the CMP URL so the download starts during early HTML parsing rather than waiting for the parser to encounter the <script> tag. The defer attribute still governs when execution happens, but the payload arrives earlier.


3. Dynamically injected defer attribute has no effect

Symptom: Scripts are injected via document.createElement('script') with script.defer = true, but execution order is still non-deterministic.

Cause: defer is a parser-level attribute. Dynamically created script elements are not part of the parser’s defer queue regardless of the defer property’s value. Browsers treat them as async by default.

Fix: Use script.async = false for ordered execution, or chain injections with onload Promises as shown in the implementation above.


4. Lighthouse flags a deferred script as render-blocking

Symptom: A <script defer> tag appears in the Lighthouse “Eliminate render-blocking resources” audit.

Cause: Lighthouse’s render-blocking heuristic is driven by the script’s position in the document relative to when the browser discovers it versus when the first paint occurs. A defer tag placed after critical CSS links but before the LCP image can still inflate Lighthouse’s blocking time estimate if the fetch is slow.

Fix: Move the <script defer> tag after all critical CSS <link> elements, and consider adding fetchpriority="low" to signal that the script should not compete with LCP resources. See fixing render-blocking warnings from Google Tag Manager for the GTM-specific configuration.


Symptom: On return visits with a valid consent cookie, a marketing script fires before the CMP consent-granted event. Compliance audit logs show timestamps preceding the event.

Cause: The browser loads a cached version of the CMP script that resolves consent synchronously before your event listener attaches, so the event fires and is missed.

Fix: Read consent state directly from the CMP’s synchronous API (e.g., window.__tcfapi('getTCData', 2, callback)) on page load rather than relying solely on the event. Gate dynamic injection on both the event and a direct state check at initialization.


Frequently Asked Questions

Does async preserve execution order between two async scripts?

No. async scripts execute as soon as they finish downloading, regardless of their position in the document. If two async scripts depend on each other, whichever finishes first runs first. Use defer or onload-chained dynamic injection to enforce ordering.

Can I use defer on an inline script block?

No. defer (and async) are only meaningful on scripts with a src attribute. Inline scripts always execute synchronously during parsing. If you need to defer inline logic, wrap it in a DOMContentLoaded listener or move the code to an external file.

Should I use type="module" instead of defer?

<script type="module"> is deferred by default and also applies strict mode. For modern applications using ES modules it is the natural choice. However, module scripts are always deferred — you cannot make a module script async unless you explicitly add the async attribute. For third-party scripts from external CDNs that are not ES modules, defer on a classic script remains the practical choice.

Does fetchpriority interact with defer?

Yes, orthogonally. fetchpriority controls where the browser places this script’s download in the network queue. defer controls when the browser executes it. A defer script with fetchpriority="low" still executes before DOMContentLoaded in document order — it just downloads at lower priority than the default, which is useful for non-critical analytics SDKs that should not compete with LCP images.


Up: Script Loading Fundamentals & Priority Optimization