Skip to content

Using Fluent with a lot of roots is slow #648

Open

Description

@julienw

See the bugzilla bug here => https://bugzilla.mozilla.org/show_bug.cgi?id=1988776

STR:

  1. Open the attachment of that bug
  2. Click the button at the top

and note that it takes ~1 seconde to update.

The source code for this is https://github.com/julienw/mutationobserver-issue

The context: I'm building a Lit-based app using mozilla-central MozLitElement. This is a wrapper on the upstream LitElement class that handles localization through Fluent by using Fluent's connectRoot on every shadow root: https://searchfox.org/firefox-main/rev/938e8f38c6765875e998d5c2965ad5864f5a5ee2/toolkit/content/widgets/lit-utils.mjs#160
This in turns calls observe on this root:

this.mutationObserver.observe(newRoot, this.observerConfig);

What happens is that Lit updates the page by removing or adding some nodes.
Then a lot of these nodes are registering or unregistering themselves with Fluent as roots.
When the node is removed from the DOM, disconnectRoot is called, then it self calls observer.disconnect() (from pauseObserving) then observer.observe() (from resumeObserving) on all roots again. And we end up doing that a lot as a result of lit removing elements. The testcase shows this case when it displays less rows than the previous step.

Also applyTranslations does that, which means we do that once per root that has a change. The testcase shows this case when it displays more rows than the previous step.

So if I'm not wrong the complexity is n².

Now if inside the MutationObserver implementation itself there's another loop (for example in getReceiverFor), does that make it n³?

Possible solutions:

  • change the MutationObserver API to expose an unobserve method so that we can unobserve just one root. But this would need a spec change :/
  • have one MutationObserver object for each root. (but I recall a performance issue when doing that for ResizeObserver, so we might have surprises doing that too)
  • instead of disconnect and observe-ing again, use a local boolean state that would be used inside the MutationObserver callback. One drawback is that when translating a large page, Firefox will collect all changes to call the callback, that would be discarded, and that might be a performance issue. Also I'm not sure of the timing of calling the MutationObserver callback and so this could be prone to races.

For now I fixed my issue by, instead of adding/removing nodes, controlling their CSS display property. Moving forward it could be a good idea to integrate into Lit's localization library in https://github.com/lit/lit/tree/main/packages/localize-tools/src/formatters. Another option would be to have a converter from FTL files to XLIFF files (but I don't know if all features of FTL are convertible to XLIFF) so that the Lit machinery could consome them.

Activity

  1. eemeli commented on Sep 18, 2025

    @eemeli
    Member

    have one MutationObserver object for each root. (but I recall a performance issue when doing that for ResizeObserver, so we might have surprises doing that too)

    There might be some better balance achievable by batching roots into observers. Tuning this to work well in all/many different systems would be tricky though.

    instead of disconnect and observe-ing again, use a local boolean state that would be used inside the MutationObserver callback. One drawback is that when translating a large page, Firefox will collect all changes to call the callback, that would be discarded, and that might be a performance issue. Also I'm not sure of the timing of calling the MutationObserver callback and so this could be prone to races.

    There's a risk here of introducing another multiplicative slowdown, as I don't think the MutationRecord the callback gets includes the observed root. We'd need to look it up from each record's ancestry, and if we need to compare all the ancestor nodes to all roots for all mutations, that seems bad in a case where we might have many roots, yes?

    Moving forward it could be a good idea to integrate into Lit's localization library in https://github.com/lit/lit/tree/main/packages/localize-tools/src/formatters.

    From what I can tell, @lit/localize doesn't support DOM localization, so connecting Fluent strings to specific attributes would need to be done manually. But in general, yes, having a more natively Lit localization would be Quite Good.

    Another option would be to have a converter from FTL files to XLIFF files (but I don't know if all features of FTL are convertible to XLIFF) so that the Lit machinery could consome them.

    We've tools that can already enable a generic FTL ↔️ XLIFF conversion, but @lit/localize uses a rather specific subset of features that's a bit distant from Fluent: No message attributes, no plural or other selector support, no message or term references, and indexed rather than named variable placeholders.

  2. julienw commented on Sep 18, 2025

    @julienw
    ContributorAuthor

    have one MutationObserver object for each root. (but I recall a performance issue when doing that for ResizeObserver, so we might have surprises doing that too)

    There might be some better balance achievable by batching roots into observers. Tuning this to work well in all/many different systems would be tricky though.

    instead of disconnect and observe-ing again, use a local boolean state that would be used inside the MutationObserver callback. One drawback is that when translating a large page, Firefox will collect all changes to call the callback, that would be discarded, and that might be a performance issue. Also I'm not sure of the timing of calling the MutationObserver callback and so this could be prone to races.

    There's a risk here of introducing another multiplicative slowdown, as I don't think the MutationRecord the callback gets includes the observed root. We'd need to look it up from each record's ancestry, and if we need to compare all the ancestor nodes to all roots for all mutations, that seems bad in a case where we might have many roots, yes?

    Well currently pauseObserving pauses for every roots by calling disconnect so I don't think we'd need to look at each record's ancestry. Or am I missing something?

    Moving forward it could be a good idea to integrate into Lit's localization library in https://github.com/lit/lit/tree/main/packages/localize-tools/src/formatters.

    From what I can tell, @lit/localize doesn't support DOM localization, so connecting Fluent strings to specific attributes would need to be done manually. But in general, yes, having a more natively Lit localization would be Quite Good.

    They don't mention it explicitely, but it's possible that msg() works on an attribute. Although that' would be very different to how Fluent works... (localizing an element vs localizing a string).

    Another option would be to have a converter from FTL files to XLIFF files (but I don't know if all features of FTL are convertible to XLIFF) so that the Lit machinery could consome them.

    We've tools that can already enable a generic FTL ↔️ XLIFF conversion, but @lit/localize uses a rather specific subset of features that's a bit distant from Fluent: No message attributes, no plural or other selector support, no message or term references, and indexed rather than named variable placeholders.

    Ah I see, that's very limited indeed.

  3. julienw commented on Sep 18, 2025

    @julienw
    ContributorAuthor

    Possible solutions:

    • change the MutationObserver API to expose an unobserve method so that we can unobserve just one root. But this would need a spec change :/

    Smaug mentioned whatwg/dom#126 to me, looks like zibi already thought of that back then.

  4. julienw commented on Sep 11, 2026

    @julienw
    ContributorAuthor

    I think one part of the problem is we translate twice.

    In MozLitElement we forcefully call translateFragment(this.renderRoot) in the update function (https://searchfox.org/firefox-main/rev/938e8f38c6765875e998d5c2965ad5864f5a5ee2/toolkit/content/widgets/lit-utils.mjs#223).

    This already calls applyTranslations once, and therefore already pauseObserving and resumeObserving once.

    Probably this should be in firstUpdated instead?

    Because the MutationObserver also translates the changes.

    This would reduce the problem by half already. Even if the main issue is still the use of the MutationObserver.

    So to avoid the MutationObserver completely when we don't need it, I suggest a solution in #671.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions