Systems and ML at Firefox: Index

From Wiki
Systems and ML at Firefox
Page metadata
First created Mar 25, 2026
Last edited Jul 3, 2026
Three variants of the privacy widget on the Firefox new-tab page
Three widget variants tested on the new-tab page during the Nova redesign.

I spent October 2025 through April 2026 on Firefox’s Privacy team, and most of what I have to show for it is one system: a way to measure what Enhanced Tracking Protection, Firefox’s tracker blocker, actually saves people. The image above is where it surfaces, the privacy widget on the new-tab page, the most-seen screen for 200M+ Firefox users. The headline numbers, so you know where this is going: the estimator cuts error 37.6% against the table Firefox could otherwise deploy, ships as a 500 KB artifact predicting in ~50 microseconds, and the core system went from scoping to landed in about ten weeks. The estimation system, data path, and widget are my work; the C++ ONNX integration into Gecko I did in collaboration with Tim Huang, the senior staff engineer on the team. The research is written up and submitted to IMC (Internet Measurement Conference).

This page walks the system the way I’d present it: the problem and why it’s hard, how I broke it down, the data, the naive solution and its wall, the model that earns its place, the artifact and how it stays fresh, the systems path inside the engine, and the surface on top.

The product requirement

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.

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.

Impact and difficulty

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.

Where the rest of the field is: Safari surfaces a count of blocked trackers. Brave shipped a page-level estimate in 2019 from a linear model (R² = 0.68). Nobody has a credible per-request answer. That’s the gap this work fills.

What made it hard splits three ways, and I only understood the third one late:

  1. A novel measurement process. Per-request tracker cost isn’t defined anywhere in industry, and the target is a counterfactual: a blocked request is never sent, so its cost never exists to measure. Costs are wildly unequal (a 258 B pixel vs. a 170 KB script, 600× apart), so a flat guess fails; estimation has to run from pre-send signals only.
  2. Collection at scale. Reliably collect and stream measurements across sandboxed processes to the browser’s most-seen surface, without costing privacy or performance. The thing being measured is a privacy feature.
  3. A product problem. Which metrics users actually care about; a component on one of the highest-visibility surfaces in any piece of software; evals for whether it actually increases privacy awareness.

Breaking down the problem

How do you even begin? I started from the product requirement and walked backwards. Show each user what blocking saved them: that needs a surface, and the new tab is the only sensible home. A surface needs per-user weekly numbers, and Firefox recorded almost nothing about its own blocking (origins were thrown away at write time), so a data path has to exist: capture, store, aggregate, across process boundaries. The number itself is unobservable, so something has to estimate the counterfactual. And the estimator can’t train on-device, so an offline training-and-distribution pipeline has to feed the online inference path, with the versioned model as the contract between them. Each part exists because the previous one demands it.

The three tracks ran in parallel, February to mid-April, about ten weeks from scoping to landed: roughly 24 pull requests across five teams (Product, Security, ML, Infra/Performance, New Tab), plus the IMC paper. The bottleneck was never the code, it was review cycles across teams; the mitigation was small, independently-landable pull requests, so something was always landing while something else waited on review.

The data

Measuring the cost landscape

Histogram of per-request transfer size on a log scale, with a 39.5% spike at zero
The distribution we have to predict. Per-request transfer size across 3.5 million blocked requests, log scale. Two features make this hard: 39.5% of requests return exactly zero bytes (beacons, pixels, cookie-sync endpoints), and the rest run a heavy tail from a 565-byte median out to a 152 KB 99th percentile. A zero-inflated heavy tail is exactly the shape ordinary squared-error loss handles worst, and the reason the loss function turned out to matter more than the model.

The first thing to understand is the shape of the quantity itself. Across 3.5 million blocked requests, transfer size is wildly uneven: 39.5% 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.

Share of blocked-tracker requests vs. share of bytes, by category
The cost landscape ETP acts on. Share of blocked requests vs. share of blocked bytes, by category. A 26-domain tag-manager category carries 41% of all blocked bytes from just 6% of requests, while advertising is 66.5% of requests but only 17.8% of bytes. A privacy product that only counts blocks treats the 200-byte pixel and the 90 KB bundle identically; the bytes are where the real intervention lives.

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.

CategoryDomains% of requests% of bytesMedian B/req
Tag manager266.340.644
Content (CDN)5739.125.36,072
Advertising2,60566.517.8168
Social1217.911.01,407
Analytics3228.63.143
Consent provider380.70.81,220
Fingerprinting150.10.17,117
Other8920.81.4532

Within-domain cost variance

Per-request transfer-size spread within five individual tracker domains, on a log scale
Why a single per-domain cost can't work. Per-request transfer size inside five real tracker domains, log scale. Within one domain the spread runs three orders of magnitude, and the share of zero-byte requests swings from 0% on 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.

The naive solution first

Lookup-table granularity

Lookup-table ladder versus the model on test-set MAE and artifact size
The deployability frontier. Every lookup-table variant plotted on test-set MAE (x) with full-population artifact size annotated. The path LUT (red) matches the model's accuracy but weighs 187 MB; the model (blue) hits the same accuracy at 500 KB, the only point in the low-error, low-size corner. This is the case for ML over a table: not the most accurate thing possible, the most accurate thing that ships.

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.

Labels for an unobservable target

Before any model can train, there’s a prior problem: on a user’s machine the label never exists, because the blocked request was never sent. The move is to train where the quantity is observable: HTTP Archive crawls the web monthly with trackers allowed, so transfer size is recorded. I train URL → bytes there and deploy into the blocked setting. The sample is a deterministic 1% hash: 3,490,824 requests over 3,723 domains, reproducible by construction. The transfer is sound because byte size is server-determined, not browser- or block-determined, which I validated later on paired fetches. Two evaluation splits: a random train/validation/test split (every hyperparameter tuned on validation, test touched exactly once), and a forward-in-time split against crawls 1, 3, and 6 months out, so staleness gets measured instead of hidden.

The estimation system

Per-request estimation, and why the loss function carries it

Tweedie loss vs squared error: 23% MAE reduction holding architecture and features constant
The controlled experiment. One variable changed (the loss), architecture and features held constant. Squared error trails Tweedie (p=1.5) by ~23% in MAE, a gap that dwarfs the ~4% sensitivity to the Tweedie power across p=1.2 to 1.8, so p=1.5 is representative, not tuned. The loss is the dominant modeling choice.

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.)

ApproachArtifact sizeMAE (bytes)95% CISpearman ρ
Global median<1 B19,905------
Domain LUT88 KB9,008------
Domain + type LUT (deployable)114 KB6,802[6,633, 6,977]0.94
Path LUT (187 MB, not deployable)187 MB4,326[4,154, 4,516]0.93
XGBoost + Tweedie500 KB4,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.

Scatter of predicted versus actual transfer size on log-log axes, clustered on the diagonal
Calibration across five orders of magnitude. Predicted vs. actual transfer size for held-out requests, log-log. Points hug the diagonal from single bytes to megabytes, tightest in the 10 KB to 100 KB band where the high-impact tracker scripts concentrate. The model is not just low-error on average, it is calibrated across the whole range, which is what lets the per-request estimates sum into an honest weekly total.

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

Model versus path-lookup-table error on seen and unseen URL paths
Generalization vs. memorization. Test rows split by whether the URL path was seen in training. On the ~92% of rows the path LUT has memorized the answer for, the model matches it (2,264 vs 1,811 bytes) at 350x smaller size; on the ~8% of unseen-path rows it wins outright (27,224 vs 33,471). Matching memorization where the table is strongest and winning where it breaks down is the evidence the model generalizes through URL structure.

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

CDF of weekly aggregation error: model concentrates near zero, LUT has a long tail
Why per-request accuracy isn't the user-facing metric. CDF of weekly-total error for model (blue) vs. lookup table (red), simulated at N=100/200/500 requests per week. Blue sits far left: the model lands within 10% of the true weekly total far more often. The model's errors are near-zero-mean and cancel under summation; the LUT's are systematic bias and compound, so it gets worse as N grows. Low bias beats low variance once you aggregate.

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/weekModel (uniform)LUT (uniform)Model (correlated)LUT (correlated)
506.8%20.8%14.2%34.5%
1006.3%20.9%12.5%34.2%
2006.0%21.9%12.9%36.4%
5005.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

Temporal staleness: model versus LUT advantage at 1, 3, and 6 month horizons
The staleness test. Evaluated on crawls 1/3/6 months out. The per-request advantage over the lookup table holds at +33.5% / +28.9% / +25.6%; the user-facing aggregation advantage erodes gently from +13 pp to +5.7 pp but stays positive throughout. The 3-month aggregation gap is what sets the quarterly retraining cadence.

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. The C++ integration into Gecko we did in collaboration. We wrote the paper together.

The artifact, and keeping it fresh

What all of it compiles down to: a 500 KB ONNX model (XGBoost exported as a tree-ensemble regressor) plus a ~2 MB feature transform, about 5 MB resident, loaded once. Inference is 50 µs median, 93 µs at p99, single-thread CPU, measured over 10k calls, and it runs asynchronously after the block, off the security-critical path. It ships through Remote Settings, the same pipe as the Disconnect blocklist it rides next to, so a model update is a data update, not a binary release. One tradeoff worth naming: trees over a neural net. A character-CNN I trained as a controlled ablation ranked well but landed 25% worse on absolute error, and an embedding stack big enough to compete breaks the size budget.

A model you ship once isn’t the story; keeping it fresh is. The staleness study sets the cadence at quarterly, not by policy: the weekly-sum edge is +10.8 pp at three months and still positive at six. The loop as designed: fresh monthly crawl on BigQuery, a scheduled retrain, validation gates against the incumbent model (the same forward-time eval that set the cadence, so the pipeline can’t ship a regression), then an Autograph-signed Remote Settings publish behind human dual sign-off, staged by sampled targeting. This is a paved road at Mozilla: Firefox Translations already ships its neural-translation models as Remote Settings attachments. The tradeoff here is freshness against operational risk; monthly retraining buys little (the edge decays slowly) while every publish stays a reviewed, staged event.

The systems path

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.

The block decision itself is made on the privileged side: when the parent-side HTTP channel opens, the async URL classifier checks the request against the local Disconnect-derived tables and cancels it before a connection is made. The content process observes the classification result on its channel, and that is where the measurement layer hangs: on a block, the estimator reads the channel’s pre-send state (URI, content-policy type, loading principal, category) plus a precomputed domain lookup, scores it with the ONNX model, and accrues the estimate to the page’s in-memory log. Nothing from the wire, and nothing that feeds back into the block decision.

On the read side, protections.sqlite is parent-only; the New Tab is sandboxed and has to ask. The service aggregates seven days into one flat JSON object and returns it over IPC. Only counts cross the boundary; private windows get null, not zero.

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 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 systems decisions, and the alternatives each one beat.

  1. 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. Considered: content-side aggregation behind a policy check. Rejected because a policy can be mis-written; the process boundary can’t. Privilege boundary where the security model wants it, not where it’s convenient.
  2. 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. Considered: a normalized origins table (heavier migration, join-heavy reads, for a column only the top-K query reads) and per-block direct writes (disk I/O behind every block). Pay the cost on write, because the read is on the critical path and the write isn’t.
  3. 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. For the top-K query itself I considered a running heap maintained at write time, the classic streaming answer, and rejected it: the weekly candidate set is tiny, so sort-at-read costs microseconds while heap state would tax every write. Put the invariant where it can’t be forgotten.
  4. Schema migration — adding per-origin attribution meant migrating a schema already deployed in millions of profile databases. ALTER TABLE ADD COLUMN with a default is the one migration that can’t fail on a well-formed DB. Considered: rebuild-and-copy, which is rollback-capable but can fail mid-copy. It degrades gracefully because I can’t roll back a user’s machine.

The surface on top

Main article: New Tab Privacy Metrics.

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 service, schema, and flush layers are in the systems section above.

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 landed over ten weeks, in parallel, 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 is also instrumented and experiment-ready. The lifecycle records as Glean events, sliceable per variant; the pref-gated variants (three insight × three mascot) make the surface A/B-ready on Nimbus, Firefox’s experimentation platform, whose recipes ship over the same Remote Settings pipe as everything else. That closes the loop on the product problem from the top of the page: whether the surface moves privacy awareness becomes something you measure per-arm, not assume. And aggregate telemetry can go further: the New Tab content ping already ships over OHTTP with no client id, and DAP/Prio-style secret-shared aggregation (Divvi Up) means per-arm measurement without any per-user readout at all. Stateful UI plus per-arm telemetry is also the substrate a multi-armed bandit needs; personalization over which insight and which presentation is future work the architecture already permits.

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.

SmartBlock placeholder showing preserved text and links from a blocked Twitter embed
SmartBlock placeholder. Three layers of XSS defense around the extracted text and link.

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