Notifio is a desktop app that watches rental search pages and emails you when a new listing appears. It ships as two macOS DMGs and one Windows installer from the download page, built with electron-builder out of the build block in package.json.

A while ago I went through the include half of that config and found three paths in it that refer to nothing at all. This post is the other half: the 33 negations, what each one is actually worth in megabytes, and what got into the package anyway.

The short version: the list leaves out 454 MB of the 528 MB sitting in node_modules, one of its globs matches nothing, and a third of the asar it produces is React source code that no process in the app ever requires.

The list

"files": [ "dist-ts/**/*", "public/**/*", "assets/**/*", "playwright-browsers/**/*", "node_modules/**/*", "package.json", "!node_modules/.pnpm/typescript*/**/*", "!node_modules/.pnpm/vite*/**/*", "!node_modules/.pnpm/esbuild*/**/*", "!node_modules/.pnpm/electron@*/**/*", ... "!**/*.map", "!**/*.ts", "!**/README*", "!**/LICENSE*" ] 

42 entries. 33 of them are negations, and 25 of those name a directory inside pnpm's content store. That shape exists because of pnpm: node_modules/* is a tree of symlinks into node_modules/.pnpm, electron-builder follows them, so the only place a package can be excluded by name is the store path.

A list like that is a set of claims, and nobody checks them. So I checked them.

Measuring a glob in megabytes

The whole measurement is a fnmatch against the store directory plus du:

base = 'node_modules/.pnpm' dirs = os.listdir(base) # 380 entries  for glob in negations: # '!node_modules/.pnpm/vite*/**/*'  prefix = glob[1:].split('/**')[0][len(base) + 1:] matches = [d for d in dirs if fnmatch.fnmatch(d, prefix)] kb = sum(du(os.path.join(base, d)) for d in matches) 

The result, sorted by what it saves:

Glob Store dirs matched Size
electron@* 1 296.1 MB
electron-winstaller* 1 30.7 MB
typescript* 1 22.8 MB
@img* 3 17.4 MB
lightningcss* 4 17.4 MB
@rolldown* 2 15.6 MB
esbuild* 1 10.2 MB
@esbuild* 1 10.1 MB
@electron-internal* 1 7.0 MB
@types* 20 5.9 MB
app-builder-lib* 1 5.4 MB
@tailwindcss* 4 3.0 MB
13 more 17 12.3 MB
Total 57 453.9 MB

node_modules is 528 MB. The exclusion list names 454 MB of it, which is the number that makes the rest of the config legible: without those 25 lines the installer would carry its own build toolchain, and the toolchain is bigger than the product.

Three things in that table are worth more than their row.

The largest exclusion is the framework the app is built on. electron@* is 296 MB, more than half of everything excluded, and leaving it out is not a size optimisation, it is correctness. electron-builder supplies the Electron runtime itself as the shell around the package. The copy in devDependencies is the one electron . uses in development. Shipping it would put a second, unused Electron inside the first one.

rollup* matches nothing. It matched something once. The project runs Vite 8, which rolls its bundling into @rolldown, and @rolldown* is in the list right underneath at 15.6 MB. A glob matching nothing is not an error in electron-builder and should not be, because dist-ts/**/* legitimately matches nothing until pnpm compile has run. So the stale line costs nothing and announces nothing, and it is the one line in the config I am going to leave alone.

@types* matches 20 directories to save 5.9 MB. That one is already covered by !**/*.ts, which drops every TypeScript source file in the package. It stays because 5.9 MB of files that cannot run is still 5.9 MB.

What 42 lines turn into

A build writes release/builder-debug.yml, which is the config as electron-builder actually understands it. Our 42 entries come out as 196 lines: two pattern sets per architecture, four lists in total, because files inside node_modules are matched against a different set from everything else.

arm64: firstOrDefaultFilePatterns: - '!assets{,/**/*}' - '!release{,/**/*}' - dist-ts/**/* ... - '!**/*.{iml,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,...}' - '!**/{.git,.hg,.svn,CVS,...,.DS_Store,thumbs.db,.gitignore,...}' nodeModuleFilePatterns: - '**/*' - dist-ts/**/* ... - '!**/README*' - '!**/LICENSE*' 

The two prepended negations at the top are electron-builder's, and so is the long junk filter at the bottom of the first list. That junk filter is not in the second list. Every default exclusion for editor files, VCS directories and .DS_Store applies to the app's own tree and not to the dependency tree, which is the tree with 74 packages in it.

What survived

The arm64 build produces app.asar at 21.7 MB with 1,274 files in it, plus app.asar.unpacked at 349 MB, which is 344 MB of Chromium and 5 MB of the two packages that cannot live inside an archive.

Reading the asar header gives a per-package byte count, and the top of that list is not what I expected:

<playwright-browsers> 714344.8 KB (unpacked, both arches at this point) playwright-core 10614.3 KB react-dom 7145.6 KB playwright 4303.7 KB tldts 919.5 KB <dist-ts> 285.4 KB <public> 255.9 KB react 165.3 KB scheduler 79.2 KB 

react-dom, react and scheduler are 7.4 MB between them, which is 34 percent of the packed asar. Nothing in the app requires any of them:

$ grep -rn "from 'react'\|require('react'" app/src/*.ts $ echo $? 1 

The main process is 28 TypeScript modules and not one of them imports React. The window's UI does, but Vite compiles that at build time into public/assets/index-BVnJWuyv.js, which is 242 KB, and the renderer's own .tsx sources never reach the package at all, because src/ is not in the include list.

So React ships twice. Once as 242 KB of compiled bundle that actually runs, and once as 7.4 MB of unminified source, development builds and profiling builds that nothing loads. The reason is a one-word decision made long before any of this: react and react-dom are in dependencies rather than devDependencies, and node_modules/**/* means every runtime dependency. They are only ever needed by the Vite build, which is development. That is a two-line fix I have not made yet, and the measurement is the thing worth publishing first.

For scale, the compiled UI that the product actually paints is 256 KB including its stylesheet. The React source beside it is 29 times that.

74 packages from 6 dependencies

package.json declares six production dependencies: dotenv, express, playwright, react, react-dom and tldts. The asar contains 74 package directories. The other 68 are Express's dependency tree, which is the price of using an HTTP server as the bridge between the main process and the window, something I have written about separately.

The files that should not be there at all

Three negations exist to drop documentation. Measured against the actual package:

  • READMEs: 0 survived. !**/README* works.
  • Licences: 7 survived. !**/LICENSE* is case sensitive and anchored to the start of the basename, so merge-descriptors/license, ms/license.md and five files named something.js.LICENSE inside Playwright all walk straight past it.
  • Changelogs: 21 survived, every one of them named HISTORY.md. !**/CHANGELOG* only knows one of the two conventions, and the Express ecosystem uses the other one.

Then there are 15 .nycrc files, which are code coverage configs for test runners that are not installed, and 11 markdown files of AI agent instructions:

/node_modules/dotenv/skills/dotenv/SKILL.md /node_modules/playwright/lib/agents/playwright-test-generator.agent.md /node_modules/playwright/lib/agents/playwright-test-heal.prompt.md /node_modules/playwright-core/lib/tools/trace/SKILL.md ... 

59 files and 204 KB of text in total. Against a 291 MB DMG that is nothing, and I am not going to pretend otherwise. It is interesting for a different reason: the packages a 2026 install tree pulls in now contain prompts, skills and agent definitions, and a packaging config written before any of that existed has no line for them.

What I would take from this

Two edits are worth making. react and react-dom belong in devDependencies, which is 7.4 MB and a third of the asar. !**/HISTORY* belongs next to the changelog filter.

The broader thing is the measurement itself, which took about twenty lines of Python. An exclusion list is the one part of a build config that is silent when it is wrong in either direction: a glob that matches nothing says nothing, and a glob you forgot to write says nothing either. If you have a packaged Electron app, matching your negations against node_modules and then reading your own asar header is an afternoon that will tell you something about your installer you did not know.

If you want to see what the result weighs, the DMGs and the installer are on the download page, and what the app does with all that Chromium is described on the help page.