Every JavaScript bundler handles inline <script> tags wrong

There are two main ways to include JavaScript code on a web page. You can use a <script> tag with a src attribute pointing to a JavaScript file:

<script src="https://mywebsite.example/script.js"></script>

Or you can write your JavaScript code directly inside the <script> tag, which is sometimes called inlining it:

<script>
  console.log("Hello!");
</script>

(You can also write JavaScript inline in event handler attributes like onclick, and twenty years ago browsers used to have ways to embed JavaScript in CSS, but anyway this post is about <script> tags.)

Traditionally, you’d use the src attribute for long scripts that were reused on multiple web pages (so the browser would only need to download the .js file once), and you’d use an inline <script> tag for short scripts that were specific to one page (so the browser wouldn’t need to make a separate HTTP request to download them). But there’s a growing trend of using inline <script> tags for every script on your page. Game creation tools like Twine have been doing that for a long time, so that you can download the whole game as a single HTML file. A couple years ago, the SvelteKit web framework came out with a “self-contained apps” mode to do the same thing. And more recently, some AI websites have started encouraging people to create and share “HTML artifacts” that include all the JavaScript as part of the HTML file.

There’s a funny pitfall that can happen if you take a bunch of existing JavaScript code and put it into an inline <script> tag: what happens if that JavaScript code contains the string </script>?

<script>
  console.log("</script>");
</script>

The answer is that the browser’s HTML parser doesn’t care about JavaScript syntax at all, so as soon as it sees the characters </script>, it ends the script. This usually means that the script gets an “unterminated string literal” syntax error, and then everything that comes afterwards gets inserted into the page as text.

This is pretty well-known as a way to do cross-site scripting attacks against React apps, but there’s an even funnier variant that the HTML specification calls the script data double escaped state:

<script>
  console.log("<!--", "<script>");
</script>

If a script contains the string <!--, and then at some point later contains the string <script>, then the next time the browser sees a </script>, it won’t end the script. (So whatever appears after the inline <script> tag will become part of the script, probably causing a syntax error but maybe creating a cross-site scripting vulnerability.) This is just, like, a weird loophole they added to the HTML specification to improve compatibility with twenty-year-old websites. Hilarious!

You can also combine other JavaScript features to create these sequences of characters outside of a string literal. A less-than sign plus a regular expression literal makes </script>:

if (1</script>/.exec(someString).index) { /* ... */ }

Just like with string literals, as soon as the browser’s HTML parser sees those characters </script> next to each other, it’ll immediately end the script, and the /.exec(... part will get dumped into the page as text.

You can similarly combine less-than signs, greater-than signs, the “not” operator, and the prefix decrement operator to create stupid but technically valid JavaScript code that includes both of the ingredients for the script data double escaped state:

var x = 5;
var script = 1<!--x;
if (1<script>4) { /* ... */ }

Well, actually, there’s another weird loophole that affects this code: Annex B.1.1 of the ECMAScript specification says that JavaScript interpreters inside of browsers should treat the string <!-- as the start of a line comment, similar to // comments, unless the code is inside a JavaScript module. JavaScript interpreters not inside of web browsers get to choose whether or not they do this, so technically this program has two different valid meanings under the ECMAScript specification. (Different syntax highlighting libraries disagree about how to handle this code, too. How does your favorite text editor display it?)

Anyway, it’s obviously pretty rare for people to hand-write weird code like this in an inline <script> tag. But it’s fairly common for people to use “bundlers” or “build tools” that promise to take all the random junk they got from NPM or ChatGPT or wherever, combine it into a single script, and “minify” it to make it as small as possible. Those tools should definitely avoid causing these kinds of syntax problems, right? How well do they do?

How build tools should handle this stuff

You could imagine a few basic rules that build tools should follow to avoid generating code that has these issues. Things like:

  1. If a regular expression literal starting with script> appears right after a less-than sign, add a space in between them.
  2. If the program contains a less-than sign, followed by a not operator, followed by a prefix decrement operator, add a space somewhere in there.
  3. If a string literal, regular expression literal, or template literal includes </script> or <!--, replace one of the characters with an escape sequence like \x3C.

These rules all result in JavaScript code which works the same, but doesn’t create confusion for the HTML parser, save for a couple of exceptions. It’s possible for programs to tell the difference between an escaped and unescaped character in a regular expression literal by using the source property:

console.log(/<!--/.source);
console.log(/\x3C!--/.source);

Different build tools might come up with different ways of handling this. Some tools might rewrite the literals as something like new RegExp("\x3C!--"), under the assumption that there isn’t any code on the page that tampers with the global RegExp constructor. Other tools might just accept that RegExp.prototype.source will return a slightly different value sometimes, like how Function.prototype.toString() returns different code if the function has been minified. (The ECMAScript specification already has a bunch of wiggle room to allow browsers to escape RegExp.prototype.source however they want, so there’s probably not much harm in a little extra escaping here.)

Similarly, template tag functions can access the raw property of their first argument to see the exact characters that appear in the template literal:

function customTag(strings) {
  console.log(strings, strings.raw);
}
customTag`</script>`
customTag`\x3C/script>`

This one is a bit trickier to deal with. There isn’t as much wiggle room as there is for regular expression literals; build tools probably can’t get away with just allowing the value to be different, because that would break common use cases like String.raw`` literals. But the popular build tools are all written by smart, capable teams, so I’m sure they’ve all come up with a solution. In fact, I bet they’ve consistently handled all of these cases, and maybe even found some I wasn’t aware of. Right?

Testing build tools

So, alright, here’s a snippet that contains a bunch of the different cases that don’t work with inline <script> tags:

function customTag(strings) { return strings.raw; }

var a = "</sCrIpT>";
var b = `</sCrIpT>`;
var c = customTag`</sCrIpT>`;
var d = 1</sCrIpT>/.exec(a).index;

var e = "<!-- " + "<sCrIpT>";
var f = `<!-- ` + `<sCrIpT>`;
var g = customTag`<!-- <sCrIpT>`;
var h = /<!-- <sCrIpT>/;
var sCrIpT = 12345;
var i = 1 <!--sCrIpT<sCrIpT>4; 
var j = 1 < ! --sCrIpT<sCrIpT>4; 

console.log(a, b, c, d, e, f, g, h, i, j);

(I’ve used mocking SpongeBob case for the word “script” just in case any build tools forgot that the HTML parser is case-insensitive.)

For fairness’s sake, if a tool doesn’t claim to generate code that’s valid for an inline <script> tag, I won’t fault them for not doing so as long as they’re consistent about it. They will still need to handle the <!-- line comment syntax since that matters even for external .js files. (I’ve included the var j line just in case a tool parses <!-- as a line comment but still generates it when it’s trying to remove unnecessary spaces from the code.)

So, without further ado, how do all the popular tools fare?

ESBuild 0.28.1

ESBuild is a bundler for JavaScript and CSS written in the Go programming language. According to its documentation, it attempts to generate code that you can “inline directly into an HTML file” by default.

Unfortunately, pasting my snippet into its online playground shows that this is not quite the case. ESBuild does get a lot of things right: it adds spaces where appropriate to avoid both of the cases with the less-than operator, uses the shortest possible escape sequence to handle </script> appearing in a string/template literal, and even injects a tiny bit of polyfill code to set raw correctly for custom template tags. I also like that it emits a warning about <!-- being interpreted as a line comment in the var i line.

But ESBuild forgot to handle <!-- appearing in string/regex/template literals, so it FAILS this test.

Terser 5.48.0

Terser is a Javascript minifier written in JavaScript. It’s often used by other JavaScript build tools, such as Webpack and Rollup, to make the output as small as possible. Terser has a configuration option called inline_script, which is on by default, that claims to “escape HTML comments and the slash in occurrences of </script> in strings”. While it doesn’t explicitly promise this, it’s pretty obvious that the point of this option is to make the output safe to use in an inline <script> tag.

Unfortunately, pasting my snippet into Terser’s REPL shows that it doesn’t quite achieve this. While it does escape string literals like it said, and even adds spaces to the less-than operators, it does not attempt to escape template literals or regular expression literals. That’s a FAIL for Terser.

SWC 1.15.43

SWC is a compiler and minifier written in Rust. It claims to be used by Next.js, Parcel, and Deno.

By default, SWC does not attempt to make code suitable for use in an inline <script> tag. It does have a configuration option called inlineScript, but its documentation warns that it’s “mostly not implemented yet”. Enabling the option in the JSON configuration in SWC’s playground reveals that it does indeed attempt to escape string literals containing </script> and <!--, but does not handle any cases involving template or regular expression literals. It doesn’t even add a space between the less-than operator and the script> regular expression literal.

I might have given SWC a pass because they don’t really claim to be able to do this in their documentation. But they also publish an HTML minifier that applies this same incorrect processing to the contents of inline <script> tags inside HTML documents (seemingly without even turning on their own inlineScript option!), so that’s a FAIL for sure.

Oxc a4db731

Oxc is another suite of JavaScript-related tools written in Rust. Its website claims that its minifier is used in Rolldown, which claims to be used in the latest version of Vite, and is available as an option in SvelteKit.

Bafflingly, Oxc’s playground reports syntax errors in my snippet, but then minifies it anyway. It adds spaces to the less-than operator, and escapes </script> in all the places it can appear, but doesn’t attempt to fix the raw value for custom template tags like ESBuild does. Also, they forgot about <!-- appearing inside string/template/regex literals. FAIL.

Bun 1.3.14

Bun is a suite of JavaScript-related tools produced by an AI company. It includes tools for bundling and minifying JavaScript.

Bun actually fails to parse my snippet, throwing an error about the <!-- line comment syntax not being implemented. With the var i line commented out, Bun doesn’t attempt to produce code that would work in an inline <script> tag.

However, Bun also offers one of those trendy “standalone HTML” modes that puts all the scripts into inline <script> tags. In this mode, Bun finds a new and exciting way to get it wrong: it adds a backslash in between the less-than operator and the regular expression literal in the var d line of my snippet, which is not valid in JavaScript. Bun otherwise works similarly to Oxc, including failing to escape <!-- inside literals. FAIL.

UglifyJS 3.19.3

UglifyJS is a JavaScript minifier written in JavaScript. It used to be pretty popular, but it’s fallen out of favor lately, and hasn’t been updated in a couple of years. It has an inline_script option documented similarly to the one in Terser.

Like Terser, UglifyJS escapes both </script> and <!-- in string literals, and adds a space to the var j line, but doesn’t handle any of the other cases in my snippet. FAIL.

Closure Compiler 20260629

Closure Compiler is a JavaScript build tool written in Java by a major search engine company. While it used to be pretty popular among web developers, these days it’s pretty much only used internally by the company. Supposedly, some of that company’s products use Closure Compiler to optimize inline <script> tags.

On my snippet, Closure Compiler escapes both </script> and <!-- in both string and regular expression literals, but doesn’t touch template literals at all. It adds a space to the var j line, but not the var d line. Similarly to ESBuild, it emits a warning about <!-- starting a line comment in the var i line, which is nice.

It’s a pretty unique combination of behaviors! Closure Compiler has been around for a long time, so I can imagine whoever implemented support for template literals just forgot about the </script> stuff, but even that doesn’t excuse failing to handle the “less-than sign next to regular expression literal” case. FAIL.

github.com/tdewolff/minify 2.24.13

github.com/tdewolff/minify is a collection of minifiers written in the Go programming language. Its HTML minifier processes the contents of inline <script> tags using its JavaScript minifier.

I put an HTML version of my snippet into its online playground, and unfortunately it didn’t correctly handle my mocking-Spongebob-case </sCrIpT> tags. Also, while it did add whitespace to the var j line correctly, it did not escape <!-- in string/regex/template literals.

I feel a little bad picking on this one since it seems to be primarily written by one individual as a hobby project, and is less popular than the other tools covered in this post. (I hadn’t heard of it before, but it came up in this list of JavaScript minifiers I found online.) It seems like a strong effort in light of that, but for now, it’s a FAIL.

JSMin 2026-03-03

JSMin is a JavaScript minifier written in C. Rather than fully parsing the JavaScript code, it attempts to use character-based rules to determine which whitespace characters can be safely omitted from the program. It was originally released in 2001, and its GitHub repository was last updated in 2026, so it's more mature than most JavaScript developers.

While JSMin makes no attempt to produce code that’s suitable for use in an inline <script> tag, it unfortunately removes the whitespace from the var j line in my snippet, introducing a <!-- that would be treated as a line comment even in an external script file. That’s a FAIL, and I suspect many similar character-based minifiers would fail for the same reason.

babel-minify 0.5.1

babel-minify is a JavaScript minifier based on Babel, a JavaScript code transformer written in JavaScript. While Babel itself is still an active project, babel-minify has not been updated since 2022. Nonetheless, it doesn’t have an official deprecation notice, so it’s fair game as far as I’m concerned.

babel-minify makes no attempt to produce code that would work inside of an inline <script> tag, and it doesn’t look like Babel has any option to do that at all. It correctly avoids producing a <!-- line comment in the var j line. Babel is therefore worthy of the highest honor this post has to offer: DID NOT ATTEMPT.

Conclusion

It seems that, at the time of writing, there is no JavaScript build tool that can correctly produce code for use in inline <script> tags. What can we learn from this?

If you’re writing a JavaScript build tool, I guess you need to remember:

If you’re looking for a JavaScript build tool, ESBuild seems to have the best approach to this issue, even though it’s not perfect yet. (I imagine they’ll fix the <!-- cases pretty quickly once I get around to filing a bug report.) Oxc gets second place because it didn’t try as hard to keep custom template tags working. Down at the bottom of the leaderboard, you probably shouldn’t use 25-year-old character-based minifiers, or JavaScript tools made by AI companies.

This may seem like kind of an obscure problem, but I’ve actually written code that includes these kinds of patterns in the past. My game URA Winner! uses document.write() in a few places to replace the page with an entirely new page, including new inline scripts and stuff. If I had been using modern JavaScript syntax and one of these build tools, there’s a good chance I would’ve been affected by this issue. Besides, I think we should be able to trust our tools to handle all possible cases correctly, including the really weird ones.

If you happen to know of any tools like that, or a case that I missed, let me know at carter.sande@duodecima.technology!

(Also, the rules for CVE vulnerability reports are pretty lax these days, so I imagine people will try to file CVEs against a bunch of these tools even though this isn’t really a security vulnerability. If you do that, please mention “Carter Sande” so I can put this on my resume.)