- Oct 7, 2026
- 3 min read
Lazy-Loading the Cal.com Embed with IntersectionObserver
My consulting page ends with a booking calendar, a Cal.com inline embed. It sits below the hero, the process, the FAQ and the qualifier. Most visitors never scroll that far, and every one of them was downloading it.
The audit reported 1.26 MB of JavaScript for a mostly static page. Most
of that comes with the embed: embed.js, and then the booking app running
inside its iframe.
The snippet Cal.com gives you
Cal.com’s inline embed is a small loader plus three calls:
(function (C, A, L) {
// …defines window.Cal, which injects embed.js on first use
})(window, "https://app.cal.com/embed/embed.js", "init");
Cal("init", calNamespace, { origin: "https://app.cal.com" });
Cal.ns[calNamespace]("inline", {
elementOrSelector: "#my-cal-inline-consulting",
config: { layout: "month_view" },
calLink: calLink,
});
Cal.ns[calNamespace]("ui", { hideEventTypeDetails: false, layout: "month_view" });
The first Cal(...) call appends the embed.js script tag, so all of this
runs as soon as the page is parsed.
Wrap it, then observe
I wrapped the whole snippet in a function, unchanged, and called it from an
IntersectionObserver:
// Cal.com's embed is ~1 MB of JavaScript: load it only when the
// calendar comes near the viewport, not on first paint.
function loadCal() {
(function (C, A, L) { /* …unchanged… */ })(window, "https://app.cal.com/embed/embed.js", "init");
Cal("init", calNamespace, { origin: "https://app.cal.com" });
Cal.ns[calNamespace]("inline", { /* …unchanged… */ });
Cal.ns[calNamespace]("ui", { /* …unchanged… */ });
}
var calTarget = document.getElementById("my-cal-inline-consulting");
if ("IntersectionObserver" in window) {
var calObserver = new IntersectionObserver(function (entries) {
if (entries.some(function (e) { return e.isIntersecting; })) {
calObserver.disconnect();
loadCal();
}
}, { rootMargin: "800px 0px" });
calObserver.observe(calTarget);
} else {
loadCal();
}
Three details:
rootMargin: "800px 0px"starts loading when the calendar is still 800 px below the screen, so for someone scrolling down it’s usually ready by the time they reach it.disconnect()before loading makes sure it runs only once.- Anchor links still work. The header’s “Book now” jumps to
#calendar; the jump brings the container into view, and the observer fires.
The container keeps its min-height: 600px, so nothing shifts when the
iframe arrives.
Testing that it is really lazy
“It still works” isn’t enough. I wanted to see that nothing was requested before scrolling, and that everything was requested after. Playwright can count requests:
const reqs = [];
page.on('request', (r) => /cal\.com/.test(r.url()) && reqs.push(r.url()));
await page.goto(url, { waitUntil: 'networkidle' });
console.log('before scroll', reqs.length); // 0
await page.locator('#calendar').scrollIntoViewIfNeeded();
await page.waitForTimeout(6000);
console.log('after', reqs.length,
'iframe', await page.locator('#my-cal-inline-consulting iframe').count()); // 87, 1
Zero Cal.com requests on load; 87 after scrolling, and the iframe appears.
I ran it with the site’s Content Security Policy enabled, which allows
app.cal.com in script-src, connect-src and frame-src, and it reported
no violations.
Result
Together with self-hosting the fonts, the consulting page’s mobile performance went from 88 to the mid-to-high 90s. More importantly, a visitor who reads the page and leaves no longer pays for a calendar they never saw.
Takeaways
- Third-party widgets below the fold should load on approach. Booking calendars, chat bubbles, maps and video players are the usual cases.
- Wrap the vendor snippet; don’t rewrite it. The fewer changes you make, the easier the next vendor update.
- Measure requests, not feelings. Zero before scrolling, all of them after.