Systems and ML at Firefox: Index

I spent October 2025 through April 2026 on Firefox’s Privacy team. The work below is the system we built for measuring and quantifying what Enhanced Tracking Protection, Firefox’s tracker blocker, is actually doing for users. The image above is where it all surfaces: the privacy widget on the new-tab page, the thing a quarter-billion people see when they open a tab. Everything below exists to put an honest number on it, in a way that fits Mozilla’s privacy mission: no browsing data leaves the device. Three parts: a machine learning model, the systems infrastructure underneath it, and the new-tab surface on top. I’ll walk through them in that order. It’s roughly the order I came to understand the problem.
Impact
Six months on the Firefox Privacy team, across systems, research, and product.
Systems in the browser engine
The data path inside Gecko, Firefox’s C++ engine, that turns the firehose of tracker-blocking events into the number the widget shows: a multi-process aggregation service bridging the privileged parent process (which owns SQLite and the disk) to the content-process widget over IPC, a v1→v2 schema migration to add per-origin attribution, a write-path refactor that cut a 20-tracker page from 20 DB ops to 6, and a synchronous-flush freshness contract so the widget reflects what just happened. Alongside it, security patches on Gecko’s trust boundary — the parts of the engine that touch the user’s profile data and cross the process boundary.
New-tab product, shipped to ~210 million users
A privacy surface on the new-tab page, the most-seen screen in the browser, reaching ~210 million monthly users. It finally shows people the protection they’ve been trusting Firefox to provide silently for years. Before this work, it didn’t exist.
Frontier research in ML & networking
No browser, and no standards body, has had an honest way to say what tracker-blocking actually saves a user. They report raw counts that treat a tracking pixel and a 200 KB script the same. The research I coauthored is the first to put a credible per-tracker cost on it — and the model ships as a 500 KB ONNX artifact doing live inference inside Gecko’s C++ runtime, sized to fit the browser binary and the release train.
Teams, stakeholders, users
All of it across five teams: Privacy, Product & Design, Localization, Performance, and Security. The hardest part wasn’t the code. It was landing each layer with the team that owns it, working through the product and design stakeholders who decided what the widget should say, and keeping the user, not just the diff, at the center of every decision. Tim Huang, the staff engineer on the team, coauthored the paper with me and taught me more about how to set up a question than how to answer one. I name people throughout because they shaped the work.
A handful of Gecko security patches and smaller privacy surface-area work are at the bottom.
Measuring and quantifying privacy in Firefox
Privacy is the reason a lot of people choose Firefox, and Enhanced Tracking Protection is what delivers it — quietly blocking trackers on roughly 210 million people’s machines, every day, where no one ever sees it work. The team’s bet was that invisible protection should be made visible: put a number on the new-tab page so people can see the thing they trust Firefox to do.
That bet rests on a market the data describes clearly. Privacy is no longer a niche preference — it is one of the few axes a browser can still compete on, and users are already moving along it. In Pew’s 2023 survey, 81% of U.S. adults said they have little control over how companies use their data, 49% had stopped using a product over privacy concerns, and — the number that matters most here — 44% had switched to a browser or search engine that doesn’t track them. People are changing browsers specifically for this, and the competitors built on that pitch are compounding: Brave crossed 100 million monthly users in 2025, adding ~2.5 million net new users a month on an explicitly privacy-first message, while Firefox has shed roughly 10–15 million monthly users a year since its ~205M peak in 2020. For Firefox, privacy isn’t a feature to polish — it’s the differentiator the whole product is staked on, and the one most likely to win a defecting Chrome user or hold a wavering Firefox one. The catch is that a protection no one can see can’t pull anyone: ETP has run silently for years, doing real work that never shows up in a user’s decision to stay or switch. Surfacing it honestly is therefore not a research nicety. It is a retention-and-acquisition lever at the scale of millions of users — which is the bar this work was measured against.
But which number? The honest-looking answer — “147 trackers blocked this week” — is the one we shipped first, and it lies. A one-byte tracking pixel counts exactly the same as a 200 KB script, so the count tracks how many ads a site tried to load, not how much the browser actually saved you. The number people can feel is cost: bytes that never crossed the wire, seconds your phone didn’t spend on someone else’s tracker. Getting an honest version of that in front of 210 million people, with no browsing data ever leaving the device, is the problem.
And it’s a genuinely hard one, for a structural reason: ETP cancels the request before it goes out, so the response never arrives. You can’t measure the cost of something that never happened. You have to predict it — and that turns a product question into a research one. The three layers below are how we got there: a model that predicts the cost, the multi-process systems layer in the C++ engine that feeds it, and the surface that shows it to users.
1. Machine Learning Research, the model
Main article: Machine Learning Research. Slide deck: tracker-cost-presentation.pdf.
This was most of my six months, coauthored with Tim Huang, the senior staff engineer on the team. The question it answers is simple to state and surprisingly hard to solve: when ETP blocks a tracker request, how many bytes did it just save the user? The browser never sees the response, so the cost is unobservable. We have to estimate it from what the browser can see at block time: the URL, the resource type, the request metadata. The rest of this section is the reasoning that took that question from a guess to a shippable number, in the order I actually worked through it.
Measuring the cost landscape

The first thing to understand is the shape of the quantity itself. Across 3.5 million blocked requests, transfer size is wildly uneven: 39.4% of requests return exactly zero bytes (beacons, tracking pixels, cookie-sync endpoints that answer with an empty body), and the rest stretch from a 565-byte median out to a 152 KB tail. This zero-inflated heavy tail is the central modeling challenge, and it foreshadows the single most important decision later: the loss function.

Before estimating what’s unobservable, I measured what was observable: where the blocked bytes actually concentrate. They are sharply skewed. A category of just 26 tag-manager domains accounts for 41% of all blocked bytes from only 6% of requests, while advertising is two-thirds of the requests but under a fifth of the bytes. This is the first reason a block count is the wrong metric: it weights a 200-byte advertising pixel the same as a 90 KB tag-manager bundle. The bytes are where the real intervention lives, and the bytes are what we have to predict.
| Category | Domains | % of requests | % of bytes | Median B/req |
|---|---|---|---|---|
| Tag manager | 26 | 6.3 | 40.6 | 44 |
| Content (CDN) | 573 | 9.1 | 25.3 | 6,072 |
| Advertising | 2,605 | 66.5 | 17.8 | 168 |
| Social | 121 | 7.9 | 11.0 | 1,407 |
| Analytics | 322 | 8.6 | 3.1 | 43 |
| Consent provider | 38 | 0.7 | 0.8 | 1,220 |
| Fingerprinting | 15 | 0.1 | 0.1 | 7,117 |
| Other | 892 | 0.8 | 1.4 | 532 |
Within-domain cost variance

platform.twitter.com to 59% on pagead2.googlesyndication.com. A single per-domain average is wrong for almost every request under that domain. The cost lives in the URL path, not the domain.The obvious estimator is a per-domain average: look up the typical cost for googletagmanager.com and use it whenever ETP blocks that domain. It fails for a structural reason. The cost is determined by the URL path, not the domain. Inside a single tracker domain, response sizes span three orders of magnitude (median within-domain coefficient of variation 0.94, rising to 3.0 in the tail). One number per domain is wrong for almost every request under that domain.
Lookup-table granularity

So the real question is one of granularity: how fine does the lookup key need to be? Key on domain, and you get a small table that is badly inaccurate. Add resource type, it improves. Key on the full URL path, and accuracy reaches its ceiling, but the table now needs an entry per path, which extrapolates to roughly 187 MB at full deployment scale. The accurate table is too large to ship; the shippable table is too coarse to be honest. That gap, between the accuracy you want and the size you can afford, is precisely what a learned model is for.
Per-request estimation, and why the loss function carries it

A model can reach path-level accuracy at a fraction of the size by generalizing across similar URLs instead of memorizing each one. The headline result: the model is a 500 KB artifact that matches the accuracy of the 187 MB path table, 350× smaller, and reduces error 37.6% over the table Firefox could otherwise deploy. The single decision that mattered most was not the model family but the loss function. Tracker response sizes look like insurance claims: a spike of near-zero beacons and a heavy tail of large scripts. Borrowing the Tweedie loss that actuaries use for exactly that distribution, instead of ordinary squared error, accounts for a ~23% accuracy gain on its own, larger than any feature or tuning decision. (The model is gradient-boosted trees, exported to ONNX.)
| Approach | Artifact size | MAE (bytes) | 95% CI | Spearman ρ |
|---|---|---|---|---|
| Global median | <1 B | 19,905 | --- | --- |
| Domain LUT | 88 KB | 9,008 | --- | --- |
| Domain + type LUT (deployable) | 114 KB | 6,802 | [6,633, 6,977] | 0.94 |
| Path LUT (187 MB, not deployable) | 187 MB | 4,326 | [4,154, 4,516] | 0.93 |
| XGBoost + Tweedie | 500 KB | 4,246 | [4,079, 4,442] | 0.93 |
The path LUT and the model tie on accuracy; only one of them ships. A linear model on the same features (the Brave-style approach) lands at MAE 9,651, worse than the lookup table, because least-squares can’t handle the heavy tail. That is the case for this model in one line: not the most accurate thing possible, the most accurate thing that fits in 500 KB.

A single MAE number can hide a model that is accurate on average but wrong at the extremes. The predicted-versus-actual scatter is the check: across five orders of magnitude, from single bytes to megabytes, the predictions track the diagonal, tightest in the 10 KB to 100 KB band where the costly tracker scripts live. The model is calibrated across the full range, not just near the median, which is the property that makes summing the per-request estimates into a weekly total trustworthy.
Generalization, and the honest version of the result

The result I’m most careful about, because the honest reading is subtler than the headline. Split the test set by whether a URL’s path was seen in training. On the ~92% of requests where the path was seen, the path table has the exact answer memorized, and the model does not beat it there, it matches it (and slightly trails per-request: 2,264 vs 1,811 bytes), which is what you’d expect when one side memorized and the other generalized. What matters is that it matches memorization at 350× smaller size, and with lower systematic bias. On the ~8% of unseen paths, the expensive ones, the model wins outright (27,224 vs 33,471) by generalizing from URL structure where the table can only fall back to a coarse average. The model doesn’t beat memorization on its home turf; it ties it at a fraction of the size and pulls ahead exactly where memorization breaks and where the user reads the number.
Why the user-facing number is the aggregate, not the request

This is the result a data scientist cares most about, and it reframes everything above. The user never sees a per-request prediction. They see a weekly total. And on the weekly total, per-request accuracy is not what matters: bias is. The lookup table’s errors are systematic (it consistently underpredicts scripts, overpredicts beacons), so they compound under summation. The model’s errors are roughly mean-zero, so they cancel. The model lands within 6% of the true weekly total against the table’s 22%, and the gap grows with more requests. This is why “matching but not beating” the path table per-request is fine: on the number the user actually reads, the model wins.
| Requests/week | Model (uniform) | LUT (uniform) | Model (correlated) | LUT (correlated) |
|---|---|---|---|---|
| 50 | 6.8% | 20.8% | 14.2% | 34.5% |
| 100 | 6.3% | 20.9% | 12.5% | 34.2% |
| 200 | 6.0% | 21.9% | 12.9% | 36.4% |
| 500 | 5.1% | 23.2% | 11.2% | 36.7% |
Median weekly aggregation error. The model’s lead holds under both uniform sampling and domain-correlated browsing (repeatedly drawing from a few sites, how people actually browse), and widens as the user accumulates more requests, because the table’s per-domain bias compounds harder the more you sum it.
Robustness checks

Three checks a data person would ask for, beyond the random split. Temporal staleness: evaluated on crawls 1, 3, and 6 months out, the model holds a +33.5% / +28.9% / +25.6% per-request edge, and a user-facing aggregation edge that erodes only from +13 pp to +5.7 pp. This is what sets the quarterly retraining cadence. Correlated browsing: when I re-run the simulation drawing repeatedly from a few domains (how people actually browse) the model’s advantage widens, because the table’s per-domain bias compounds harder. Cross-browser and in-page validation: the training data is Chrome-based and the model deploys into Firefox, so I tested the assumption directly. A paired Firefox/Chrome fetch of 46 tracker URLs agreed on byte size 97.8% of the time (median ratio 1.000), and a 35-page in-page Firefox crawl of 6,172 real blocked requests preserved the 7 to 9 point aggregation advantage on the actual deployment distribution.
Tim and I co-designed the feature set and argued through the modeling decisions together; the methodology lessons I took from this are mostly his. He led the C++ integration into Gecko, which I paired on. We wrote the paper together.
2. Systems: the multi-process data path that feeds the model
The model doesn’t ship into a vacuum. Before any prediction can reach a user, Firefox needs to know what got blocked, when, on whose behalf, and which process is allowed to read the answer — across a process boundary, without the content process ever touching the profile database directly. That’s the systems layer: turning a firehose of raw block events into clean, queryable, per-category aggregates the model and the widget can both consume, while respecting Gecko’s process-isolation model.
Firefox runs in multiple processes. The parent process owns privileged operations: SQLite, the disk, system calls. Content processes render web pages and can’t reach the database directly. Blocking events fire inside content processes when ETP intercepts a tracker request, accumulate in an in-memory ContentBlockingLog for that page, get persisted to protections.sqlite on a periodic flush, and become visible to anything that asks the parent process for the data. The cost model, and the widget on top of it, both live downstream of that pipeline.
I worked on four pieces of it, each landed through review with the engineer who owns that part of the codebase. Working in this part of Firefox meant the safety properties were never just mine to assert: anything touching the user’s profile database or the process boundary got scrutinized by someone who had owned it for years.
The aggregation service (Bug 2010368 / D279464, reviewer: emz). PrivacyMetricsService turns raw protections.sqlite rows into a clean JSON shape that the widget and the model can consume. Date-range queries, per-category bucketing, edge cases (empty days, time-zone normalization), and the IPC contract that exposes the result to content processes. The lazy XPCOM service-getter pattern was new to me; once I understood it I started seeing it across the codebase.
The schema extension (Bug 2027450 / D290707, reviewer: timhuang). The existing schema stored aggregate counts per type per day, with origin information discarded at write time. “Top trackers blocked this week” required attribution, which meant a v1-to-v2 schema migration, batching writes by type instead of by origin (one DB op per ETP category per day instead of one per blocked domain), and storing per-origin counts as a JSON blob inside the row. The C++ nsIContentBlocker write path changed to group origins before insert. The migration runs once on database open and can’t fail on a well-formed database, which is the conservative property you want on something living in the user’s profile.
The live-flush fix (Bug 2030052 / D295196 + D295197, reviewer: timhuang). The widget reads from disk, but recent block events live in memory until the next periodic flush, so “what just got blocked” wasn’t visible to the widget for a few seconds. The fix adds a synchronous flush in the query path so the database commits the in-memory log before the read returns. The design tension is whether freshness lives at the storage layer or the caller. I went storage-layer because “top trackers” implies current data and pushing the responsibility to every caller is a recipe for someone forgetting. Tim’s review was in flight when I left.
The cross-platform configuration resolver (main article: ETP Infrastructure). The model’s predictions only mean something if you know what ETP is actually doing in the context the prediction is for, and ETP behaves differently across three axes: Standard vs. Strict mode, normal vs. private browsing, desktop vs. Android. There was no single source of truth that resolved all three.
The desktop side (Bug 2011450 / D281665, reviewer: manuel): a 982-line Python Sphinx extension that parses firefox.js, StaticPrefList.yaml, and all.js, resolves the Standard and Strict mode values per feature, handles the encoding conventions (btp is a boolean, cookieBehavior5 means “set the cookie behavior pref to 5”), and outputs the resolved matrix as a markdown table at documentation build time. The document stays accurate by construction.
The Android side (Bug 2020402 / D286029, D289805, reviewer: manuel) was harder. Fenix uses a category-based configuration (AD, ANALYTIC, SOCIAL, CRYPTOMINING, FINGERPRINTING, CONTENT, FULL) that maps down to Gecko prefs at runtime through ContentBlocking.Settings, in a way you can’t parse from source. The only reliable way to know what Android sets is to run it. So I built a second pipeline that launches a Fenix nightly under instrumentation, navigates through both modes and both browsing contexts, reads the resolved prefs out of the running process, and emits them in the same schema as the desktop pipeline. The two outputs combine into a single matrix covering desktop Standard, desktop Strict, desktop Private, Android Standard, Android Strict, and Android Private. Three followup patches caught edge cases the original extension missed: pref comment parsing (Bug 2020396 / D286360), private-browsing cell labels (Bug 2020394 / D286358), and a Strict-mode resolver bug for Bounce Tracking Protection’s non-standard feature code (Bug 2020404 / D286581).
The four systems decisions, in one place. Each piece above came down to a single judgment call:
- Process boundary — block events fire in sandboxed content processes; the database lives in the privileged parent. I put the aggregation service in the parent and exposed it over IPC, so the content process never touches the DB. Privilege boundary where the security model wants it, not where it’s convenient.
- Write volume — every page fires dozens of blocks. I pre-aggregated at write time and batched by category (20 DB ops → 6), so the read the user waits on stays cheap. Pay the cost on write, because the read is on the critical path and the write isn’t.
- Freshness — recent blocks live in memory until a periodic flush, so the widget could read stale. I added a synchronous flush in the query path, at the storage layer rather than the caller. Put the invariant where it can’t be forgotten.
- Schema migration — adding per-origin attribution meant migrating a schema already deployed in millions of profile databases. The migration runs once on open and can’t fail on a well-formed DB. It degrades gracefully because I can’t roll back a user’s machine.
3. New Tab Privacy Metrics, the surface on top
Main article: New Tab Privacy Metrics.

about:protections, used for design review.The new-tab privacy surface is the part of the page that says “147 trackers blocked this week.” Before this work it didn’t exist. I built it across five patches.
The aggregation service (Bug 2010368) and schema extension (Bug 2027450) and live-flush fix (Bug 2030052) are all in the systems section above. On top of them sit two more patches.
The front-end component (Bug 2010369 / D279564, reviewers: emz, fluent-reviewers, desktop-theme-reviewers, flod, jules). A <privacy-metrics> web component that calls the aggregation service, renders the breakdown by category, and degrades gracefully when the service returns no data. Built with Fluent for localization, themed against the Firefox design tokens, accessible by keyboard and screen reader. Five reviewers across four teams on one patch.
The Nova redesign (Bug 2027870 / D291085). This is the part that was a product conversation, not a coding task. The widget sits on the new-tab page, the most-viewed surface in the browser, so its shape was decided with the people who own that page and the design team during the Nova redesign. I built three visual variants and a demo grid on about:protections so design review had something concrete to react to, and the variant that shipped across desktop and mobile was the one that survived that conversation, not the one I started with.
The five patches took four months to land because each one had to clear a different group of stakeholders: the privacy team (emz, Tim) for the service and database layers, the Fluent reviewers and flod for localization, the desktop-theme reviewers, the new-tab page owners and jules for the surface, the performance reviewers for anything in the page’s render path. A single web-component patch alone went through five reviewers across four teams. The thing I didn’t expect to learn at Mozilla is that the technical work is maybe half of it; the other half is landing each layer cleanly with the team that owns it, instead of as one mega-patch that stalls in cross-team disagreement. The widget exists in its current shape because that collaboration worked, not just because the code did.
The widget reports counts today. The data pipeline I built (the aggregation service, the per-origin schema) was designed to accept additional dimensions. When the cost model from section 1 lands, it plugs into the same service layer to add bytes-saved and time-saved without architectural change.
The adjacent work
Gecko Security Patches
Main article: Gecko Security Patches.

Three patches in Gecko, Firefox’s C++ rendering engine. Two are under Mozilla security embargo, so I’ve written them at the class-of-issue level until the bugs are de-restricted: defensive range checking in a browser-side compression decoder consuming server-pushed data on a trust boundary, and marking clipboard writes from private contexts as sensitive so the OS clipboard history doesn’t archive them across devices.
The third, SmartBlock embed link preservation, is public: Bug 1976290 / D273937, reviewers: manuel, webcompat-reviewers, twisniewski, freddyb (security). It extends SmartBlock, Firefox’s mechanism for keeping pages working when ETP blocks their trackers. When ETP blocks a Twitter or Instagram embed, the user used to see a blank placeholder. This patch extracts the embed’s text and links, sanitizes them through three layers of XSS defense (Sanitizer API, allowlist on the extracted nodes, CSP on the placeholder element), and re-renders them in place.
These were scoped patches alongside platform staff and the security team (manuel, freddyb, twisniewski, webcompat-reviewers). Bug fixes and small features, not architectural changes. Most of the work was loading enough of the surrounding Gecko code into my head to be sure my fix didn’t break some assumption three modules over, and the rest was the security reviewers checking exactly that. They caught two of my early patches before they shipped, which is the system working the way it’s supposed to on a trust boundary.
Additional Work
Main article: Additional Work.
The smaller patches that fill out the privacy surface area:
- SmartBlock shims that handle blocked third-party content gracefully: Facebook posts, localized header copy, Glean probe expiry.
- Notification permission telemetry, my first patch on the team, instrumenting the full lifecycle with seven events on Glean, Mozilla’s privacy-by-design telemetry framework.
- URL tracking-parameter stripping across three iterations: Google Analytics, Amazon and Audible internationals, and a default-clean-copy preference.
- Clear-data dialog modernization: 2,645 lines removed across 46 files, eight reviewers, three rounds of review.
- A Mozilla Toronto engineering event I organized in March 2026 with 30+ UofT students and four Mozilla engineers.
Index
- Machine Learning Research: Cost-Aware Tracker Blocking in Firefox. Per-request cost estimation for blocked tracker requests, deployed alongside Enhanced Tracking Protection.
- Building Firefox's New-Tab Privacy Surface. Five-patch buildout of the Firefox privacy metrics surface: backend service, front-end component, database extension, Nova redesign, and a live-flush fix.
- Gecko Security Patches. Two embargoed security patches in Gecko (a decoder bounds check and a private-context clipboard fix), plus an XSS-safe DOM injection for the SmartBlock embed surface.
- ETP Infrastructure: A Build-Time Pipeline for Firefox Privacy Configuration. A 982-line Sphinx extension that auto-generates Firefox's Enhanced Tracking Protection capability matrix from source, extended to cover Android.
- Additional Work. Smaller patches across the Firefox privacy surface: SmartBlock shims, notification telemetry, URL-stripping rules, the clear-data dialog migration, and a Toronto engineering event I organized.