There are three practical ways to add JavaScript to a web page: an inline <script> tag, an external <script src="app.js"> tag, and an inline event handler attribute on an element. They are not equal choices — the external file is what you will use on every real project, and the defer and async attributes decide when it runs.
What are the three ways to add JavaScript to a page?
Any JavaScript in a page arrives through one of these doors, and all three end up doing the same thing: giving the browser a piece of source text to compile and run.
| Approach | Written where | Use it when |
|---|---|---|
Inline <script> tag | Inside the HTML document | Small experiments, a page that will never be reused |
External <script src> tag | A separate .js file | Almost always: real sites, shared behaviour, caching |
| Inline event handler | An attribute on an element | Never in new code; you will meet it in old code |
There is a fourth, newer door: ES modules, loaded with <script type="module">. Modules get their own scope, strict mode and import/export, and they are deferred by default. They have their own chapter later in this tutorial; for now the important thing is that the classic <script> tag still does the work for most pages.
What does an inline script tag look like?
Put the code directly between the <script> and </script> tags. There is no src, so the browser uses whatever text sits between them:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Inline script</title>
</head>
<body>
<h1>Inline script</h1>
<button id="go">Count</button>
<p id="output">0</p>
<script>
let count = 0;
const output = document.getElementById("output");
document.getElementById("go").addEventListener("click", () => {
count += 1;
output.textContent = String(count);
});
</script>
</body>
</html>
That page works, but it has three problems that grow quickly. The code cannot be cached, so every visitor downloads it again. It cannot be reused on a second page. And it cannot be tested, version-controlled or edited with syntax highlighting. Inline scripts are fine for a five-line experiment and a liability in production.
Why an external file is the right default
Move the same JavaScript into its own file and point a tag at it. The rule of thumb: <script src="app.js" defer></script> in the <head>.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>External script</title>
<link rel="stylesheet" href="styles.css" />
<script src="app.js" defer></script>
</head>
<body>
<h1>External script</h1>
<button id="go">Count</button>
<p id="output">0</p>
</body>
</html>
and app.js holds the behaviour:
let count = 0;
const output = document.getElementById("output");
document.getElementById("go").addEventListener("click", () => {
count += 1;
output.textContent = String(count);
});
The browser now downloads app.js once and reuses the cached copy on every later page view, so a change to the behaviour ships without re-sending the markup. defer also means the script waits for the HTML to be parsed before it runs, which is exactly what the code above needs — it looks up #output at the top level.
What do defer and async actually change?
Both attributes download your script in the background instead of blocking the HTML parser. They differ in one thing: when the code runs.
<!-- defer: download in parallel, run after parsing, keep document order -->
<script src="analytics.js" defer></script>
<script src="app.js" defer></script>
<!-- async: download in parallel, run the instant it arrives, any order -->
<script src="widget.js" async></script>
Without either attribute, a classic script blocks the parser: the browser stops reading HTML, fetches the script, runs it to completion, and only then carries on. Two classic scripts therefore run strictly one after the other, in the order they appear.
// Classic scripts run one at a time, in document order. This tiny model
// reproduces the rule so you can watch it happen.
const order = [];
function runClassicScript(name) {
order.push(`${name} ran`);
}
runClassicScript("analytics.js");
runClassicScript("app.js");
console.log(order.join(" -> "));
Run that and the console prints analytics.js ran -> app.js ran. Swap the two call lines and the output swaps with them, because classic scripts never overlap.
With defer, all deferred scripts still run in document order among themselves, but they all wait for the page to finish parsing first. With async, each script runs the moment its download finishes, so the order depends on the network.
// Deferred scripts keep document order even if the network delivers them
// out of order. Async scripts would run in download order instead.
const documentOrder = ["analytics.js", "app.js"];
const downloadOrder = ["app.js", "analytics.js"];
const deferExecution = documentOrder.filter((name) => downloadOrder.includes(name));
const asyncExecution = downloadOrder;
console.log("download order: ", downloadOrder.join(" -> "));
console.log("defer runs as: ", deferExecution.join(" -> "));
console.log("async would run: ", asyncExecution.join(" -> "));
The two answers differ, and that difference is the whole point. If app.js depends on something analytics.js defines, defer keeps that guarantee and async breaks it.
Why does a script in the head fail to find my button?
This is the most common first-week error: you put a <script> in the <head>, call document.getElementById("go"), and get null. The script ran perfectly. The problem is timing.
The browser parses HTML from top to bottom. When it reaches a <script> tag it pauses, runs the code, and resumes parsing afterwards. A script in the <head> therefore runs before the <body> exists — there is no #go element yet, and getElementById correctly returns null.
Three fixes, in order of preference. Add defer (or async when order does not matter). Move the script to the end of the <body>. Or wait for the DOM to be ready. The third option exists but is rarely the best one, because it forces your whole script into a callback:
document.addEventListener("DOMContentLoaded", () => {
const output = document.getElementById("output");
document.getElementById("go").addEventListener("click", () => {
output.textContent = "clicked";
});
});
Compare that with the top-level version in the previous section. Same behaviour, one less layer of indentation, no waiting at all — because defer already guarantees the DOM exists. Reach for DOMContentLoaded only when you have to support a script you do not control.
Do inline event handlers still work?
They do. They are also the weakest option:
<button onclick="count += 1; output.textContent = count;">Count</button>
<p id="output">0</p>
The code lives in an HTML attribute, so it is invisible to most editors, untestable, impossible to reuse, and it has to reach into variables declared elsewhere — count and output above are only in scope if some script put them on the window. It also breaks the moment you need to pass the clicked element, because this inside an inline handler is the element, which is a trap you will meet again when functions come up.
Treat inline handlers as something to read and understand, not something to write. The moment you need more than one line, move to addEventListener.
Which approach should you reach for?
- Use
<script src="app.js" defer></script>in the<head>for application code. One file, cacheable, order-safe. - Use an inline tag only for a throwaway experiment or a tiny snippet that must run before anything else.
- Use
asyncfor independent extras such as analytics or ad tags, where arrival order does not matter. - Use
DOMContentLoadedonly when you do not control when your script is loaded. - Never write new inline event handlers; translate them to
addEventListenerwhen you touch the file.
Try it yourself: create index.html and app.js side by side. First put a plain <script src="app.js"></script> at the end of <body> and have app.js log "loaded". Then move the tag into the <head> without defer and confirm it still logs but now runs before the body exists. Finally add defer and re-check the order in the console. Three placements, three different execution points, one file you did not have to rewrite.