How to Add JavaScript to an HTML Page: Choosing Between defer, async, and Modules

Adding JavaScript to an HTML page is easy at the syntax level. The difficult part is choosing when the browser should download and execute the code. A script can have the correct filename and still fail because it runs before the element it needs exists, executes in an unexpected order, or is loaded from the wrong path.

This guide compares four common approaches: a classic external script, a deferred script, an asynchronous script, and a JavaScript module. It also shows how to diagnose the most common loading failures. If you need the next step after loading a file, read What Is the DOM in JavaScript? and Introduction to APIs for Beginners.

Start with the simplest external file

A small project can begin with two files:

project/
├── index.html
└── script.js

The HTML file references the JavaScript file with the src attribute:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>JavaScript loading example</title>
  </head>
  <body>
    <h1 id="title">Waiting for JavaScript</h1>
    <script src="script.js"></script>
  </body>
</html>
const title = document.querySelector('#title');
title.textContent = 'JavaScript loaded';

This version works because the script appears after the heading. The browser parses the heading before it runs script.js. The same script placed in the <head> without another loading strategy may run too early.

The src path is resolved relative to the HTML document. If the file is inside a js directory, use src="js/script.js". A 404 response in the Network panel usually means the path or filename is wrong. Capitalization matters on many servers, so Script.js and script.js are not interchangeable.

When a classic script blocks parsing

A classic external script without async or defer is fetched and evaluated as the browser parses the document. When the browser reaches the script, it can pause parsing while the file is downloaded and executed. That may be acceptable for a tiny script at the end of <body>, but it is a poor default for a large file in the <head>.

The MDN reference for the script element documents these loading modes and their differences. The important distinction is that download timing and execution timing are separate decisions.

defer: a reliable default for page code

Use defer when a classic external script should download while the HTML is being parsed but execute after the document has been parsed:

<head>
  <script src="script.js" defer></script>
</head>

A deferred script can safely query elements from the document because the HTML parser has completed before the script runs. Multiple deferred classic scripts preserve their order, which matters when one file depends on another:

<script src="config.js" defer></script>
<script src="app.js" defer></script>

Here, app.js can rely on code initialized by config.js, assuming the dependency is intentionally designed that way. defer is ignored for inline classic scripts because there is no external file to download. It is also not the right tool for scripts that must run independently as soon as they become available.

async: for independent work

Use async when a script does not depend on the document’s parsing state or on another script’s execution order:

<script src="analytics-widget.js" async></script>

The browser downloads the file in parallel and evaluates it as soon as it is ready. If two async scripts finish downloading in different orders, they can execute in different orders. That makes async risky for application code with dependencies:

<script src="library.js" async></script>
<script src="app.js" async></script>

app.js may run before library.js, even though the library appears first in the HTML. If the application needs predictable ordering, use defer, modules, or an explicit dependency strategy instead.

Do not combine async and defer expecting a compromise. For classic scripts, the presence of both makes the script behave as async. Choose the behavior you actually need.

Modules: when the project has multiple files

JavaScript modules use type="module":

<script type="module" src="main.js"></script>

A module can import functions from another module:

// greeting.js
export function createGreeting(name) {
  return `Hello, ${name}`;
}
// main.js
import { createGreeting } from './greeting.js';

const heading = document.querySelector('h1');
heading.textContent = createGreeting('Alex');

Module scripts are deferred by default. They also use strict mode and have their own module scope, which reduces accidental global variables. The MDN JavaScript modules guide explains imports, exports, module paths, and dynamic loading.

When loading modules directly in a browser, serve the project over HTTP rather than opening the file with a file:// URL. A small local development server avoids origin restrictions and makes the environment closer to a deployed website. Module paths should be explicit relative paths, usually including the .js extension:

import { formatDate } from './format-date.js';

The decision table

SituationRecommended approachReason
One tiny experiment at the end of the documentInline or classic external scriptMinimal setup; the needed HTML already exists.
Application code that reads the pageExternal script with deferThe document is parsed before execution and order is predictable.
Independent analytics or widgetasyncIt can run whenever it is ready without blocking application dependencies.
Code split across files with imports and exportstype="module"Modules provide explicit boundaries and are deferred by default.
Multiple classic files with a known orderMultiple deferred scriptsDownloads can overlap while execution remains ordered.

Why defer does not fix a wrong selector

A correctly scheduled script can still fail if it queries the wrong element:

const button = document.querySelector('#save-button');
button.addEventListener('click', saveForm);

If the HTML contains id="submit-button", the result is null and the next line throws an error. The loading strategy and the selector are separate concerns. Inspect the rendered HTML and log the query result before calling a method on it:

const button = document.querySelector('#save-button');
console.log(button);

If the result is null, check the spelling, capitalization, document frame, and whether the element is created later by another script.

A practical debugging checklist

When JavaScript does not appear to work, follow the request chain instead of guessing:

  1. Open the page source or Elements panel and confirm that the script element exists.
  2. Open DevTools and inspect the Network panel while reloading the page.
  3. Check whether the JavaScript file returns a successful response instead of 404 or 500.
  4. Open the Console and read the first error, not only the last one.
  5. Verify that the script path uses the correct folder, filename, extension, and capitalization.
  6. Log the result of each important querySelector() call.
  7. If using modules, serve the page through a local HTTP server and verify every import path.
  8. If several files are involved, decide whether their execution order is guaranteed.

The browser cannot infer your dependency graph from the visual order of unrelated async scripts. Make the relationship explicit. If the dependency is important, represent it in imports, module boundaries, or a documented loading order.

Preloading is not the same as executing

It is easy to confuse the browser’s resource hints with script execution. A file can be discovered early, downloaded with a particular priority, and still execute according to the rules of its script type. Adding preload or changing a loading attribute does not automatically make dependencies safe.

For example, if a script depends on a DOM element, the important question is still whether that element exists when the script runs. If a module imports another module, the important question is whether the import graph and server response are valid. Performance hints can help the browser find resources sooner, but they do not replace an execution model.

Use the Performance panel when a page feels slow. Look for long gaps before the document response, large JavaScript downloads, long tasks on the main thread, and scripts that trigger repeated layout work. A loading attribute is only one part of the page’s performance profile.

Do not optimize a loading attribute without measuring the page’s actual bottleneck. A script may download quickly but spend a long time parsing or executing. Another may be small but trigger additional requests or expensive work after it runs. Compare a normal reload with a throttled connection, and test the interaction that matters to the user rather than judging performance only from a local development machine.

Two loading strategies that often get confused

Placing a script at the end of <body> and using defer can both prevent a script from running before the markup it needs, but they communicate different intentions. The body placement relies on the current document order. defer states that the file may be downloaded earlier but should wait for document parsing to finish. For a small one-file exercise, either approach may be understandable. For a growing project, defer in the <head> makes the loading decision easier to see and keeps the HTML layout from determining application behavior.

Modules add another distinction: a module’s imports are part of its dependency graph. A module can be deferred and still fail because an imported file returns HTML instead of JavaScript, the path is wrong, or the server sends an unexpected MIME type. When that happens, inspect the failed request instead of changing random attributes.

Choosing the right loading strategy for a beginner project

For most beginner applications, start with an external file and defer. It keeps HTML and JavaScript separate, avoids parser-blocking execution, and allows the script to work with the parsed document. Move to modules when the project has multiple files or when explicit imports make the code easier to understand. Reserve async for genuinely independent scripts.

The most useful habit is to ask two questions before adding a script: what does this code depend on, and when must it run? Once those answers are clear, the correct HTML is usually straightforward. Test the chosen behavior in the same kind of environment where the page will be served.

Continue with: What Is the DOM in JavaScript? or Common JavaScript Mistakes Beginners Make.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top