Skip to main content
Bloomberg JS Blog

Source Maps: Shipping Features Through Standards

Published on

Source maps are a vital part of modern web development. Today, we have an official standard, a large group of members, and many exciting features in development! But it wasn't always this way.

It may surprise you to learn that, for years, there was no official standard describing the source map format. On one hand, it is incredible that for 10 years, bundlers, browsers, and devtools worked together with only a shared Google Doc between them! On the other hand, it became impossible to add new features, deprecate old features, and build the necessary devtools to support dozens of deviations.

Why do we need Source Maps? #

Web development used to be simple! You’d write a bit of JavaScript, stick it in a <script> tag, and send it to your users. This also made debugging very simple: the code that ran on the website was the exact code you authored.

As web development became more complex, tools began to emerge to optimize large JavaScript applications. In 2009, Google released Closure Tools, a suite of tools meant to address the growing complexity with applications like Google Maps, Google Docs, and Gmail.

Closure Tools consisted of four discrete tools: an optimizing compiler, a template language, a JavaScript framework, and a developer tool. The compiler would eventually become Google’s Closure Compiler and the developer tool would heavily influence the future of Chrome DevTools.

The compiler and devtools have always gone hand-in-hand, and they form an important relationship that exists to this day. If developers are going to use a compiler to optimize, minify, and alter their source code, they will need devtools that will allow them to map the generated compiler output back to their source code! That is exactly the gap that source maps address.

What’s inside a source map? #

Now that we know what purpose source maps serve, let’s dive into what information is inside a source map file.

A source map is just a JSON file. Although some of the fields include encoded data, it should be mostly readable if you open a .map file in your favorite IDE. As an example, in out.js.map, you'll probably see something like this:

{
  "version": 3, // Always the integer 3
  "file": "out.js", // Optional: name of the generated file
  "sourceRoot": "", // Optional: prefix prepended to each entry in "sources"
  "sources": ["foo.js", "bar.js"], // Required: list of original source URLs/paths (or null)
  "sourcesContent": [null, null], // Optional: inlined source text, aligned with "sources"
  "names": ["src", "maps"], // Optional: symbol names referenced by "mappings"
  "mappings": "A,AAAB;;ABCDE", // Required: encoded mapping data (base64-VLQ deltas)
  "ignoreList": [0] // Optional: indexes into "sources" considered "third-party"
}

We can interpret most of the file simply by reading it! It has the name of the generated JavaScript file, the one or more source files that went into it, what code was in each of those files, and a list of any files to mark as third-party or library code! The only part we can’t read plainly is the mappings field.

How mappings work #

Let’s think about mappings for a minute. What information would we need to accurately trace the location where a developer might set a breakpoint in a generated file like bundle.js all the way back to the exact file and position it came from in a source file like App.ts?

What information do we need in mappings? #

All we really need is:

Conceptually, we could imagine representing these with arrays of numbers, assuming we also had an array of file names. We could design something like:

{
  "sources": ["foo.js"], // One original source file
  "mappings": [
    [0, 1, 2] // "sources" index 0, line 1, column 2
  ]
}

But now we need a way to map a position in the generated code to one of these mappings. So maybe we add a new field that contains mappings for each line in the generated file, pointing to an entry in our mappings array! Something like:

{
  "sources": ["foo.js"], // One original source file
  "mappings": [
    [0, 1, 2], // "sources" index 0, line 1, column 2
    [0, 1, 20]
  ],
  "lineMaps": [
    [1, 1, 1, 1, 1, 2, 2, 2, 2, 2] // The first 5 columns all map to the first entry in the `mappings` array and the rest map to the second entry
  ]
}

You could reduce the source map size by omitting duplicates in the lineMaps array. So, the same information could be represented like:

{
  "sources": ["foo.js"], // One original source file
  "mappings": [
    [0, 1, 2], // "sources" index 0, line 1, column 2
    [0, 1, 20]
  ],
  "lineMaps": [
    [1, , , , 2, , , , 2] // The first 5 columns all map to the first entry in the `mappings` array and the rest map to the second entry
  ]
}

This would work nicely! The only real problem is, lineMaps is going to be enormous for large files. Even using sparse arrays, you will have an array entry per character! Even a modest web app, when minified, will contain between 200,000 and 2,000,000 characters.

This per-character mapping model was actually used in early source map revisions (1 and 2), before being replaced in Revision 3.

Revision 3: The modern source map #

Reducing the size of source maps became a top priority as JavaScript bundles grew into hundreds of thousands, and eventually millions, of characters.

Revision 3 (written in 2011, still the format we use today) made four key architectural changes to dramatically shrink map size:

  1. Moved from per-character mapping IDs to segment-based source location entries
    1. Instead of mapping each character in a generated file, it only keeps track of where the mapping changes.
  2. mappings is now a string, containing segments encoded using Base64 VLQ
  3. Removed the lineMaps array
    1. Instead of two arrays that reference each other, we now have one mappings string, which encodes, for each segment, the:
      1. Generated column
      2. Source index
      3. Original line
      4. Original column
      5. Optional name index
    2. Note: Generated lines are separated by ; in the mappings string.
  4. Switched from absolute to relative encoding
    1. Instead of storing generatedColumn: 120, we now store generatedColumn: +3.

With Revision 3, the source map above can be represented like:

{
  "version": 3,
  "file": "bundle.js",
  "sources": ["foo.js"],
  "names": [],
  "mappings": "AAAE,KAAI"
}

These changes mean source maps are much smaller (thanks to VLQ) and much easier to encode and decode.

A decade without a standard #

Revision 3 landed in 2011 and it worked well! It worked so well that the entire web ecosystem decided to stop touching it for years. New bundlers spun up, emitting Revision 3 source maps, while browsers continued to build devtools that would consume Revision 3 source maps.

Things were going great in the sense that breakpoints always landed in "roughly" the right spot. But the state it was in made it impossible to add new features, deprecate old behaviors, or correct ambiguity.

Adding a new feature without a standard #

Despite the complications, one new feature did manage to make its way through the ambiguity and into bundlers and devtools alike!

As JavaScript frameworks began to grow in popularity, a common problem that faced developers was the need to step through callstacks consisting of dozens of framework files and only a few files they authored. In 2014, Google’s Chrome DevTools allowed developers to create ignore lists, specifying files or patterns they wished to exclude from stack traces and step-through debugging.

This was great, but it would be far better if bundlers could let devtools know which files to ignore without forcing the developer to manually go through and list files. To solve this problem, in 2022, Google began looking for an x_google_ignoreList array inside source maps. If found, they would automatically add any files in the array to their DevTools ignoreList.

This feature was so popular that Firefox began to support x_google_ignoreList in 2023! This is an amazing success story about open source tooling, web frameworks, and browsers collaborating without a formal group or standard. However, it also made it abundantly clear that we needed an easier way to communicate.

While adding the ignoreList was successful, most large companies wanted a way to decode function names in stack traces, and this became incredibly difficult to coordinate without a standard.

Imagine the following code:

// sample.js
const penne = () => {
  throw Error();
};
const spaghetti = () => penne();
const orzo = () => spaghetti();
orzo();

If you don’t transpile, optimize, or bundle your code, you will get perfectly readable stack traces! But if you do, you will likely end up reading through callstacks with single letter function names that are impossible to trace back to your source code.

// **original** output                           // **compiled** output
Error                                            Error
    at penne (sample.js:2:33)                        at r (out.js:1:82)
    at spaghetti (sample.js:3:25)         vs         at o (out.js:1:97)
    at orzo (sample.js:4:25)                         at n (out.js:1:107)

pasta-sourcemaps logo

To solve this problem, Bloomberg created pasta-sourcemaps. Pasta takes advantage of the same x_ prefix by adding a x_com_bloomberg_sourcesFunctionMappings to our source maps. We can then parse this list back out in our devtools and apply the original function names back to the stack traces.

Though this feature was wanted by all browsers and most devtools, it was too large and too complex to simply ship. Although Chrome did ship support for pasta-sourcemaps behind a flag, it became abundantly clear we needed an official group to discuss this feature’s intricacies.

TG4 #

In 2023, Bloomberg set out to begin the process of standardizing source maps. We began, as many inspirational tech stories do, meeting in a humble Google office in Munich. We gathered a group of engineers from Bloomberg, Google, Mozilla, Vercel, Igalia, and JetBrains and began organizing years worth of questions and confusions around source maps from the ecosystem-at-large.

TG4 members from Vercel, Mozilla, Google and Igalia sit together working through source maps issues

With our new group of constituents involved in browsers, devtools, compilers, and open source, we shopped around for a "venue" for the source map standard and landed on Ecma International, the same industry association that TC39 uses.

In October of 2023, we became an official Task Group underneath the TC39 umbrella.

Our Task Group (TC39-TG4) spent 2024 working through an enormous backlog of issues filed against source maps. Topics discussed included improving correctness, formalizing unofficial features, writing specification text, creating tools to validate and test source maps, and new features we would add after we became an official standard.

At the end of 2024, we were officially standardized as ECMA-426.

Members of TG4 have dinner together to celebrate the standard's completion

Future Work #

In 2025, we began to dream big with ideas for new features. We came up with five ideas and began work to implement two of them: Scopes and Range Mappings.

Scopes #

The Scopes proposal builds off Bloomberg’s pasta-sourcemaps, but generalizes the idea far beyond function names. The goal is to let source maps reflect the realities of modern JavaScript compilation by embedding scope and binding information directly into the map (instead of forcing devtools to re-parse your sources and "guess").

That gives bundlers and devtools a common language to communicate things like:

  1. Functions that were inlined by the compiler
    • So a debugger can reconstruct missing stack frames, and "Step Over / Step Out" behaves like the original code even when the runtime no longer has a real function boundary.
  2. Variables that were renamed, folded, or erased
    • Including cases like minification renaming, constant folding, or compilation patterns where a "real" authored binding doesn't exist as a variable in generated JavaScript at all.
  3. The scopes that were removed or introduced by transforms
    • So devtools can hide compiler-internal frames/scopes, and revive original ones, without each tool inventing its own heuristics.
  4. How to compute a binding's value in generated code
    • The proposal can encode expressions a debugger can evaluate in the generated scope to present the "original" binding value back to the user.

Range Mappings #

Range Mappings is a much smaller (but extremely practical) idea: sometimes we want a mapping to apply to an entire range of text, not just a single point.

Today, mappings are essentially "pins" in the generated file. If a tool asks "what is column 137 mapped to?" and there isn’t an exact pin at 137, consumers usually fall back to the closest previous mapping on that line. That loss of precision becomes especially painful when you try to compose source maps (TypeScript to JS, then JS to minified JS), because you can only reliably compose mappings that exist in both maps at exactly the right places.

Range mappings address this by letting a generator mark specific mappings as "range mappings," meaning:

Assume every character after this mapping (up to the next mapping) is mapped by applying the same offset into the original source.

In other words, you get the precision of "map every character," without actually emitting a mapping per character. This is particularly powerful for transforms like "strip types," in which huge stretches of runtime code are identical.

To avoid breaking the mappings field, the proposal adds a new field, rangeMappings, that encodes (per generated line) which mapping segments should be treated as range mappings.

Range Mappings Example #

Imagine you have a JavaScript file with a comment at the top that you plan to strip out.

// header comment we will strip
console.log("hi");

After you compile it, you get this result:

console.log("hi");

Without Range Mappings, the source map for the following operation would look like:

{
  "version": 3,
  "file": "out.js",
  "sources": ["input.js"],
  "sourcesContent": ["// header comment we will stripnconsole.log('hi');n"],
  "names": [],
  "mappings": "AACA"
}

This works well, except we will lose all column precision. The source map says "I know things on line 1 used to be on line 2," but that’s all.

With Range Mappings, we can specifically say that line 1 used to be on line 2 and it is a range mapping, so preserve the column information!

{
  "version": 3,
  "file": "out.js",
  "sources": ["input.js"],
  "sourcesContent": ["// header comment we will stripnconsole.log('hi');n"],
  "names": [],
  "mappings": "AACA",
  "rangeMappings": "B"
}

This approach allows browsers to provide greatly improved experiences with their console and step-through debugging, with no added cost to the user.

Wrapping up #

For years, source maps have "just worked" based on a shared understanding across browsers and tools. ECMA-426 gives us a real specification and a place to evolve.

This process has been an incredible ride, and I think it really highlights the best parts of open source. So many people with knowledge to share have put in hard work to benefit the entire JavaScript ecosystem.

If you’re a bundler author, a devtools engineer, or you maintain anything that produces or consumes source maps (error monitoring, replay debuggers, etc), TG4 is exactly where we want your input. The proposal work is happening in the open and we would love to hear from you!

If you are a consumer of source maps, stay tuned! Scopes and Range Mappings will be coming to browser devtools near you soon!


Jon Kuperman

Jon Kuperman is a Technical Product Manager in the Office of the CTO at Bloomberg, where he is in charge of SDLC and developer tooling for the Bloomberg Terminal. He is a TC39 delegate and the convenor of TC39-TG4, the task group for the Source Map specification. Jonathan has been heavily involved in JavaScript and open source for many years.