Ivan Akulov
@iamakulov.com
Web perf engineer @ Framer. Prev. web perf consultant (Google, Appsmith, Toggl, etc). Getting React interactions 2-4x faster. GDE. He/him 🏳️🌈
this needs to nerdsnipe someone from Chromium to investigate this :P
November 10, 2025 at 5:12 PM
this needs to nerdsnipe someone from Chromium to investigate this :P
In the app I spotted this issue in (a typical complex React app), these setTimeouts were ~1500 calls down the `recursivelyTraversePassiveMountEffects` stack
November 10, 2025 at 5:12 PM
In the app I spotted this issue in (a typical complex React app), these setTimeouts were ~1500 calls down the `recursivelyTraversePassiveMountEffects` stack
Okay, I noticed that I cannot repro these slow setTimeouts outside of React / TanStack Query.
So I tried to profile this, and apparently setTimeout calls are much (10×) slower if you do them deep within a call stack (eg React’s recursivelyTraversePassiveMountEffects)? bsky.app/profile/did:...
So I tried to profile this, and apparently setTimeout calls are much (10×) slower if you do them deep within a call stack (eg React’s recursivelyTraversePassiveMountEffects)? bsky.app/profile/did:...
Okay, so this is pretty wild: apparently, in Chromium, the deeper you are in the call stack, the slower your `setTimeout()` calls become?
gist.github.com/iamakulov/85...
gist.github.com/iamakulov/85...
November 10, 2025 at 5:06 PM
Okay, I noticed that I cannot repro these slow setTimeouts outside of React / TanStack Query.
So I tried to profile this, and apparently setTimeout calls are much (10×) slower if you do them deep within a call stack (eg React’s recursivelyTraversePassiveMountEffects)? bsky.app/profile/did:...
So I tried to profile this, and apparently setTimeout calls are much (10×) slower if you do them deep within a call stack (eg React’s recursivelyTraversePassiveMountEffects)? bsky.app/profile/did:...
setTimeout calls also become 2× slower if you have previously set ~750 timers (doesn’t matter whether they already fired):
November 10, 2025 at 4:58 PM
setTimeout calls also become 2× slower if you have previously set ~750 timers (doesn’t matter whether they already fired):
hahaha, full circle :D
November 10, 2025 at 3:57 PM
hahaha, full circle :D
I might publish the impl I posted in that issue as an npm package!
November 10, 2025 at 3:56 PM
I might publish the impl I posted in that issue as an npm package!
(Question prompted by spending an hour to implement userland setTimeout batching :D)
November 9, 2025 at 10:19 PM
(Question prompted by spending an hour to implement userland setTimeout batching :D)
> once [2] gets shipped, you should switch to "eager".
fresh from @perfnow.nl: it will ship v soon, in Chrome 143 <3
fresh from @perfnow.nl: it will ship v soon, in Chrome 143 <3
October 31, 2025 at 2:20 PM
> once [2] gets shipped, you should switch to "eager".
fresh from @perfnow.nl: it will ship v soon, in Chrome 143 <3
fresh from @perfnow.nl: it will ship v soon, in Chrome 143 <3
Currently in Chrome Canary behind a flag (enable at chrome://flags):
October 30, 2025 at 5:29 PM
Currently in Chrome Canary behind a flag (enable at chrome://flags):
But to fix other things (like missing links on ⌘C), we had to carefully inject Framer’s page tree information into the schema, something that also took several iterations.
This part of the project isn’t *fully* complete. But hopefully we’ll wrap it up soon 🙌
This part of the project isn’t *fully* complete. But hopefully we’ll wrap it up soon 🙌
October 24, 2025 at 10:18 AM
But to fix other things (like missing links on ⌘C), we had to carefully inject Framer’s page tree information into the schema, something that also took several iterations.
This part of the project isn’t *fully* complete. But hopefully we’ll wrap it up soon 🙌
This part of the project isn’t *fully* complete. But hopefully we’ll wrap it up soon 🙌
So, another big part of this project was moving this logic back into the schema, to make sure it covers all code paths.
This wasn’t always easy! Some things, like stripping unnecessary <b>s that come from Google Docs, were simple:
This wasn’t always easy! Some things, like stripping unnecessary <b>s that come from Google Docs, were simple:
October 24, 2025 at 10:18 AM
So, another big part of this project was moving this logic back into the schema, to make sure it covers all code paths.
This wasn’t always easy! Some things, like stripping unnecessary <b>s that come from Google Docs, were simple:
This wasn’t always easy! Some things, like stripping unnecessary <b>s that come from Google Docs, were simple:
That was working. But it wasn’t great!
👎 <pre><code> will only get converted during export, not on copy-paste
👎 Google Docs would get sanitized only on paste, but not during import
👎 If both code paths did implement similar logic, they’d often implement it somewhat differently
👎 <pre><code> will only get converted during export, not on copy-paste
👎 Google Docs would get sanitized only on paste, but not during import
👎 If both code paths did implement similar logic, they’d often implement it somewhat differently
October 24, 2025 at 10:18 AM
That was working. But it wasn’t great!
👎 <pre><code> will only get converted during export, not on copy-paste
👎 Google Docs would get sanitized only on paste, but not during import
👎 If both code paths did implement similar logic, they’d often implement it somewhat differently
👎 <pre><code> will only get converted during export, not on copy-paste
👎 Google Docs would get sanitized only on paste, but not during import
👎 If both code paths did implement similar logic, they’d often implement it somewhat differently
Over time, each of these code paths grew a bunch of custom logic:
• ⌘V would do extra sanitization for Google Docs (to strip e.g. <b>s that it puts around everything ↓)
• HTML import/export would convert code blocks from <pre><code> into <template> (and back)
• etc
• ⌘V would do extra sanitization for Google Docs (to strip e.g. <b>s that it puts around everything ↓)
• HTML import/export would convert code blocks from <pre><code> into <template> (and back)
• etc
October 24, 2025 at 10:18 AM
Over time, each of these code paths grew a bunch of custom logic:
• ⌘V would do extra sanitization for Google Docs (to strip e.g. <b>s that it puts around everything ↓)
• HTML import/export would convert code blocks from <pre><code> into <template> (and back)
• etc
• ⌘V would do extra sanitization for Google Docs (to strip e.g. <b>s that it puts around everything ↓)
• HTML import/export would convert code blocks from <pre><code> into <template> (and back)
• etc
3️⃣ In Framer, there are multiple ways to get text into a text editor:
• You can type something, adding images or tables with a button
• You can ⌘V something from Notion, Google Docs, etc.
• You can import HTML with plugins
• You can type something, adding images or tables with a button
• You can ⌘V something from Notion, Google Docs, etc.
• You can import HTML with plugins
October 24, 2025 at 10:18 AM
3️⃣ In Framer, there are multiple ways to get text into a text editor:
• You can type something, adding images or tables with a button
• You can ⌘V something from Notion, Google Docs, etc.
• You can import HTML with plugins
• You can type something, adding images or tables with a button
• You can ⌘V something from Notion, Google Docs, etc.
• You can import HTML with plugins
That solved it. However, after implementing this <pre><code> logic, I discovered that I’m actually... not the first person to do that. What?
October 24, 2025 at 10:18 AM
That solved it. However, after implementing this <pre><code> logic, I discovered that I’m actually... not the first person to do that. What?
To fix this, we used “progressive enhancement”.
We’d still serialize code blocks into <template></template> that Framer uses. But inside that <template>, we’d put a <pre><code> tag that all other editors understand ↓
We’d still serialize code blocks into <template></template> that Framer uses. But inside that <template>, we’d put a <pre><code> tag that all other editors understand ↓
October 24, 2025 at 10:18 AM
To fix this, we used “progressive enhancement”.
We’d still serialize code blocks into <template></template> that Framer uses. But inside that <template>, we’d put a <pre><code> tag that all other editors understand ↓
We’d still serialize code blocks into <template></template> that Framer uses. But inside that <template>, we’d put a <pre><code> tag that all other editors understand ↓
2️⃣ Code blocks. How hard can that be?
A month ago, if you tried to copy a code block from Framer to Notion, it would just not paste. It would be missing!
Reason? We serialized code blocks into our internal format, which other editors did not understand:
A month ago, if you tried to copy a code block from Framer to Notion, it would just not paste. It would be missing!
Reason? We serialized code blocks into our internal format, which other editors did not understand:
October 24, 2025 at 10:18 AM
2️⃣ Code blocks. How hard can that be?
A month ago, if you tried to copy a code block from Framer to Notion, it would just not paste. It would be missing!
Reason? We serialized code blocks into our internal format, which other editors did not understand:
A month ago, if you tried to copy a code block from Framer to Notion, it would just not paste. It would be missing!
Reason? We serialized code blocks into our internal format, which other editors did not understand:
Solving this was tricky. The solution was clear (use separate schemas for CMS and Canvas). But, over the years, our code grew to rely on having a single global schema!
So shipping this required some careful API redesign + a lot of iteration on making it type-safe. E.g., one intermediate design:
So shipping this required some careful API redesign + a lot of iteration on making it type-safe. E.g., one intermediate design:
October 24, 2025 at 10:18 AM
Solving this was tricky. The solution was clear (use separate schemas for CMS and Canvas). But, over the years, our code grew to rely on having a single global schema!
So shipping this required some careful API redesign + a lot of iteration on making it type-safe. E.g., one intermediate design:
So shipping this required some careful API redesign + a lot of iteration on making it type-safe. E.g., one intermediate design:
What’s happening above is:
• There’s a canvas with some text
• Canvas text editor is *not designed* to support images. There’s no button to add one!
• But, under the hood, Canvas uses a schema that *does* support images!
• So I can just ⌘V one
• Hooray?
• There’s a canvas with some text
• Canvas text editor is *not designed* to support images. There’s no button to add one!
• But, under the hood, Canvas uses a schema that *does* support images!
• So I can just ⌘V one
• Hooray?
October 24, 2025 at 10:18 AM
What’s happening above is:
• There’s a canvas with some text
• Canvas text editor is *not designed* to support images. There’s no button to add one!
• But, under the hood, Canvas uses a schema that *does* support images!
• So I can just ⌘V one
• Hooray?
• There’s a canvas with some text
• Canvas text editor is *not designed* to support images. There’s no button to add one!
• But, under the hood, Canvas uses a schema that *does* support images!
• So I can just ⌘V one
• Hooray?
1️⃣ And here comes the first challenge.
Framer has several separate text editors (CMS, canvas, etc). For ✨historical reasons✨, all these editors used *the same schema*. So if you added image support to CMS, you’d inadvertently add it to *every editor*.
This led to cute bugs:
Framer has several separate text editors (CMS, canvas, etc). For ✨historical reasons✨, all these editors used *the same schema*. So if you added image support to CMS, you’d inadvertently add it to *every editor*.
This led to cute bugs:
October 24, 2025 at 10:18 AM
1️⃣ And here comes the first challenge.
Framer has several separate text editors (CMS, canvas, etc). For ✨historical reasons✨, all these editors used *the same schema*. So if you added image support to CMS, you’d inadvertently add it to *every editor*.
This led to cute bugs:
Framer has several separate text editors (CMS, canvas, etc). For ✨historical reasons✨, all these editors used *the same schema*. So if you added image support to CMS, you’d inadvertently add it to *every editor*.
This led to cute bugs:
To use ProseMirror, you define a schema with everything your text editor supports. E.g., if it supports images, you’d define a schema like:
schema = {
image: {
parseDOM: …, // how to parse an image from the editor
toDOM: …, // how to serialize an image back
}
}
schema = {
image: {
parseDOM: …, // how to parse an image from the editor
toDOM: …, // how to serialize an image back
}
}
October 24, 2025 at 10:18 AM
To use ProseMirror, you define a schema with everything your text editor supports. E.g., if it supports images, you’d define a schema like:
schema = {
image: {
parseDOM: …, // how to parse an image from the editor
toDOM: …, // how to serialize an image back
}
}
schema = {
image: {
parseDOM: …, // how to parse an image from the editor
toDOM: …, // how to serialize an image back
}
}