3942 stories
·
3 followers

How we migrated lovable.dev away from Next.js and turned it into another Lovable app

1 Share

lovable.dev is a pretty busy website: we have 42M+ monthly unique visitors. It's also pretty complex: close to 400 routes, 910K+ lines of non-generated code, supports over 150 agent tools, and even has a small IDE with syntax highlighting inside. It's now hosted on Lovable, the same way as any other Lovable app.

Why we did it

Before we migrated, lovable.dev was a Next.js app hosted on Vercel. (We originally hosted it elsewhere, but started to hit issues as we scaled, like build compatibility between local & prod or poor loading times in certain geographies.) Vercel performed admirably, and our issues went away as soon as we moved to it. That said, we decided to migrate our hosting to Lovable for several reasons.

The main reason to use our own product is dogfooding. We want to feel our users' pain and we want to have the shortest possible feedback loop to keep making our product better for users.

We also want to push the frontier of single-app scaling. We were already good at hosting tens of millions of apps, where the median app is small and low-traffic. Supporting tens of millions of visitors for a single app is a different problem with its own unique challenges. Every improvement we make for ourselves automatically benefits every builder who runs their app on Lovable.

And finally, we wanted to pass any internal knowledge on making the best web apps back to our builder agent—and make every user see the benefits for their apps. With a unified tech stack it's easier than ever.

Background on how we host apps

Today Lovable builds and hosts primarily TanStack Start apps. The framework fits our needs for isomorphic execution, simplicity of deployment and type safety particularly well; see our post “Building apps using TanStack Start” for more background on reasoning and how we use the framework. Each published Lovable app is built as its own worker for Cloudflare's workerd runtime. Then a single entry worker serves every app by loading the code, creating a worker dynamically and dispatching a request to it.

Every dynamic worker lives in its own V8 isolate sandbox. An isolate is a private V8 heap that lives inside a controlling process—a cheaper alternative to using a separate process. Think containers vs. virtual machines. Isolate instantiation cost is proportional to the worker bundle size and could be 4ms at the cheap end up to 1s for a huge bundle. Isolates are cached and reused, so that loading and instantiation overhead is amortized across many requests. This is the part that makes running millions of apps economical. Isolates are typically evicted on an LRU basis or when they get over the memory limit. Eviction is not always graceful—we'll get to that later.

We migrated lovable.dev to TanStack Start to serve it the same way—now it's just one of the possible destinations among 60M+ of our users' apps. There are fewer than 200 lines of code unique to the lovable.dev serving path (mainly handling a different release metadata format and a different observability setup).

A single app loader worker handling hostname resolution, common infrastructure and access checks, dispatching to one of many app worker bundles—one of which is the lovable.dev bundle.

lovable.dev is served by the same app loader worker as every other Lovable app—it is just one more bundle among 60M+.

Migration strategy

Migrating a rapidly-developed app is a kind of race where the finish line is running away from you. I started it as a lone developer looking at 350K lines of code. By the time the migration was complete, six months later, the app had grown to over 850K lines (and we've already added 60K on top since then). People here just never stop shipping new things.

Chart of weekly migration progress from March to July 2026: migrated lines climbing from near zero to 850K while unmigrated lines fall from 375K to zero, with migration percentage reaching 100%.

Migration progress, week by week. The total kept growing under us the entire time.

The overall plan was shaped by one of my big professional regrets from before Lovable: working on a big-bang migration in parallel with the old system that was still running and switching after achieving feature parity. This time I chose a different approach: we'd rewrite it gradually while keeping everything working on both frameworks. In retrospect this proved to be the most important single choice made in the migration.

Running two frameworks in parallel

We needed to run both frameworks in parallel and dispatch requests to the right one. This way the migration would happen route by route rather than all at once.

A web-proxy worker handling framework routing including A/B rollouts and preview protection, sending migrated routes to the app worker bundle and old routes to Next.js on Vercel.

A proxy worker in front of both frameworks decides, per route and per user, which one serves the request.

One important aspect of this setup is user experience. Crossing the line between frameworks means hard navigation—the user's browser needs to load a new document and a new set of resources. Compared to that, internal (soft) navigation only loads some scripts and the data for the new route, reusing everything else. In practice hard navigation is much slower (~5s vs. ~1.5s median for live users before the migration), so we needed to keep it as infrequent as possible.

Diagram contrasting a slow initial navigation and fast internal navigations within each framework against the slow cross-framework hard navigation between Next.js and TanStack Start.

Navigations within a framework are cheap. Crossing between frameworks costs a full document load.

To achieve this I mapped all our routes onto typical user journeys and created migration groups: routes users moved between frequently were migrated together. I ended up with five major groups and a few minor ones. Each group's rollout was controlled by a feature flag to make the switch within a group gradual—during rollout a certain configurable % of visitors would be randomly assigned one framework or another.

Smoothing hard navigations

One neat trick for making the visual experience of hard navigations better is the browser's cross-document View Transitions API: a single CSS at-rule that applies a smooth cross-fade transition, replacing the default white flash.

@view-transition { navigation: auto; }

The transition only fires when both the outgoing and incoming page carry the rule. After we added it, my colleagues stopped noticing hard navigations other than by their longer duration.

Framework stickiness

One interesting problem we solved along the way was keeping each user on whichever app they first landed on. If user A gets the Next.js version of the project settings they should stay on Next.js for all routes in the settings group. Same for user B who landed on the TanStack Start version—they should stay on their framework within a group. To solve this we extracted the route registry along with feature flag metadata and used it as a single source of truth for our proxy and both frameworks. Every framework understood, through wrapped router components, which routes should use soft vs. hard navigation.

Deterministic feature flags

How do you test end-to-end when you have randomized feature flags? I built a way to override framework selection deterministically so that tests could cover both frameworks reliably and verify they both work as expected. The proxy server sees internal search parameters and assigns the feature flag(s) specified in them instead of a random value. This is a generally useful capability for any system with feature flags—you want your tests to be deterministic and not affected by the current randomized rollouts.

Sharing the code

The next goal was creating a way to share the code between Next.js and TanStack Start. The target state was 5–10% framework-specific code and 90–95% of code being framework-agnostic and shared between both. In the end we got there—right before its removal, Next.js specific code was 3% of our web codebase.

Two small boxes labelled TanStack Start and Next.js above the caption “keep this layer as small as possible”, sitting on top of one large box labelled “shared code, framework agnostic” with the caption “put ~everything here”.

The target shape: a thin framework layer over a large framework-agnostic core.

I selected a dedicated root folder for shared code and wired the #shared/ alias into both frameworks' module resolution (package.json imports, mirrored in tsconfig.json paths for the typechecker). I added lint rules to verify that shared code never imports either framework.

// web/package.json (TanStack Start)
"imports": { "#shared/*": "./shared/*" }

// app/package.json (Next.js)
"imports": { "#shared/*": "../web/shared/*" }
// works identically in either framework
import { buildUrlPath } from "#shared/lib/routes";
// oxlint.config.ts—shared code stays framework-agnostic, enforced
{
  files: ["web/shared/**/*.{ts,tsx}"],
  rules: {
    "no-restricted-imports": ["error", {
      paths: [
        { name: "next", message: "Shared code cannot depend on Next.js." },
        { name: "@tanstack/react-start", message: "Shared code cannot depend on TanStack Start." },
        { name: "@tanstack/react-router", message: "Shared code cannot depend on TanStack Router." },
      ],
      patterns: [
        { group: ["next/*"], message: "Shared code cannot depend on Next.js." },
        { group: ["node:*"], message: "Shared code cannot use Node.js APIs. Must be runtime-agnostic." },
      ],
    }],
  },
}

Describing this whole setup in AGENTS.md and iterating a few times got us to a state where any new feature was written in a portable way by default.

Preventing regressions

At some point in the middle of the migration I added another check: no new feature code outside of #shared. “Feature code” is a fuzzy concept, but using agentic automation instead of a deterministic check made it verifiable. This helped by both preventing new non-portable code from appearing and also by educating developers who were for any reason not aware of the ongoing migration. Here's an example of such a check finding that a PR is compliant:

Automated check output on a PR: status OK, feature yes, policy compliant—new feature logic is correctly placed in web/shared/lib/favicon and the only app/ touch is a two-line wiring call in the framework-bound instrumentation client.

The portability check explains its verdict per file, so authors learn the rule at the moment it matters.

Adapters and compatibility layer

Once you have shared code, how do you handle the situation when some deeply nested component needs to import next/link to show a navigation button? I'd rather not keep each such component in the framework-specific part of the code. I briefly considered doing some dependency injection via a top-level Provider, but that meant replacing imports with reading from a context—a big change that can also have runtime overhead if you're not careful.

What I ended up implementing is a sort of interface-based dependency injection. Shared code declares a common denominator interface, and each framework implements it. Then, thanks to TypeScript import aliases, you just import the thing you need instead of interacting with the dependency injection mechanism explicitly.

// app/tsconfig.json:  "@platform/router": ["./lib/router/next-adapter.ts"]
// web/tsconfig.json:  "@platform/router": ["./lib/router/tanstack-adapter.ts"]

// next-adapter.ts
export { usePathname } from "next/navigation";

// tanstack-adapter.ts
export function usePathname(): string {
  return useLocation({ select: (loc) => loc.pathname });
}

// shared code, works under either framework
import { usePathname } from "@platform/router";

Platform adapters were the last missing piece to unlock moving 90+% of the code to the shared section.

Trimming to the core

One way to keep the framework-dependent part small is to use fewer framework APIs in the first place. They would need to be replaced with something else in the end anyway—so why not start early and swap some framework-specific APIs with something framework-independent? For us that was next/font and next/image, plus our authentication and i18n solutions, each of which was built on a third-party library that relied on Next.js.

Doing those early migrations while still running Next.js reduced overall risk and gave us some quick wins. For example, the new in-house auth library improved stability and brought a 10x+ reduction in the number of Firebase Auth calls. And a new i18n solution improved both runtime performance (~3x lower CPU cost of i18n initialization at page load) and compile-time safety (a misspelled translation key became a typecheck error).

Now that we depended on a smaller slice of the framework, the migration itself could start.

Three stages: a Next.js box listing core, next/router, next/fonts, next/image and third-party libs; then the same box reduced to core and next/router with portable replacements beside it; then Next.js and TanStack Start side by side over the same portable replacements.

Replacing framework APIs with portable equivalents first shrank the surface the migration had to cross.

AI-assisted code move

When I planned the migration, the original strategy was “let's build all the tools then distribute the bulk of the work across the teams who own and maintain product features”. It didn't survive contact with reality—in a good way. First it became “agents draft the PRs, owners review and test”. Then the drafts turned out to be good enough that I reviewed and landed them myself, batch after batch. In the end, no team ever received their share of the migration.

A few techniques naturally evolved as migration work went forward.

The first was a set of skills and agent knowledge that supported asking “Move component X to shared web code” and getting a production-ready PR back. When you expect to repeat some kind of work tens or hundreds of times, it pays off to extract the common reusable parts of your prompts—the same way you extract reusable code instead of copy-pasting it. This also greatly simplifies the human handoff. The first few times when I said to a colleague “ask your agent to migrate this component—it already knows how” and it actually worked, it felt like magic. You can save so much time on coordinating a big team effort when all you need to communicate to humans is a high-level overview and their agents can fill in required details as needed. In a few months I replaced the underlying web framework while the ever-growing development team was busy doing their work and people barely noticed—this felt amazing!

The second useful improvement was raising the level of abstraction for agentic planning. I started at the bottom of the ladder: low-level goals set and tracked by me, planning and implementation done by the agents. For example, when making sure we can show a project list fully in TanStack Start, I identified 21 React context providers that all needed to be migrated to shared code. Each provider was tracked as its own Linear ticket. Somewhere in the middle of migrating those providers I noticed how few interesting decisions each one actually needed. And all the boring stuff should go to AI as soon as I could figure out a good enough way to delegate it. The delegation ended up looking like a /goal loop applied one level up, to planning rather than implementation:

  1. Define a measurable migration metric (e.g. “How many agent tools still have a Next.js UI”) and write a script to measure it deterministically.
  2. Feed that to a planner agent to identify large enough batches of migration work: “identify coherent subset of the migration from the above that can be implemented as a PR stack, 12 PRs max, 800..1600 lines/PR preferred size”. Those batches are then handed off to implementation agents. I wrote more about the overall process in a post about scaling agent coding.
  3. Keep going until the metric reaches zero or there's a hard roadblock. Identify the next metric to bring you closer to the complete migration and repeat.

A third useful finding was migration-specific review automation that identified non-portable patterns in new code and told authors how things should be refactored instead. Every merged PR was analyzed by the agent to make sure it met compatibility requirements. Small fixes to existing features were exempt to reduce friction. Every new feature or big change that wasn't compatible triggered an alert to the author. The fix, as usual, was “tell your agent to make it compatible, it already knows how”. This way everyone knew just enough about making web code compatible and learned it at the right time. Thanks to this automation, we ended up having unusually few “oops, this has never been ported” surprises at the end of the migration.

So, in the end agents did a good enough job of migrating the code and verifying correctness. Agent-driven exploratory checks, smoke tests I ran myself and the staged rollout gave me enough confidence that I never needed to involve code owners at all. What would've been a multi-team coordination effort two years ago was done by a single developer and a pack of agents. The results of the migration usually passed smoke testing with a few small fix iterations. And things looked good enough to start serving real users with the TanStack Start version of lovable.dev.

Switching the traffic

Traffic was switched one route group at a time. We went through a multi-stage A/B rollout for every route group: limited internal testing → company-wide internal testing → 1% of users and then gradually all the way to 100%. For a big integrated system like ours it's effectively impossible to predict all its behavior analytically, so the main quality controls you have are a good feedback loop, the ability to roll back fast, and the ability to fix things quickly. For the first few route groups, internal testing took up to a week as we found and fixed all the post-migration issues. The duration was similar for external users—we had fewer issues, but we took more time to make sure we got proper signal from a small % rollout before moving further.

Having multiple ongoing rollouts in parallel can be confusing, so I made sure we only had one external rollout climbing from 1% to 100% at a time; the next one waited in the queue even if internal results showed it was ready to start. There was always some parallel work migrating further parts of the system, and therefore no rush to roll out. The last few groups took about a week end-to-end, and were boring in a good sense.

Staggered rollout timeline: each route group goes through an internal rollout then a 1% to 100% external rollout, taking one to three weeks per group and two months in total.

One external rollout climbing at a time, staggered across route groups—two months end to end.

When you're watching rollout logs, the most obvious failure modes are crashes and functional bugs. Less obvious but at least equally important is system performance. We monitored server response time, server error rates and web vitals, and tracked how frameworks compared with each other on each. We managed to identify and fix most of the performance issues before they affected a large share of users. Not all, though—and if I were doing it again I'd spend even more time on monitoring performance and be more aggressive about rolling back changes that look slow in the metrics.

One takeaway on performance optimization: you want to look at both synthetic metrics and the ones from real users. Synthetic metrics are less noisy and easy to integrate into an agentic loop, so optimizations are easier to make. And real user metrics are your ultimate target: they move slowly and are harder to influence directly, but they reflect the real state of the system for the users.

All in all the rollout took about two months, and most of it happened while still migrating code (only the last 2–3 weeks were solely about rollout with no migration work happening anymore). There's definitely a risk-vs-time tradeoff here, and I'm pretty happy with the relatively conservative approach we took on rollout. For the most part. In one case things went wrong spectacularly.

Out-of-memory incident

I had just finished the rollout of the biggest route group—it went smoothly, and I got more optimistic than I should have. The next route group was the dashboard: less code, less risk, but it sat on every authenticated user's journey. Internal rollout was uneventful, and I started slowly increasing the percentage in the public rollout. 1%. 5%. On Friday before lunch I set it to 20%. And in the afternoon complaints about performance started to arrive. And then our error rate went up—gradually, then suddenly. It moved from 0.1% to 0.5% at first. Then it went to around 50% in just a few minutes, and all the alerts we had went off.

The incident lasted 11 minutes. As is often the case, rolling back the commit that broke everything was much faster and easier than understanding why it had happened in the first place. Turns out we were living too close to the edge: in our case, the memory limit of our runtime environment. Migrating some static data JSON for the public website (a completely unrelated feature, hidden behind a flag that was turned off) pushed us over the limit. The few added MB became the last straw. The dashboard rollout increase was not the root cause, it just exposed more people to the problem at the worst possible time.

Why did it break? Remember, V8 isolates are supposed to be reused many, many times—ours were serving fewer than 10 requests on average before being killed for going over the memory limit. For comparison, our current baseline is 500–10K requests per isolate, mostly influenced by deployment frequency. And when an isolate is killed, every request it's currently processing errors out.

What did I do wrong here? Did not measure initial memory use. Ignored early warnings and accepted a 0.1% error rate as something we could figure out later, when the migration was over. In other words, too much optimism and too little looking at the actual data.

Once it was clear exactly what went wrong, the long-term fix looked like applying more AI to the problem. “Here's how to measure memory usage in server requests. Give me 10 ideas on how to reduce it. Prototype and measure. Apply each good one as a PR”. All you need is time, tokens and engineering taste to see which ideas are promising. And lately we're seeing zero out-of-memory errors on a median day.

Some examples of memory improvements that helped us there:

  • Don't parse multi-megabyte JSONs with static content at module level—those objects stay in server memory forever. Importing as raw strings and parsing in request handlers cut memory consumption between 2x and 12x depending on specific route.
  • Excluding unused fields from list APIs saved us a few MBs on templates—we have hundreds of them and catalog pages never needed full descriptions.
  • Replacing client-only code with empty stubs in the server bundle. Some parts of lovable.dev are essentially an IDE in the browser and most of that code wasn't needed server side. Excluding the TypeScript compiler, Prettier, the syntax highlighter and similar components saved us around 9MB in server bundle size, which translated to 18MB of worker memory. Bundle bytes count roughly double in memory: V8 uses a two-byte format for the whole string if it contains even one character outside of Latin-1. The whole bundle is loaded as a single string and our locale data guarantees that string has international characters in it. When your total limit is 128MB things like this start to count.

How TanStack Start compared to Next.js

Some personal reflections after using Next.js for many years and then migrating my largest project ever away from it.

Better local developer experience

TanStack Start uses Vite dev server, which starts faster and consumes several times less RAM compared to Next.js. On our codebase we're talking about the difference between 10s start / 1.5GB RAM on TanStack and 70s start / 8GB RAM for Next.js (numbers from my Macbook M4 Max, no other major workloads during measurement). And I've seen some frustrated colleagues reporting 20GB+ RAM from Next.js—not exactly what you want to see when you're trying to debug some backend service and don't need the local frontend all that much.

You never have enough RAM, even on the latest hardware, so low memory use is a breath of fresh air. And start time matters more when you switch between branches many times per hour.

That was on Next.js v16.2 with Turbopack enabled; the new v16.3 claims up to 90% reduction in memory usage for dev server.

Next.js abstractions still confuse me

Next.js was the first popular framework to tackle all the complex aspects of server/client isomorphism. No wonder it had to go through a few iterations before arriving at the current state. I've seen many developers, myself included, being confused by server components, server actions, layout vs page separation. Maybe it's a skill issue, but it's genuinely annoying.

TanStack Start builds on accumulated industry experience here and introduces fewer abstractions, which I find easier to understand. I don't see similar confusion with TanStack's server functions, route loaders and nested routes—they feel more intuitive.

My agents perform better with TanStack Start

I remember seeing obsolete and misguided advice from agents when dealing with tricky Next.js issues (trying to use page router API in app router, assuming caching defaults from v14 still apply in v16, etc.). Often the culprit was outdated internet knowledge, or the agent lost track of what the right solution looks like for the specific Next.js version we were running at the time. The fact that Next.js v12, v14 and v16 are so different from each other does not make it easy for the agents—many training sources aren't explicit about framework version, pages vs. app router, etc.

I feel that TanStack Start, being new, does not have this problem. My agents tend to get it right the first time; the mistakes I see aren't related to misunderstanding the framework. My read: for agents, a small but consistent training corpus beats a large one full of internal contradictions. An agent can fill a knowledge gap by reading the docs and the surrounding code, but it can't easily unlearn a confidently wrong habit.

For a large TanStack Start app you may need a lot of bundler configuration

Optimal code bundling feels like a mostly solved problem in Next.js. My production builds are usually efficient out of the box. The main optimization levers I was using were removing dependencies and adding bundle split points via next/dynamic.

TanStack Start, on the other hand, required quite a bit of low-level configuration to get good results. Agents understand this type of optimization well, but you still need to recognize the need and then set them to work. Our current build configuration for lovable.dev has 17 custom build plugins (custom code splitting, support of multiple custom development environments, assets pipeline, etc.). Your mileage may vary but don't expect to need zero configuration for an application of our scale.

What we got out of it

The main reason and the main payoff of the migration is the dogfooding. But we got a few other things on the side.

Performance

For our users, most web vitals are at parity. We got faster response times in general: -49% in median TTFB. But a slower long tail: we started with p90 up to 2x slower, and it took some work to bring it back (now -16% vs. the original). Client-side metrics did not change much, but I feel they are easier to optimize now.

For ourselves, we got faster build times both for local development and CI/CD. Our website's production build was often the slowest CI check—12+ minutes wasn't uncommon. Now it takes 6–9 minutes, and we haven't even done a single optimization pass yet: I found two more minutes to cut just while drafting this section. When your company deploys hundreds of times per day, every minute matters.

AI-assisted tooling

Handling that much code single-handedly forced me to push the envelope on the scale of AI coding. My colleagues and I still use the skills and tools I built for the migration in our day-to-day work.

Self-editing

One other nice consequence of using the same stack for lovable.dev and our users' apps is that it makes self-editing easier to support. We can now use Lovable to edit lovable.dev and preview changes in real time. This has become our main self-service editing flow for non-technical employees at Lovable—everyone is a builder and everyone can make Lovable itself better. Developers also use self-editing daily, but that story deserves its own post later.

What's next

Lovable has an opinionated tech stack—TanStack Start web applications. But our agents and infrastructure can already support a much wider spectrum. For development, we can run anything in our sandbox VMs. We want to use this capability to let anyone import and edit any existing software. So, don't be surprised if we let you import Next.js apps in the not-so-distant future. Ironic, considering we just migrated away from it—but ultimately we want to enable Lovable to help you edit any software.

Read the whole story
emrox
2 hours ago
reply
Hamburg, Germany
Share this story
Delete

Browser De-Slop

1 Share

Modern browsers ship with an insane amount of bloatware.

You can point and click in the browser settings to disable most of it. Or, you can de-slop the browser by force using a policy file.

To get started, create a JSON file at the appropriate path for your distribution. Here’s a few that I’m aware of:

Allegedly, these policies are also supported on Windows via Group Policy and MacOS using plist files. I wouldn’t know, since I use a real operating system.

You should read the docs for each option to understand what it does. My configuration here does a few things:

{
  "policies": {
    "ExtensionSettings": {
      "uBlock0@raymondhill.net": {
        "install_url": "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi",
        "installation_mode": "force_installed"
      }
    },
    "3rdparty": {
      "Extensions": {
        "uBlock0@raymondhill.net": {
          "toOverwrite": {
            "filterLists": [
              "user-filters",
              "ublock-filters",
              "ublock-badware",
              "ublock-privacy",
              "ublock-quick-fixes",
              "ublock-unbreak",
              "easylist",
              "easyprivacy",
              "adguard-spyware-url",
              "urlhaus-1",
              "plowe-0",
              "fanboy-cookiemonster",
              "ublock-cookies-easylist",
              "fanboy-thirdparty_social",
              "ublock-annoyances"
            ]
          },
          "toAdd": {
            "trustedSiteDirectives": [
              "intranet.example.com"
            ]
          }
        }
      }
    },
    "UserMessaging": {
      "WhatsNew": false,
      "ExtensionRecommendations": false,
      "UrlbarInterventions": false,
      "SkipOnboarding": true
    },
    "OverridePostUpdatePage": "",
    "OverrideFirstRunPage": "",
    "EnableTrackingProtection": {
      "Value": false,
      "Cryptomining": false,
      "Fingerprinting": false,
      "Locked": false
    },
    "Cookies": {
      "Behavior": "reject-tracker-and-partition-foreign",
      "BehaviorPrivateBrowsing": "reject-tracker-and-partition-foreign"
    },
    "NoDefaultBookmarks": true,
    "DisablePocket": true,
    "DisableAppUpdate": true,
    "CaptivePortal": false,
    "Certificates": {
      "Install": [
        "/usr/local/share/certs/trusted/your-custom-root-ca.crt"
      ]
    },
    "DisableFeedbackCommands": true,
    "DisableFirefoxAccounts": true,
    "DisableFirefoxStudies": true,
    "DisableTelemetry": true,
    "DontCheckDefaultBrowser": true,
    "OfferToSaveLoginsDefault": false,
    "DNSOverHTTPS": {
      "Enabled": false
    },
    "SearchSuggestEnabled": false,
    "Homepage": {
      "URL": "about:home",
      "StartPage": "homepage"
    },
    "SearchEngines": {
      "Add": [
        {
          "Name": "ddg",
          "URLTemplate": "https://duckduckgo.com/?q={searchTerms}",
          "Method": "GET",
          "IconURL": "https://duckduckgo.com/favicon.ico",
          "Alias": "ddg",
          "Description": "DuckDuckGo",
          "SuggestURLTemplate": "https://duckduckgo.com/ac/?q={searchTerms}&type=list"
        }
      ],
      "Default": "ddg"
    },
    "FirefoxHome": {
      "Search": true,
      "TopSites": false,
      "SponsoredTopSites": false,
      "Highlights": false,
      "Pocket": false,
      "SponsoredPocket": false,
      "Snippets": false
    },
    "AIControls": {
      "Default": {
        "Value": "blocked",
        "Locked": true
      }
    },
    "ExtensionUpdate": true,
    "Preferences": {
      "dom.security.https_only_mode": {
        "Value": true,
        "Status": "locked"
      },
      "dom.push.connection.enabled": {
        "Value": false,
        "Status": "default"
      },
      "browser.urlbar.suggest.quicksuggest.nonsponsored": {
        "Value": false,
        "Status": "locked"
      },
      "browser.urlbar.suggest.quicksuggest.sponsored": {
        "Value": false,
        "Status": "locked"
      },
      "browser.toolbars.bookmarks.visibility": {
        "Value": "newtab",
        "Status": "default"
      },
      "browser.safebrowsing.malware.enabled": {
        "Value": false,
        "Status": "locked"
      },
      "browser.safebrowsing.phishing.enabled": {
        "Value": false,
        "Status": "locked"
      },
      "browser.safebrowsing.downloads.enabled": {
        "Value": false,
        "Status": "locked"
      },
      "browser.newtabpage.activity-stream.feeds.section.topstories": {
        "Value": false,
        "Status": "locked"
      },
      "browser.newtabpage.activity-stream.showSponsoredCheckboxes": {
        "Value": false,
        "Status": "locked"
      },
      "browser.newtabpage.activity-stream.widgets.system.weather.enabled": {
        "Value": false,
        "Status": "default"
      },
      "browser.urlbar.suggest.quicksuggest.all": {
        "Value": false,
        "Status": "locked"
      },
      "browser.tabs.groups.smart.userEnabled": {
        "Value": false,
        "Status": "default"
      },
      "signon.management.page.breach-alerts.enabled": {
        "Value": false,
        "Status": "locked"
      },
      "privacy.fingerprintingProtection.pbmode": {
        "Value": false,
        "Status": "default"
      },
      "signon.firefoxRelay.feature": {
        "Value": "disabled",
        "Status": "locked"
      }
    }
  }
}

You should read the docs for these settings to make sure they’re right for you. My configuration here does a few things:

Unfortunately, Chrome does not support adding custom certificate authorities via the policy file anymore (I resorted to some hacky automation that adds the certificate to the user’s ~/.pki/nssdb on first login).

{
  "AdvancedProtectionAllowed": false,
  "AlternateErrorPagesEnabled": false,
  "AutofillCreditCardEnabled": false,
  "BackgroundModeEnabled": false,
  "BlockThirdPartyCookies": true,
  "BrowserGuestModeEnabled": false,
  "BrowserLabsEnabled": false,
  "BrowserNetworkTimeQueriesEnabled": false,
  "BrowserSignin": 0,
  "CloudPrintProxyEnabled": false,
  "CloudReportingEnabled": false,
  "DefaultBrowserSettingEnabled": false,
  "DefaultCookiesSetting": 1,
  "DnsOverHttpsMode": "off",
  "EnableAuthNegotiatePort": true,
  "EnableMediaRouter": false,
  "MetricsReportingEnabled": false,
  "NetworkPredictionOptions": 2,
  "PasswordManagerEnabled": false,
  "PaymentMethodQueryEnabled": false,
  "PrivacySandboxAdMeasurementEnabled": false,
  "PrivacySandboxAdTopicsEnabled": false,
  "PrivacySandboxPromptEnabled": false,
  "PrivacySandboxSiteEnabledAdsEnabled": false,
  "PromotionalTabsEnabled": false,
  "SafeBrowsingProtectionLevel": 0,
  "SearchSuggestEnabled": false,
  "SyncDisabled": true,
  "TranslateEnabled": false,
  "UrlKeyedAnonymizedDataCollectionEnabled": false,
  "DefaultSearchProviderEnabled": true,
  "DefaultSearchProviderName": "DuckDuckGo",
  "DefaultSearchProviderImageURL": "https://duckduckgo.com/favicon.ico",
  "DefaultSearchProviderEncodings": ["UTF-8"],
  "DefaultSearchProviderSearchURL": "https://duckduckgo.com/?q={searchTerms}",
  "DefaultSearchProviderSuggestURL": "https://duckduckgo.com/ac/?q={searchTerms}&type=list",
  "DefaultSearchProviderNewTabURL": "https://duckduckgo.com/chrome_newtab",
  "ExtensionSettings": {
    "ddkjiahejlhfcafbddmgiahcphecmpfh": {
      "installation_mode": "force_installed",
      "update_url": "https://clients2.google.com/service/update2/crx"
    }
  },
  "3rdparty": {
    "extensions": {
      "ddkjiahejlhfcafbddmgiahcphecmpfh": {
        "disableFirstRunPage": true,
        "defaultFiltering": "complete",
        "noFiltering": [
          "intranet.example.com"
        ],
        "rulesets": [
          "+default",
          "+annoyances-cookies",
          "+annoyances-notifications",
          "+annoyances-others",
          "+annoyances-overlays",
          "+annoyances-social",
          "+annoyances-widgets",
          "+adguard-spyware-url"
        ]
      }
    }
  }
}
Read the whole story
emrox
1 day ago
reply
Hamburg, Germany
Share this story
Delete

AliExpress webpage keeping multipoint Bluetooth headphones active with WebAudio fingerprinting

1 Share

Recently I ran into a strange problem with my Bluetooth headphones. They support multipoint Bluetooth audio, so they can be connected to my PC and phone at the same time. Normally the PC takes priority playing audio, with my phone being able to play audio when nothing is playing on the PC.

Usually I listen to music on my phone but with notifications or youtrube playing through the PC, this works reliably until I open an AliExpress page in Firefox or Chrome (other browsers untested).

Shortly after loading the AliExpress homepage, audio from my phone would stop playing. Closing the AliExpress tab fixes it immediately. Muting the tab/firefox/Windows does not help, and there is no visible video, music, or other media playing on the page. 

This seemed suspicious enough to investigate.

Looking for hidden media

My first thought was an autoplaying product video or advertisement, so I checked for the usual suspects:

  • <audio> and <video> elements
  • calls to HTMLMediaElement.play()
  • active Media Session metadata
  • media requests
  • embedded frames containing media

None of these showed anything useful. There were no audio or video elements, no media playback calls, and navigator.mediaSession.playbackState remained none.

A clue was that the problem did not begin immediately. It appeared after the page had been sitting idle for several seconds. I instrumented the page before loading it and watched the Web Audio API instead of only looking for conventional media elements.

The basic idea was to wrap the AudioContext constructor and record whenever a page created an audio-processing context:

const OriginalAudioContext = window.AudioContext;

window.AudioContext = class extends OriginalAudioContext {

constructor(...args) {

        super(...args);

        console.log("AudioContext created", {

            state: this.state,

            stack: new Error().stack

        });

    }

};

I also wrapped AudioNode.prototype.connect() so I could see whether anything was connected to the context's audio destination.

That finally found it, two hidden audio contexts!

During an idle capture of the AliExpress homepage, the page created two AudioContext objects. Both entered the running state and both connected nodes to AudioContext.destination.

At the same time there were still:

  • zero <audio> or <video> elements
  • zero media play() calls
  • no active Media Session
  • no audible sound

The constructor stack traces pointed to two scripts:

<a href="https://assets.aliexpress-media.com/g/AWSC/uab/1.140.0/collina.js" rel="nofollow">https://assets.aliexpress-media.com/g/AWSC/uab/1.140.0/collina.js</a>

<a href="https://assets.aliexpress-media.com/g/AWSC/fireyejs/1.231.67/fireyejs.js" rel="nofollow">https://assets.aliexpress-media.com/g/AWSC/fireyejs/1.231.67/fireyejs.js</a>

The first context was created by collina.js, while the second came from fireyejs.js. Both sit under an AWSC directory and appear to be part of Alibaba's browser security and anti-abuse tooling.

The scripts are extremely obfuscated, but enough names and operations survive for AI to work out what the audio code is doing.

What the audio code does

Both scripts build a WebAudio graph resembling this:

Sawtooth oscillator

    -> AnalyserNode

    -> ScriptProcessorNode

    -> GainNode set to zero

    -> AudioContext.destination

The oscillator generates a known waveform. The analyser measures the result after it has passed through the browser's audio implementation, and the script reads frequency data from it.

The gain is set to zero, so the user should not hear anything. However, the graph is still connected to the system audio destination. Connecting it to the destination causes the browser to actively process the graph, even though the final volume is zero.

This is very different from an autoplaying video. There is no media element for the browser's normal tab mute control to stop. As far as the page is concerned, it is performing live audio processing.

In my case, that appears to have been enough for Firefox or Windows to keep the Bluetooth audio path active, preventing my multipoint headphones from switching cleanly back to the phone.

This looks like fingerprinting

The WebAudio test is not the only measurement in these scripts. Inspection of the bundles found code that queries or measures:

  • canvas rendering and toDataURL()
  • WebGL renderer information, extensions, and shader precision
  • audio oscillator and analyser output
  • screen and viewport dimensions
  • device pixel ratio
  • hardware concurrency and device memory
  • installed browser plugins
  • supported audio and video formats
  • WebRTC behaviour
  • browser performance timing
  • mouse, touch, focus, and scroll events
  • device motion and orientation
  • properties commonly associated with browser automation

There is also code for serialising and encrypting results, making requests to Alibaba telemetry services, and sending data with fetch() or sendBeacon().

This is a fairly comprehensive browser and device fingerprint.

Audio fingerprinting works because small differences in browser versions, operating systems, audio libraries, and hardware can produce slightly different results from the same generated signal. It is not necessarily enough to uniquely identify a device by itself, but it becomes much more useful when combined with canvas, WebGL, hardware, timing, and interaction data.

I cannot see what AliExpress does with the resulting data after it reaches their servers. It may be used as a persistent device identifier, but it could also be one input into a fraud or bot-detection score. 

Why AliExpress would want this

AliExpress has plenty of reasons to distinguish normal shoppers from automated or suspicious clients as well as tracking users browsing habits. The site has to deal with account takeovers, fake accounts, scraping, automated purchasing, payment fraud, review manipulation, and abuse of coupons or new-customer promotions. They also like most large businesses make use of large datasets of user behaviour to better market products and services.

Cookies are not especially reliable for this purpose because they can be cleared, copied, or replaced. A fingerprint made from many independent browser measurements is harder to manipulate consistently.

Interaction data can also help determine whether a browser is controlled by a person or automation. From AliExpress's perspective, this could reduce fraud and allow trusted customers through without showing a CAPTCHA every few pages. (Not that Aliexpress shies away from their AI generated CAPTCHAs)

Personally I do not want a shopping homepage silently exercising my graphics, audio, WebRTC, hardware, and motion APIs, etc, to track my behaviours, especially if it has such an annoying effect as blocking my music. Perhaps if AliExpress wasn't blocking my music I never would've looked into what the site was doing.

Blocking it with uBlock Origin

I tested blocking the two identified script families. With both requests blocked, the AliExpress homepage continued to render and no AudioContext objects or destination connections appeared during the control capture.

In Firefox, I use the official uBlock Origin extension by Raymond Hill. To block the scripts open the uBlock dashboard, select My filters, and add:

! AliExpress AWSC fingerprinting scripts

||assets.aliexpress-media.com/g/AWSC/uab/*/collina.js$script,domain=aliexpress.com

||assets.aliexpress-media.com/g/AWSC/fireyejs/*/fireyejs.js$script,domain=aliexpress.com

Click Apply changes, close any existing AliExpress tabs, and open the site again. Existing tabs need to be closed because blocking a script does not shut down an audio context that it has already created.

These rules are deliberately narrow. They block only the two observed script families and only when requested by AliExpress. I would not be surprised if this stops working in the future, I'll cross that bridge when it comes to it.

Because these scripts appear to be connected with anti-fraud systems, blocking them may cause extra CAPTCHAs or problems during login or checkout. So far the homepage and ordinary product browsing still work, but I would temporarily disable the rules if AliExpress refuses a legitimate login or payment.

Why I am blocking it

The anti-fraud use case is understandable, but this implementation has several problems.

It runs on the general shopping homepage before I perform a sensitive action. It collects a broad set of device and behavioural measurements, the implementation is deliberately difficult to inspect, and there is no visible indication that the page has started a live audio-processing graph.

It also produced a very real hardware side effect. A silent fingerprinting test was able to interfere with Bluetooth multipoint switching, while the browser's mute control did nothing.

If a hidden analytics or security feature can take ownership of an audio path strongly enough to change how external hardware behaves, blocking it seems like a reasonable trade-off.

I also cannot prove how long AliExpress stores the fingerprint or whether it is used across other Alibaba properties. The client code proves that extensive fingerprint-like measurements are collected and transmitted, but server-side retention and identity linkage are not visible from the browser. Can you really trust anyone to have your best interests at heart?

TL;DR

The AliExpress homepage silently creates two running WebAudio graphs from heavily obfuscated Alibaba security scripts. The graphs generate and analyse a waveform as part of a much larger browser fingerprint, then connect through a zero-gain node to the system audio destination preventing the user from hearing anything.

On my setup, this appears to keep the PC's Bluetooth audio path active and prevents multipoint headphones from switching back to a phone. Muting the tab does not fix it because there is no conventional media element to mute.

Blocking collina.js and fireyejs.js with the two uBlock Origin rules above prevented the hidden audio contexts from being created and means I can happily listen to my music without being interrupted while browsing Aliexpress.

Read the whole story
emrox
1 day ago
reply
Hamburg, Germany
Share this story
Delete

The Future of CSS: Target Multiple Classes with the Class Prefix Selector

1 Share

To target multiple classes that share the same prefix, you’d typically have to resort to brittle attribute selectors or add extra base classes to your markup. To make things easier, CSS is getting a new selector: The Class Prefix Selector (.prefix-*).

~

⚠️ This post is about an upcoming CSS feature. You can’t use it … yet.

This feature is hot off the press — it was resolved on only two weeks ago — and currently only exists in spec text. The spec will most likely see some changes before this is ready for a browser to implement.

~

The Problem: Targeting Multiple Prefixed Classes

When coming up with classnames for use in the class attribute, a common practice is to use a prefix to retain some grouping or hierarchy. You might be familiar with classes like .btn-primary, .btn-secondary, .btn-danger, and so on.

To apply a base style to all of these buttons today, you typically have to list them all out, or introduce a separate .btn base class:

/* Adding a base class */
.btn {
  padding: 0.5rem 1rem;
  border-radius: 4px;
}

/* Or listing everything... yuck! */
.btn-primary,
.btn-secondary,
.btn-danger {
  padding: 0.5rem 1rem;
  border-radius: 4px;
}

Some of you even resort to substring-matching attribute selectors, but those can be notoriously brittle and ugly when dealing with multiple classes on a single element:

/* Works, but can be error-prone with whitespace */
[class^="btn-"],
[class*=" btn-"] {
  padding: 0.5rem 1rem;
}

~

The Solution: The Class Prefix Selector

Just two weeks ago, at the CSS Working Group F2F meeting in Berlin (August 2026), we resolved to add a dedicated Class Prefix Selector to the CSS Selectors Level 5 specification. The idea was originally pitched by Lea Verou back in 2024 (w3c/csswg-drafts/#10001).

The syntax is incredibly straightforward:

.btn-* {
  padding: 0.5rem 1rem;
  border-radius: 4px;
}

That’s it! The -* part at the end makes the selector a Class Prefix Selector and will try to match any class that begins with that hyphen-separated prefix.

It’s a huge win for utility classes and design systems, allowing you to easily target groups of related elements without having to bloat your HTML payload or write fragile attribute selectors.

~

What about the empty string?

An interesting question that popped up during the discussions is whether .foo-* should match the empty string (w3c/csswg-drafts/#14291), meaning: should .foo-* also match an element that merely has the .foo- class?

While the exact default behavior is still being ironed out, currently the selector is specified to only match classes that start with the prefix and that have at least one character beyond the prefix (and the first such character beyond the prefix is not also a hyphen)

So no, class="foo-" would NOT be matched by .foo-*, which I think is fine. That same selector also would not match class="foo--", which is also probably fine.

~

What about non-dashes?

The Class Prefix Selector is currently limited to hyphen-separated prefixes, at least at first. Other separators, like _, might be added as possibilities in the future as we receive request from authors like yourself about what would be needed.

One thing that is already quite clear right now, is that there must at least be some separator. Arbitrary prefixes (like .foo*) are not going to be allowed for at least two reasons:

  1. You could accidentally overselect: .foo* would also match .footer
  2. Selector Performance: Browsers typically create buckets for class selectors for quick selector matching. Adding arbitrary wildcards defeat that optimization.

Similarly, wildcards in the middle of a selector (such as .card-*-primary) are also not going to be allowed.


# Browser Support

💡 Although this post was originally published in August 2026, the list below is constantly being updated. Last update: August 20, 2026.

Since this was literally just resolved at the CSSWG F2F in Berlin two weeks ago, browser support is currently non-existent. To follow along with the progress – if any – you can follow these browser issues:

Chromium (Blink)

❌ No Support

Subscribe to CrBug #543356377 to follow along.

Firefox (Gecko)

❌ No Support

There is no bug tracking this yet.

Safari (WebKit)

❌ No Support

There is no bug tracking this yet.

This feature is still in its early days and needs to be fleshed out further, so could be that it takes a few more years before you can use it in production …


# Feature Detection

You can feature detect support with a regular @supports rule:

@supports selector(.foo-*) {
  /* Browser has support */
}

The following CodePen uses this and will light green when you browser supports it:

See the Pen
CSS Class Prefix Selector Support test
by Bramus (@bramus)
on CodePen.


Spread the word

Feel free to reshare one of the following posts on social media to help spread the word:

~

🔥 Like what you see? Want to stay in the loop? Here's how:

I can also be found on 𝕏 Twitter and 🐘 Mastodon but only post there sporadically.

Read the whole story
emrox
2 days ago
reply
Hamburg, Germany
Share this story
Delete

The Future of CSS: Target Multiple Classes with the Class Prefix Selector

1 Share
.btn-* { padding: 0.5rem 1rem; border-radius: 4px; }
Read the whole story
emrox
2 days ago
reply
Hamburg, Germany
Share this story
Delete

Bun 1.4 Rust rewrite is not looking good

1 Share

I care about Bun. I have been rooting for it since the initial release in 2022. I switched all my development from Node to Bun. I used it in the development of the Nue framework and now with my new project Hertta.

The last three months have not looked good for Bun. It started as one of the most impressive individual engineering projects I have seen, but has now turned into this weird AI-powered creature with continuous false promises and an increasingly frustrated community.

In the next version of Bun

In the next version of Bun used to be a positive tweet to watch for. For years it meant a feature had been implemented, tested, and would ship in a few days. This changed after the Rust rewrite. Now the posts are false promises about the upcoming release:

It’s now three months and counting since the last stable release, the longest gap in Bun’s history since 2022. Nothing unusual there. Software slips, that’s normal. It’s just that an account which used to communicate with real dates and real numbers has switched to vibing. And the user reaction is what you’d expect after constant false promises:

@jarredsumner okay I’m editing blog post it’s mostly done if I say a date you won’t believe me but let’s say tomorrow

We totally believe in you, Jarred

Rejoice fellas, tomorrow in Jarred Standard Time zone means we have a new blog coming next week.

You won’t care, but personally I am switching to go now. It’s not even funny, you are just stringing your users along again and again.

How can we believe you? You always make promises that you can’t keep, tomorrow, next week, Monday...

If you need 2 months to release it you can just say that instead of saying you’ll ‘release it tomorrow’ every week

Bun on GitHub

The Bun 1.4 rewrite is a big bet on AI. In the past month, 15.8k commits came from robobun, 1.6k commits from autofix-ci[bot], and 790 commits from Jarred.

6 months ago, most of Bun’s PRs came from people prompting Claude. Nowadays, most of Bun’s PRs come from Claude prompting Claude.

The project has over 5k open pull requests, which is the largest number of pull requests I’ve seen. For comparison, OpenClaw has 2.2k, and React has 441. GitHub recommends staying under 1,000 open PRs against a single branch before mergeability checks start timing out.

The biggest worry is, of course, the code itself. In the early days Jarred’s work was inspirational. I thought he was a true Zig talent, until I read Zig creator Andrew Kelley’s thoughts on the Bun rewrite:

We became increasingly horrified at the programming practices we saw in Bun’s codebase. Hacks on top of hacks. Abuse of assertions. Jarred was already writing slop well before he had access to LLMs.

What was the problem with Zig?

This rewrite is the most closely watched real-world test of whether AI agents can take over a production codebase with a human mostly directing rather than reading. Anthropic’s own reputation is also on the line: if this goes well, it is real proof of what agentic coding can do. If it goes badly, it will send a signal in the opposite direction.

The number of unsafe blocks in the Rust code suggests the rewrite did not deliver the memory safety that was given as the reason for doing the rewrite in the first place. Instead this rewrite feels more like an Anthropic ad.

And was Zig really the problem? Bun’s early identity was built on Zig: its performance, its fast compile times, its low friction, its direct memory control with a small team.

It feels like Jarred and Anthropic decided early on that this was going to be written in Rust, and used Zig’s memory issues as the excuse to let the world know how powerful Claude is. A rewrite like this would make great headlines, and it certainly did. Now we’re looking at the long tail of issues from the rewrite they didn’t prepare for.

Maybe Bun should have put that same AI-assisted effort into disciplined, human-understood Zig instead of a full language change. I never saw Jarred seriously engage with this option.

And ‘tomorrow’ has come and gone. Still no v1.4.

¯\_(ツ)_/¯

Read the whole story
emrox
3 days ago
reply
Hamburg, Germany
Share this story
Delete
Next Page of Stories