Our production JavaScript ships no source maps, so I identified 1.2 MB of it by its copyright strings
Our FAQ page is static prose. One heading, a definition list of questions and answers, a header, a footer, and a 606 byte logo. Its HTML is 12,550 bytes over the wire.
It also references 19 JavaScript files totalling 1,237,376 bytes uncompressed. We wanted an inventory of what those actually are, and there are no source maps to ask.
Step one: confirm there is nothing to ask
$ for u in $(the 19 chunk URLs); do curl -s -o /dev/null -w '%{http_code}\n' "https://pub-trivia.app$u.map" done 403 403 403 ... 19 times And zero of the 19 files contains a sourceMappingURL comment. productionBrowserSourceMaps is not set in next.config.mjs, so none are emitted for the browser, which is the default and a reasonable one.
The 403 is worth a note of its own. A missing file under /_next/static/ on Vercel answers 403, not 404, so "no source map" and "wrong URL" are indistinguishable from the outside. We spent a minute checking we had not typo'd the filenames.
Step two: notice that one of the 19 is free
Reading the script tags rather than just their src attributes:
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js?dpl=dpl_..." noModule=""> That one file is 112,594 bytes, and it is the biggest single thing in the list after the React chunk. Any browser that understands ES modules, which is every browser anyone reading this uses, skips nomodule scripts entirely. It is never fetched.
What is in it? Its own copyright string:
2014-2024 Denis Pushkarev (zloirock.ru) license: https://github.com/zloirock/core-js/blob/v3.38.1/LICENSE core-js 3.38.1. Next builds a legacy polyfill bundle, marks it nomodule, and modern browsers ignore it. Worth knowing before you panic about a 112 KB polyfill chunk in your HTML, and worth knowing that the pinned version is readable from the minified output even without a map.
Which lets us reconcile the numbers, and that reconciliation is how we know the method is sound:
| measurement | bytes |
|---|---|
| all 19 chunks, brotli, measured with curl | 399,148 |
the nomodule core-js chunk, brotli | 41,343 |
| what a modern browser should therefore fetch | 357,805 |
what the browser reports: sum of encodedBodySize over its 18 script requests | 357,997 |
192 bytes apart. The browser fetches 18, not 19, exactly as the attribute says.
Incidentally, gzip of all 19 is 388,162 bytes against brotli's 399,148. Brotli is the larger of the two, which is the same thing we measured on this site's HTML a while back. Vercel serves brotli when you offer it.
Step three: fingerprint the rest
With no maps, identification means finding strings that only one library ships. Useful ones:
| library | string that gives it away |
|---|---|
| react-dom | Minified React error |
| scheduler | unstable_scheduleCallback |
| core-js | the zloirock.ru copyright |
| framer-motion | projectionNodeConstructor, MotionConfigContext |
| Radix UI | data-radix, DismissableLayer |
| tailwind-merge | conflictingClassGroups, classGroupId |
| next/navigation | next-router-state-tree |
| React Compiler output | react.memo_cache_sentinel |
Run that over the 19 files:
| bytes | identified as |
|---|---|
| 429,957 | react-dom, scheduler, theme switcher, error client |
| 113,457 | next/navigation router |
| 112,594 | core-js (nomodule, not downloaded) |
| 93,523 | framer-motion |
| 91,780 | framer-motion |
| 53,922 | error reporting client |
| 52,608 | unidentified |
| 46,456 | Radix UI |
| 45,255 | unidentified |
| 37,211 | Radix UI, lucide icons, theme switcher |
| 34,276 | react-dom, next/navigation |
| 28,925 | tailwind-merge, clsx |
| 28,129 | unidentified |
| 26,678 | framer-motion, Radix UI |
| 19,724 | Radix UI |
| 11,015 | bundler runtime |
| 8,373 | bundler runtime |
| 1,928 | error reporting client |
| 1,565 | bundler runtime |
Totals by library, uncompressed, for one page of prose:
- framer-motion: 185,303 bytes across two chunks, plus a share of a third.
- Radix UI: 130,069 bytes across four chunks.
- react-dom and the scheduler: the 429,957 byte chunk, shared with everything else.
- tailwind-merge and clsx: 28,925 bytes, to resolve class name conflicts at runtime.
react.memo_cache_sentinel appears in 5 of the 19, which is the React Compiler's memo caches showing up in the output. We have written about those separately.
What the inventory says
The 185 KB of animation library on this page exists for a footer wordmark that fades as you scroll, is aria-hidden, and is hidden md:flex so it does not render on a phone at all. We have written about that one before and it is still there.
The 130 KB of Radix is the header's dropdown menu plus a toast notification system mounted in the root layout, on a marketing site where nothing public ever raises a toast.
Neither of those is a crime. Both are what happens when a component library is adopted once, in the root layout, and then every page inherits it whether or not the page has any interaction in it. The FAQ page has no form, no button that does anything, and no state. It downloads 358 KB of compressed JavaScript to arrive at that conclusion.
And the honest limit of the method
Three chunks totalling 125,992 bytes, about 10 per cent of the total, carry no signature we could match. They have no application strings in them either: no "PubTrivia", no "quiz", nothing from our own code. They contain bundler runtime markers, a few Suspense references, and otherwise nothing that names itself.
That is the real cost of shipping without source maps. Not that an attacker cannot read your code, because they can read all of it, but that you cannot either. Ten per cent of what we send to every visitor is a byte count and nothing more. If we wanted to know, the answer is not a cleverer grep, it is turning on productionBrowserSourceMaps for one build, taking the inventory, and turning it off again.
Reproduce it
View source on pub-trivia.app/faq and count the <script src="/_next/static/chunks/..."> tags. There are 19. Find the one with noModule on it. Then pick any chunk URL, append .map, and watch it answer 403. Then open the Network panel, filter to JS, and count 18 requests instead of 19.
If you want a page with slightly more JavaScript on it, the round planner is a working tool with no backend, and it is one chunk and 4,711 bytes heavier than the FAQ page. The tool is not what the bundle is for.